我用AI搭建了一个实时交易平台,然后时钟开始说谎
作者用Claude作为AI副驾,构建了一个OTC利率互换的实时价格匹配平台。上线运行一个月后,出现会话时长异常延长、连接断开、数据闪烁等问题。通过分析错误分布(连续倍数而非离散倍数),作者诊断出是异步事件循环中的竞争条件:一个慢速客户端阻塞了广播循环,导致计时器被饥饿。修复方案是使用绝对截止时间计时器代替循环计数,并将广播改为并发执行加超时。这揭示了'异步'并不自动带来并发,理解系统行为比代码生成更重要。
在之前的文章中,我描述了给AI赋予对我生产基础设施的只读SSH访问权限,用于审计、文档和监控。逻辑上的下一步就是让它不仅能读取,还能构建。于是我用Claude作为AI副驾,构建了一个真实的应用程序:一个面向场外利率互换的价格匹配平台。这个平台具备实时性、WebSocket连接、单点登录认证,以及一个能自动匹配多个对手方互补兴趣的引擎。技术栈包括FastAPI、asyncio和SQLite。该平台已投入生产,由一位试点经纪商及其背后的约十五家银行对手方使用。
上线后的第一个月一切正常,但随后经纪商向我反馈了几个奇怪的问题:会话在时钟还有剩余时间时被切断;交易员的持仓名义金额闪烁,出现又消失;会话计时异常,运行时间过长。我的时钟开始说谎了。以下是我如何发现原因,以及这个经历教会我关于“异步”一词的真谛。
症状:会话无法按时结束
平台的核心是一个固定时长的匹配会话。例如配置为十分钟:一个倒计时,然后是一个匹配窗口,最后结束。所有参与者看到同一个时钟。但在生产环境中,配置为600秒的会话实际持续了1202秒(两倍),另一个持续了1803秒(三倍)。回顾大约四十个会话,我没找到干净的整数倍数,而是发现了以下分布:1.0倍、1.2倍、1.41倍、1.5倍、2.0倍、2.5倍、2.67倍、3.0倍、3.5倍……最高达5.51倍。一个本应持续十分钟的会话可能运行将近一小时。没有人能解释原因,包括我在内。
第一次错误转向:“是计时器的问题”
直觉反应:计时器代码有bug,比如重复触发或忘记停止。我将其隔离并单独测试:两秒倒计时、三秒匹配,结果精确无误,一次转换,没有重复触发。计时器在隔离下是完美的。更奇怪的是(或者说更好的是),在生产中倒计时是精确的:30秒,分毫不差。只有匹配阶段出现了偏移。区别是什么?倒计时期间几乎无事发生,而匹配阶段非常活跃:订单通过WebSocket到达、每个客户端每三秒轮询一次API、价格推送每秒多次推送数据、服务器持续向所有人广播状态。
计时器没有坏,它被饥饿了。
关键线索:错误形状
这个细节彻底改变了诊断方向,并从此成为我的法则:错误分布的形状告诉你bug的类型。离散bug(如计时器重置或重复计数)会产生离散错误:干净的整数倍数,如×2、×3,绝不会出现×2.67。但我看到的是连续分布:1.2倍、1.41倍、2.67倍、3.5倍、5.51倍——一个连续的斜坡。连续斜坡不像逻辑错误,而更像是竞争:负载越大,速度越慢,且呈比例关系。拉伸系数与会话的活动量相关。从此,我不再寻找计时器bug,而是寻找阻塞事件循环的东西。
原因:一个慢客户端阻塞了所有人
asyncio运行在单线程上。整个服务器——计时器、订单、广播、WebSocket心跳——共享一个事件循环。这个循环是协作式的:直到一段代码通过实际的await交出控制权,其他代码才能运行。我的计时器通过计数await asyncio.sleep(1)调用来工作。理论上,每次循环等于一秒。但实际上,sleep(1)只有在循环有空调用它时才会恢复。如果循环忙于其他事务,计时器的每一“秒”都会延长一秒加上延迟。累积足够多的延迟,十分钟的会话就会变成五十分钟。
罪魁祸首是广播函数。它被声明为async,但却是逐个发送给每个客户端,没有超时,并且在每次计时器滴答和每次交易时都被等待。只要有一个慢速或半死的客户端(例如冻结的标签页、过期的令牌、网络侧TCP缓冲区满),向该客户端发送数据就会阻塞整个广播循环。而只要这个循环被阻塞,计时器滴答就要等待,所有其他客户端的心跳也都要等待。一个坏掉的客户端拖慢了整个平台。
真相大白的那一刻
一个真正的根本原因在于它能解释所有症状,而不仅仅是一个。“会话运行过长” → 计时器被饥饿拉伸;“在时钟还有时间时断开” → WebSocket心跳到达太晚,浏览器认为连接已死并断开,宽限后客户端被踢出,时钟冻结在最后一刻;“名义金额闪烁” → 广播到达延迟且乱序;日志中大量“令牌过期” → 慢请求引发客户端重试循环,进一步加重循环负载。五个不同的抱怨,一个共同的疾病。我不是有五个bug,而是只有一个,戴了五副面具。
修复分两部分
- 使用墙上时钟,而非循环计数。我不再依赖计数sleep(1)。在每个阶段开始时,我固定一个绝对截止时间:deadline = time.monotonic() + duration。每次滴答时,剩余时间为ceil(deadline - now),当now >= deadline时阶段立即结束。如果循环饱和导致一次滴答延迟,截止时间不会移动:会话会按时结束。循环的滞后不再拉伸时间,而是被追赶。(使用time.monotonic()而非datetime.now():需要一个不会倒退、不受NTP调整影响的时钟。)
- 并发广播,每个客户端有界。我将广播重写为并行发送(asyncio.gather),每次发送包裹在asyncio.wait_for(..., timeout=2s)中。慢客户端?该轮消息跳过,不断开连接——它只是慢,不是死。真正死的客户端?移除。结果:广播最坏情况约2秒,而不是所有发送时间的总和。
教训可以用一句话概括:async def并不带来并发。一个循环中的await send()仍然是顺序的。并发需要显式的gather。
验证后才宣布胜利
我不愿“部署后再祈祷”。我编写了一个测试,在每次滴答中注入一秒同步阻塞,制造饥饿,可复现。修复前(循环计数):匹配时间变为两倍。修复后(墙上时钟):无论注入多少阻塞,时长精确。第二个测试用三个客户端:一个快速、一个慢速(5秒)、一个死亡,检查有界广播行为:慢速的被跳过,死亡的被移除,快速的不等待任何人。两个测试通过后,我部署了。
我的收获
“异步”并不意味着“并发”。这是整个故事的核心概念错误。asyncio给了你并发的可能性,但并不免费。一个逐次等待每个操作的循环仍然是顺序的,只是礼貌地阻塞。永远不要让一个客户端的慢速影响共享状态。任何涉及网络的操作都必须有超时。一个对端的背压绝不能劫持所有人的时钟。用时钟测量时间,而不是循环次数。计数sleep(1)调用是赌循环永远不会延迟,但延迟总是会发生。
错误的形状是诊断工具:干净的整数倍数 → 寻找离散逻辑错误;连续分布 → 寻找竞争。这个简单的区分为我节省了数天时间。
最后,回到本博客的主题:一位非开发出身的IT主管使用AI将平台投入生产。AI编写了大部分代码。但这个bug并非通过代码生成解决,而是通过推理:追踪连续分布、形成饥饿假设、在“墙上时钟”和“计数器”之间做出选择。AI是优秀的伙伴,用于检测、测量和编写修复代码,但理解问题根本原因仍然需要人类。这正像我三个月前所说的:AI不会取代工程师,而是为他们腾出时间去做真正的工作:理解。