Merge branch 'main' into dev
# Conflicts: # tests/test_migrations.py
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
# 生产服务器高负载排查与优化落地实证报告
|
||||
|
||||
- **记录时间**: 2026-10-03
|
||||
- **服务器环境**: 远程生产机 `47.114.114.225`(主机别名:`order+douyu_huya`),8 核 CPU / 16GB 内存
|
||||
- **涉及容器服务**:
|
||||
- `live-hub-py-douyu-login-1` / `live-hub-py-mysql-1` (当前 live-hub-py 服务)
|
||||
- `order_site-postgres-1` / `order_site-backend-1` (工单平台 Node.js / PG)
|
||||
|
||||
---
|
||||
|
||||
## 1. 核心状态登记簿 (Status Registry)
|
||||
|
||||
| 类别 | 项目 | 现状描述 / 数据实证 | 确信度 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **已知 (Known)** | **Postgres 偶发 CPU 197%** | JIT 编译器在复杂聚合统计查询(`COUNT/FILTER`)时产生巨大的即时编译开销,单次耗时达 8.8s。执行 `ALTER SYSTEM SET jit = off;` 后,查询耗时直接降到 369ms(提速 24 倍)。 | 100% (已验证) |
|
||||
| **已知 (Known)** | **MySQL 任务全表 filesort 尖刺** | 客服列表轮询接口 `GET /api/douyu/tasks?handbook_scope=elite`,跨表 JOIN `accounts` 过滤后按 `id DESC LIMIT 100` 排序。导致 24 万条历史任务中符合条件的 1.1 万条全部写入临时表并产生 `Using filesort`,MySQL 周期性冲到 111.45% CPU,日志频繁报 `Out of sort memory`。 | 100% (已验证) |
|
||||
| **已知 (Known)** | **工单后台 Node.js 周期波峰** | `order_site-backend-1` 内有拼多多同步、打手抢单等后台调度(约 15~20s 周期),瞬时聚合打到 PG,形成系统的“忽高忽低”。 | 95% (日志证实) |
|
||||
| **目标 (Target)** | **彻底消除 MySQL 全表排序** | 将 `account_assigned_to` 冗余到 `douyu_tasks` 表,建立 `(account_assigned_to, handbook_scope, id)` 复合索引,消除 JOIN 与 filesort,降为单表倒序索引扫描。 | 100% (已达成) |
|
||||
| **目标 (Target)** | **零事故安全热更上线** | 完成数据库平滑迁移(24 万行数据回填与索引建立),发布 `live-hub-py` 容器,恢复业务调度与监控。 | 100% (已达成) |
|
||||
| **未知/待观察 (Unknown)** | **工单平台 Node/PG 定时聚合** | `order_site-postgres-1` 的业务慢查询(如未带索引的统计)仍存在周期性 CPU 波动(需工单系统代码级加索引优化,与 live-hub-py 相互隔离)。 | 90% |
|
||||
|
||||
---
|
||||
|
||||
## 2. 优化方案设计与实施
|
||||
|
||||
### 2.1 数据库结构升级与迁移
|
||||
|
||||
迁移文件:`web/backend/migrations/versions/20261003_0040_douyu_tasks_account_assigned_to.py`
|
||||
1. 为 `douyu_tasks` 添加 `account_assigned_to` 列(外键关联 `users.id`);
|
||||
2. 历史数据回填:
|
||||
```sql
|
||||
UPDATE douyu_tasks dt
|
||||
JOIN accounts a ON a.id = dt.account_id
|
||||
SET dt.account_assigned_to = a.assigned_to;
|
||||
```
|
||||
3. 建立覆盖排序的高性能复合索引:
|
||||
- `ix_douyu_tasks_assigned_scope_id (account_assigned_to, handbook_scope, id)`
|
||||
- `ix_douyu_tasks_account_assigned_to_id (account_assigned_to, id)`
|
||||
|
||||
### 2.2 业务查询与状态同步改造
|
||||
|
||||
1. **创建任务时自动注入**:
|
||||
在 `douyu_service.py` 的 `create_douyu_tasks()` 中,创建 `DouyuTask` 时自动带入当前账号的 `assigned_to`。
|
||||
2. **账号分配变更时级联更新**:
|
||||
在 `accounts.py` 的 `assign_account()` 中,当管理员将账号移交给新客服时,同步更新该账号下任务的 `account_assigned_to`。
|
||||
3. **彻底消除跨表排序**:
|
||||
在 `douyu.py` 的 `_visible_tasks_query()` 中,非管理员查询直接使用单表条件:
|
||||
```python
|
||||
# 优化前:需要 JOIN Account 表
|
||||
# query = query.join(Account).filter(Account.assigned_to == current.id)
|
||||
# 优化后:纯单表条件,走复合索引
|
||||
query = query.filter(DouyuTask.account_assigned_to == current.id)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 部署上线与实测效果验证
|
||||
|
||||
### 3.1 迁移与部署过程
|
||||
- **镜像构建**: 多阶段构建完成,前端 dist 与后端代码更新打包。
|
||||
- **历史数据回填**: 生产环境共回填 239,118 条历史任务的所属客服数据。
|
||||
- **alembic_version**: 成功推进到 `20261003_0040`。
|
||||
|
||||
### 3.2 优化前后量化对比
|
||||
|
||||
| 指标维度 | 优化前状态 | 优化后状态 (生产实测) | 改善幅度 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| **查询执行计划 (EXPLAIN)** | `Using temporary; Using filesort` | `Backward index scan` | **彻底消除 filesort** |
|
||||
| **索引命中** | 仅命中 `account_id`,跨表无法索引排序 | 命中 `ix_douyu_tasks_assigned_scope_id` (len 71) | **直接命中最优复合索引** |
|
||||
| **单次查询耗时** | 400 ~ 500 ms | **5.7 ms (0.0057s)** | **提速 ~87 倍** |
|
||||
| **MySQL 排序内存报错** | 频繁报 `Out of sort memory` | **0 次报错**,完全脱离 sort buffer | **彻底根治** |
|
||||
| **MySQL 容器瞬时 CPU** | 周期性拉升至 **111.45%** | 平稳维持在 **1.86% ~ 11%** | **降低 90%+** |
|
||||
| **系统平均负载 (load avg)** | 峰值 12.8 ~ 14.5 | 逐步平稳回落至 7.6 ~ 8.8 | **系统稳定性显著增强** |
|
||||
|
||||
### 3.3 生产线上业务运转状态
|
||||
- `/api/health` 检查:`{"status":"ok"}`
|
||||
- WebSocket 连接:客服工作台正常连接,斗鱼批量赠送精英令、改名、改密任务均在正常并发执行,状态流转顺畅。
|
||||
@@ -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()
|
||||
@@ -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_0040"
|
||||
HEAD_REVISION = "20261003_0041"
|
||||
|
||||
# 迁移新增、但不声明在模型里的复合查询索引。
|
||||
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")
|
||||
@@ -9,6 +9,7 @@ from sqlalchemy import (
|
||||
BigInteger,
|
||||
Boolean,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
@@ -215,6 +216,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")
|
||||
|
||||
|
||||
@@ -299,7 +299,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,
|
||||
@@ -358,6 +362,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,
|
||||
|
||||
@@ -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="无权查看斗鱼任务")
|
||||
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user