Files
live-hub-py/docs/事故复盘-CK误删与恢复.md
T
yml2213 cd60a48039 docs: 记录 CK 误删事故复盘与恢复方法,并补两条硬规则
事故:删除登录任务时误删了 5019 个仍持有有效 CK 的记录(显示状态是
attempt_status,与是否持有 CK 语义不同)。已用 binlog 全部恢复。

复盘含:影响范围、根因、修复、可复用的 binlog 恢复流程,以及途中踩到的
四个坑(MySQL 8.4 无 mysqlbinlog、解出的列名是 UNKNOWN_COL* 导致假阴性、
tail 吞进度、JSON bytes 键)。
2026-10-03 11:11:37 +08:00

5.1 KiB
Raw Blame History

事故复盘:登录任务误删导致 5019 个账号的 CK 丢失(2026-10-03)

一句话结论

斗鱼账号的 CK 只存在 login_tasks.cookie 这一个地方(accounts 表没有 cookie 列)。 列表上显示的「状态」是最近一次尝试(attempt_status),于是一条显示「失败」的行 完全可能仍持有有效 CK。清空失败任务时按「显示状态」删除,就把 5019 个账号的登录态 一起删了。已通过 binlog 全部恢复。

影响

项 值
误删时间窗 2026-10-03 02:18:46 ~ 02:25:43 UTC(北京时间 10:18 ~ 10:25)
删除方式 页面上连续点击删除,每次约 150~200 条,共 24 批
删除总行数 5601 行
其中持有 CK 5019 行(5019 个账号)
无 CK 的历史行 582 行
恢复结果 5601 行全部恢复;持有 CK 的记录 8246 → 13265

根因

数据模型决定的:

login_tasks.cookie   ← 斗鱼 CK 的唯一存放处(enc:v1:... 加密)
accounts             ← 没有 cookie 列

cookie_holding_filter()(services/douyu_service.py)就是靠它判断账号有没有 CK:

LoginTask.cookie.isnot(None) & (LoginTask.cookie != "")

而列表接口序列化时:

status=attempt_status or t.status     # 显示的是「最近一次尝试」
message=(attempt_message if attempt_status else "") or t.message

两者不是一回事。生产实测当时有 752 行处于「显示 failed、但 cookie 非空」的状态 —— 每一条都对应一个仍可用的账号登录态。

新加的「清空失败」按显示状态筛:

or_(and_(attempt_status != "", attempt_status == "failed"),
    and_(attempt_status == "", status == "failed"))

于是这 752 行连同其它 success 行一起被选中删除。这不是单纯的边界疏漏,是把两个 语义不同的字段当成了同一个。

修复

routers/login.py:

  • 批量删除只删 空 cookie 的行,持有 CK 的一律跳过,响应里报告跳过数量;
  • 单条删除遇到持有 CK 的行直接 409,提示「如需清理请先处理该账号的 CK」;
  • 前端确认弹窗写明「持有 CK 的记录会跳过」;
  • 回归测试补 5 条,专门覆盖「显示失败但持有 CK」这个场景(tests/test_login_tasks_pagination.py::TestCkProtection)。

判定用 cookie,不用显示状态 —— 这是这次事故最核心的一条。

恢复过程(可复用)

前提:生产 MySQL 是 ROW 格式 binlog,保留 30 天(binlog_expire_logs_seconds=2592000)。 没有定期逻辑备份,binlog 是唯一退路。

  1. 定位时间窗:先用 automation_runs 的「剩余待检」骤降(6249 → 3729)确认发生过删除, 再用 binlog 精确定位;
  2. 加速定位:binlog.000041 有 279MB,从头流式解析很慢。写了 binlog_time_index.py:只读每个事件的 19 字节头(时间戳/类型/大小), 按 event_size 跳跃前进、不解码行数据,秒级建立「时间 → 位置」索引, 把要解析的量从 279MB 降到 7MB;
  3. 提取被删行:extract_deletes.py 用 pymysqlreplication 流式读取 DeleteRowsEvent,拿到全部列的原始值(含加密 CK);
  4. 验证可解:抽样用 decrypt_value() 确认取回的 cookie 能解密(约 1670 字符);
  5. 恢复:restore_deletes.py 带原始 id INSERT IGNORE 回写,可重复执行。

途中踩的坑(重要)

坑 表现 解法
MySQL 8.4 镜像没有 mysqlbinlog find / 也找不到,容器里无法解 binlog 改用 Python 的 pymysqlreplication(临时装进应用容器 venv,事后卸载)
解出的列名是 UNKNOWN_COL0..19 第一版用 values["cookie"] 永远取到空,得出「0 条持有 CK」的假阴性 按 information_schema 的列序号映射。差点据此误判「没有损失」
管道接 tail 吞掉进度 后台任务看起来一直没输出 去掉 tail,并改用独立线程定时打印进度
json.dump 遇到 datetime / bytes 键 ck_check_result 的 JSON 键是 bytes 递归清洗 + default=str

⚠️ 最危险的是第二条:假阴性会让人得出「没事」的结论。凡是靠脚本判定「有没有丢数据」, 都必须先验证解出来的值非空且正确(本例中先 dump 一行原始结构就发现了)。

沉淀下来的规则

  1. 删除登录任务 = 删除该账号的 CK。任何按条件批量删除的入口,判定必须基于 cookie 字段;
  2. 显示状态 ≠ 数据状态:attempt_status 是「最近一次尝试」,status/cookie 才是 「当前是否持有 CK」。写筛选、统计、删除时先想清楚要哪一个;
  3. binlog 是这类事故的唯一退路:不要关 log_bin、不要缩短保留期;做批量删除类功能前 先确认恢复路径可用;
  4. 生产镜像缺 mysqlbinlog:需要解 binlog 时直接用 pymysqlreplication,别在容器里找工具。