本节摘要:v5 架构文档里有一句哲学式表述:「HTTP API 不拥有长循环」(官方口径)。本节把它拆成可论证的工程命题。先做思想实验:把交易循环写进 Flask 路由里会发生什么——答案是四个场景轮流杀死它:请求超时、Gunicorn worker 回收、滚动重启、水平扩容。再给正解分工:长循环住在 trading-worker 里,状态写进 PostgreSQL,指令走 Redis 队列,backend 只做无状态的读写与投递。然后解释这条边界带来的三个红利(可重启、可观测、可扩容)。最后把思想迁移到你自己负责的系统:任何「请求生命周期」与「业务长周期」错配的地方,都适用同一个公式;并附一次滚动重启的走查与症状速查表,把哲学落回排障现场。
假设我们把驱动策略的循环放进 backend 的一个 HTTP 路由里——「访问 /start-trading 就开跑」。这段代码当天就能演示,第二周开始出事:
| 场景 | 机理 | 对交易循环的杀伤 |
|---|---|---|
| 请求超时 | HTTP 语义假定请求短时完成,网关与客户端都有超时 | 长循环被连接超时切断,状态悬在半空 |
| worker 回收 | Gunicorn 等应用服务器会周期性回收 worker 进程 | 循环随进程被杀,且无任何交接 |
| 滚动重启 | 发布新版本时进程被有序替换 | 每次发版等于一次计划内「事故」 |
| 水平扩容 | 无状态假设下同一请求可能落到任意实例 | 多实例各跑一份循环,订单重复发送 |
结论不是「HTTP 框架不好」,而是生命周期错配:请求以秒计,交易循环以小时天计;把长周期状态塞进短周期容器,容器每一次正常的生老病死都是对内容的谋杀。
四个场景不是并列的假设,更像一部按周上演的连续剧(示意时间线):
交易循环放进路由之后(示意) 第 1 天 页面一点就开始跑,演示成功 第 4 天 网关超时切断长连接 ──▶ 场景一:请求超时 第 9 天 应用服务器回收 worker ──▶ 场景二:循环随进程蒸发 第 12 天 发新版本,滚动重启 ──▶ 场景三:每次发版都是计划内事故 第 15 天 流量上涨,加上第二个实例 ──▶ 场景四:两份循环、订单翻倍
连续剧的可怕之处在于每一幕单独看都像偶发故障:超时像网络抖动,循环消失像内存泄漏,订单翻倍像交易所 bug。只有把四幕放在一起看,病因才指向同一个——生命周期错配。
backend(短生命周期) trading-worker(长生命周期) ┌────────────────────────┐ ┌────────────────────────────┐ │ 处理 HTTP 请求 │ │ 交易主循环:驱动策略、订单 │ │ 校验输入、读写数据库 │ 队列 │ 管理交易所连接、处理回报 │ │ 把「意图/任务」投进队列 ────┼──────▶ │ 从队列领意图、从库读状态 │ │ 不持有任何交易状态 │ ◀─────┼─ 把进展与结果写回数据库 │ └────────────────────────┘ 进展/结果 └────────────────────────────┘ │ ▲ ▼ │ 读 PostgreSQL 18(唯一事实源)──────────────┘
分工公式:**状态入库,循环进 worker,API 只读写数据库与队列。**三个成分各自的存在理由:
对照第 2.1 节的职责表会发现:这不是一句口号,而是六进程划分的直接依据——每个进程的生命周期与它的职责匹配。
可重启。trading-worker 挂掉后重启,从数据库恢复订单与策略状态,从队列补领未处理意图;backend 重启则几乎无感。运维动作(升级、迁移、扩容)从「事故」降级为「例行动作」。
可观测。既然一切状态都在库里,监控与审计不需要侵入进程:订单走到哪一步、策略持有什么仓位,查库即知;Prometheus 抓取的指标与库内事实互相印证(第 11 章展开)。
可扩容。backend 无状态,可以多实例分摊流量;celery-worker 按队列深度加机器。唯一不允许随意多实例的恰是拥有循环的 worker——而它的规模问题被队列与分片隔离在最小范围内(分片策略以官方文档为准)。
这条哲学的适用范围远大于交易系统。检视你负责的任何服务,问三个问题:
任何一问答「是/要/会」,就值得套用公式:状态入库、循环进 worker(或定时任务)、入口只做读写与投递。配套的工程功课——请求纪律、超时与失败兜底、幂等设计——与《Jev 决策编程》第 08 章《工程化生产》里对决策服务的纪律要求同源,值得对照阅读。
把「正解分工」放进一次真实的发版日(示意走查):
滚动重启的并行世界(示意) backend 实例 A ──被替换──▶ 新实例 B 上线 页面中断数秒 trading-worker ────────────────▶ 循环继续 订单照常推进 │ └─▶ 实例 B 从数据库读到的订单状态与 A 生前所见一致 ──▶ 这就是「无状态」的全部含义:换实例不换事实
发版窗口里策略恰好产生了两个 intent 并落库——它们不受 backend 替换影响,因为消费它们的是 trading-worker:worker 从队列领走意图、从库读上下文、执行后写回。门面换血与循环续命同时发生,互不惊扰。
反过来的症状速查表——出现这些症状,多半是哪条边界没守住:
| 症状 | 没守住的那条 | 正确修法 |
|---|---|---|
| 加实例后订单重复发送 | 长循环进了无状态层 | 循环搬进 worker,入口只投递 |
| 发版后后台任务消失 | 循环挂在请求生命周期上 | 状态入库,重启后按库恢复 |
| 任务永远卡在「处理中」 | 进展只存在内存里 | 每步落库,对账任务兜底 |
| 界面一卡循环就停 | 门面与循环同进程 | 门面与循环分进程部署 |
# lifecycle_demo.py —— 生命周期错配的最小模拟(纯标准库) import random def serve_request_with_loop(ticks): """反模式:循环寄生在请求里,请求结束(或被杀)时产出随进程蒸发""" decisions = [] for _ in range(ticks): decisions.append(random.choice(["hold", "open"])) return decisions # 没有任何人替你保存这份返回值 def worker_loop(queue_in, store, ticks): """正解:循环在 worker 里,输入走队列,每一步写进 store""" for tick in range(ticks): if queue_in and queue_in[0] == tick: queue_in.pop(0) # 领指令,不关心它来自哪个请求 store.append((tick, "tick")) # 留痕:重启后从 store 恢复 return len(store) if __name__ == "__main__": random.seed(7) print("反模式产出(随请求生灭,无处恢复):", serve_request_with_loop(5)) print("正解留痕步数(可从 store 恢复):", worker_loop([1, 3], [], 5))
两段代码的计算量一样,差别只在「东西放在哪」:反模式的产出活在函数返回值里,正解的进展活在 store 里。前者一次回收就清零,后者重启后照着痕迹继续——这正是第二部分分工公式的最小化身。
静态边界(2.1)与设计动机(2.2)都已就位,还差动态图景。下一节让一笔订单、一段行情、一次对账在六进程之间完整走一遍——架构从此在你眼里是流动的。