机器人

高性能 Webhook 与长轮询架构及基准测试

本文对比 Telegram 机器人获取更新的两种方式:主动轮询与被动接收。从实际运维角度分析各自所需条件、典型故障、消息丢失行为与安全防护,并给出选择建议与常见问题解答。

内容导读

本文对比 Telegram 机器人获取更新的两种方式:主动轮询与被动接收。从实际运维角度分析各自所需条件、典型故障、消息丢失行为与安全防护,并给出选择建议与常见问题解答。

高性能 Webhook 与长轮询架构及基准测试主题插画

机器人必须知道有事情发生了。只有两种安排:你的程序反复询问,或者 Telegram 调用你的程序。关于这个主题的所有文字,都是对这句话的展开。

它被当作一个性能决策来呈现,而它主要是一个运维决策。问题不在于哪个更快。而在于你愿意运行什么,以及当它没有运行时会发生什么。

完成这一步后你将能够做到

  • 根据真正起决定作用的理由来选择模式。
  • 避免每种模式各自已知的两种故障。
  • 说明机器人宕机期间发送的消息会怎样。
  • 在不信任到达内容的前提下保护一个公开端点。

主动询问,还是被调用

主动询问是指你的程序向 Telegram 打开一个连接并等待,保持连接打开直到有内容到达或超时,然后立即再次询问。它对你没有任何要求,只需要能够发起出站连接,这就是为什么它能在笔记本电脑上、在企业防火墙后面、在没有固定地址的家庭网络中运行。

被调用是指你注册一个公开地址,Telegram 在每次更新发生时将其发送到该地址。它需要一台带有有效证书的可访问服务器,作为交换,你的任何东西都不必在循环中运行。

注意什么不是区别所在,尽管到处都把它当作头条。主动询问模式中的延迟是一个请求结束到下一个请求开始之间的间隔,也就是毫秒级,而不是你配置的超时时间。对于回答人们的机器人来说,两种模式都感觉是即时的,以延迟为由做选择,是在最不重要的维度上做选择。

这与第 085 步中的问题不同 那一步问的是内容如何从某个来源进入你的频道——一个订阅源、一个商店、一个代码仓库。这一步问的是你的机器人如何得知任何事情,包括人们发给它的消息。两者恰好使用了相同的两个词,这就是为什么同时涵盖两者的教程会把它们混为一谈。

每种模式实际对你提出的要求

先读最后一列。那才是实践中一旦出了问题后通常做选择的地方。

模式 你需要什么 适合 如何出错
主动询问 只需要一个出站连接。不需要地址、不需要证书、不需要开放端口。 开发、小型机器人、任何运行在你不控制网络的地方的东西。 两个副本互相争抢。 启动第二个实例,更新会在它们之间不可预测地分配——这就是机器人隔一条消息才回复一条的典型症状。
被调用 一个带有有效证书的公开地址,随时可访问。 任何正确托管的东西、任何能扩展到多个工作进程的东西、任何你不想让它空转的东西。 沉默。 如果你的端点很慢或宕机,投递会被重试一段时间然后被丢弃,而且没有任何东西告诉你更新丢失了。

你不能同时运行两者,而且在它们之间切换是配置更改而非重写——前提是你没有围绕其中一种来构建机器人的其余部分。保持处理逻辑独立于更新如何到达,是让日后切换只需五分钟的小纪律。

明智的默认做法是从主动询问开始。它在你还在决定机器人做什么的时期就消除了整整一类设置工作,而且它在你自己的机器上和服务器上运行方式完全相同。当你确实不方便让一个进程持续运行,或者你需要多个工作进程时,再转向被调用模式。

导致大多数 webhook bug 的规则

立即应答;之后再做事。

一个在回复之前执行慢操作的端点——数据库写入、调用另一个服务、生成某些东西——最终会耗时过长。投递被视为失败并被重试。你的慢操作随后会再次运行,可能运行好几次,针对同一个事件。这就是某种机器人特有的重复回复、重复扣费和重复发帖的来源。

修复是结构性的,而不是变得更快的问题。接受更新,确认收到,把它放到队列上,让别的东西去做实际工作。并且让处理逻辑可以安全地运行两次,因为重试是正常的,而一个只有在每个事件恰好到达一次时才行为正确的系统,是一个在等待糟糕下午的系统。

同样的纪律在主动询问模式中也有回报,原因不同:一个阻塞的处理程序会拖住它后面的一切,所以一个慢操作会同时让机器人对所有用户都卡住。

你的机器人关闭期间会发生什么

这是值得带走的事实,即使你一行代码都不写,因为它决定了你能承诺什么。

更新会被保留一段有限的时间,然后被丢弃。 一个停了一下午的机器人回来时会收到期间积累的内容;一个停了一周的机器人不会收到这一周的内容。没有人会收到通知,人们发送的消息就是从未被处理。

两个后果。对于任何事务性的东西——一个订单、一笔付款、一个访问请求——待处理更新的队列不是可靠的存储,而一个依赖最终收到所有内容的系统需要一种从自己的记录中核对的方式。当你故意让机器人下线时,提前在机器人本身中说明,因为替代方案是人们对着虚空写字,然后得出结论说自己被无视了。

一个更小的相关控制:你完全可以限制自己接收哪些类型的更新。一个只回答命令的机器人不需要收到它所在每个群组中每条被编辑的消息,缩小这个范围既能减少流量,也能减少对你从未打算处理的东西做出反应的机会。

公开端点会收到任何人发送的任何东西

你注册地址的那一刻,它就在公共互联网上,会被探测。两道防线,按重要性排序。

使用共享密钥。 Telegram 可以在每次投递时附带一个你选择的值,你的端点在做任何事之前先检查它。没有它的请求会被拒绝而不被解析。这很便宜,它就是为此而生的,而且被跳过得出奇地频繁。

无论如何都不要信任其中的内容。 正确的密钥证明请求来自 Telegram;它并不能让其中的数据变得可信,因为其中的数据是陌生人输入你的机器人的任何东西。更新中的一切都是用户输入:名字、消息文本、文件名、按钮的内容。像对待来自开放网络的表单提交那样对待它,也就是说完全不要信任。

而第 086 步中的凭证规则在这里尤其适用:地址本身不是秘密,但令牌是,而出现在部署脚本、容器镜像或日志行中的令牌,就是一个已经离开大楼的令牌。

现在试试——五分钟

四个步骤。第二步会产生这一步中最有教育意义的故障。

  1. 在你自己的机器上以主动询问模式运行一个机器人 不需要地址、不需要证书、不需要托管。十五行代码,它就能回复你。
  2. 启动它的第二个副本 观察两者交替接收消息,并注意到没有任何地方说明正在发生这种情况。现在你就能在生产环境中认出它了。
  3. 停止机器人,给它发三条消息,再启动它 三条都到了。这告诉你保留行为是存在的;上面那一节告诉你不要依赖它。
  4. 写下你的令牌存储在哪里 如果答案涉及一个你会编辑的文件,先解决这个问题,然后这一步中的其他一切才有意义。

扩展性问题,诚实的回答

大多数机器人从不需要考虑吞吐量,而那些需要考虑的会撞上一堵与预期不同的墙。

瓶颈几乎从来不是接收。 两种模式投递更新的速度都超过典型机器人的需要。限制一个繁忙机器人的是发送:Telegram 会限制机器人发送消息的速度,尤其是发往单个聊天时,任何向一长串人广播的设计都会撞上这一点,而不是撞上任何与 webhook 有关的东西。一个给数千用户发消息的机器人是一个带速率限制的队列,把它构建成一个尽可能快发送的循环,就是它失败的原因。

第二个瓶颈是你自己的慢工作。 如果每次更新都触发对某个外部东西的调用,你的容量就是那个外部东西的容量,没有任何投递模式能改变这一点。这就是上面那一节中队列的理由:它把到达的尖峰转化为更长的队列,而不是转化为失败。

剩下的就是诚实的总结。为了简单选择主动询问,当你需要多个工作进程或不喜欢空闲进程时选择被调用,把你的工程精力投入到队列、重试和速率限制上——因为问题实际会出现在那里。

这个阶段的常见问题

我的机器人只隔一条消息才回复一条。出了什么问题?

有两个副本在运行并各自独立地请求更新,所以每个大约收到一半。它发生在一次没有停止前一个进程的部署之后,或者当开发实例被留在笔记本电脑上运行时。没有错误消息,因为从 Telegram 的角度看没有任何问题——有人请求了,更新就被交出去了。先检查是否有第二个进程;这比代码中的任何 bug 都更常是答案。

我需要来自特定提供商的证书吗?

你需要一个正常客户端会接受的有效证书,任何普通证书颁发机构都能提供,包括免费的自动化证书。行不通的是在没人能验证的主机上使用自签名证书,而导致间歇性失败的是证书过期而没人注意到。自动化续期是选择这种模式的一部分,而不是可选的附加项。

我可以在不部署任何东西的情况下测试 webhook 吗?

可以,使用一个隧道工具,它暴露一个指向你机器的临时公开地址。它对于观察真实的更新负载到达确实很有用。要注意该地址在运行期间是公开的,而且你是在针对一个真实的机器人注册它——这就是为什么应该用一个一次性的机器人来做这件事,而不是你的频道所依赖的那个。

我怎么知道更新是否正在丢失?

不能从 Telegram 知道,这是令人不安的部分。Telegram 可以报告你的 webhook 最近的错误状态,当感觉不对劲时值得检查一下,但没有通知,也没有可以重放的积压。实际的答案是,在到达时、在任何处理之前记录你收到的每一次更新,这样“我们收到了吗”就是一个关于你自己系统的问题,而不是对别人系统的猜测。

一个机器人必须在所有地方使用同一种模式吗?

是的——这个设置属于机器人本身,而不是属于某个部署,注册一个地址会完全关闭主动询问模式。这就是为什么在生产副本运行时还要运行本地开发副本,需要一个第二个机器人,而不是第二份配置。保留一个单独的测试机器人,带有自己的令牌和自己的发布频道,是避免一整类令人困惑的夜晚的安排。

无服务器托管适合吗?

它天然适合被调用模式,因为事件之间没有任何东西需要保持运行。需要检查的是这一步反复回到的那些点:你的函数能否快速确认并推迟工作,冷启动是否会把你推过确认窗口,以及你在两次调用之间依赖的任何东西——尤其是对话状态——是否真的存储在某个地方,而不是待在下次不会存在的内存中。

← 返回 TG知识库