一行 drop_collection 引发的生产事故

从一次静默的 stock_list 清空,看「先删后取」这颗隐形炸弹

一行 drop_collection 引发的生产事故

从一次静默的 stock_list 清空,看「先删后取」这颗隐形炸弹

「量化实战手记」— 记录从想法到落地的真实开发历程

摘要:一次例行的股票列表更新,在生产环境静默清空了整个 stock_list 集合——没有报错,没有日志,直到下游选股全部失效才被发现。根因是更新函数用了「先删后取」叠加异常静默吞掉,TDX 行情服务一旦不可用,数据就被永久清空。本文复盘事故时间线与根因定位,并用「先取后删 + 非空熔断」让数据丢失在结构上变得不可能——而不是依赖正确的错误处理。

引言:一声不吭的清空

一次例行的股票列表更新,把生产环境的 stock_list 集合清空了。

没有报错,没有异常日志。直到下游选股策略开始返回空结果,才发现股票代码基础数据没了。

stock_list 是整个系统的地基——选股、信号、回测、实盘,全都靠它知道「市场里到底有哪些股票」。地基没了,上面的楼还在跑,只是每根柱子都指向虚无。

这次事故真正可怕的地方,不是数据丢了,而是丢了之后系统一声不吭。这种「平时能跑、极端情况下静默失败」的代码,才是生产环境里最危险的那种。

第一章:事故还原

执行 stock.list save 是日常运维操作:从通达信拉取最新的全市场股票代码,刷新 stock_list 集合。逻辑朴素到几乎不会出错。

但那天,TDX 行情服务恰好不可用——容器重启也好,网络抖动也罢,总之连接不上。

1 执行 stock.list save 2 drop_collection('stock_list') ← 数据已清空 3 QA_fetch_get_stock_list() 连接 TDX 下载 4 TDX 不可用,重试 3 次失败,抛异常 5 except ... pass 异常被吞,无任何报错

图 1:事故时间线——红色为数据丢失的关键节点

两个问题在这里叠加:

第一,drop_collection 排在 fetch 之前。删的时候,新数据还没拿到手。

第二,异常被 except ... pass 静默吞掉。下载失败了,却像什么都没发生。

单独看,每个都不致命。组合起来就是一句话:删了,没补上,没人知道。

原则:危险操作(删除)和它的补偿操作(下载)之间,不能留「可能失败的间隙」;更不能在补偿失败时保持沉默。让失败响亮,是运维底线。

第二章:根因定位

追调用链:stock.list saveQA_SU_save_stock_listsave_tdx.py 第 1841 行。

看到函数体的第一行就是 drop_collection('stock_list')fetch 藏在后面的 try 里。典型的「先删后取」。

第一反应是疑惑:这么明显的反模式,怎么会没人改?答案就在同一个文件里——而且让人哭笑不得。

函数更新顺序非空守卫状态
QA_SU_save_stock_listdrop → fetch❌ 唯一异类
QA_SU_save_etf_listfetch → droplen > 0
QA_SU_save_hkstock_listfetch → droplen > 0
QA_SU_save_usstock_listfetch → droplen > 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 删),但消除的是一类问题。

回头看,事故的本质不是某一行代码写错了,而是两个「看起来无所谓」的设计选择叠在一起:先删后取,加上异常静默

真正的教训是:生产系统里最危险的代码,从来不是报错的那行,而是那种极端情况下一声不吭、继续假装正常的那行。

1 执行 stock.list save 2 先 fetch 拉取(stock_list 原封不动) 3 熔断判断:len(pandas_data) > 1000 ? 4 通过 → drop + insert,更新完成 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

关键不变式:旧数据被删除 ⟸ 新数据已拉取且通过校验。这个蕴涵关系,是靠代码顺序和测试共同保证的。

浙ICP备2026022231号-1      浙公网安备33011002019439号