2.2 API 不拥有长循环


2.2 API 不拥有长循环

本节摘要:v5 架构文档里有一句哲学式表述:「HTTP API 不拥有长循环」(官方口径)。本节把它拆成可论证的工程命题。先做思想实验:把交易循环写进 Flask 路由里会发生什么——答案是四个场景轮流杀死它:请求超时、Gunicorn worker 回收、滚动重启、水平扩容。再给正解分工:长循环住在 trading-worker 里,状态写进 PostgreSQL,指令走 Redis 队列,backend 只做无状态的读写与投递。然后解释这条边界带来的三个红利(可重启、可观测、可扩容)。最后把思想迁移到你自己负责的系统:任何「请求生命周期」与「业务长周期」错配的地方,都适用同一个公式;并附一次滚动重启的走查与症状速查表,把哲学落回排障现场。

学习目标

  • 复述四个「HTTP 进程杀死长循环」的场景及机理。
  • 写出正解分工公式并解释每一项的存在理由。
  • 说清这条边界带来的可重启、可观测、可扩容三红利。
  • 把「请求与循环分离」迁移到自己系统的一次设计评审里。

一、思想实验:交易循环写进路由

假设我们把驱动策略的循环放进 backend 的一个 HTTP 路由里——「访问 /start-trading 就开跑」。这段代码当天就能演示,第二周开始出事:

场景 机理 对交易循环的杀伤
请求超时 HTTP 语义假定请求短时完成,网关与客户端都有超时 长循环被连接超时切断,状态悬在半空
worker 回收 Gunicorn 等应用服务器会周期性回收 worker 进程 循环随进程被杀,且无任何交接
滚动重启 发布新版本时进程被有序替换 每次发版等于一次计划内「事故」
水平扩容 无状态假设下同一请求可能落到任意实例 多实例各跑一份循环,订单重复发送

结论不是「HTTP 框架不好」,而是生命周期错配:请求以秒计,交易循环以小时天计;把长周期状态塞进短周期容器,容器每一次正常的生老病死都是对内容的谋杀。

四个场景不是并列的假设,更像一部按周上演的连续剧(示意时间线):

交易循环放进路由之后(示意) 第 1 天 页面一点就开始跑,演示成功 第 4 天 网关超时切断长连接 ──▶ 场景一:请求超时 第 9 天 应用服务器回收 worker ──▶ 场景二:循环随进程蒸发 第 12 天 发新版本,滚动重启 ──▶ 场景三:每次发版都是计划内事故 第 15 天 流量上涨,加上第二个实例 ──▶ 场景四:两份循环、订单翻倍 ​

连续剧的可怕之处在于每一幕单独看都像偶发故障:超时像网络抖动,循环消失像内存泄漏,订单翻倍像交易所 bug。只有把四幕放在一起看,病因才指向同一个——生命周期错配。

二、正解分工

backend(短生命周期) trading-worker(长生命周期) ┌────────────────────────┐ ┌────────────────────────────┐ │ 处理 HTTP 请求 │ │ 交易主循环:驱动策略、订单 │ │ 校验输入、读写数据库 │ 队列 │ 管理交易所连接、处理回报 │ │ 把「意图/任务」投进队列 ────┼──────▶ │ 从队列领意图、从库读状态 │ │ 不持有任何交易状态 │ ◀─────┼─ 把进展与结果写回数据库 │ └────────────────────────┘ 进展/结果 └────────────────────────────┘ │ ▲ ▼ │ 读 PostgreSQL 18(唯一事实源)──────────────┘ ​

分工公式:**状态入库,循环进 worker,API 只读写数据库与队列。**三个成分各自的存在理由:

  • 状态入库——进程可以被杀,事实不能丢;重启后的恢复依据是库,不是内存。
  • 循环进 worker——worker 不暴露 HTTP,没有请求语义的枷锁,生命周期由自己与编排系统管理。
  • API 只读写与投递——backend 保持无状态,于是超时、回收、重启、扩容对它全都无害。

对照第 2.1 节的职责表会发现:这不是一句口号,而是六进程划分的直接依据——每个进程的生命周期与它的职责匹配。

三、边界带来的三个红利

可重启。trading-worker 挂掉后重启,从数据库恢复订单与策略状态,从队列补领未处理意图;backend 重启则几乎无感。运维动作(升级、迁移、扩容)从「事故」降级为「例行动作」。

可观测。既然一切状态都在库里,监控与审计不需要侵入进程:订单走到哪一步、策略持有什么仓位,查库即知;Prometheus 抓取的指标与库内事实互相印证(第 11 章展开)。

可扩容。backend 无状态,可以多实例分摊流量;celery-worker 按队列深度加机器。唯一不允许随意多实例的恰是拥有循环的 worker——而它的规模问题被队列与分片隔离在最小范围内(分片策略以官方文档为准)。

四、迁移到你自己的系统

这条哲学的适用范围远大于交易系统。检视你负责的任何服务,问三个问题:

  1. 有没有一段逻辑的存活时间显著长于承载它的请求?(如报表生成、会话状态机、设备连接。)
  2. 这段逻辑的状态放在进程内存里吗?进程重启后要不要「对账」才能恢复?
  3. 水平扩容第二个实例时,这段逻辑会不会跑两份?

任何一问答「是/要/会」,就值得套用公式:状态入库、循环进 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 里。前者一次回收就清零,后者重启后照着痕迹继续——这正是第二部分分工公式的最小化身。

本节要点回顾

  • 四个杀伤场景:请求超时、worker 回收、滚动重启、水平扩容;根因是生命周期错配。
  • 分工公式:状态入库,循环进 worker,API 只读写数据库与队列。
  • 三红利:可重启、可观测、可扩容,全部来自「计算与状态分离」。
  • 迁移三问:存活时长、状态位置、扩容份数——命中即套公式。
  • 这条边界是六进程划分的直接依据,不是附带说明。
  • 症状速查:订单重复、任务消失、卡在处理中、界面拖垮循环——各自对应一条没守住的边界。

静态边界(2.1)与设计动机(2.2)都已就位,还差动态图景。下一节让一笔订单、一段行情、一次对账在六进程之间完整走一遍——架构从此在你眼里是流动的。


作者与出处
原作者: 灏天文库
整理: 灏天文库整理
本站整理收录,版权归原作者/开源协议所有;欢迎通过原文链接访问源仓库。
发布者: 作者: 灏天文库 转发
评论区 (0)
U