一行 drop_collection 引发的生产事故
从一次静默的 stock_list 清空,看「先删后取」这颗隐形炸弹
一行 drop_collection 引发的生产事故
从一次静默的 stock_list 清空,看「先删后取」这颗隐形炸弹
「量化实战手记」— 记录从想法到落地的真实开发历程
摘要:一次例行的股票列表更新,在生产环境静默清空了整个 stock_list 集合——没有报错,没有日志,直到下游选股全部失效才被发现。根因是更新函数用了「先删后取」叠加异常静默吞掉,TDX 行情服务一旦不可用,数据就被永久清空。本文复盘事故时间线与根因定位,并用「先取后删 + 非空熔断」让数据丢失在结构上变得不可能——而不是依赖正确的错误处理。
引言:一声不吭的清空
一次例行的股票列表更新,把生产环境的 stock_list 集合清空了。
没有报错,没有异常日志。直到下游选股策略开始返回空结果,才发现股票代码基础数据没了。
stock_list 是整个系统的地基——选股、信号、回测、实盘,全都靠它知道「市场里到底有哪些股票」。地基没了,上面的楼还在跑,只是每根柱子都指向虚无。
这次事故真正可怕的地方,不是数据丢了,而是丢了之后系统一声不吭。这种「平时能跑、极端情况下静默失败」的代码,才是生产环境里最危险的那种。
第一章:事故还原
执行 stock.list save 是日常运维操作:从通达信拉取最新的全市场股票代码,刷新 stock_list 集合。逻辑朴素到几乎不会出错。
但那天,TDX 行情服务恰好不可用——容器重启也好,网络抖动也罢,总之连接不上。
图 1:事故时间线——红色为数据丢失的关键节点
两个问题在这里叠加:
第一,drop_collection 排在 fetch 之前。删的时候,新数据还没拿到手。
第二,异常被 except ... pass 静默吞掉。下载失败了,却像什么都没发生。
单独看,每个都不致命。组合起来就是一句话:删了,没补上,没人知道。
原则:危险操作(删除)和它的补偿操作(下载)之间,不能留「可能失败的间隙」;更不能在补偿失败时保持沉默。让失败响亮,是运维底线。
第二章:根因定位
追调用链:stock.list save → QA_SU_save_stock_list → save_tdx.py 第 1841 行。
看到函数体的第一行就是 drop_collection('stock_list'),fetch 藏在后面的 try 里。典型的「先删后取」。
第一反应是疑惑:这么明显的反模式,怎么会没人改?答案就在同一个文件里——而且让人哭笑不得。
| 函数 | 更新顺序 | 非空守卫 | 状态 |
|---|---|---|---|
| QA_SU_save_stock_list | drop → fetch | 无 | ❌ 唯一异类 |
| QA_SU_save_etf_list | fetch → drop | len > 0 | ✅ |
| QA_SU_save_hkstock_list | fetch → drop | len > 0 | ✅ |
| QA_SU_save_usstock_list | fetch → drop | len > 0 | ✅ |
表 1:同一文件里四个 *_list 函数的范式对比
同一个文件,三个兄弟函数(ETF、港股、美股)全部是「先取后删 + 非空守卫」,唯独 stock_list 是「先删后取 + 无守卫」。
它是没改完的旧代码,一个被遗忘的异类。
这直接给了修复方向:不是去发明什么新方案,而是把 stock_list 拉齐到兄弟水平,让这个特殊情况消失。
原则:代码库里往往已经藏着正确答案。对齐已有约定,比重新设计一套更可靠——它已经被三个函数验证过了。
第三章:修复实现
三个改动,核心只有一个:把 drop 从「函数首行」挪到「fetch 通过之后」。
为什么要这样做:先拿到数据、确认它有效,才允许动旧数据。这样无论 fetch 成功还是失败,旧数据都原地不动。
def QA_SU_save_stock_list(client=DATABASE, ...):
try:
# ① 先拉取,此时 stock_list 原封不动
stock_list_from_tdx = QA_fetch_get_stock_list()
pandas_data = QA_util_to_json_from_pandas(stock_list_from_tdx)
# ② 熔断:A股全市场代码必远超 1000,异常/空返回直接跳过
if len(pandas_data) > 1000:
client.drop_collection('stock_list') # 拿到且校验通过才删
coll = client.stock_list
coll.create_index('code')
coll.insert_many(pandas_data)
else:
QA_util_log_info(
'stock_list 拉取异常:仅 {} 条,跳过更新以保护现有数据'
.format(len(pandas_data)))
这段代码在做什么:fetch 失败时根本走不到 drop,旧数据天然安全;返回少量异常数据时被熔断拦下,照样不碰旧数据。
熔断阈值选了 1000 而不是兄弟函数的 > 0。A股全市场约 5400 只,1000 是很保守的下限——它能挡住「TDX 返回了几条垃圾数据」这种比「完全空」更隐蔽的情况。
关于改在哪里:QUANTAXIS 是项目自己维护的 fork(sunflower/QUANTAXIS),以 editable 方式安装,改源码即时生效。如果它是个只读的 pip 包,就得在 freshquant 自己的封装层加防护——好在不必。
原则:让数据丢失在结构上不可能,而不是依赖任何人「记得处理异常」。fetch 失败时走不到 drop,是靠代码顺序保证的,不是靠纪律。
第四章:验证与防御
「应该安全」和「证明安全」是两回事。改完必须证伪最初的担忧:TDX 不可用时,stock_list 到底还会不会被删?
写了一个完全离线的测试:用一个只记录副作用的 FakeClient 替代真实数据库,monkeypatch 掉网络拉取,分别喂 5 条数据(模拟 TDX 不可用)和 2000 条(正常情况)。
# 关心一件事:stock_list 有没有被 drop
def test_fetch_failure_preserves_data():
st.QA_fetch_get_stock_list = lambda: make_df(5) # 模拟 TDX 不可用
client = FakeClient()
st.QA_SU_save_stock_list(client=client)
assert "stock_list" not in client.dropped # 没被删
assert "stock_list" not in client.inserted # 也没写入垃圾
结果干脆:5 条时 drop_collection 根本没被调用,打印「跳过更新以保护现有数据」;2000 条时正常更新。
这个测试的真正价值,是把「我认为修好了」变成「机器证明修好了」。以后任何人动这段代码,跑一下测试就知道有没有破坏这条安全特性。
原则:关键的不变式要有测试守护。安全特性不是靠 code review 维持的,是靠一条 failing-fast 的测试钉死的。
总结:让危险发生在确认安全之后
整个修复只动了 21 行(16 增 5 删),但消除的是一类问题。
回头看,事故的本质不是某一行代码写错了,而是两个「看起来无所谓」的设计选择叠在一起:先删后取,加上异常静默。
真正的教训是:生产系统里最危险的代码,从来不是报错的那行,而是那种极端情况下一声不吭、继续假装正常的那行。
图 2:修复后的安全流程——无论哪条分支,stock_list 都不会丢失
核心原则:让失败变得响亮,让危险操作发生在确认安全之后。结构性安全,永远优于行为性纪律。
附录:技术速查(给技术读者的彩蛋)
如果你也想排查自己项目里的同类隐患,三个检查点:
① drop-before-fetch 反模式检测:任何「先 drop_collection / TRUNCATE / DELETE,再 INSERT」的更新函数,都要复核顺序——危险操作必须在补偿操作成功之后。
② editable fork 检测:改第三方库前,先确认改的是源码还是 site-packages 副本。import xxx; print(xxx.__file__) 看一眼,别改了半天不生效。
③ 非空熔断模板:
if len(new_data) < THRESHOLD:
log('拉取异常,跳过更新以保护现有数据')
return
# 通过熔断,才允许 drop + insert
关键不变式:旧数据被删除 ⟸ 新数据已拉取且通过校验。这个蕴涵关系,是靠代码顺序和测试共同保证的。