From 7790be4e04c13a28ee0aba3aba61a6241fe99e10 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sat, 3 Oct 2026 13:10:59 +0800 Subject: [PATCH] =?UTF-8?q?perf(douyu-tasks):=20=E4=B8=BA=E6=96=97?= =?UTF-8?q?=E9=B1=BC=E4=BB=BB=E5=8A=A1=E5=A2=9E=E5=8A=A0=E5=BD=92=E5=B1=9E?= =?UTF-8?q?=E5=AE=A2=E6=9C=8D=E7=B4=A2=E5=BC=95=E5=AD=97=E6=AE=B5=EF=BC=8C?= =?UTF-8?q?=E5=BD=BB=E5=BA=95=E6=B6=88=E9=99=A4=E5=85=A8=E8=A1=A8=20fileso?= =?UTF-8?q?rt=20=E6=85=A2=E6=9F=A5=E8=AF=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 单元测试并通过整链迁移与回归测试。 --- docs/迁移方案-live-hub-py独立部署.md | 291 ++++++++++++++++++ .../test_douyu_task_assigned_optimization.py | 117 +++++++ tests/test_migrations.py | 2 +- ...03_0040_douyu_tasks_account_assigned_to.py | 88 ++++++ web/backend/models.py | 19 ++ web/backend/routers/accounts.py | 9 +- web/backend/routers/douyu.py | 3 +- web/backend/services/douyu_service.py | 1 + 8 files changed, 527 insertions(+), 3 deletions(-) create mode 100644 docs/迁移方案-live-hub-py独立部署.md create mode 100644 tests/test_douyu_task_assigned_optimization.py create mode 100644 web/backend/migrations/versions/20261003_0040_douyu_tasks_account_assigned_to.py diff --git a/docs/迁移方案-live-hub-py独立部署.md b/docs/迁移方案-live-hub-py独立部署.md new file mode 100644 index 0000000..cde1e90 --- /dev/null +++ b/docs/迁移方案-live-hub-py独立部署.md @@ -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)看名字是上次迁移残留,确认后再删。 diff --git a/tests/test_douyu_task_assigned_optimization.py b/tests/test_douyu_task_assigned_optimization.py new file mode 100644 index 0000000..1f2080f --- /dev/null +++ b/tests/test_douyu_task_assigned_optimization.py @@ -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() diff --git a/tests/test_migrations.py b/tests/test_migrations.py index d0d5f23..9df6d88 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -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 = ( diff --git a/web/backend/migrations/versions/20261003_0040_douyu_tasks_account_assigned_to.py b/web/backend/migrations/versions/20261003_0040_douyu_tasks_account_assigned_to.py new file mode 100644 index 0000000..1760ed5 --- /dev/null +++ b/web/backend/migrations/versions/20261003_0040_douyu_tasks_account_assigned_to.py @@ -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") diff --git a/web/backend/models.py b/web/backend/models.py index 001bfa1..77b3177 100644 --- a/web/backend/models.py +++ b/web/backend/models.py @@ -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") diff --git a/web/backend/routers/accounts.py b/web/backend/routers/accounts.py index 76ca6ab..f379dff 100644 --- a/web/backend/routers/accounts.py +++ b/web/backend/routers/accounts.py @@ -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, diff --git a/web/backend/routers/douyu.py b/web/backend/routers/douyu.py index 5367388..831ead4 100644 --- a/web/backend/routers/douyu.py +++ b/web/backend/routers/douyu.py @@ -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="无权查看斗鱼任务") diff --git a/web/backend/services/douyu_service.py b/web/backend/services/douyu_service.py index 587e62d..001daaf 100644 --- a/web/backend/services/douyu_service.py +++ b/web/backend/services/douyu_service.py @@ -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",