每当用户连续点击发送按钮时,机器人就会开始连续吐出两三次相同的答案。这种重复现象只出现在那些打字速度极快、在 AI 开始思考前就能发出多条消息的用户身上,由于这种模式非常罕见,它在生产环境中隐藏了很长时间。一个过早释放的数据库锁——在获取后仅几毫秒就被释放——导致对话处于无保护状态,允许多个进程对同一个提示词进行回答。
为什么锁失效了
代码通过单次数据库调用获取锁,然后立即将控制权交还给请求处理器。锁的生命周期仅以毫秒计,远短于 AI 模型生成响应所需的时间。当模型开始工作时,锁已经消失了,因此没有任何机制能阻止第二个请求获取相同的对话记录并发出另一个回复。
出现了两种症状:
- 连续发送完全相同的回复。
- 针对同一个问题出现措辞略有不同的回复,因为每个进程都会根据相同的用户输入构建自己的提示词。
由于大多数用户在发送消息之间会有停顿,这个 bug 一直没有被察觉。只有打字极快的人才会触发竞态条件,而且这种情况非常罕见。
治标不治本的修复方案
最初的应对方案是在消息到达后增加一个短暂的延迟,希望能对快速输入进行“防抖”(debounce)。当两条消息接踵而至时,这确实起到了作用,但如果 AI 还在生成文本时出现了第三条消息,该方案就会失效。
当计时器和对话数据存储在同一个存储桶中时,出现了第二个问题。当机器人完成请求处理时,它会覆盖计时器记录,实际上删除了自己的倒计时。系统无法追踪哪些消息已经得到了回答,从而为进一步的重复回复打开了大门。
构建可靠的防护机制:版本计数器、隔离的计时器和租约
团队围绕三大支柱重新设计了流程:
- 版本计数器 (Version counter) —— 每条传入的消息都会增加一个存储在对话中的计数器。该计数器告知系统自上次回复以来已收到多少条消息,从而在生成响应时能够轻松检测到新输入。
- 专用防抖窗口 (Dedicated debounce window) —— 计时器现在存储在独立的存储区域,与对话负载隔离。对防抖持续时间设置硬上限,防止用户无限期地使机器人停滞。
- 会话租约 (Session lease) —— 原有的锁被带有明确过期时间戳的“租约”所取代。租约通过“比较并交换”(CAS)操作来获取:进程读取当前的租约值,仅在旧值匹配时才写入新值,从而获得对对话的独占权。如果进程崩溃,租约会自动过期,将对话释放给下一个处理器。
新流水线的工作原理
- 消息到达 —— 系统增加版本计数器并(重新)设置防抖计时器。系统立即向客户端返回响应,而不启动 AI。
- 计时器到期 —— 计时器处理器尝试获取租约。如果 CAS 成功,处理器继续执行;否则它会退避,因为已知另一个进程已拥有该对话。
- 检查新输入 —— 处理器将当前的版本计数器与计时器开始时记录的值进行比较。如果计数器已增加,它会将待处理的消息聚合为一个单一的提示词。
- 生成回复 —— AI 模型运行一次,生成一个涵盖所有近期用户输入的单一答案。
- 最终校验 —— 在回复发送之前,处理器再次读取版本计数器。如果在生成期间收到了更新的消息,则丢弃该回复并重新启动计时器,确保不会向用户发送过时的答案。
这种方法消除了重复回复,限制了对话可能被停滞的时间,并且由于租约会自动过期,因此可以从进程崩溃中自动恢复。
启示
在临界区开始之前就消失的锁根本起不到任何保护作用。通过将转瞬即逝的数据库锁替换为带有明确过期时间的租约,并将计时器与对话数据隔离,即使在用户打字速度极快的情况下,机器人现在也能保证提供单一且最新的回复。这一事件强调了一个永恒的教训:并发保护机制的生命周期必须长于它们所保护的任务,否则它们就会变成让 Bug 溜过去的隐形屏障。
