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 项测试全绿通过。
This commit is contained in:
yml2213
2026-10-02 16:30:53 +08:00
parent 5c13599dff
commit adaf4ed240
5 changed files with 721 additions and 1 deletions
@@ -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<string, string> = {}
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<string, string>,
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')
})
@@ -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<string, WorkerRiskTracker>()
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<void> => {
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
}
}
}
+5
View File
@@ -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)
@@ -41,6 +41,18 @@ export async function logoutWorkerSession(token: unknown) {
}
}
interface CachedVerifiedSession {
session: WorkerSession
expiresAtMs: number
}
const verifiedSessionCache = new Map<string, CachedVerifiedSession>()
const VERIFIED_SESSION_CACHE_TTL_MS = 3_000
export function clearVerifiedWorkerSessionCacheForTest(): void {
verifiedSessionCache.clear()
}
export async function verifyWorkerSessionToken(token: unknown): Promise<WorkerSession> {
ensureWorkerAuthConfigured()
const normalizedToken = String(token || '').trim()
@@ -51,6 +63,13 @@ export async function verifyWorkerSessionToken(token: unknown): Promise<WorkerSe
})
}
// 命中 3 秒内存校验缓存:并发请求直接复用,避免同一页面连续发 3~5 个请求打爆数据库连接池
const now = Date.now()
const cached = verifiedSessionCache.get(normalizedToken)
if (cached && cached.expiresAtMs > 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<WorkerSe
})
}
return {
const verifiedSession: WorkerSession = {
sessionId,
workerId: Number(worker.id),
username: worker.username,
@@ -131,6 +150,23 @@ export async function verifyWorkerSessionToken(token: unknown): Promise<WorkerSe
expiresAt,
sessionVersion: currentSessionVersion,
}
verifiedSessionCache.set(normalizedToken, {
session: verifiedSession,
expiresAtMs: now + VERIFIED_SESSION_CACHE_TTL_MS,
})
// 控制缓存总数,防止内存泄漏
if (verifiedSessionCache.size > 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) {
@@ -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)。