docs: 记录 CK 误删事故复盘与恢复方法,并补两条硬规则
事故:删除登录任务时误删了 5019 个仍持有有效 CK 的记录(显示状态是 attempt_status,与是否持有 CK 语义不同)。已用 binlog 全部恢复。 复盘含:影响范围、根因、修复、可复用的 binlog 恢复流程,以及途中踩到的 四个坑(MySQL 8.4 无 mysqlbinlog、解出的列名是 UNKNOWN_COL* 导致假阴性、 tail 吞进度、JSON bytes 键)。
This commit is contained in:
@@ -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`,别在容器里找工具。
|
||||
@@ -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 步)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user