From cd60a48039cb3030385e03a05d44b859db59ce55 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sat, 3 Oct 2026 11:11:37 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E8=AE=B0=E5=BD=95=20CK=20=E8=AF=AF?= =?UTF-8?q?=E5=88=A0=E4=BA=8B=E6=95=85=E5=A4=8D=E7=9B=98=E4=B8=8E=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E6=96=B9=E6=B3=95=EF=BC=8C=E5=B9=B6=E8=A1=A5=E4=B8=A4?= =?UTF-8?q?=E6=9D=A1=E7=A1=AC=E8=A7=84=E5=88=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 事故:删除登录任务时误删了 5019 个仍持有有效 CK 的记录(显示状态是 attempt_status,与是否持有 CK 语义不同)。已用 binlog 全部恢复。 复盘含:影响范围、根因、修复、可复用的 binlog 恢复流程,以及途中踩到的 四个坑(MySQL 8.4 无 mysqlbinlog、解出的列名是 UNKNOWN_COL* 导致假阴性、 tail 吞进度、JSON bytes 键)。 --- docs/事故复盘-CK误删与恢复.md | 102 ++++++++++++++++++++++++++++++++++ docs/自动化与资料收集规划.md | 6 ++ 2 files changed, 108 insertions(+) create mode 100644 docs/事故复盘-CK误删与恢复.md diff --git a/docs/事故复盘-CK误删与恢复.md b/docs/事故复盘-CK误删与恢复.md new file mode 100644 index 0000000..a3a9d40 --- /dev/null +++ b/docs/事故复盘-CK误删与恢复.md @@ -0,0 +1,102 @@ +# 事故复盘:登录任务误删导致 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`,别在容器里找工具。 diff --git a/docs/自动化与资料收集规划.md b/docs/自动化与资料收集规划.md index e1f8bc8..fa8158e 100644 --- a/docs/自动化与资料收集规划.md +++ b/docs/自动化与资料收集规划.md @@ -1359,6 +1359,11 @@ OSS_PREFIX=collect - **迁移是应用启动时自动跑的**(`database.init_db()` → `run_migrations()` → `alembic upgrade head`)。 所以「部署」就等于「跑迁移」,**不要**再手动执行一遍;同时也意味着**迁移写坏了会挡住整个应用启动**, 必须保持幂等(先 `sa.inspect` 查存在再改)。想先看效果可在本地 dev 库跑; +- **⚠️ 删除登录任务 = 删除该账号的 CK**(2026-10-03 出过事故,丢过 5019 个 CK,详见 + [事故复盘](事故复盘-CK误删与恢复.md)):斗鱼 CK **只存在 `login_tasks.cookie`**, + `accounts` 表没有 cookie 列。列表显示的「状态」是 `attempt_status`(最近一次尝试), + 与「是否持有 CK」(`status`/`cookie`)**语义不同** —— 当时有 752 行「显示失败但持有 + 有效 CK」。任何按条件筛选/删除的入口,判定必须基于 `cookie` 字段; - **⚠️ 动手前先核实外部库的真实表结构,别信文档和早期分析**(P0 踩过一次,差点白干): 规划 §6.2 写的 `orders.order_no`(PK) 在真实库里**根本不存在**,实际是 `platform_order_id`(且**非唯一**,541 组重复)。同一订单号下还有「发货方内部行 + @@ -1389,6 +1394,7 @@ OSS_PREFIX=collect | OSS | 桶 `live-hub-assets`(私有+版本控制)、RAM `live-hub-oss`、`.env` 已配 | P2 用 `oss2`;**不要用 HeadBucket** | | 迁移 head / 测试基线 | `20261002_0039` / 338 条(2026-10-03) | 见 C.1;**应用启动会自动跑迁移**,0039 已在生产应用 | | 时区 | 库/`NOW()` 都是 UTC(`--default-time-zone=+00:00`),接口返回 `+00:00`,前端转北京时间 | **别把 `SELECT NOW()` 当真实时间**,详见 README「时区口径」 | +| 备份 | **没有定期逻辑备份**;依赖 MySQL ROW 格式 binlog(`log_bin=ON`,保留 30 天) | 误删只能靠 binlog 恢复;**不要关 binlog 或缩短保留期**;镜像里没有 `mysqlbinlog`,用 `pymysqlreplication` | ### C.4 P1a 施工单(✅ 已按此完成,8 步)