docs: 解耦方案修订——不做双写,代码一次切换
重新推敲后否掉双写:它是两处真相(写点漏一处就悄悄发散,比报错更难发现)、 会掩盖新路径的 bug(旧数据还在,页面看着是对的),而它买到的回退保险本来 也不需要它——accounts.cookie 是权威副本,随时能反向回填出旧列。 改为一次切换 + 旧列延后 drop。补充两条不可省项: 1) 迁移必须自校验(回填不全就中止启动,fail closed),expected 运行时算; 2) 在途判定仍要查 login_tasks(实测 255 行在途),需补 (account_id, attempt_status) 索引。 另补实测边界:0 行「有重登状态但无 CK」→ 回填不会漏重登进度; 登录服务里「一行一账号」的复用逻辑是耦合产物,切换时其第一段查询 (cookie_holding_filter)必须一起重写,否则静默退化。
This commit is contained in:
+151
-36
@@ -1,6 +1,6 @@
|
||||
# 设计:登录任务与 CK 解耦
|
||||
|
||||
> 状态:**待评审**(未动代码)
|
||||
> 状态:**待评审**(未动代码)|**方案已修订:不做双写,代码一次切换,旧列留到下次发版再 drop(§6)**
|
||||
> 起因:2026-10-03 误删事故([复盘](事故复盘-CK误删与恢复.md))。当前靠「删除时跳过持有 CK 的记录」
|
||||
> 兜住,但那只是**临时护栏** —— 根因是两类数据挤在同一张表里。
|
||||
|
||||
@@ -57,7 +57,7 @@ relogin_last_error: Mapped[str] = mapped_column(String(512), default="", nullabl
|
||||
relogin_failure_kind: Mapped[str] = mapped_column(String(16), default="", nullable=False)
|
||||
```
|
||||
|
||||
`login_tasks` 的对应列在 Phase 2 才删;Phase 1 期间保留(双写),随时可回退。
|
||||
`login_tasks` 的对应列**本次不动**(既不读写、也不 drop),留到下一次发版再清理 —— 这是回退后路,见 §6、§7。
|
||||
|
||||
### 3.2 语义澄清
|
||||
|
||||
@@ -104,7 +104,7 @@ WHERE a.cookie IS NULL OR a.cookie = '';
|
||||
|
||||
- 用**窗口函数**取每组最近一行。不要用 `GROUP_CONCAT(... ORDER BY id DESC)` 取 id ——
|
||||
它受 `group_concat_max_len`(默认 1024)限制,账号一多就静默截断;
|
||||
- 只在 `accounts.cookie` 为空时更新(**幂等**,可重跑;也不会覆盖 Phase 1 之后新写入的 CK);
|
||||
- 只在 `accounts.cookie` 为空时更新(**幂等**,可重跑;也不会覆盖切换之后新写入的 CK);
|
||||
- **不要一条 UPDATE 打全表**:1.4 万账号、单账号 CK 约 1.7KB,一次性更新会长时间持锁。
|
||||
改为先 `SELECT` 出 `(account_id, last_id)` 列表,再按 **200 一批** 更新;
|
||||
- 生产 MySQL 8.4 支持窗口函数 ✅。
|
||||
@@ -136,7 +136,7 @@ SELECT (SELECT COUNT(*) FROM accounts WHERE cookie<>''),
|
||||
(SELECT COUNT(DISTINCT account_id) FROM login_tasks WHERE cookie<>'');
|
||||
```
|
||||
|
||||
## 5. 读写切换清单(Phase 1 全量切到 accounts)
|
||||
## 5. 读写切换清单(一次切到 accounts)
|
||||
|
||||
### 5.1 读(判定「谁有 CK / 取 CK」)
|
||||
|
||||
@@ -197,64 +197,179 @@ db.query(Account)
|
||||
- `DELETE /cookies/{id}` 的语义要从「清掉这条记录的 CK」改成「清掉该账号的 CK」,接口入参
|
||||
从 task_id 变成 account_id(**前端要改,且要提示不可逆**)。
|
||||
|
||||
## 6. 分步实施
|
||||
### 5.5 删除护栏的调整
|
||||
|
||||
### Phase 1 —— 建立账号级 CK(可随时回退)
|
||||
现状(事故后临时护栏,`routers/login.py`):
|
||||
|
||||
1. 迁移 `20261002_0040`:加列 + 回填 + 校验(§4);
|
||||
2. `models.py`:`Account` 加列;`LoginTask` 的列**先不动**;
|
||||
3. 写路径**双写**:`account.* = 新值` 的同时照旧写 `task.*`(保证回退时数据不丢);
|
||||
4. 读路径全量切到 `accounts`(§5.1);
|
||||
5. 自动化作业改为按账号调度(§5.3);
|
||||
6. **解除临时护栏**:删除登录任务不再限制持有 CK 的记录(此时删历史已不影响 CK);
|
||||
前端提示文案同步改回;
|
||||
7. 上线后观察:巡检覆盖数、重登成功率、Cookie 页数据量与迁移前一致。
|
||||
| 护栏 | 现状 | 改造后 |
|
||||
| --- | --- | --- |
|
||||
| 持有 CK 的行不可删 | 按 `cookie != ""` 拦 | **去掉** —— 删历史不再影响 CK |
|
||||
| 在途尝试的行不可删 | 按 `attempt_status in (pending, running)` 拦 | **保留** —— 批次线程正拿着这条记录 |
|
||||
|
||||
> Phase 1 完成后,用户目标已达成(历史可自由删除)。双写是回退保险。
|
||||
前端「清空失败」的确认文案要同步改回(不再提「持有 CK 的记录会跳过」),
|
||||
改为提示「进行中的会跳过」。
|
||||
|
||||
### Phase 2 —— 去掉冗余(观察 1~2 周后)
|
||||
## 6. 实施方式:不做双写,一次切换
|
||||
|
||||
1. 停止写 `login_tasks.cookie`(历史行只留 `cookie_snapshot` 或干脆不留,见决策 D);
|
||||
2. 停止写 `login_tasks.ck_check_*` / `relogin_*`;
|
||||
3. 迁移 `20261002_00XX`:drop 这些列;
|
||||
4. 清理读取代码里对 `LoginTask.cookie` 的残留引用。
|
||||
> **结论(2026-10-03 修订)**:**不采用双写**,代码只切一次。但**列不在同一次发布里 drop** ——
|
||||
> 见 §6.3 的两条不可省项。
|
||||
|
||||
### 6.1 为什么双写是错的
|
||||
|
||||
起初写的是「先双写 + 读切换,稳定后再停写」。重新推敲后否掉了,双写在这里**弊大于利**:
|
||||
|
||||
1. **两处真相 → 发散风险**。双写要求每个写入点都记得写两边;漏掉任何一处,两个存储就
|
||||
悄悄不一致。而这类不一致**比直接报错更难发现** —— 两边单独看都「有值」,测试也未必覆盖。
|
||||
2. **旧路径会掩盖新路径的 bug**。只要 `login_tasks.cookie` 还在被写,新代码即使读错了地方,
|
||||
页面看上去也可能是对的(因为旧数据仍在),真实缺陷被推迟到「停止双写」那一刻才爆发。
|
||||
3. **它买到的回退保险,其实不需要它也能拿到**。关键事实:**`accounts.cookie` 是 CK 的权威副本,
|
||||
任何时候都能反向回填出 `login_tasks.cookie`**。也就是说「回退」不缺数据来源,
|
||||
缺的只是一次反向回填脚本。
|
||||
4. 双写还要多写一遍、多删一遍代码,净增工作量。
|
||||
|
||||
### 6.2 一次切换的具体做法
|
||||
|
||||
1. **迁移 `20261002_0040`**:加列 + 回填 + **自校验**(§4)+ 保留 `login_tasks` 旧列(不动);
|
||||
2. **代码一次改到位**:读(§5.1)与写(§5.2)**全部**切到 `accounts`,
|
||||
自动化改为按账号调度(§5.3),调整删除护栏(§5.5);
|
||||
3. **旧列留在库里但不映射进模型**(或映射但不再读写)—— 零额外工作,纯属留后路;
|
||||
4. 稳定运行 1~2 周后,**下一次发版**再 drop 旧列。
|
||||
|
||||
### 6.3 不可省的两条(不是「分步」,是「一次切换」的必要条件)
|
||||
|
||||
**(1) 迁移必须自校验,失败就中止启动。**
|
||||
|
||||
回填是「读旧、写新」的一次性动作,一旦漏掉一部分账号,切到新读路径后这些账号的 CK
|
||||
**直接消失**(表现为「账号突然没有 CK」,且无从察觉是漏回填)。所以迁移里必须:
|
||||
|
||||
```python
|
||||
expected = db.execute(text(
|
||||
"SELECT COUNT(DISTINCT account_id) FROM login_tasks WHERE cookie IS NOT NULL AND cookie <> ''"
|
||||
)).scalar()
|
||||
actual = db.execute(text(
|
||||
"SELECT COUNT(*) FROM accounts WHERE cookie IS NOT NULL AND cookie <> ''"
|
||||
)).scalar()
|
||||
if actual < expected:
|
||||
raise RuntimeError(f"回填不完整:期望 {expected},实际 {actual},中止迁移")
|
||||
```
|
||||
|
||||
`expected` 必须**运行时算**,不能写死 —— 这个数在长(实测:13264 → 13339,因为重登在跑)。
|
||||
校验不过就让迁移抛错,宁可应用起不来(fail closed),也不要带着半份 CK 起来。
|
||||
|
||||
**(2) 在途判定仍要查 `login_tasks`,且要给它加索引。**
|
||||
|
||||
`cookie_operable_filter()` 现在排除的是「该记录正在被批次处理」。CK 搬到账号后,
|
||||
「在途」是**某次尝试**的属性,只能留在 `login_tasks`(实测当前 **255 行**在途):
|
||||
|
||||
```python
|
||||
in_flight = (
|
||||
select(LoginTask.id)
|
||||
.where(LoginTask.account_id == Account.id)
|
||||
.where(LoginTask.attempt_status.in_(("pending", "running")))
|
||||
.exists()
|
||||
)
|
||||
cookie_operable = and_(Account.cookie != "", ~in_flight)
|
||||
```
|
||||
|
||||
现有索引 `ix_login_tasks_account_status_id (account_id, status, id)` 只能吃到 `account_id` 前缀,
|
||||
过滤的是 `attempt_status`,**建议再补一个 `(account_id, attempt_status)` 索引**,
|
||||
否则每轮 200 条选行会走回表(巡检 5 分钟一轮,积压时更明显)。
|
||||
|
||||
### 6.4 与「删除历史」的关系(本次改造的验收点)
|
||||
|
||||
改造完成后:
|
||||
|
||||
- 删除任意登录历史行 → CK 仍在账号上、健康状态仍在、重登进度仍在;
|
||||
- 但**在途尝试的行仍不可删**(批次线程正拿着它)—— 这条护栏语义从「保护 CK」变成
|
||||
「保护正在进行的尝试」,是合理的、要保留的;
|
||||
- 前端「清空失败」的提示文案要相应改回:不再提示「持有 CK 的记录会跳过」。
|
||||
|
||||
### 6.5 顺带简化:可以删掉的「一行一账号」补丁
|
||||
|
||||
`login_service.py:565-597` 为了让 CK 待在行里,写了三段复用逻辑(复用持 CK 行 → 复用失败行
|
||||
→ 新建),并注明「**一个斗鱼账号只保留一条持有 CK 的记录**」。这个不变量**正是耦合的产物**。
|
||||
|
||||
⚠️ **这里有个必须一起改的连带点**:该逻辑第一段用的是 `cookie_holding_filter()`,而它将被
|
||||
重定义为**作用于 `Account`**;且切换后 `login_tasks.cookie` 不再写入、恒为空。所以这段查询
|
||||
必须一起重写,否则会静默退化(永远命中不了第一段)。
|
||||
|
||||
一次切换时的写法(保持行为简单、可预期):
|
||||
|
||||
```python
|
||||
# ① 复用该账号在途/最近一条未完成的行,避免同一账号并发两行
|
||||
task = (
|
||||
db.query(LoginTask)
|
||||
.filter(LoginTask.account_id == aid)
|
||||
.filter(LoginTask.attempt_status.in_(("pending", "running")))
|
||||
.order_by(LoginTask.id.desc())
|
||||
.first()
|
||||
)
|
||||
# ② 否则新建一行(历史而已,允许一个账号多行)
|
||||
if task is None:
|
||||
task = LoginTask(batch_id=batch_id, account_id=aid, created_by=self.created_by)
|
||||
self.db.add(task)
|
||||
```
|
||||
|
||||
即:**不再关心「哪一行持有 CK」**(CK 在账号上),只需要保证「同一账号不并发两行」。
|
||||
真正的「一行一账号」约束消失了。
|
||||
|
||||
> 实测该「不变量」本身已经漏水:近一小时新增 324 行、166 个账号有 2 行历史 ——
|
||||
> 说明复用逻辑在并发/长跑下并不严格。这也是「靠应用层约定维持数据模型」不可靠的又一例证。
|
||||
|
||||
## 7. 回退方案
|
||||
|
||||
- **Phase 1 回退**:代码回退到旧版本即可 —— `login_tasks` 的列与数据**全程双写、未被删**,
|
||||
旧代码读它照样工作。新增的 `accounts` 列留着不影响旧代码;
|
||||
- **回填出错**:回填只在 `accounts.cookie` 为空时执行且幂等,可重跑;
|
||||
- **Phase 2 回退**:需要恢复列结构(有迁移 downgrade)但**数据无法自动还原**(列已 drop),
|
||||
因此 Phase 2 前必须先做一次逻辑备份。**这也是建议尽快加每日备份的原因**。
|
||||
按「不做双写、保留旧列」的方式,回退比原方案更简单:
|
||||
|
||||
| 场景 | 处理 | 是否需要动数据 |
|
||||
| --- | --- | --- |
|
||||
| 新代码有 bug | **回退代码**(部署上一版镜像)即可。旧列还在,旧代码读 `login_tasks.cookie` 能跑 | 不需要 |
|
||||
| 旧列数据已过期(切换后新登录的 CK 只在 accounts) | 跑一次**反向回填**:把 `accounts` 的 CK/状态写回 `login_tasks` 的持 CK 行 | 需要,但有权威副本,脚本 20 行 |
|
||||
| 回填本身不完整 | 迁移自校验会中止启动;修好回填脚本重跑(幂等) | 不需要 |
|
||||
| 已经 drop 旧列后才发现问题 | 迁移 downgrade 恢复列 + 反向回填 | 需要,且**这是唯一不可逆的一步** → drop 前先做逻辑备份 |
|
||||
|
||||
结论:**只要不在同一次发布里 drop 旧列,这次改造的每一步都可回退。** 真正不可逆的只有最后的
|
||||
清理动作,所以把它单独放到下一次发版,并且那之前先有备份。
|
||||
|
||||
## 8. 测试计划
|
||||
|
||||
| 层次 | 用例 |
|
||||
| --- | --- |
|
||||
| 迁移 | 加列/回填幂等;回填条数与预期一致;校验 SQL 为 0/相等 |
|
||||
| 迁移 | 加列/回填幂等;回填条数与预期一致;**自校验不过时必须中止**(构造漏回填场景) |
|
||||
| 单元 | `cookie_holding_filter()` 依据账号;`latest_cookie()` 读账号;清空 CK 写账号 |
|
||||
| 回归 | **删除全部历史行后,账号 CK 与其健康/重登状态仍在**(这条是本次改造的验收核心) |
|
||||
| 回归 | 登录成功写账号 CK;重登失败/停止不破坏已有 CK(沿用 `test_login_attempt_isolation.py`) |
|
||||
| 自动化 | `ck_health` / `ck_relogin` 按账号选行;无历史行的账号也能被巡检/重登 |
|
||||
| 回归 | **在途尝试的行仍不可删**(护栏语义变了,但护栏要留着) |
|
||||
| 自动化 | `ck_health` / `ck_relogin` 按账号选行;**没有历史行的账号也能被巡检/重登** |
|
||||
| 自动化 | 在途排除用的是账号级 `NOT EXISTS`,验证并发下不会与批次抢同一个账号 |
|
||||
| 接口 | Cookie 页各接口在账号级语义下返回正确;导出内容与迁移前一致 |
|
||||
| 生产 | 迁移后对比:持 CK 账号数(应 13264)、巡检每轮 200 条、重登成功率 |
|
||||
| 接口 | `DELETE /cookies/{id}` 清的是**入参那个账号**的 CK(改错会清错账号,重点覆盖) |
|
||||
| 生产 | 迁移后对比:持 CK 账号数(动态基线)、巡检每轮 200 条、重登成功率 |
|
||||
|
||||
## 9. 待确认的决策点
|
||||
|
||||
| # | 决策 | 选项 | 建议 |
|
||||
| --- | --- | --- | --- |
|
||||
| **A** | Cookie 页的记录单位 | ①改为账号(一行一账号)②保留历史行、只是 CK 从账号取 | ①更符合解耦目标,但前端改动大;②改动小但仍会混淆 |
|
||||
| **B** | `login_tasks` 是否保留 `cookie` 作快照 | ①保留(审计「这次登录产出什么」)②不留(最干净) | ①:CK 是加密串,1.4 万行约 28MB,可接受,且便于排障 |
|
||||
| **A** | Cookie 页的记录单位 | ①改为账号(一行一账号)②保留历史行、只是 CK 从账号取 | ①更符合解耦目标;②改动小但仍会混淆 |
|
||||
| **B** | `login_tasks` 是否保留 `cookie` 作快照 | ①保留(审计「这次登录产出什么」)②不留(最干净) | ①:CK 是加密串,约 1.4 万行 ≈ 28MB,可接受,且便于排障 |
|
||||
| **C** | 清理历史的行为 | ①仍不许删持有 CK 的行(保守)②任意删,CK 不受影响 | ②——这才叫解耦;①就没必要做这个改造了 |
|
||||
| **D** | 是否顺带把历史做归档/保留策略 | ①加 `automation_runs` 那样的 90 天清理(规划 §11.4)②不做 | ①可顺带解决「任务列表 1.4 万条」的体量问题 |
|
||||
| **E** | 重登/巡检的调度粒度 | ①账号(推荐)②仍按最近一次登录行 | ①:账号才是状态的载体 |
|
||||
| ~~F~~ | ~~是否双写~~ | —— | ✅ **已定:不双写**(§6.1),代码一次切换、旧列延后 drop |
|
||||
| **G** | 每日逻辑备份是否随本次一起做 | ①一起做(drop 列前必须有)②先不做 | ①(§11) |
|
||||
|
||||
## 10. 工作量与风险
|
||||
|
||||
- 涉及:1 个迁移、`models.py`、4 个 service、7 个 router、2 个自动化作业、前端 3 个页面 + 类型、约 10 个测试文件;
|
||||
- 粗估 **2~3 人日**(Phase 1 约 1.5 天,Phase 2 半天);
|
||||
- **最大风险是 Cookie 页**(决策 A):它的接口语义从「记录」变成「账号」,前后端要一起改,
|
||||
且 `DELETE /cookies/{id}` 的入参含义变了 —— 改错会变成「点删除清错账号的 CK」,比这次事故更糟。
|
||||
建议 Phase 1 里**先不动 Cookie 页的删除接口**,只切读取(列表/导出/检测),删除接口单独一轮处理。
|
||||
- 生产上自动化在跑,迁移要选在巡检空闲窗口(当前 5 分钟一轮,无真正的空窗,但迁移只锁
|
||||
`accounts` 的 1.4 万行、分块执行,影响可控)。
|
||||
- 粗估 **2~2.5 人日**(去掉双写后比原估的 2~3 天更少:少了双写与后续停写两轮改动);
|
||||
- **最大风险是 Cookie 页**:它的接口语义从「记录」变成「账号」,前后端要一起改,
|
||||
且 `DELETE /cookies/{id}` 的入参含义变了 —— **改错会变成「点删除清错账号的 CK」,比这次事故更糟**。
|
||||
这一处要单独写用例(见 §8 最后一条),并在上线后手工验证一次删除清的是同一个账号;
|
||||
- 生产上自动化在跑(5 分钟一轮,无真正空窗),迁移只动 `accounts` 的 1.4 万行且分块执行,影响可控;
|
||||
但**回填 SELECT 与切换之间存在的竞态**要注意:迁移在应用启动时执行,此时旧容器已停止
|
||||
(compose 会先停后起),所以窗口极小;回填按 `finished_at` 取最新并写成幂等,必要时可重跑自愈。
|
||||
|
||||
## 11. 与「备份」的关系
|
||||
|
||||
这次改造本身是安全的(旧列保留、可反向回填),但**最后的 drop 列是不可逆的**。
|
||||
叠加 2026-10-03 的事故(当时唯一退路是 binlog),建议把**每日逻辑备份**作为这个改造的
|
||||
前置项一起做掉 —— 否则「不可逆的一步」仍然只靠 30 天 binlog 兜底。
|
||||
|
||||
Reference in New Issue
Block a user