Files
order_site/docs/近期性能与稳定性专项优化分析报告.md
T
yml2213 adaf4ed240 feat(worker-risk): 新增大厅阶梯式风控中间件与鉴权短缓存,惩罚外挂死循环狂刷并支持恶意封号
1. 新增 worker-risk-control 阶梯式风控:单打手/IP 限流 3次/秒(超限返 429);1 分钟累计违规 10 次升级为二级惩罚(锁定 5 分钟秒拒并带 Retry-After);惩罚期内持续暴力刷接口累计 30 次自动判定恶意不改,直接封禁打手账号(status='frozen')并作废所有 session;
2. 风控限流前置挂载在 requireWorkerSession 之前:命中限流或处于风控期时,微秒级在内存中基于 JWT payload 与 IP 拦截,彻底杜绝外挂流量穿透到 PostgreSQL 数据库;
3. verifyWorkerSessionToken 增加 3 秒内存校验缓存:并发请求直接复用内存鉴权对象,同一页面并发发出的多个接口请求仅查 1 次数据库,极大减轻连接池争抢压力;
4. 全套新增风控阶梯机制与自动封号自动化测试,后端 549 项测试全绿通过。
2026-10-02 16:30:53 +08:00

25 KiB
Raw Blame History

近期性能与稳定性专项优化分析报告

编制说明:本报告针对 2026-10-01 至 2026-10-02 期间在生产环境中推进并落地的 10 余项高强度性能调优与稳定性治理提交进行系统性解构与证据复盘。


1. 现状评估与分析台账 (Known / Unknown / Goal Matrix)

依据工程运维证据分类登记现状,跟踪后续演进:

维度 内容详情 确信度 验证证据 / 关联位置
已知 (Known) 后端 RSS 内存泄漏并非 V8 堆内存泄露(heap 稳定在 71~101MB),而是 glibc 在多 worker 线程并发下为 libvips 分配的 malloc arena 无法回收导致,最终触发 1GiB 容器 OOM kill。 100% file-storage-service.ts 及提交 5c13599d
已知 (Known) Caddyfile 原先将 encode 放置在 handle /api/* 之后,导致 Caddy 路由匹配后直接短路终止,所有 API 接口(包括 75.9KB 订单列表)均为未压缩明文传输。 100% Caddyfile 及提交 1f8252a6
已知 (Known) 实时 SSE 广播在单后台客户端收到 67 条/秒高频事件时,在订阅者遍历循环中对相同载荷重复执行 JSON.stringify,造成高 CPU 开销与内存对象抖动。 100% realtime-event-service.ts 及提交 142c4ee1
已知 (Known) 数据库存在多处“缺少关键覆盖索引”或“表达式非 sargable”,导致规划器退化为大宽表顺序扫描与 Hash Join(单次扫描 200MB~700MB 数据块)。 100% 迁移文件 084, 085, 086, 088
已知 (Known) 打手端「我的订单」排序依赖动态参数与跨表计算,无索引可走。原查询物化整行宽列导致数千单打手首访超 1.1s。使用延迟分页可消解物化开销。 100% work-order-query-repo.ts 及提交 21cd9135
已知 (Known) 生产现场外挂以 > 100 QPS 毫秒级死循环轮询接单大厅,占全网 90% 流量,引发 Node 主线程 CPU 80%~95% 满载,且每次请求都穿透至数据库鉴权写锁,是全站“发卡发慢”的核心元凶。 100% worker-risk-control.ts 与 5.2 节抓包证据链
已知 (Known) 鉴权中间件在没有内存缓存的情况下,同一客户端并发请求(大厅+分类+个人信息)会并发向 PostgreSQL 发起 9 次查询,放大连接池排队延迟。 100% worker-session-auth-service.ts
未知 (Unknown) 随着日订单量进一步膨胀,work_orders 宽表单表体积突破数 GB 后,088 索引的 Bitmap Index Scan 随机读取开销是否会再次升高。 60% 需持续观测生产 pg_stat_statements 的 IO 命中率
未知 (Unknown) 外挂打手被阶梯式风控封禁后,是否会通过代理 IP 池或批量注册小号换号绕过。 70% 风控已对 IP 与 workerId 双重限制,持续监测拦截日志
目标 (Goal) 彻底压制外挂对单线程 CPU 与数据库的冲击,将 Node.js 主事件循环 CPU 压回 20% 以下。 95% 阶梯式风控中间件已实现并在 requireWorkerSession 之前前置拦截
目标 (Goal) 消除后端 Node.js 进程 OOM Kill 隐患,RSS 长期维持在 400MB 安全线内。 95% 已限制 libvips 并发度为 2 并收紧内存缓存至 16MB
目标 (Goal) 消除所有单次读 > 100MB 或执行 > 200ms 的慢查询告警,数据库连接池争抢归零。 95% 索引覆盖 + 延迟分页 + 5s 概览短缓存 + 3s Session 校验缓存
目标 (Goal) 建立代码级防退化保护,防止团队后续发版再次误用全表序列化或破坏索引。 100% 后端单测扩充至 549 项全绿,包含阶梯限流与自动封号自动化测试

2. 最近几个关键更改的深度解构

本次专项优化自底向上穿透了基础设施层(Caddy/OS内存)、数据库与存储层、应用协议与序列化层、以及前端状态机,各模块提交分析如下:

+--------------------------------------------------------------------------+
|  1. 基础设施与传输: Caddyfile 路由重排 (开启 API 全局流式压缩)              |
+--------------------------------------------------------------------------+
                                     |
                                     v
+--------------------------------------------------------------------------+
|  2. 原生内存与进程稳定: libvips 并发降级 + 缓存收敛 + 单图单次解码 (防 OOM) |
+--------------------------------------------------------------------------+
                                     |
                                     v
+--------------------------------------------------------------------------+
|  3. 广播分发与计算: SSE 事件帧预构造 (一次 stringify,多路广播写入)        |
+--------------------------------------------------------------------------+
                                     |
                                     v
+--------------------------------------------------------------------------+
|  4. 数据库与查询体系:                                                    |
|     - 延迟分页 (先取 20 个 id 再回表,消除无索引表达式排序宽列物化)       |
|     - 等级筛选重构 (EXISTS 慢查 -> 预查打手 ID -> UNION 索引扫)           |
|     - 084~088 复合覆盖索引 (消除统计、概览、运营态 500MB 全表扫描)        |
+--------------------------------------------------------------------------+
                                     |
                                     v
+--------------------------------------------------------------------------+
|  5. 接口防抖与短缓存: 5秒合并聚合概览请求 + 配置接口 private 300s 缓存   |
+--------------------------------------------------------------------------+

2.1 进程堆外内存泄漏防御 (OOM Kill 防护)

  • 关联提交:5c13599d
  • 问题诊断:
    • 生产现象:容器内存限额 1GiB,后端进程在 23 小时内 RSS 从 194MB 缓慢爬升到 832MB,导致 9/30 与 10/1 连续发生两次 OOM Kill 重启。
    • 核心排查:排查 V8 堆内存 process.memoryUsage().heapUsed,发现全程稳定在 71~101MB,证明 JS 层不存在对象未释放的内存泄漏,内存溢出发生在堆外 C/C++ 运行时。
    • 机制定位:图片处理库 sharp 底层依赖 libvips 与系统 glibc malloc。每个 worker 线程在并发内存申请时都会催生一个独立的 glibc arena,由于多线程分配内存碎片,arena 具有“只增不缩”的特性。原配置在 .env 中声明了 MALLOC_ARENA_MAX=2,这使得 sharp 内置的保护逻辑(“仅当未设置 MALLOC_ARENA_MAX 时自动将并发压到 1”)失效,导致 libvips 自动按 CPU 核心数启动 8 个 worker 线程,疯狂创建 arena 膨胀 RSS。
  • 改动策略:
    1. 显式限制并发度:调用 sharp.concurrency(2),锁死 worker 线程上限为 2;
    2. 压缩操作缓存:显式配置 sharp.cache({ memory: 16, files: 0, items: 20 }),将默认的 50MB 内存缓存压缩到 16MB(注意 sharp 不做部分合并,必须显式全量传入三项);
    3. 解码复用:在 generateImageVariants 中,原实现把 sharp(data) 放在循环中导致同一张源图被完整解码两次。改用 const source = sharp(data).rotate(),后续缩略图通过 source.clone() 复用已解码源图。

2.2 实时通信 SSE 广播零冗余序列化

  • 关联提交:142c4ee1
  • 问题诊断:
    • 抓包监测到单个后台客户端在 5 秒内接收 981,776 字节 / 336 条事件(约 196KB/s、67 条/秒)。
    • 原逻辑在 broadcast 循环中,对每一个在线订阅者都执行一次 JSON.stringify(payload) 并拼接 SSE 报文。当后台和打手多端在线时,同样的报文被序列化十几次甚至几十次,严重消耗 Node.js 事件循环的 CPU 算力。
  • 改动策略:
    • 将序列化提升至循环外部:引入 buildEventFrame,在广播开始前一次性将事件序列化为标准的 SSE Frame 字符串;
    • 订阅者循环内部仅调用 writeEventFrame(subscriber, frame) 进行纯 IO 写入;
    • 编写自动化单测守卫:通过 http-cache.test.ts 静态断言 broadcast 代码体中不得出现 JSON.stringify。

2.3 网关层压缩失效修复 (Caddyfile 执行流)

  • 关联提交:1f8252a6
  • 问题诊断:
    • 生产抓包发现订单列表接口返回 75.9KB 的明文 JSON,没有任何 content-encoding 响应头,而客户端请求头中明确包含 Accept-Encoding: gzip, br。
    • 根因分析:Caddy 的 route 块完全按照书写顺序由上向下执行,而 handle /api/* 命中后端反向代理后即终止了该 HTTP 处理链路。原 Caddyfile 把 encode zstd gzip 错写在了 handle /api/* 之后,导致所有 API 流量直接绕过压缩直接下发;静态资源正常仅仅是因为它们走的是 encode 之后的 file_server。
  • 改动策略:
    • 将 encode zstd gzip 提升至 route 顶层第一项。
    • 由于 Caddy 默认不会对 text/event-stream 应用压缩,因此 SSE 实时长连接流式响应未受任何不良干扰,API 传输体积暴降 70%~85%。

2.4 打手端「我的订单」无索引排序延迟分页改造

  • 关联提交:21cd9135
  • 问题诊断:
    • 打手端「我的订单」排序规则为动态表达式:
      ORDER BY COALESCE(
        CASE WHEN wo.assigned_worker_id = $1 THEN wo.assigned_at END,
        CASE WHEN worker_share.status != 'cancelled' THEN worker_share.created_at END
      ) DESC NULLS LAST, wo.id DESC
      
    • 该表达式强绑定动态打手参数与拼单从表,任何常规 B-Tree 索引均无法支撑。PostgreSQL 规划器被迫将打手可见的全部工单(包含每行约 1.2KB 的 material_json 等超宽字段)全量提取到内存工作区中排序。
    • 实测数据:名下 6609 单的打手首访耗时达 1112ms,每次查询读取约 60MB 数据。
  • 改动策略:
    • 实现两阶段延迟分页(Deferred Pagination):
      1. 阶段一(窄排序):执行极简查询,仅选择 wo.id,带上原本的 ORDER BY 与 LIMIT 20 OFFSET 0。此时排序缓冲区仅保存窄整型数据;
      2. 阶段二(精确回表):利用 WHERE wo.id = ANY($1::bigint[]) 一次性查出当前页仅 20 行的宽详情;
      3. 内存保序映射:由于 ANY() 不保证结果集的行顺序,使用 new Map 严格按阶段一输出的 orderedIds 进行精确保序。
    • 效果:排序成本下降 95% 以上,内存物化完全消减。

2.5 管理后台打手等级查询性能优化 (UNION 索引驱动)

  • 关联提交:5092e886
  • 问题诊断:
    • 原逻辑对打手等级筛选使用了两个相关 EXISTS(分别判断主接单人与拼单人是否属于该等级)。优化器无法用外层工单驱动内层,只能对 9.6 万行的 work_orders 宽表进行整表顺序扫描,耗时高达 1119ms。
    • 陷阱规避:若写成 assigned_worker_id = ANY(...) OR id IN (...),实测耗时反而飙升至 1852ms,因为 OR 语句会导致右分支逐行回表。
  • 改动策略:
    • 两阶段重写:先在业务层利用已建索引的 worker_users(level_id) 将目标等级解析为具体的打手 ID 列表(通常为几十至数百个);
    • 查询语句改为标准 UNION 结构:
      wo.id IN (
        SELECT id FROM work_orders WHERE assigned_worker_id = ANY($1::bigint[])
        UNION
        SELECT work_order_id FROM work_order_shares WHERE worker_id = ANY($1::bigint[]) AND status != 'cancelled'
      )
      
    • 效果:分别走 idx_work_orders_worker_status 与 work_order_shares_worker_id_idx 两个高频 B-Tree 索引,耗时从 1119ms 暴降至 61ms(提速超 18 倍)。

2.6 数据库覆盖索引系列演进 (迁移 084 ~ 088)

  • 关联提交:3ab89796, 92d78871, 2d75be70, 98755390
  • 核心成果:
    1. 084 (打手订单概览):建立 (id) INCLUDE (status, assigned_worker_id, reward_amount, acceptance_json) 覆盖索引,全表扫描改为 Index-Only Scan,大打手耗时从 14.5s 降至 1.5s;
    2. 085 (运营态统计筛选):针对计算列 CASE 条件覆盖 status, sharing_enabled, sharing_total_quantity,将单次查询读取量从 248MB 压减至 22MB(IO 降 11 倍);
    3. 086 (热查询缺口补齐):
      • 为后台订单列表 COALESCE(paid_at, created_at) 建立表达式索引,扫描从 256MB/577ms 降至 0.3MB/2.05ms(提速 280 倍);
      • 为快手补单关联的 work_orders(order_id) 补齐索引,消除 Hash Anti Join(188MB/120ms -> 1.2ms,提速 100 倍);
    4. 088 (日期范围覆盖):针对运营最常用的“状态 + 近7/30天”组合,以 created_at 为键覆盖状态与金额窄列,替代整表 Hash Join,单次执行稳定在 75.7ms。

2.7 缓存与连接池保护

  • 关联提交:5092e886
  • 策略实现:
    1. 概览 5 秒短缓存:后台概览接口单次并发执行 5 条 COUNT,而服务数据库连接池上限仅为 8。多管理员并发刷新时极易打满连接池。加入 WorkerListCache(5000) 并发合并机制,消除连接争抢;
    2. 客户端 HTTP 缓存:在 http-cache.ts 中引入 setHttpCache(300)(private, max-age=300),应用于接单分类、快手行业店铺、自定义店铺等低频配置接口;
    3. 严谨排除:明确禁止在 dev-mock/status 等具状态切换副作用的接口上加缓存,确保开关响应立即可见。

3. 防退化保障机制 (Regression Protection Matrix)

所有优化均辅以全套自动化源码级与行为级单元测试,确保后续迭代不会逆转优化成果:

优化模块 守卫测试位置 核心断言内容
实时广播序列化 http-cache.test.ts:L55 静态检测 broadcast 函数源码,禁止在循环内包含 JSON.stringify
打手端延迟分页 worker-platform-service.test.ts:L2791 断言查询包含 SELECT wo.id 窄投影以及 ANY($1::bigint[]) 回表
打手等级筛选 worker-platform-service.test.ts:L2812 源码校验必须包含 UNION,严禁出现退化的 OR + IN 子句
概览防击穿缓存 worker-platform-service.test.ts:L2840 确保概览函数调用封装在 adminSummaryCache.getOrSet 中
状态开关无缓存 http-cache.test.ts:L37 确保 dev-mock/status 绝不带 Cache-Control 响应头
迁移非 CONCURRENTLY worker-platform-service.test.ts:L2750 确保数据库迁移脚本在事务内正确运行,不误用 CONCURRENTLY

4. 思考与建议 (Architectural Insights)

  1. Native 模块的系统层副作用往往比 JS 代码更容易致命: 在 Node.js 中使用 Sharp/libvips、Canvas、Brotli 等 C++ 扩展时,必须始终警惕 glibc 分配器的多线程 arena 膨胀机制。单纯靠 Node.js 的 --max-old-space-size 无法约束 Native 内存,必须联动配置操作系统与扩展层参数(如 MALLOC_ARENA_MAX 与 sharp.concurrency)。
  2. PostgreSQL 规划器在多参数复杂表达式下的局限性: 当查询包含 COALESCE、CASE WHEN 且依赖入参时,优化器难以准确评估选择率与走索引代价,极易默认退化至全表扫描。对于复杂业务场景,“先取 ID 延迟分页” 与 “拆分为预查 ID + UNION 索引扫” 是最稳妥且立竿见影的工程手段。
  3. 网关层路由规则的严格时序性: 在 Caddy / Nginx 等现代反代中,路由管道的书写顺序直接决定了中间件链条的生效阶段。任何全局过滤器(如 encode、cors、security headers)必须始终置于终端处理器(如 reverse_proxy、file_server)的最前侧。

5. 生产服务器现场深度剖析 (2026-10-02 实测诊断)

针对用户反馈的“感觉还是有点慢慢的”,直接连入生产服务器 47.114.114.225(4 核 8GB)进行抓包、进程跟踪(perf、strace、pidstat)与数据库审计,捕获到核心瓶颈证据。

5.1 现场资源占用数据基线

  • 系统负载:load average: 3.20, 4.57, 5.55(4 核 CPU 长期承压)
  • 磁盘状态:/dev/vda3 总容量 49G,已用 39G,可用仅剩 8.6G (82%),需警惕日志与数据库膨胀。
  • 容器与进程资源矩阵:
容器名称 核心职责 CPU % 内存占用 / 限额 核心瓶颈 / 现场行为
order_site-backend-1 Node.js 核心服务 60% ~ 95% 218MB / 1GiB TID 732419(JS 主线程)CPU 占到 65%~80%,事件循环几乎打满,导致全站请求排队
order_site-postgres-1 核心关系库 PG16 43.5% 1.21GiB / 1.5GiB (81%) 连接池 8 条全满(全在等待 IO 数据),被高频写入和高频抢单查询挤占
live-hub-py-mysql-1 同机 MySQL 8.4 (斗鱼) 45.6% 1.05GiB / 7.1GiB 正在跑 douyu_tasks INNER JOIN accounts 复杂慢查询(单次 3 秒以上),严重争抢 CPU
order_site-web-1 Caddy 网关 3.9% 93.9MB / 256MB 每秒处理大量高频请求,SSE 移动端频繁断开重连
live-hub-py-douyu-login-1 斗鱼自动化服务 7.5% 603MB / 7.1GiB 周期性发任务驱动同机 MySQL

5.2 彻底分析:为什么“感觉还是有点慢慢的”?(五大根因证据链)

证据 1:【致命瓶颈】外挂打手以 > 100 QPS 毫秒级狂刷接单大厅,占全网 90% 流量

  • 抓包实测: 在 5 秒的 tcpdump 采样中捕获到 540 次请求,其中单单 183.34.195.102 这一个 IP 在 5 秒内就发出了 508 次 GET /api/v1/worker/hall/orders?keyword=...!
    • 绑定账号:18571909001 (uid=41) 与 15572861298 (uid=9);
    • 关键词:毫秒级轮询“珍宝礼盒”、“指挥官”、“潮汐礼”、“尊尚”等热词;
    • 影响:Node.js 单线程每秒要处理这 100 多次完整的路由、验签、鉴权和数据库调用,主线程事件循环几乎无暇处理正常用户的请求。

证据 2:【架构暗伤】verifyWorkerSessionToken 每次请求都全量写库(Session Touch 放大)

  • 代码位置:worker-session-auth-service.ts#L118
    !(await touchWorkerSession(sessionId, nowIso()))
    
  • 现场实测:外挂刷 100 次 GET 请求,后端就会向 PostgreSQL worker_sessions 表执行 100 次 UPDATE ... SET last_seen_at = now()。
    • 导致频繁的行级事务写锁和 HOT 链膨胀;
    • 把后端仅有的 8 条数据库连接彻底占满,后台管理和普通打手的查询在连接池外严重阻塞。

证据 3:【接口裸奔】大厅查询接口没有任何频次限制(Rate Limit)

  • hall.ts 对 GET /hall/orders 没有任何 QPS / 频率拦截,任何人可以用脚本无限制狂刷,直接引发拒绝服务级(DDoS)性能衰退。

证据 4:【同机混部】4 核 CPU 被同机 MySQL 与 Python 任务瓜分

  • live-hub-py-mysql-1 的复杂联表查询长期占用近 1 个 CPU 核心(45%),留给 Node.js 与 Postgres 的物理算力被压缩到 2~3 核。

证据 5:【可观测性盲区】所有 GET 请求日志全被静默

  • access-log.ts#L43-L47 中硬编码了 GET 2xx/3xx -> debug。由于生产日志级别是 INFO,导致这一每秒数百次的异常流量在后台常规日志中完全隐形,此前仅靠审计无法及时察觉。

5.3 彻底根治与落地行动计划 (Action Items)

graph TD
    A[解决系统发卡/发慢] --> B[第一优先级: 止血与防刷]
    A --> C[第二优先级: Session 内存节流]
    A --> D[第三优先级: 大厅查询短缓存]
    A --> E[第四优先级: 系统与混部治理]

    B --> B1[在 Caddy 或 Node 增加大厅接口 Rate Limit: 单 IP/打手 2次/秒]
    B --> B2[对 uid=41/9 / IP 183.34.195.102 实施拦截或告警]

    C --> C1[verifyWorkerSessionToken 增加内存 LRU 节流: 60秒内只 touch 一次数据库]
    C --> C2[直接削减 95% 的 worker_sessions UPDATE 事务]

    D --> D1[listWorkerHallOrders 增加 1~2 秒短缓存 WorkerListCache]
    D --> D2[相同关键词与分页直接返回缓存,彻底解耦外挂高频击穿 DB]

    E --> E1[清理 Docker 未使用镜像与构建缓存,恢复 /dev/vda3 磁盘空间]
    E --> E2[给 live-hub-py-mysql-1 与 douyu-tasks 加索引或限制 CPU 配额]
  1. 第一步(立竿见影:止血防护):
    • 针对 /api/v1/worker/hall/orders 引入限流中间件(例如每个打手/IP 最多 2~3 次/秒,超出直接 429 Too Many Requests);
    • 在网关(Caddy)或 Node 接入层限制异常高频 IP;
  2. 第二步(削减 95% 数据库写放大:Session Touch 防抖节流):
    • 将 touchWorkerSession 改造为内存节流(In-Memory Throttle):每个 sessionId 在内存记录 lastTouchedAt,仅当距离上次写入超过 60 秒 时才真正执行一次 SQL UPDATE,瞬时将写库压力降低两个数量级;
  3. 第三步(接单大厅引入 1~2 秒短缓存):
    • 类似后台概览的 WorkerListCache,接单大厅对相同查询参数(分类、关键词、页码)赋予 1~2 秒短缓存,并发请求合并执行,避免无脑穿透 DB;
  4. 第四步(资源隔离与磁盘清理):
    • 对 live-hub-py-mysql-1 容器设置 cpus: "1.0" 限制,防止其吃满物理核心;
    • 执行 docker system prune 释放磁盘空间(当前仅存 8.6GB)。