事故:删除登录任务时误删了 5019 个仍持有有效 CK 的记录(显示状态是 attempt_status,与是否持有 CK 语义不同)。已用 binlog 全部恢复。 复盘含:影响范围、根因、修复、可复用的 binlog 恢复流程,以及途中踩到的 四个坑(MySQL 8.4 无 mysqlbinlog、解出的列名是 UNKNOWN_COL* 导致假阴性、 tail 吞进度、JSON bytes 键)。
5.1 KiB
5.1 KiB
事故复盘:登录任务误删导致 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 是唯一退路。
- 定位时间窗:先用
automation_runs的「剩余待检」骤降(6249 → 3729)确认发生过删除, 再用 binlog 精确定位; - 加速定位:
binlog.000041有 279MB,从头流式解析很慢。写了binlog_time_index.py:只读每个事件的 19 字节头(时间戳/类型/大小), 按event_size跳跃前进、不解码行数据,秒级建立「时间 → 位置」索引, 把要解析的量从 279MB 降到 7MB; - 提取被删行:
extract_deletes.py用pymysqlreplication流式读取DeleteRowsEvent,拿到全部列的原始值(含加密 CK); - 验证可解:抽样用
decrypt_value()确认取回的 cookie 能解密(约 1670 字符); - 恢复:
restore_deletes.py带原始 idINSERT 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 一行原始结构就发现了)。
沉淀下来的规则
- 删除登录任务 = 删除该账号的 CK。任何按条件批量删除的入口,判定必须基于
cookie字段; - 显示状态 ≠ 数据状态:
attempt_status是「最近一次尝试」,status/cookie才是 「当前是否持有 CK」。写筛选、统计、删除时先想清楚要哪一个; - binlog 是这类事故的唯一退路:不要关
log_bin、不要缩短保留期;做批量删除类功能前 先确认恢复路径可用; - 生产镜像缺
mysqlbinlog:需要解 binlog 时直接用pymysqlreplication,别在容器里找工具。