C#在Heroku处理SIGTERM解决Telegram Bot实例冲突报错
问题根因
- 报错
Conflict: terminated by other getUpdates request; make sure that only one bot instance is running的核心原因是同一个Bot Token同时存在两个活跃的getUpdates长连接。Heroku在dyno部署、重启、休眠唤醒时,会先给旧实例发送SIGTERM信号,预留10秒优雅关闭窗口,窗口结束后直接发送SIGKILL强杀进程,同时启动新实例。如果旧实例在10秒窗口内没有主动断开getUpdates连接,新实例启动后就会和未完全退出的旧实例抢占连接,触发Telegram API的冲突报错。 - 你当前的信号处理代码存在两个致命问题:
- 信号监听逻辑用
Task.Factory.StartNew放到后台线程执行,属于fire-and-forget(发后不理)模式,信号触发后执行回调时,没有阻塞主进程等待清理完成,旧实例的Bot客户端还在持续发送getUpdates请求。 - 回调逻辑没有主动停止Bot轮询、等待正在处理的消息任务完成,旧连接没有被主动断开,完全靠Heroku的SIGKILL强制回收进程。
- 信号监听逻辑用
正确实现方案
核心思路是:收到SIGTERM信号后第一时间触发轮询取消,主动断开getUpdates连接,等待所有正在执行的业务逻辑跑完,在Heroku的10秒窗口内完成所有清理后再退出进程。
- 第一步:重构信号监听类,同时兼容SIGTERM(容器终止信号)和SIGINT(本地调试Ctrl+C中断信号),暴露取消令牌用于绑定Bot轮询生命周期,不要用后台线程跑信号等待。
- 第二步:将信号的取消令牌传入Bot的轮询启动方法,收到信号时自动触发轮询停止。
- 第三步:信号触发后主动等待轮询任务终止,设置8秒超时(留2秒余量避免触发Heroku的强制杀进程逻辑),确认所有连接断开后再退出。
修正后的代码实现
1. 重构后的Unix信号处理类
using Mono.Unix; using Mono.Unix.Native; public class UnixExitSignal : IDisposable { private readonly UnixSignal[] _trackedSignals = { new(Signum.SIGTERM), new(Signum.SIGINT) }; private readonly CancellationTokenSource _exitCts = new(); /// <summary> /// 进程退出触发的取消令牌,可绑定到Bot轮询、业务逻辑的生命周期 /// </summary> public CancellationToken ExitToken => _exitCts.Token; /// <summary> /// 阻塞当前线程等待退出信号 /// </summary> public void WaitForExit() { // 阻塞等待信号,不要放到后台线程执行 UnixSignal.WaitAny(_trackedSignals, -1); // 收到信号后触发取消通知 _exitCts.Cancel(); } public void Dispose() { _exitCts.Dispose(); foreach (var signal in _trackedSignals) { signal.Dispose(); } } }
2. 主程序接入示例(以官方Telegram.Bot库为例)
using Telegram.Bot; using Telegram.Bot.Polling; using Telegram.Bot.Types; // 初始化Bot客户端 var botClient = new TelegramBotClient("替换为你的Bot Token"); // 注册业务更新、错误处理器 var updateHandler = new DefaultUpdateHandler(HandleUpdateAsync, HandleErrorAsync); // 初始化信号监听 using var exitSignal = new UnixExitSignal(); CancellationToken exitToken = exitSignal.ExitToken; // 启动长轮询,绑定退出令牌 Task pollingTask = botClient.StartReceivingAsync( updateHandler, receiverOptions: new ReceiverOptions { // 按业务需求配置允许接收的更新类型 AllowedUpdates = Array.Empty<UpdateType>() }, cancellationToken: exitToken ); // 阻塞主线程等待退出信号 exitSignal.WaitForExit(); try { // 最多等待8秒让轮询停止、业务逻辑执行完成 pollingTask.Wait(TimeSpan.FromSeconds(8)); } catch (AggregateException ex) when (ex.InnerExceptions.All(e => e is OperationCanceledException)) { // 轮询取消属于预期行为,忽略该异常 } // 到此所有getUpdates连接已主动断开,可安全退出 return; // 业务更新处理逻辑 async Task HandleUpdateAsync(ITelegramBotClient client, Update update, CancellationToken ct) { // 所有长耗时操作记得传入ct,收到退出信号可及时中断 // 替换为你的业务代码 } // 错误处理逻辑 Task HandleErrorAsync(ITelegramBotClient client, Exception exception, CancellationToken ct) { // 替换为你的错误日志逻辑 return Task.CompletedTask; }
避坑说明
- Heroku的优雅关闭窗口固定为10秒,所有清理、等待逻辑总耗时不能超过这个值,设置8秒超时是为了预留网络请求、资源回收的缓冲时间,避免进程被强制杀死。
- 不要用
Task.Run/Task.Factory.StartNew把信号等待逻辑放到后台线程,必须让主线程阻塞在信号等待逻辑上,否则.NET运行时可能在清理逻辑未执行完时就提前退出进程。 - 所有业务逻辑中的长耗时操作(比如数据库读写、外部接口请求)都要传入绑定了退出信号的
CancellationToken,收到信号后及时中断,避免拖慢关闭流程。 - 同一Bot Token同一时间只能跑一个长轮询实例,不要在Heroku上配置多个dyno跑同一个Bot,否则必然触发409冲突。
内容的提问来源于stack exchange,提问作者Wheela Studio
相关产品推荐
相关产品推荐

