# 事故复盘:登录任务误删导致 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: ```python LoginTask.cookie.isnot(None) & (LoginTask.cookie != "") ``` 而列表接口序列化时: ```python status = attempt_status or t.status # 显示的是「最近一次尝试」 message = (attempt_message if attempt_status else "") or t.message ``` 两者不是一回事。生产实测当时有 **752 行**处于「显示 failed、但 cookie 非空」的状态 —— 每一条都对应一个仍可用的账号登录态。 新加的「清空失败」按**显示状态**筛: ```python 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`,别在容器里找工具。