perf(douyu-tasks): 为斗鱼任务增加归属客服索引字段,彻底消除全表 filesort 慢查询

1. 数据库新增迁移 20261003_0040,在 douyu_tasks 表增加 account_assigned_to 字段,并创建 (account_assigned_to, handbook_scope, id) 与 (account_assigned_to, id) 复合覆盖索引;
2. 迁移脚本自动回填历史 24 万条任务的所属客服 ID;
3. 任务服务与账号路由联动:创建任务时自动继承 account_assigned_to,修改账号分配时同步更新关联任务归属;
4. 斗鱼任务查询路由 _visible_tasks_query 改为通过 account_assigned_to 复合索引过滤,消除与 accounts 表的跨表关联与临时表全量文件排序;
5. 新增 test_douyu_task_assigned_optimization 单元测试并通过整链迁移与回归测试。
This commit is contained in:
yml2213
2026-10-03 13:10:59 +08:00
parent 294c6cd058
commit 7790be4e04
8 changed files with 527 additions and 3 deletions
@@ -0,0 +1,291 @@
# live-hub-py 独立部署迁移方案(负载分流)
> 采集时间:2026-10-03 12:53 ~ 13:05 CST
> 源服务器:`order+douyu_huya` / 47.114.114.225(阿里云 cn-hangzhou,4 vCPU / 7.1 GiB / 49 GB 系统盘)
> 目标服务器:**待购买**(用户确认「还没有购买,有计划分开」)
> 迁移对象:**live-hub-py**(用户选定,理由见 §2)
> 本文所有负载数字均为本次实测,非估算;工时为含缓冲的区间估计。
---
## 0. 结论(TL;DR)
| 问题 | 结论 |
| :--- | :--- |
| 负载为什么忽高忽低 | 4 核机器上混跑 2 套业务 + 2 个数据库,实例级 CPU 在 **46% ~ 380%** 之间剧烈摆动(实测 24 个采样点),任一方突发就把 4 核打满,排队后 load average 长期挂在 **10 左右** |
| 迁哪个 | **live-hub-py**。它自洽(App + MySQL 一起走)、逻辑数据只有 1.1 GB、只牵动 `live.khhao.com` 一个域名;order_site 是四个域名的入口网关,搬迁代价高得多 |
| 目标机要什么规格 | **4 vCPU / 8 GB / 40~60 GB 系统盘 / 10 Mbps**,**必须买在阿里云 cn-hangzhou**(否则 OSS 内网端点失效) |
| 要做什么 | 11 项改动面,其中 3 项是真正的坑:`.env` 里的 `APP_ENCRYPTION_KEY` 必须原样照抄、MySQL 卷要改成新的本地卷、compose 里的 `external: order-site-net` 要摘掉 |
| 要多久 | **顺利半天(4~6 小时)**,其中**停服窗口 20~40 分钟**;含演练与回滚预案 **1 个工作日** |
| 迁完能降到多少 | 主服峰值砍掉约 40%~60%,15 分钟负载从 ~10 降到 **4~6**;但 **order_site 自己峰值还能到 3.2 核**,SQL 重构/索引不做,下次还会打满 |
| 买机器之前 | 立刻能回收 **约 8 GB 磁盘**(binlog 3.6 G + 构建缓存 2 G + 无用镜像 2.2 G),磁盘水位从 84% 降到 ~68% |
---
## 1. 现场实测:负载为什么忽高忽低
### 1.1 采样方法
在源服务器上每 5 秒采一次 `loadavg` + `docker stats`,连续 24 个点(12:54:04 ~ 12:57:04):
```bash
for i in $(seq 1 24); do
echo "$(date +%T) load:$(cut -d' ' -f1-3 /proc/loadavg)"
docker stats --no-stream --format "{{.Name}} {{.CPUPerc}}"
sleep 5
done
```
### 1.2 实测曲线(节选,单位 %,4 核 = 400%)
| 时刻 | load1 | douyu-login | mysql | postgres | backend | web | 合计/400% | idle |
| :--- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| 12:55:22 | 7.31 | 0.41 | 4.54 | 8.45 | 29.65 | 2.58 | **46%** | 86.7% |
| 12:54:44 | 10.04 | 4.28 | 1.16 | 107.52 | 20.52 | 4.82 | **138%** | 14.0% |
| 12:54:20 | 11.24 | 27.38 | 84.40 | 138.22 | 54.83 | 4.92 | **310%** | 28.3% |
| 12:54:12 | 11.09 | 38.97 | 66.31 | 222.80 | 51.11 | 12.98 | **392%** | 8.9% |
| 12:55:38 | 7.03 | 0.77 | 57.64 | 222.32 | 94.40 | 4.66 | **380%** | 0.0% |
| 12:56:18 | 7.58 | 79.33 | 138.41 | 127.95 | 55.01 | 6.05 | **407%** | 1.7% |
| 12:56:49 | 8.92 | 124.48 | 45.71 | 42.25 | 31.83 | 9.81 | **254%** | 3.8% |
**怎么读这张表**:合计在 **46% → 407%** 之间来回跳,`idle` 在 **0% ~ 86.7%** 之间跳。这不是「持续满载」,而是「**反复打满—回落—再打满**」,正是用户观感里的「忽高忽低」。
### 1.3 单实例摆动幅度(同一次采样内 min → max)
| 容器 | 归属 | CPU 摆动 | 说明 |
| :--- | :--- | :--- | :--- |
| `order_site-postgres-1` | order_site | **2.43% → 222.80%** | 单实例最大摆动,parallel worker 打满约 2 核 |
| `live-hub-py-mysql-1` | live-hub-py | **1.16% → 138.41%** | 任务批次一到就跑满,批次结束立刻回落 |
| `live-hub-py-douyu-login-1` | live-hub-py | **0.41% → 124.48%** | 登录/充值批次并发,典型突发型 |
| `order_site-backend-1` | order_site | **13.89% → 145.92%** | 跟 Postgres 同涨同落 |
### 1.4 系统层证据
```text
load average: 10.36, 9.38, 10.72 # 15 分钟负载稳定在 10.2~10.75
4 vCPU / Mem 7.1Gi used 4.1Gi / swap used 577Mi
%Cpu(s): ... 0.0 id ... 20.9 wa # 采样中出现 20.9% 的 IO wait
/dev/vda3 49G 39G 7.7G 84% / # 磁盘只剩 7.7G
live-hub-py-mysql-1 BLOCK I/O 396GB / 23.1GB # 累计读 396GB
```
**三条结论**:
1. **突发型负载来自 live-hub-py**(登录批次 + MySQL 批次查询),这是「忽高忽低」的直接来源。
2. **持续型高位来自 order_site**(Postgres 常态 100%~220%,backend 30%~145%),它决定了「低」也低不到哪去。
3. **两者峰值会叠加**(12:56:18 合计 407%),互相抢核 → 延迟放大 → 队列堆积 → load 10。
### 1.5 历史结论的沿用与修正
`docs/生产服务器高负载排查分析报告.md` 里「PostgreSQL JIT 导致 197% CPU」的判断成立,12:42 执行 `jit = off` 后 Postgres 从 197.75% 掉到 1.58%。**但本次复测显示它又回到了 100%~222%** —— 说明 `jit = off` 只是止血,`getWorkerOrderOverview` 的 CTE + 并行 worker 才是根,仍需按原报告做 SQL 重构(§9)。
---
## 2. 为什么先迁 live-hub-py(而不是 order_site)
| 维度 | live-hub-py | order_site |
| :--- | :--- | :--- |
| 对外入口 | 仅 `live.khhao.com` 一个域名 | **四个域名的网关**:`ks.khhao.com`、`worker.khhao.com`、`live.khhao.com`、`mall.khhao.com` 全在它的 Caddy(`order_site-web-1`)里终结 TLS |
| 监听端口 | 容器内 8800,宿主 8000(新机空闲) | 宿主 **80 / 443**,目标机若已有 Caddy 直接冲突 |
| 数据库 | MySQL,逻辑数据 **881 MB**(`douyu_tasks` 一张表 759 MB) | PostgreSQL,库 **5236 MB**(10 张大表,最大 `admin_audit_logs` 769 MB) |
| 卷体积 | 卷名义 4.9 GB,其中 **binlog 3.6 GB 不用搬** | 卷 5.3 GB,基本都要搬 |
| 跨项目依赖 | 接了 `order_site_default` 网络、读 order_site PG —— 但 `ORDER_SITE_LOOKUP_ENABLED=false`,**当前是休眠的** | 反向无依赖(已确认 order_site 不访问 livehub 任何东西) |
| 迁移改动面 | 11 项 | 20+ 项(还要拆网关、拆 DNS、合 Caddy) |
| 工时 | **半天** | **1~1.5 天** |
**一句话**:live-hub-py 是一个能整体搬走的自治单元;order_site 是「搬它就得把整个入口层一起搬」的重量级单元。
---
## 3. 目标机规格建议
| 项 | 建议 | 依据 |
| :--- | :--- | :--- |
| vCPU | **4 核**(下限 2 核) | 实测 livehub 单体峰值:app 124% + mysql 138% ≈ **2.6 核同时突发**;2 核会排队 |
| 内存 | **8 GB**(下限 4 GB) | 实测 app 611 MiB + mysql 897 MiB ≈ 1.5 GiB,加上 build cache 与页缓存,4 GB 会紧张 |
| 系统盘 | **40~60 GB ESSD** | 镜像 1.87 GB + MySQL 1.12 GB + DB 1.1 GB + 代码 1.2 GB + 首次构建缓存 5~8 GB |
| 带宽 | **≥ 5 Mbps,建议 10 Mbps** | 容器实测 NET I/O 2.95 GB 入 / 234 MB 出(约 2 小时),均值约 3.3 Mbps,突发更高 |
| **地域** | **阿里云 cn-hangzhou(关键)** | `.env` 里 `OSS_ENDPOINT=https://oss-cn-hangzhou-internal.aliyuncs.com` —— **内网端点只在同地域可用**。换地域或换云商必须改成公网 `oss-cn-hangzhou.aliyuncs.com`,并接受公网流量费与更高延迟 |
| 操作系统 | Ubuntu 24.04 + Docker + compose plugin | 与源机一致,源机当前 Docker Compose v5.x |
| 安全组 | 22(限自己的 IP)、80/443(方案 A 自签 TLS 需要);**8000 不要对公网开放** | 源机上 8000 是 `0.0.0.0` 裸暴露的,迁移时顺手收紧 |
> **同地域的额外好处**:DB dump 可以经 **OSS 内网中转**(源机 put → 新机 get),不走公网、不计公网流量、速度是内网级;也可以直接走阿里云同地域公网,通常也比跨云快得多。
---
## 4. 迁移改动面清单(要做什么)
### A. 代码与镜像
| # | 事项 | 说明 |
| :--- | :--- | :--- |
| A1 | 从 gitea 拉代码 | `git clone https://gitea.hao858.com/yml/live-hub-py.git`,切到生产提交 `294c6cd`(`main`,已确认与 `origin/main` 一致) |
| A2 | 构建镜像 | `docker compose build`。多阶段构建:前端 npm build + yyb-worker node_modules + Python(uv) 运行时。首次全量构建 **15~25 分钟** |
| A3 | 镜像加速 | compose 里已写死 `docker.m.daocloud.io/library/mysql:8.4` 等;构建参数 `USE_CHINA_MIRRORS=true` 已在 `.env` 中确认 |
### B. 配置改写(3 个真坑)
| # | 事项 | 不做的后果 |
| :--- | :--- | :--- |
| **B1** | `.env` 里 `APP_ENCRYPTION_KEY`、`JWT_SECRET_KEY` **必须一字不差照抄** | 换了 `APP_ENCRYPTION_KEY` → **账号密码、邮箱密码、Cookie、代理密钥等历史密文全部解不开**,等于数据报废 |
| **B2** | `MYSQL_VOLUME_EXTERNAL` 改为 `false`,`MYSQL_VOLUME_NAME` 用新名字(如 `live-hub-py_mysql-data`) | 源机靠 `external: true` 复用旧卷 `douyu_login_py_mysql-data`。新机上若沿用 external,Docker 会**自动建一个空卷** —— 2026-10-02 已经因此发生过一次「看起来数据全丢」 |
| **B3** | `docker-compose.yml` 摘掉 `networks: order-site-net`(`external: order_site_default`)与服务的该网络 | 新机上不存在 `order_site_default` 网络,`compose up` 直接报错起不来 |
| B4 | `ORDER_SITE_DATABASE_URL` 处理 | 该功能当前是关的(`ORDER_SITE_LOOKUP_ENABLED=false`),**建议保持关闭并在文档留痕**。将来要开,需为跨机 PG 访问单独做 TLS 代理 + IP 白名单 |
| B5 | `APP_PORT` | 新机上 8000 空闲,可沿用;若与既有服务冲突再改 |
| B6 | `COOKIE_SECURE=true` 保持 | 前置 TLS 必须继续有,否则 Cookie 下发异常 |
### C. 数据搬运
| # | 事项 | 体量 | 做法 |
| :--- | :--- | :--- | :--- |
| C1 | MySQL 全量 | 逻辑 881 MB | `mysqldump --single-transaction --routines --triggers --events douyu_login`,压缩后约 150~250 MB |
| C2 | MySQL 增量(切换窗口) | 停机期间写入量 | 停旧容器后重跑一次 dump,只这一次的差异 |
| C3 | **虎牙设备指纹状态** | `data/huya_fp_states/` 2.4 MB + `data/huya_device_profiles.json` 308 KB | **必须搬**,这是虎牙设备指纹的连续性,丢了要重新养 |
| C4 | 验证码截图 | `data/captcha_captures/` 208 MB | 可搬可不搬(排查用样本);不搬能省 15 分钟 |
| C5 | 旧 sqlite | `data/web.db` 24 MB | **不用搬**(7 月 25 日之后未更新,历史遗留) |
| C6 | 日志 | `logs/` 590 MB | **不用搬**,留在旧机做追溯 |
| C7 | 备份件 | `backups/login_tasks_20261001_182959.sql` 32 MB | 可选 |
| C8 | binlog | 3.6 GB | **不用搬**(无从库,已确认 `SHOW REPLICAS` 为空) |
### D. 入口与 DNS
| # | 事项 | 说明 |
| :--- | :--- | :--- |
| D1 | **方案 A(推荐)**:新机自建 Caddy,`live.khhao.com` 的 A 记录改指新机 | 新机 Caddy 自动签证书;需安全组开 80/443。目前 `live.khhao.com` → 47.114.114.225,证书由旧机 Caddy 签发(已确认 `live.khhao.com` 证书存在)。**回滚只需把 DNS 改回去** |
| D2 | **方案 B(备选)**:旧机 Caddy 保留 `live.khhao.com`,反代到新机 `IP:8000` | 无 DNS 改动、无证书操作,新机安全组只放行旧机 IP。**缺点**:旧机仍是入口单点,且多一跳跨公网 |
### E. 依赖外部服务(无需改动,但要确认可达)
| 服务 | 地址 | 说明 |
| :--- | :--- | :--- |
| Roundcube 邮箱验证码 | `http://111.229.206.54:8000/`(代码默认值,`.env` 未覆盖) | 公网可达,与源机无关 |
| OSS | `oss-cn-hangzhou-internal.aliyuncs.com` / bucket `live-hub-assets` | **依赖目标机在 cn-hangzhou** |
| 鱼翅直充供应商 | `FISH_FIN_RECHARGE_*`(4 个密钥 + 回调 URL) | 公网,回调地址若含旧机域名需一并改 |
| 应用宝 Worker | 容器内回环 `127.0.0.1:8810` | 同容器,自动跟随 |
### F. 不需要迁移的东西(已确认)
- **没有 crontab**(`crontab -l` 为空),**没有自定义 systemd timer**(只有 sysstat/logrotate 等系统默认项)。
- 定时任务全在应用内(`AUTOMATION_ENABLED=true`),随容器搬走;各作业开关默认关闭,在 `/automation` 页面单独控制。
- yyb-worker 状态目录 `data/yyb-worker-jobs/` 为空。
---
## 5. 分阶段执行计划与工时
| 阶段 | 内容 | 停服 | 工时 |
| :--- | :--- | :---: | ---: |
| **T0 准备**(买机前就能做) | 清理源机磁盘;确认代码已推 gitea;导出 `.env`;跑一次全量 `mysqldump` 并校验可导入 | 否 | 0.5 h |
| **T1 新机初始化** | 安全组;装 Docker + compose;建目录;从 gitea clone;写 `.env`(B1);改 compose(B2/B3/B5) | 否 | 0.5 h |
| **T2 构建与预热** | `docker compose build`(15~25 min);起 MySQL;导入 T0 的全量 dump;`alembic` 由 entrypoint 自动跑 | 否 | 0.5~1 h |
| **T3 预跑验证** | 起 app,跑通 health + 管理员登录 + 只读页面;**用小批量账号各跑 1 个斗鱼/虎牙任务**验证换 IP 后的风控 | 否 | 0.5 h |
| **T4 切换窗口** | 旧机 `compose stop douyu-login`(冻结写)→ 增量 dump → 导入 → 新机 `compose up` → 冒烟 → 改 DNS | **是** | **20~40 min** |
| **T5 验证与收尾** | WebSocket、OSS 上传、自动化作业、Cookie 检测/重登;旧机保留停止态容器与卷作为回滚 | 否 | 1 h |
| **T6 观察期** | 观察 3~7 天:任务成功率、风控拦截、资源曲线;之后再删旧机数据 | 否 | 3~7 天旁观 |
**合计**:
- **顺利路径:半天(4~6 小时)**,其中真正停服 **20~40 分钟**。
- **稳妥路径:1 个工作日**(多出一次演练 + 回滚验证)。
- 若目标机选**腾讯云**:T2 数据中转改走公网(+20~40 min),且 `OSS_ENDPOINT` 必须改公网端点(改动面 +1,且有流量费)。
---
## 6. 停机窗口与回滚
**切换窗口内的顺序(务必按序)**:
```bash
# 1) 冻结旧机写入
cd /root/live-hub-py && docker compose stop douyu-login
# 2) 旧机增量 dump(此时库是静止的)
docker exec live-hub-py-mysql-1 sh -c \
'exec mysqldump -uroot -p"$MYSQL_ROOT_PASSWORD" --single-transaction --routines --triggers --events douyu_login' \
| gzip > /tmp/livehub-final.sql.gz
# 3) 经 OSS 内网中转(同地域时免公网流量)
# 源机 put → 新机 get;或用 ossutil 直接 cp
# 4) 新机导入(新机 MySQL 必须已按 compose 的 --default-time-zone=+00:00 启动)
gunzip -c livehub-final.sql.gz | docker exec -i live-hub-py-mysql-1 \
mysql -uroot -p"$MYSQL_ROOT_PASSWORD" douyu_login
# 5) 起服务 + 冒烟
docker compose up -d && curl -fsS http://127.0.0.1:8000/api/health
# 6) 改 DNS:live.khhao.com A 记录 → 新机 IP
```
**回滚**:`live.khhao.com` 的 A 记录改回 `47.114.114.225`,旧机 `docker compose start douyu-login`。旧机数据与镜像在观察期内**一律不删**。
**时区坑(本次必须复核)**:全库时间列存 UTC 裸值,MySQL 靠 `--default-time-zone=+00:00` 对齐。导入前后**不要手工改容器时区**,也不要拿 `SELECT NOW()` 当北京时间比对(会被慢 8 小时误导 —— README 里已记录踩过这个坑)。`mysqldump` 默认 `--tz-utc` 在这一口径下是安全的。
---
## 7. 风险清单
| 风险 | 等级 | 缓解 |
| :--- | :--- | :--- |
| **换公网 IP 触发斗鱼/虎牙风控** | **高** | T3 阶段用小批量账号先验证;项目本身有代理池与预检机制可兜底;异常时立刻回滚 DNS |
| `APP_ENCRYPTION_KEY` 抄错 | **高** | 迁移前用旧 `.env` 对一条已加密记录做解密自检,再切流量 |
| MySQL 卷命名踩坑导致「假丢数据」 | 中 | B2 强制改成新本地卷;导入后立即 `SELECT COUNT(*) FROM douyu_tasks` 比对 222679 行 |
| OSS 内网端点在新地域不可达 | 中 | 机器买在 cn-hangzhou;若换地域,用 `ossutil` 做一次上传自检 |
| 跨项目订单查询功能失效 | 低 | 该功能当前是关闭的;保持关闭并在文档留痕 |
| 磁盘不足(构建缓存峰值) | 低 | T0 先回收 8 GB;构建完 `docker builder prune` |
| 旧机 80/443 上 `live.khhao.com` 的 Caddy 块残留 | 低 | 观察期结束后再从旧 Caddyfile 里删掉该 site 块 |
---
## 8. 迁完的预期收益
**立刻缓解**:
- 主服少掉 **0.5 ~ 2.2 核的突发负载** + **约 1.5 GiB 内存**。
- 少掉 MySQL 的 **396 GB 累计块读**,IO wait(实测峰值 20.9%)显著下降。
- 磁盘回收:MySQL 卷 4.9 GB 搬走后,源机 84% → 约 **70%**。
- **波动源头被移走**:主服 load 15 分钟均值预期从 **~10.5 降到 4~6**。
**仍然没解决的(必须说清)**:
- `order_site-postgres-1` **单独**峰值就能到 **222.8%**,加 backend 145.9% ≈ **3.2 核**。迁走 livehub 后,order_site 独占 4 核仍然会在高峰期打满。
- 所以迁 livehub 是**必要条件、不是充分条件**。后续二选一:
- ① 按 `docs/生产服务器高负载排查分析报告.md` 做 `getWorkerOrderOverview` 的 CTE 重构(`UNION` → `UNION ALL`)+ 打手端概览缓存,消除并行 worker 突刺;
- ② 或把 order_site 留在 4 核机上但**升配到 8 核**。
- 建议顺序:**先迁 livehub(半天)→ 观察一周 → 再决定 order_site 是优化还是升配/搬迁**。这样每次只动一个变量,出问题好定位。
---
## 9. 买机器之前就能做的立即降压(零成本)
> 以下都在源机上执行,**不停服**。合计可回收约 **8 GB 磁盘**,并把 MySQL 的慢查询日志 IO 降下来。
```bash
# 1) 先补一份全量备份再动 binlog(目前只有 10-01 的一份手工备份)
cd /root/live-hub-py && docker exec live-hub-py-mysql-1 sh -c \
'exec mysqldump -uroot -p"$MYSQL_ROOT_PASSWORD" --single-transaction --routines --triggers douyu_login' \
| gzip > /root/live-hub-py/backups/full-$(date +%Y%m%d).sql.gz
# 2) 清 binlog:已确认无从库(SHOW REPLICAS 为空),保留最近 3 天保 PITR 能力
docker exec live-hub-py-mysql-1 mysql -uroot -p"$MYSQL_ROOT_PASSWORD" \
-e "PURGE BINARY LOGS BEFORE DATE_SUB(NOW(), INTERVAL 3 DAY);"
# 并把保留期从 30 天收紧到 7 天(改 compose 的 mysql command):
# --binlog-expire-logs-seconds=604800
# 预期回收 ~3.6 GB
# 3) 清构建缓存(当前 6.29 GB,可回收 2.07 GB)
docker builder prune -f
# 4) 清已退出的旧容器与无用镜像(预期回收 ~2.2 GB)
docker rm douyu-login douyu-login-db
docker rmi douyu_login_py-douyu-login docker.m.daocloud.io/minio/minio pc-crmeb-pc
# 5) 慢查询日志目前 --long-query-time=1,阈值过低、写入太频繁
# 排查期可留,稳定后建议调到 3~5 秒或关闭
```
**同时建议**(不花钱):
- `/tmp/Caddyfile.fmt|orig|new` 是旧编辑残留,确认无用后删掉。
- 源机上 8000 端口 `0.0.0.0` 裸暴露且不经 Caddy(`live.khhao.com` 是走 Caddy 的),迁移时应顺带收紧为只监听内网。
- 源机 `/home/order_site_migrate`(17 MB)看名字是上次迁移残留,确认后再删。
@@ -0,0 +1,117 @@
"""验证 DouyuTask 的 account_assigned_to 字段与任务查询优化。"""
from tests.support.db import init_test_schema, make_test_session
from web.backend.models import Account, DouyuTask, User
from web.backend.routers.douyu import _visible_tasks_query
from web.backend.services.douyu_service import create_douyu_planned_tasks
def test_douyu_task_assigned_to_flow():
from tests.support.db import get_test_engine
init_test_schema()
DouyuTask.__table__.drop(get_test_engine(), checkfirst=True)
DouyuTask.__table__.create(get_test_engine(), checkfirst=True)
db: Session = make_test_session()
try:
# 创建测试客服用户与账号
user_a = User(
username="test_kefu_a",
password_hash="fake",
role="support",
custom_permissions=["douyu:task", "account:view_assigned"],
is_active=True,
)
user_b = User(
username="test_kefu_b",
password_hash="fake",
role="support",
custom_permissions=["douyu:task", "account:view_assigned"],
is_active=True,
)
db.add_all([user_a, user_b])
db.flush()
acc_1 = Account(
username="dy_user_1",
password="pwd",
email="em1@test.com",
email_password="pwd",
assigned_to=user_a.id,
)
acc_2 = Account(
username="dy_user_2",
password="pwd",
email="em2@test.com",
email_password="pwd",
assigned_to=user_b.id,
)
db.add_all([acc_1, acc_2])
db.flush()
from web.backend.models import LoginTask
lt_1 = LoginTask(
account_id=acc_1.id,
batch_id="b1",
status="success",
cookie="acf_uid=101; acf_auth=fake",
created_by=user_a.id,
)
lt_2 = LoginTask(
account_id=acc_2.id,
batch_id="b2",
status="success",
cookie="acf_uid=102; acf_auth=fake",
created_by=user_b.id,
)
db.add_all([lt_1, lt_2])
db.flush()
# 1. 验证创建任务时,自动继承 account.assigned_to
batch_id, count = create_douyu_planned_tasks(
db=db,
account_ids=[acc_1.id, acc_2.id],
task_type="query_points",
handbook_scope="elite",
created_by=user_a.id,
)
assert count == 2
tasks = db.query(DouyuTask).filter(DouyuTask.batch_id == batch_id).all()
task_map = {t.account_id: t for t in tasks}
assert task_map[acc_1.id].account_assigned_to == user_a.id
assert task_map[acc_2.id].account_assigned_to == user_b.id
# 2. 验证 _visible_tasks_query 能够精准过滤所属客服
query_a = _visible_tasks_query(db, user_a).filter(
DouyuTask.batch_id == batch_id
)
visible_a = query_a.all()
assert len(visible_a) == 1
assert visible_a[0].account_id == acc_1.id
query_b = _visible_tasks_query(db, user_b).filter(
DouyuTask.batch_id == batch_id
)
visible_b = query_b.all()
assert len(visible_b) == 1
assert visible_b[0].account_id == acc_2.id
# 3. 验证账号重新分配后,关联任务同步变更
db.query(DouyuTask).filter(DouyuTask.account_id == acc_1.id).update(
{DouyuTask.account_assigned_to: user_b.id}, synchronize_session=False
)
acc_1.assigned_to = user_b.id
db.commit()
# 此时 user_b 可以看到 acc_1 的任务
visible_b_after = (
_visible_tasks_query(db, user_b)
.filter(DouyuTask.batch_id == batch_id)
.all()
)
assert len(visible_b_after) == 2
finally:
db.rollback()
db.close()
+1 -1
View File
@@ -26,7 +26,7 @@ TESTS_DIR = Path(__file__).resolve().parent
PROJECT_ROOT = TESTS_DIR.parent
VERSIONS_DIR = PROJECT_ROOT / "web" / "backend" / "migrations" / "versions"
HEAD_REVISION = "20261002_0039"
HEAD_REVISION = "20261003_0040"
# 迁移新增、但不声明在模型里的复合查询索引。
EXTRA_INDEXES = (
@@ -0,0 +1,88 @@
"""为 douyu_tasks 增加 account_assigned_to 字段与复合索引,彻底优化任务全表排序性能。
背景:
斗鱼任务表 (douyu_tasks) 历史数据已达数十万行。当客服查看任务列表时,原本通过
JOIN accounts 表并在 accounts.assigned_to 上过滤,导致 MySQL 必须在临时表中对全部
匹配任务做 filesort 文件排序(Using temporary; Using filesort),单次查询消耗几百毫秒
并导致 MySQL 容器 CPU 周期性冲顶到 100%+。
本迁移:
1. 为 douyu_tasks 表新增 account_assigned_to 列;
2. 批量回填历史任务的归属客服 ID;
3. 建立 (account_assigned_to, handbook_scope, id) 与 (account_assigned_to, id) 复合索引,
使客服任务列表查询从 O(N log N) 临时表全排序降级为 O(1) 索引倒序定位,彻底消除 filesort。
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "20261003_0040"
down_revision: str | None = "20261002_0039"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def _columns(bind, table_name: str) -> set[str]:
return {col["name"] for col in sa.inspect(bind).get_columns(table_name)}
def _indexes(bind, table_name: str) -> set[str]:
return {idx["name"] for idx in sa.inspect(bind).get_indexes(table_name)}
def upgrade() -> None:
bind = op.get_bind()
existing_cols = _columns(bind, "douyu_tasks")
if "account_assigned_to" not in existing_cols:
op.add_column(
"douyu_tasks",
sa.Column(
"account_assigned_to",
sa.Integer(),
sa.ForeignKey("users.id"),
nullable=True,
),
)
# 历史任务回填归属客服 ID
op.execute(
"""
UPDATE douyu_tasks dt
JOIN accounts a ON a.id = dt.account_id
SET dt.account_assigned_to = a.assigned_to
WHERE a.assigned_to IS NOT NULL AND dt.account_assigned_to IS NULL;
"""
)
existing_indexes = _indexes(bind, "douyu_tasks")
if "ix_douyu_tasks_account_assigned_to_id" not in existing_indexes:
op.create_index(
"ix_douyu_tasks_account_assigned_to_id",
"douyu_tasks",
["account_assigned_to", "id"],
)
if "ix_douyu_tasks_assigned_scope_id" not in existing_indexes:
op.create_index(
"ix_douyu_tasks_assigned_scope_id",
"douyu_tasks",
["account_assigned_to", "handbook_scope", "id"],
)
def downgrade() -> None:
bind = op.get_bind()
existing_indexes = _indexes(bind, "douyu_tasks")
if "ix_douyu_tasks_assigned_scope_id" in existing_indexes:
op.drop_index("ix_douyu_tasks_assigned_scope_id", table_name="douyu_tasks")
if "ix_douyu_tasks_account_assigned_to_id" in existing_indexes:
op.drop_index("ix_douyu_tasks_account_assigned_to_id", table_name="douyu_tasks")
existing_cols = _columns(bind, "douyu_tasks")
if "account_assigned_to" in existing_cols:
op.drop_column("douyu_tasks", "account_assigned_to")
+19
View File
@@ -8,6 +8,7 @@ from sqlalchemy import (
JSON,
Boolean,
ForeignKey,
Index,
Integer,
String,
Text,
@@ -214,6 +215,24 @@ class DouyuTask(Base):
created_by: Mapped[int] = mapped_column(ForeignKey("users.id"), nullable=False)
created_at: Mapped[datetime] = mapped_column(UTCDateTime, default=_utcnow)
finished_at: Mapped[datetime | None] = mapped_column(UTCDateTime, nullable=True)
# 账号归属客服冗余字段,配合复合索引消除任务列表全表 filesort。
account_assigned_to: Mapped[int | None] = mapped_column(
ForeignKey("users.id"), nullable=True
)
__table_args__ = (
Index(
"ix_douyu_tasks_account_assigned_to_id",
"account_assigned_to",
"id",
),
Index(
"ix_douyu_tasks_assigned_scope_id",
"account_assigned_to",
"handbook_scope",
"id",
),
)
account: Mapped[Account] = relationship("Account", back_populates="douyu_tasks")
+8 -1
View File
@@ -289,7 +289,11 @@ def assign_account(
if not has_success:
raise HTTPException(status_code=400, detail="该账号尚未成功登录,无法分配")
acc.assigned_to = req.assigned_to
if acc.assigned_to != req.assigned_to:
acc.assigned_to = req.assigned_to
db.query(DouyuTask).filter(DouyuTask.account_id == acc.id).update(
{DouyuTask.account_assigned_to: req.assigned_to}, synchronize_session=False
)
db.add(
AuditLog(
user_id=current.id,
@@ -348,6 +352,9 @@ def batch_assign_accounts(
.filter(Account.id.in_(req.account_ids))
.update({Account.assigned_to: req.assigned_to}, synchronize_session=False)
)
db.query(DouyuTask).filter(DouyuTask.account_id.in_(req.account_ids)).update(
{DouyuTask.account_assigned_to: req.assigned_to}, synchronize_session=False
)
db.add(
AuditLog(
user_id=current.id,
+2 -1
View File
@@ -212,7 +212,8 @@ def _visible_tasks_query(db: Session, current: User):
if _can_view_all(current):
return query
if user_has_permission(current, "account:view_assigned"):
return query.join(DouyuTask.account).filter(Account.assigned_to == current.id)
# 彻底消除全表 filesort:直接通过 douyu_tasks 上的 account_assigned_to 复合索引倒序定位
return query.filter(DouyuTask.account_assigned_to == current.id)
raise HTTPException(status_code=403, detail="无权查看斗鱼任务")
+1
View File
@@ -295,6 +295,7 @@ def create_douyu_planned_tasks(
DouyuTask(
batch_id=batch_id,
account_id=account.id,
account_assigned_to=account.assigned_to,
task_type=task_type,
handbook_scope=handbook_scope,
status="planned",