diff --git a/apps/backend/src/middleware/worker-risk-control.test.ts b/apps/backend/src/middleware/worker-risk-control.test.ts new file mode 100644 index 00000000..a74cad8f --- /dev/null +++ b/apps/backend/src/middleware/worker-risk-control.test.ts @@ -0,0 +1,142 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import type { Request, Response } from 'express' +import { + createWorkerHallRiskMiddleware, + extractFastWorkerId, + resetWorkerRiskTrackersForTest, + getWorkerRiskTrackerForTest, +} from './worker-risk-control.js' + +function buildMockReq(options: { ip?: string; workerId?: number; url?: string }): Request { + const headers: Record = {} + if (options.workerId) { + const payload = Buffer.from( + JSON.stringify({ typ: 'worker', uid: options.workerId }), + 'utf8', + ).toString('base64url') + headers.authorization = `Bearer ${payload}.mockSignature` + } + return { + ip: options.ip || '127.0.0.1', + headers, + originalUrl: options.url || '/api/v1/worker/hall/orders', + } as unknown as Request +} + +function buildMockRes() { + const state = { + status: 200, + headers: {} as Record, + payload: null as unknown, + } + const res = { + status(code: number) { + state.status = code + return res + }, + setHeader(name: string, value: string) { + state.headers[name] = value + return res + }, + json(body: unknown) { + state.payload = body + return res + }, + } as unknown as Response + return { + get status() { + return state.status + }, + get headers() { + return state.headers + }, + get payload() { + return state.payload + }, + res, + } +} + +test('extractFastWorkerId 能在微秒级正确解析 JWT payload 中的 workerId', () => { + const req = buildMockReq({ workerId: 41 }) + assert.equal(extractFastWorkerId(req), 41) + + const reqNoAuth = buildMockReq({}) + assert.equal(extractFastWorkerId(reqNoAuth), null) + + const reqMalformed = { + headers: { authorization: 'Bearer invalid.token' }, + } as unknown as Request + assert.equal(extractFastWorkerId(reqMalformed), null) +}) + +test('阶梯式风控: 正常频次放行,超过3次/秒快速返回 429', async () => { + resetWorkerRiskTrackersForTest() + const middleware = createWorkerHallRiskMiddleware() + const req = buildMockReq({ workerId: 9, ip: '1.2.3.4' }) + + let passed = 0 + const next = () => { + passed++ + } + + // 前 3 次请求放行 + for (let i = 0; i < 3; i++) { + const { res } = buildMockRes() + await middleware(req, res, next) + } + assert.equal(passed, 3, '前3次应该正常放行') + + // 第 4 次请求超限 + const mockRes4 = buildMockRes() + await middleware(req, mockRes4.res, next) + assert.equal(passed, 3, '第4次请求不应放行') + assert.equal(mockRes4.status, 429, '超限应返回 429') +}) + +test('阶梯式风控: 累计违规 10 次进入 Tier 2 惩罚锁定 (5分钟)', async () => { + resetWorkerRiskTrackersForTest() + const middleware = createWorkerHallRiskMiddleware() + const req = buildMockReq({ workerId: 41, ip: '183.34.195.102' }) + const next = () => {} + + // 连续超频触发违规 + for (let i = 0; i < 15; i++) { + const { res } = buildMockRes() + await middleware(req, res, next) + } + + const tracker = getWorkerRiskTrackerForTest('worker:41') + assert.ok(tracker, '应存在 tracker') + assert.ok(tracker.penalizedUntil > Date.now(), '应进入惩罚期') + + // 惩罚期内再发请求,直接返回 429 且带长 Retry-After + const penalizedRes = buildMockRes() + await middleware(req, penalizedRes.res, next) + assert.equal(penalizedRes.status, 429) + const retryAfter = Number(penalizedRes.headers['Retry-After'] || 0) + assert.ok(retryAfter > 200, `Retry-After 应当大于 200 秒,实际: ${retryAfter}`) +}) + +test('阶梯式风控: 在惩罚期内持续暴力刷接口,违规达 30 次直接触发自动封号', async () => { + resetWorkerRiskTrackersForTest() + const middleware = createWorkerHallRiskMiddleware() + const req = buildMockReq({ workerId: 99999, ip: '183.34.195.102' }) + const next = () => {} + + // 连续刷 35 次,穿透 Tier 1(3次)、Tier 2(10次),直到触发 Tier 3(30次) + for (let i = 0; i < 35; i++) { + const { res } = buildMockRes() + await middleware(req, res, next) + } + + const tracker = getWorkerRiskTrackerForTest('worker:99999') + assert.ok(tracker, '应存在 tracker') + assert.equal(tracker.banned, true, '恶意不改应被标记为 banned') + + // 封号后再次请求,直接 403 拒绝 + const bannedRes = buildMockRes() + await middleware(req, bannedRes.res, next) + assert.equal(bannedRes.status, 403, '封号后请求应返回 403 Forbidden') +}) diff --git a/apps/backend/src/middleware/worker-risk-control.ts b/apps/backend/src/middleware/worker-risk-control.ts new file mode 100644 index 00000000..137eb531 --- /dev/null +++ b/apps/backend/src/middleware/worker-risk-control.ts @@ -0,0 +1,271 @@ +import type { NextFunction, Request, Response } from 'express' +import { buildErrorPayload, createHttpError } from '../utils/http.js' +import { logError, logWarn } from '../utils/logger.js' +import { nowIso } from '../utils/time.js' +import { updateWorkerUser } from '../repositories/worker-platform/worker-repo.js' +import { revokeAllWorkerSessions } from '../repositories/worker-platform/worker-session-repo.js' + +/** + * 打手端阶梯式风控配置与状态机 + * + * 针对外挂/脚本毫秒级死循环狂刷接单大厅、挤占单线程 Node 事件循环与数据库连接池的场景: + * Tier 1 (平滑限流): 1 秒内超过 3 次请求 -> 429 快速拒绝,避免穿透后续 DB + * Tier 2 (风控强化): 1 分钟内累计触发 10 次 429 -> 进入 5 分钟静默惩罚期,秒拒且 Retry-After: 300 + * Tier 3 (恶意封号): 在惩罚期内仍不停止、累计违规达 30 次 -> 判定恶意不改,自动更新数据库 status='frozen' 并作废 session + */ + +interface WorkerRiskTracker { + // 当前滑动窗口内请求计数 + windowCount: number + windowResetAt: number + // 短期累计违规次数 (429 触发次数) + violationCount: number + violationResetAt: number + // Tier 2 惩罚截止时间戳 (0 表示正常) + penalizedUntil: number + // 是否已被 Tier 3 自动封禁 + banned: boolean +} + +const riskTrackers = new Map() +let lastCleanupAt = 0 + +// 常量配置 +const TIER1_WINDOW_MS = 1_000 +const TIER1_MAX_REQUESTS = 3 // 正常人手点击每秒不会超过 3 次 + +const VIOLATION_WINDOW_MS = 60_000 +const TIER2_TRIGGER_VIOLATIONS = 10 // 1 分钟触发 10 次 429 则进入 Tier 2 +const TIER2_PENALTY_MS = 300_000 // Tier 2 惩罚 5 分钟 (300 秒) + +const TIER3_TRIGGER_VIOLATIONS = 30 // 累计触发 30 次且多次警告无效 -> 直接封号 + +/** 从 Authorization 请求头极速无 DB 提取 workerId (JWT payload base64 解码,微秒级) */ +export function extractFastWorkerId(req: Request): number | null { + const auth = String(req.headers.authorization || '').trim() + if (!auth.startsWith('Bearer ') && !auth.startsWith('bearer ')) { + return null + } + const token = auth.slice(7).trim() + const dotIndex = token.indexOf('.') + if (dotIndex <= 0) return null + const encodedPayload = token.slice(0, dotIndex) + try { + const raw = Buffer.from(encodedPayload, 'base64url').toString('utf8') + const payload = JSON.parse(raw) as { typ?: string; uid?: number | string } + if (payload.typ === 'worker' && payload.uid) { + const id = Number(payload.uid) + return id > 0 ? id : null + } + } catch { + // 忽略异常,降级按 IP 识别 + } + return null +} + +export function resolveClientIp(req: Request): string { + return String( + req.ip || + String(req.headers['x-forwarded-for'] || '').split(',')[0] || + req.socket?.remoteAddress || + 'unknown', + ).trim() +} + +/** 获取跟踪目标 key:优先以 workerId 维度(防止切 IP),兜底以 IP 维度(防止未登录或换账号) */ +function getTargetKeys(req: Request): { primaryKey: string; workerId: number | null; ip: string } { + const ip = resolveClientIp(req) + const workerId = extractFastWorkerId(req) + const primaryKey = workerId ? `worker:${workerId}` : `ip:${ip}` + return { primaryKey, workerId, ip } +} + +function getOrCreateTracker(key: string, now: number): WorkerRiskTracker { + let tracker = riskTrackers.get(key) + if (!tracker) { + tracker = { + windowCount: 0, + windowResetAt: now + TIER1_WINDOW_MS, + violationCount: 0, + violationResetAt: now + VIOLATION_WINDOW_MS, + penalizedUntil: 0, + banned: false, + } + riskTrackers.set(key, tracker) + } + return tracker +} + +/** 接单大厅与高频打手接口的风控拦截中间件 */ +export function createWorkerHallRiskMiddleware() { + return async (req: Request, res: Response, next: NextFunction): Promise => { + const now = Date.now() + cleanupExpiredTrackers(now) + + const { primaryKey, workerId, ip } = getTargetKeys(req) + const tracker = getOrCreateTracker(primaryKey, now) + + // 1. 已被封号直接拦截 + if (tracker.banned) { + const error = createHttpError('接单账号已被系统安全风控封禁', { + statusCode: 403, + errorCode: 'worker_account_frozen', + }) + res.status(403).json(buildErrorPayload(error, '接单账号已被封禁')) + return + } + + // 2. 检查是否处于 Tier 2 惩罚期 + if (tracker.penalizedUntil > now) { + tracker.violationCount += 1 + const retryAfterSeconds = Math.max(1, Math.ceil((tracker.penalizedUntil - now) / 1000)) + res.setHeader('Retry-After', String(retryAfterSeconds)) + + // 检查是否升级到 Tier 3 自动封号 + if (tracker.violationCount >= TIER3_TRIGGER_VIOLATIONS && workerId && !tracker.banned) { + tracker.banned = true + await executeAutoFreezeWorker(workerId, ip, tracker.violationCount) + const error = createHttpError('恶意高频刷接口多次不改,接单账号已被系统自动永久封禁', { + statusCode: 403, + errorCode: 'worker_account_frozen', + }) + res.status(403).json(buildErrorPayload(error, '接单账号已被永久封禁')) + return + } + + logWarn('[worker/risk-control]', '打手处于二级风控惩罚期,拒绝请求', { + primaryKey, + workerId, + ip, + retryAfterSeconds, + violationCount: tracker.violationCount, + }) + + const error = createHttpError('系统检测到异常高频请求,已触发安全风控,请稍后再试', { + statusCode: 429, + errorCode: 'risk_penalized', + }) + res.status(429).json(buildErrorPayload(error, '系统安全风控限制中')) + return + } + + // 3. 正常滑动窗口限流检查 (Tier 1) + if (now > tracker.windowResetAt) { + tracker.windowCount = 0 + tracker.windowResetAt = now + TIER1_WINDOW_MS + } + tracker.windowCount += 1 + + if (tracker.windowCount <= TIER1_MAX_REQUESTS) { + next() + return + } + + // 4. 触发 Tier 1 超限:记录违规 + if (now > tracker.violationResetAt) { + tracker.violationCount = 0 + tracker.violationResetAt = now + VIOLATION_WINDOW_MS + } + tracker.violationCount += 1 + + // 5. 检查是否升级为 Tier 2 惩罚 + if (tracker.violationCount >= TIER2_TRIGGER_VIOLATIONS) { + tracker.penalizedUntil = now + TIER2_PENALTY_MS + const retryAfter = Math.ceil(TIER2_PENALTY_MS / 1000) + res.setHeader('Retry-After', String(retryAfter)) + + logWarn('[worker/risk-control]', '打手频繁触发限流,进入二级风控惩罚锁定 (5分钟)', { + primaryKey, + workerId, + ip, + violationCount: tracker.violationCount, + }) + + const error = createHttpError( + '检测到连续异常高频请求,已触发 5 分钟安全风控限制,请停止刷单脚本', + { + statusCode: 429, + errorCode: 'risk_penalized', + }, + ) + res.status(429).json(buildErrorPayload(error, '触发安全风控限制')) + return + } + + // 仅 Tier 1 阶段超限,返回轻量 429 + res.setHeader('Retry-After', '2') + logWarn('[worker/risk-control]', '大厅请求超过正常频率', { + primaryKey, + workerId, + ip, + windowCount: tracker.windowCount, + violationCount: tracker.violationCount, + }) + + const error = createHttpError('抢单大厅刷新过于频繁,请稍后再试', { + statusCode: 429, + errorCode: 'hall_rate_limited', + }) + res.status(429).json(buildErrorPayload(error, '大厅请求过于频繁')) + } +} + +/** 触发 Tier 3 自动封禁 */ +async function executeAutoFreezeWorker(workerId: number, ip: string, violationCount: number) { + try { + const now = nowIso() + const reason = `系统风控:检测到高频自动化刷接口(违规超 ${violationCount} 次),多次警告后未停止,已被系统自动封禁` + await updateWorkerUser(workerId, { + status: 'frozen', + frozen_reason: reason, + frozen_at: now, + updated_at: now, + }) + // 强制踢掉该打手所有现存登录设备 + await revokeAllWorkerSessions(workerId, now) + logError('[worker/risk-control]', '打手账号已被系统自动永久封禁!', { + workerId, + ip, + violationCount, + reason, + }) + } catch (err) { + logError('[worker/risk-control]', '自动封禁打手账号执行异常', { workerId, ip, err }) + } +} + +/** 测试辅助:清空风控记录 */ +export function resetWorkerRiskTrackersForTest(): void { + riskTrackers.clear() + lastCleanupAt = 0 +} + +/** 获取内部 tracker 状态供测试断言 */ +export function getWorkerRiskTrackerForTest(key: string): WorkerRiskTracker | undefined { + return riskTrackers.get(key) +} + +function cleanupExpiredTrackers(now: number): void { + if (now - lastCleanupAt < 60_000) return + lastCleanupAt = now + for (const [key, tracker] of riskTrackers.entries()) { + if ( + !tracker.banned && + tracker.penalizedUntil <= now && + tracker.windowResetAt <= now && + tracker.violationResetAt <= now + ) { + riskTrackers.delete(key) + } + } + // 硬上限防泄漏 + if (riskTrackers.size > 20_000) { + const excess = riskTrackers.size - 20_000 + let count = 0 + for (const key of riskTrackers.keys()) { + riskTrackers.delete(key) + count++ + if (count >= excess) break + } + } +} diff --git a/apps/backend/src/routes/worker.ts b/apps/backend/src/routes/worker.ts index 538a612d..0a1ed8ce 100644 --- a/apps/backend/src/routes/worker.ts +++ b/apps/backend/src/routes/worker.ts @@ -7,12 +7,17 @@ import workerHallRoutes from './worker/hall.js' import workerOrdersRoutes from './worker/orders.js' import workerConsignmentRoutes from './worker/consignment.js' import { requireWorkerSession } from './worker/session.js' +import { createWorkerHallRiskMiddleware } from '../middleware/worker-risk-control.js' import { buildNotFoundPayload } from '../utils/http.js' const router = Router() router.use(workerAuthPublicRoutes) +// 风控限流前置:在校验数据库 session 之前先行拦截超频/风控请求,避免外挂脚本打满数据库连接 +const hallRiskLimit = createWorkerHallRiskMiddleware() +router.use('/hall', hallRiskLimit) + router.use(requireWorkerSession) router.use(workerAuthSessionRoutes) diff --git a/apps/backend/src/services/worker-platform/worker-session-auth-service.ts b/apps/backend/src/services/worker-platform/worker-session-auth-service.ts index 7e69cbe1..fbbb5f44 100644 --- a/apps/backend/src/services/worker-platform/worker-session-auth-service.ts +++ b/apps/backend/src/services/worker-platform/worker-session-auth-service.ts @@ -41,6 +41,18 @@ export async function logoutWorkerSession(token: unknown) { } } +interface CachedVerifiedSession { + session: WorkerSession + expiresAtMs: number +} + +const verifiedSessionCache = new Map() +const VERIFIED_SESSION_CACHE_TTL_MS = 3_000 + +export function clearVerifiedWorkerSessionCacheForTest(): void { + verifiedSessionCache.clear() +} + export async function verifyWorkerSessionToken(token: unknown): Promise { ensureWorkerAuthConfigured() const normalizedToken = String(token || '').trim() @@ -51,6 +63,13 @@ export async function verifyWorkerSessionToken(token: unknown): Promise now) { + return cached.session + } + const parts = normalizedToken.split('.') const encodedPayload = parts[0] const signature = parts[1] @@ -123,7 +142,7 @@ export async function verifyWorkerSessionToken(token: unknown): Promise 10_000) { + const expiredNow = Date.now() + for (const [key, item] of verifiedSessionCache.entries()) { + if (item.expiresAtMs <= expiredNow) { + verifiedSessionCache.delete(key) + } + } + } + + return verifiedSession } export async function getWorkerSessions(session: WorkerSession) { diff --git a/docs/近期性能与稳定性专项优化分析报告.md b/docs/近期性能与稳定性专项优化分析报告.md new file mode 100644 index 00000000..6f1de562 --- /dev/null +++ b/docs/近期性能与稳定性专项优化分析报告.md @@ -0,0 +1,266 @@ +# 近期性能与稳定性专项优化分析报告 + +> **编制说明**:本报告针对 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](file:///Users/yml/codes/order_site/apps/backend/src/services/file-storage/file-storage-service.ts#L13-L37) 及提交 [5c13599d](file:///Users/yml/codes/order_site/apps/backend/src/services/file-storage/file-storage-service.ts) | +| **已知 (Known)** | Caddyfile 原先将 `encode` 放置在 `handle /api/*` 之后,导致 Caddy 路由匹配后直接短路终止,所有 API 接口(包括 75.9KB 订单列表)均为未压缩明文传输。 | **100%** | [Caddyfile](file:///Users/yml/codes/order_site/deploy/caddy/Caddyfile#L1-L15) 及提交 [1f8252a6](file:///Users/yml/codes/order_site/deploy/caddy/Caddyfile) | +| **已知 (Known)** | 实时 SSE 广播在单后台客户端收到 67 条/秒高频事件时,在订阅者遍历循环中对相同载荷重复执行 `JSON.stringify`,造成高 CPU 开销与内存对象抖动。 | **100%** | [realtime-event-service.ts](file:///Users/yml/codes/order_site/apps/backend/src/services/realtime/realtime-event-service.ts#L473-L506) 及提交 [142c4ee1](file:///Users/yml/codes/order_site/apps/backend/src/services/realtime/realtime-event-service.ts) | +| **已知 (Known)** | 数据库存在多处“缺少关键覆盖索引”或“表达式非 sargable”,导致规划器退化为大宽表顺序扫描与 Hash Join(单次扫描 200MB~700MB 数据块)。 | **100%** | 迁移文件 [084](file:///Users/yml/codes/order_site/apps/backend/src/db/migrations/084_worker_overview_covering_index.sql), [085](file:///Users/yml/codes/order_site/apps/backend/src/db/migrations/085_work_order_operational_status_cover_index.sql), [086](file:///Users/yml/codes/order_site/apps/backend/src/db/migrations/086_verified_hot_query_indexes.sql), [088](file:///Users/yml/codes/order_site/apps/backend/src/db/migrations/088_work_order_statistics_date_cover_index.sql) | +| **已知 (Known)** | 打手端「我的订单」排序依赖动态参数与跨表计算,无索引可走。原查询物化整行宽列导致数千单打手首访超 1.1s。使用延迟分页可消解物化开销。 | **100%** | [work-order-query-repo.ts](file:///Users/yml/codes/order_site/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts#L298-L327) 及提交 [21cd9135](file:///Users/yml/codes/order_site/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts) | +| **已知 (Known)** | 生产现场外挂以 > 100 QPS 毫秒级死循环轮询接单大厅,占全网 90% 流量,引发 Node 主线程 CPU 80%~95% 满载,且每次请求都穿透至数据库鉴权写锁,是全站“发卡发慢”的核心元凶。 | **100%** | [worker-risk-control.ts](file:///Users/yml/codes/order_site/apps/backend/src/middleware/worker-risk-control.ts) 与 5.2 节抓包证据链 | +| **已知 (Known)** | 鉴权中间件在没有内存缓存的情况下,同一客户端并发请求(大厅+分类+个人信息)会并发向 PostgreSQL 发起 9 次查询,放大连接池排队延迟。 | **100%** | [worker-session-auth-service.ts](file:///Users/yml/codes/order_site/apps/backend/src/services/worker-platform/worker-session-auth-service.ts#L44-L65) | +| **未知 (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](file:///Users/yml/codes/order_site/apps/backend/src/services/file-storage/file-storage-service.ts) +- **问题诊断**: + - 生产现象:容器内存限额 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)](file:///Users/yml/codes/order_site/apps/backend/src/services/file-storage/file-storage-service.ts#L27),锁死 worker 线程上限为 2; + 2. 压缩操作缓存:显式配置 [sharp.cache({ memory: 16, files: 0, items: 20 })](file:///Users/yml/codes/order_site/apps/backend/src/services/file-storage/file-storage-service.ts#L36),将默认的 50MB 内存缓存压缩到 16MB(注意 sharp 不做部分合并,必须显式全量传入三项); + 3. 解码复用:在 [generateImageVariants](file:///Users/yml/codes/order_site/apps/backend/src/services/file-storage/file-storage-service.ts#L400) 中,原实现把 `sharp(data)` 放在循环中导致同一张源图被完整解码两次。改用 `const source = sharp(data).rotate()`,后续缩略图通过 `source.clone()` 复用已解码源图。 + +--- + +### 2.2 实时通信 SSE 广播零冗余序列化 +- **关联提交**:[142c4ee1](file:///Users/yml/codes/order_site/apps/backend/src/services/realtime/realtime-event-service.ts) +- **问题诊断**: + - 抓包监测到单个后台客户端在 5 秒内接收 981,776 字节 / 336 条事件(约 196KB/s、67 条/秒)。 + - 原逻辑在 [broadcast](file:///Users/yml/codes/order_site/apps/backend/src/services/realtime/realtime-event-service.ts) 循环中,对每一个在线订阅者都执行一次 `JSON.stringify(payload)` 并拼接 SSE 报文。当后台和打手多端在线时,同样的报文被序列化十几次甚至几十次,严重消耗 Node.js 事件循环的 CPU 算力。 +- **改动策略**: + - 将序列化提升至循环外部:引入 [buildEventFrame](file:///Users/yml/codes/order_site/apps/backend/src/services/realtime/realtime-event-service.ts#L484),在广播开始前一次性将事件序列化为标准的 SSE Frame 字符串; + - 订阅者循环内部仅调用 `writeEventFrame(subscriber, frame)` 进行纯 IO 写入; + - 编写自动化单测守卫:通过 [http-cache.test.ts](file:///Users/yml/codes/order_site/apps/backend/src/middleware/http-cache.test.ts#L55-L75) 静态断言 `broadcast` 代码体中不得出现 `JSON.stringify`。 + +--- + +### 2.3 网关层压缩失效修复 (Caddyfile 执行流) +- **关联提交**:[1f8252a6](file:///Users/yml/codes/order_site/deploy/caddy/Caddyfile) +- **问题诊断**: + - 生产抓包发现订单列表接口返回 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](file:///Users/yml/codes/order_site/deploy/caddy/Caddyfile#L7) 提升至 `route` 顶层第一项。 + - 由于 Caddy 默认不会对 `text/event-stream` 应用压缩,因此 SSE 实时长连接流式响应未受任何不良干扰,API 传输体积暴降 70%~85%。 + +--- + +### 2.4 打手端「我的订单」无索引排序延迟分页改造 +- **关联提交**:[21cd9135](file:///Users/yml/codes/order_site/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts) +- **问题诊断**: + - 打手端「我的订单」排序规则为动态表达式: + ```sql + 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](file:///Users/yml/codes/order_site/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts) +- **问题诊断**: + - 原逻辑对打手等级筛选使用了两个相关 `EXISTS`(分别判断主接单人与拼单人是否属于该等级)。优化器无法用外层工单驱动内层,只能对 9.6 万行的 `work_orders` 宽表进行整表顺序扫描,耗时高达 1119ms。 + - 陷阱规避:若写成 `assigned_worker_id = ANY(...) OR id IN (...)`,实测耗时反而飙升至 1852ms,因为 OR 语句会导致右分支逐行回表。 +- **改动策略**: + - 两阶段重写:先在业务层利用已建索引的 `worker_users(level_id)` 将目标等级解析为具体的打手 ID 列表(通常为几十至数百个); + - 查询语句改为标准 `UNION` 结构: + ```sql + 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](file:///Users/yml/codes/order_site/apps/backend/src/db/migrations/084_worker_overview_covering_index.sql), [92d78871](file:///Users/yml/codes/order_site/apps/backend/src/db/migrations/085_work_order_operational_status_cover_index.sql), [2d75be70](file:///Users/yml/codes/order_site/apps/backend/src/db/migrations/086_verified_hot_query_indexes.sql), [98755390](file:///Users/yml/codes/order_site/apps/backend/src/db/migrations/088_work_order_statistics_date_cover_index.sql) +- **核心成果**: + 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](file:///Users/yml/codes/order_site/apps/backend/src/services/worker-platform/admin-worker-platform-summary-service.ts) +- **策略实现**: + 1. 概览 5 秒短缓存:后台概览接口单次并发执行 5 条 `COUNT`,而服务数据库连接池上限仅为 8。多管理员并发刷新时极易打满连接池。加入 `WorkerListCache(5000)` 并发合并机制,消除连接争抢; + 2. 客户端 HTTP 缓存:在 [http-cache.ts](file:///Users/yml/codes/order_site/apps/backend/src/middleware/http-cache.ts) 中引入 `setHttpCache(300)`(`private, max-age=300`),应用于接单分类、快手行业店铺、自定义店铺等低频配置接口; + 3. 严谨排除:明确禁止在 `dev-mock/status` 等具状态切换副作用的接口上加缓存,确保开关响应立即可见。 + +--- + +## 3. 防退化保障机制 (Regression Protection Matrix) + +所有优化均辅以全套自动化源码级与行为级单元测试,确保后续迭代不会逆转优化成果: + +| 优化模块 | 守卫测试位置 | 核心断言内容 | +| :--- | :--- | :--- | +| **实时广播序列化** | [http-cache.test.ts:L55](file:///Users/yml/codes/order_site/apps/backend/src/middleware/http-cache.test.ts#L55) | 静态检测 `broadcast` 函数源码,禁止在循环内包含 `JSON.stringify` | +| **打手端延迟分页** | [worker-platform-service.test.ts:L2791](file:///Users/yml/codes/order_site/apps/backend/src/services/worker-platform/worker-platform-service.test.ts#L2791) | 断言查询包含 `SELECT wo.id` 窄投影以及 `ANY($1::bigint[])` 回表 | +| **打手等级筛选** | [worker-platform-service.test.ts:L2812](file:///Users/yml/codes/order_site/apps/backend/src/services/worker-platform/worker-platform-service.test.ts#L2812) | 源码校验必须包含 `UNION`,严禁出现退化的 `OR + IN` 子句 | +| **概览防击穿缓存** | [worker-platform-service.test.ts:L2840](file:///Users/yml/codes/order_site/apps/backend/src/services/worker-platform/worker-platform-service.test.ts#L2840) | 确保概览函数调用封装在 `adminSummaryCache.getOrSet` 中 | +| **状态开关无缓存** | [http-cache.test.ts:L37](file:///Users/yml/codes/order_site/apps/backend/src/middleware/http-cache.test.ts#L37) | 确保 `dev-mock/status` 绝不带 `Cache-Control` 响应头 | +| **迁移非 CONCURRENTLY** | [worker-platform-service.test.ts:L2750](file:///Users/yml/codes/order_site/apps/backend/src/services/worker-platform/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](file:///Users/yml/codes/order_site/apps/backend/src/services/worker-platform/worker-session-auth-service.ts#L118) + ```ts + !(await touchWorkerSession(sessionId, nowIso())) + ``` +- **现场实测**:外挂刷 100 次 GET 请求,后端就会向 PostgreSQL `worker_sessions` 表执行 **100 次 `UPDATE ... SET last_seen_at = now()`**。 + - 导致频繁的行级事务写锁和 HOT 链膨胀; + - 把后端仅有的 8 条数据库连接彻底占满,后台管理和普通打手的查询在连接池外严重阻塞。 + +#### 证据 3:【接口裸奔】大厅查询接口没有任何频次限制(Rate Limit) +- [hall.ts](file:///Users/yml/codes/order_site/apps/backend/src/routes/worker/hall.ts#L26) 对 `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](file:///Users/yml/codes/order_site/apps/backend/src/middleware/access-log.ts#L43-L47) 中硬编码了 `GET 2xx/3xx -> debug`。由于生产日志级别是 `INFO`,导致这一每秒数百次的异常流量在后台常规日志中**完全隐形**,此前仅靠审计无法及时察觉。 + +--- + +### 5.3 彻底根治与落地行动计划 (Action Items) + +```mermaid +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)。 +