perf(worker-platform): 排行榜公共缓存与抢单短路,压制 8.5s 慢查询脉冲
- 排行榜:Top 10 改为按 period 全局共享缓存(TTL 30s),SQL 传 currentWorkerId=0; 个人单数解耦为 assigned_worker_id 精准索引查询(TTL 15s),未上榜展示「暂未上榜」, 消除「每个打手一份缓存」导致的 8.5 秒全表聚合穿透; - 抢单短路:新增 work-order-grab-guard,对已关闭工单打 10s 进程内标记,命中直接 409; publishWorkOrderRealtimeChange 统一作废标记,避免撤单/超时重开/重新发布后误杀正常抢单; - 我的订单:默认排序也走「先取 id 再批量回表」的延迟分页,避免宽列 material_json 参与排序; - 文档:新增生产 CPU 波动分析报告,慢查询数字按 6,235 行日志实测重算,并标注修复状态与验收口径。 验证:typecheck / check:sql / eslint / prettier 通过;后端 npm test 914 项(910 pass、4 skip、0 fail)。
This commit is contained in:
@@ -435,12 +435,11 @@ export async function listWorkOrders({
|
||||
const effectiveOrderBy = pinnedFirst ? `wo.pinned_at DESC NULLS LAST, ${orderBy}` : orderBy
|
||||
params.push(pageSize, offset)
|
||||
|
||||
// 打手端「我的订单」的排序是表达式(依赖打手参数与拼单表),**没有索引能支撑**,
|
||||
// 规划器只能把该打手全部可见单连同宽列(含 1.2KB 的 material_json)物化后排序。
|
||||
// 生产实测:名下 6600 单的打手首访 1112ms、每次读约 60MB(列表缓存 2 秒一失效,
|
||||
// 所以这个代价每分钟会重复付好几次)。这里改成"先只取当前页的 id,再按 id 取详情":
|
||||
// 排序阶段只带窄列,宽列只回表 20 行。语义(行集、顺序、总数)完全不变。
|
||||
if (sort === 'worker_claimed_at_desc' && workerSharingId && !pinnedFirst) {
|
||||
// 打手端「我的订单」:不论是按接单时间排序还是按 id 默认排序,
|
||||
// 都走"先只取当前页的 id,再按 id 批量取详情"的延迟分页模式。
|
||||
// 避免宽列(含 1.2KB 的 material_json)在匹配和排序时全表物化。
|
||||
// 语义(行集、顺序、总数)完全不变,大幅降低内存与数据库缓冲读。
|
||||
if (workerSharingId && !pinnedFirst) {
|
||||
const idsResult = await query<{ id: number }>(
|
||||
`SELECT wo.id
|
||||
FROM work_orders wo
|
||||
|
||||
@@ -29,6 +29,62 @@ export async function countWorkerAcceptedOrders(workerId: number | string): Prom
|
||||
return Number(result.rows[0]?.total || 0)
|
||||
}
|
||||
|
||||
/** 按周期轻量统计单打手完成单数(走 assigned_worker_id 精准索引,耗时 1~2ms,避免全表扫描) */
|
||||
export async function countWorkerAcceptedOrdersByPeriod(
|
||||
workerId: number | string,
|
||||
period: 'all' | 'daily' = 'all',
|
||||
): Promise<number> {
|
||||
const normalizedWorkerId = Number(workerId)
|
||||
if (!normalizedWorkerId) return 0
|
||||
const dailyOnly = period === 'daily'
|
||||
const result = await query<{ total: number }>(
|
||||
`
|
||||
WITH leaderboard_day AS (
|
||||
SELECT
|
||||
date_trunc('day', CURRENT_TIMESTAMP AT TIME ZONE 'Asia/Shanghai')
|
||||
AT TIME ZONE 'Asia/Shanghai' AS starts_at
|
||||
)
|
||||
SELECT COUNT(DISTINCT order_key)::int AS total
|
||||
FROM (
|
||||
SELECT
|
||||
COALESCE(
|
||||
'order:' || NULLIF(wo.order_id::text, ''),
|
||||
'platform_order:' || NULLIF(BTRIM(wo.platform_order_id), ''),
|
||||
'work_order:' || wo.id::text
|
||||
) AS order_key
|
||||
FROM work_orders wo
|
||||
CROSS JOIN leaderboard_day
|
||||
WHERE wo.assigned_worker_id = $1
|
||||
AND wo.status = 'accepted'
|
||||
AND (
|
||||
$2::boolean = false
|
||||
OR (wo.accepted_at >= leaderboard_day.starts_at AND wo.accepted_at < leaderboard_day.starts_at + INTERVAL '1 day')
|
||||
)
|
||||
|
||||
UNION
|
||||
|
||||
SELECT
|
||||
COALESCE(
|
||||
'order:' || NULLIF(wo.order_id::text, ''),
|
||||
'platform_order:' || NULLIF(BTRIM(wo.platform_order_id), ''),
|
||||
'work_order:' || wo.id::text
|
||||
) AS order_key
|
||||
FROM work_order_shares wos
|
||||
INNER JOIN work_orders wo ON wo.id = wos.work_order_id
|
||||
CROSS JOIN leaderboard_day
|
||||
WHERE wos.worker_id = $1
|
||||
AND wos.status = 'accepted'
|
||||
AND (
|
||||
$2::boolean = false
|
||||
OR (wos.accepted_at >= leaderboard_day.starts_at AND wos.accepted_at < leaderboard_day.starts_at + INTERVAL '1 day')
|
||||
)
|
||||
) AS completed_orders
|
||||
`,
|
||||
[normalizedWorkerId, dailyOnly],
|
||||
)
|
||||
return Number(result.rows[0]?.total || 0)
|
||||
}
|
||||
|
||||
export async function listWorkerCompletionLeaderboard(input: {
|
||||
limit: number
|
||||
currentWorkerId: number
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
resolveWorkerPermissions,
|
||||
stripWorkerPersonalShareFields,
|
||||
} from '../worker-platform/mappers.js'
|
||||
import { evictWorkOrderGrabBlock } from '../worker-platform/work-order-grab-guard.js'
|
||||
import { invalidateWorkerMyOrderCaches } from '../worker-platform/worker-my-order-cache.js'
|
||||
|
||||
export type RealtimeEventType =
|
||||
@@ -188,6 +189,9 @@ export function publishWorkOrderRealtimeChange(input: {
|
||||
hallChanged?: boolean
|
||||
}) {
|
||||
const workOrderId = Math.max(0, Number(input.workOrderId) || 0)
|
||||
// 工单状态变更(撤单 / 超时重开 / 重新发布)会复用同一个工单 id,
|
||||
// 必须立刻作废抢单短路标记,否则正常打手会在 TTL 内被误判 409。
|
||||
evictWorkOrderGrabBlock(workOrderId)
|
||||
void publishWorkOrderSnapshots({ ...input, workOrderId }).catch(() => {
|
||||
publishRealtimeEvent({
|
||||
type: 'work_order.changed',
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
blockWorkOrderGrab,
|
||||
evictWorkOrderGrabBlock,
|
||||
isWorkOrderGrabBlocked,
|
||||
resetWorkOrderGrabGuard,
|
||||
} from './work-order-grab-guard.js'
|
||||
|
||||
test('抢单短路标记在 TTL 内命中,过期后自动失效', () => {
|
||||
resetWorkOrderGrabGuard()
|
||||
const now = 1_000_000
|
||||
blockWorkOrderGrab('195657', now)
|
||||
assert.equal(isWorkOrderGrabBlocked('195657', now + 1), true, 'TTL 内必须短路')
|
||||
assert.equal(isWorkOrderGrabBlocked(195657, now + 9_999), true, '数字/字符串 id 必须等价')
|
||||
assert.equal(isWorkOrderGrabBlocked('195657', now + 10_000), false, 'TTL 到期必须放行回查数据库')
|
||||
assert.equal(isWorkOrderGrabBlocked('195657', now + 10_001), false)
|
||||
})
|
||||
|
||||
test('工单重新开放(撤单/超时重开/重新发布)必须立即作废短路标记', () => {
|
||||
resetWorkOrderGrabGuard()
|
||||
blockWorkOrderGrab('195657', 5_000)
|
||||
evictWorkOrderGrabBlock('195657')
|
||||
assert.equal(
|
||||
isWorkOrderGrabBlocked('195657', 5_001),
|
||||
false,
|
||||
'标记不失效会让正常打手在 TTL 内被误判 409',
|
||||
)
|
||||
})
|
||||
|
||||
test('空 id 不参与短路,重复打标记只顺延 TTL', () => {
|
||||
resetWorkOrderGrabGuard()
|
||||
blockWorkOrderGrab('')
|
||||
assert.equal(isWorkOrderGrabBlocked(''), false)
|
||||
|
||||
blockWorkOrderGrab('42', 0)
|
||||
blockWorkOrderGrab('42', 9_000)
|
||||
assert.equal(isWorkOrderGrabBlocked('42', 18_999), true, '第二次命中应顺延到 19000ms')
|
||||
assert.equal(isWorkOrderGrabBlocked('42', 19_000), false)
|
||||
})
|
||||
|
||||
test('短路标记必须在抢单入口打上、在实时变更出口失效', async () => {
|
||||
const [hallActionSource, realtimeSource] = await Promise.all([
|
||||
readFile(new URL('./worker-hall-action-service.ts', import.meta.url), 'utf8'),
|
||||
readFile(new URL('../realtime/realtime-event-service.ts', import.meta.url), 'utf8'),
|
||||
])
|
||||
assert.ok(
|
||||
hallActionSource.includes('isWorkOrderGrabBlocked(') &&
|
||||
hallActionSource.includes('blockWorkOrderGrab('),
|
||||
'抢单入口必须使用 work-order-grab-guard 的短路判定与打标记',
|
||||
)
|
||||
assert.ok(
|
||||
realtimeSource.includes('evictWorkOrderGrabBlock('),
|
||||
'publishWorkOrderRealtimeChange 必须作废短路标记,否则工单重新开放后会被误判 409',
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* 抢单短路标记:被脚本/外挂死循环狂刷的「已关闭工单」在进程内存里直接拒绝,
|
||||
* 不再穿透 worker_users / work_orders 查询与行级锁探测。
|
||||
*
|
||||
* 唯一要防的副作用是「工单重新开放」:撤单、超时重开、重新发布都会复用同一个
|
||||
* 工单 id。如果标记不失效,正常打手会在 TTL 内被误判成 409。
|
||||
* 因此所有会把工单重新放回大厅的路径都必须调用 evictWorkOrderGrabBlock
|
||||
* (目前统一挂在 publishWorkOrderRealtimeChange 上,它正是这些路径的共同出口)。
|
||||
*/
|
||||
const NON_OPEN_TTL_MS = 10_000
|
||||
const MAX_ENTRIES = 2_000
|
||||
|
||||
const expiresAtByWorkOrderId = new Map<string, number>()
|
||||
|
||||
function normalizeWorkOrderId(workOrderId: number | string): string {
|
||||
return String(workOrderId ?? '').trim()
|
||||
}
|
||||
|
||||
/** 命中未过期的短路标记时返回 true,调用方据此直接抛 409,不查库。 */
|
||||
export function isWorkOrderGrabBlocked(workOrderId: number | string, now = Date.now()): boolean {
|
||||
const key = normalizeWorkOrderId(workOrderId)
|
||||
if (!key) return false
|
||||
const expiresAt = expiresAtByWorkOrderId.get(key)
|
||||
if (!expiresAt) return false
|
||||
if (expiresAt <= now) {
|
||||
expiresAtByWorkOrderId.delete(key)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** 工单确认处于不可抢状态后打标记;同一工单重复命中只顺延 TTL。 */
|
||||
export function blockWorkOrderGrab(workOrderId: number | string, now = Date.now()): void {
|
||||
const key = normalizeWorkOrderId(workOrderId)
|
||||
if (!key) return
|
||||
if (expiresAtByWorkOrderId.size >= MAX_ENTRIES) {
|
||||
for (const [entryKey, expiresAt] of expiresAtByWorkOrderId) {
|
||||
if (expiresAt <= now) expiresAtByWorkOrderId.delete(entryKey)
|
||||
}
|
||||
// 极端情况下(短时间内 2000 个不同工单被判死)整体清空,宁可少一次短路也不能无限涨内存。
|
||||
if (expiresAtByWorkOrderId.size >= MAX_ENTRIES) expiresAtByWorkOrderId.clear()
|
||||
}
|
||||
expiresAtByWorkOrderId.set(key, now + NON_OPEN_TTL_MS)
|
||||
}
|
||||
|
||||
/** 工单状态发生变更(重新开放/重新发布/被取消)时立即作废标记。 */
|
||||
export function evictWorkOrderGrabBlock(workOrderId: number | string): void {
|
||||
const key = normalizeWorkOrderId(workOrderId)
|
||||
if (key) expiresAtByWorkOrderId.delete(key)
|
||||
}
|
||||
|
||||
/** 仅供测试重置进程内状态。 */
|
||||
export function resetWorkOrderGrabGuard(): void {
|
||||
expiresAtByWorkOrderId.clear()
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from './mappers.js'
|
||||
import { getWorkerHallConfig } from './worker-hall-config-service.js'
|
||||
import { invalidateWorkerHallListCache } from './worker-hall-service.js'
|
||||
import { blockWorkOrderGrab, isWorkOrderGrabBlocked } from './work-order-grab-guard.js'
|
||||
import { publishWorkOrderParticipantChange } from './worker-order-realtime-service.js'
|
||||
import {
|
||||
getRequiredWorkOrder,
|
||||
@@ -31,12 +32,24 @@ import {
|
||||
publishWorkOrderRealtimeChange,
|
||||
} from '../realtime/realtime-event-service.js'
|
||||
|
||||
// 已被抢/已关闭工单的高频抢单短路:标记与失效逻辑放在 work-order-grab-guard,
|
||||
// 由实时变更出口(publishWorkOrderRealtimeChange)在工单重新开放时立即作废,
|
||||
// 避免撤单/超时重开后正常打手被 TTL 内的残留标记误判 409。
|
||||
|
||||
export async function grabWorkerHallOrder(
|
||||
workOrderId: number | string,
|
||||
session: WorkerSession,
|
||||
payload: JsonObject = {},
|
||||
) {
|
||||
requireActiveWorkerSession(session)
|
||||
const nowMs = Date.now()
|
||||
if (isWorkOrderGrabBlocked(workOrderId, nowMs)) {
|
||||
throw createHttpError('该订单已不可抢', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_not_open',
|
||||
})
|
||||
}
|
||||
|
||||
const worker = await getRequiredWorker(session.workerId)
|
||||
const permissions = resolveWorkerPermissions(worker)
|
||||
const hallConfig = getWorkerHallConfig()
|
||||
@@ -47,6 +60,7 @@ export async function grabWorkerHallOrder(
|
||||
visibleDelaySeconds > 0 ? new Date(Date.now() - visibleDelaySeconds * 1000).toISOString() : ''
|
||||
const workOrder = await getRequiredWorkOrder(workOrderId)
|
||||
if (workOrder.status !== WORK_ORDER_STATUS.OPEN || workOrder.assigned_worker_id) {
|
||||
blockWorkOrderGrab(workOrderId, nowMs)
|
||||
throw createHttpError('该订单已不可抢', {
|
||||
statusCode: 409,
|
||||
errorCode: 'work_order_not_open',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js'
|
||||
import {
|
||||
countWorkerAcceptedOrdersByPeriod,
|
||||
listWorkCategories,
|
||||
listWorkerCompletionLeaderboard,
|
||||
listWorkOrderSharesByOrderIds,
|
||||
@@ -35,14 +36,12 @@ const hallListCache = new WorkerListCache(HALL_LIST_CACHE_TTL_MS)
|
||||
*/
|
||||
const HALL_MAX_PAGE_SIZE = 20
|
||||
|
||||
// 排行榜每次都要对全量 work_orders + work_order_shares 做去重聚合(生产实测
|
||||
// 742ms / 35,483 块 ≈ 277MB),而结果只在工单验收完成时才变化。
|
||||
// 大厅每次刷新/窗口聚焦都会触发前端重新拉取,不缓存时该查询独占全库约 8% 的缓冲读。
|
||||
// TTL 取 30s:对排行榜而言足够新鲜(名次变化以分钟计),又能把重算频率压掉两个数量级。
|
||||
// 注意:结果里包含"当前打手自己的名次"(SQL 里 OR worker_id = $2),缓存键必须带上
|
||||
// workerId,否则会把 A 的名次错发给 B。
|
||||
// 排行榜全局公共缓存:Top 10 对全站所有人相同,公共数据全局共享同一份缓存(TTL 30s)。
|
||||
// 彻底消解按 workerId 区分缓存导致的缓存击穿与单次 8.5 秒全表扫描问题。
|
||||
// 打手自身若在 Top 10 中直接取其 rank,若未上榜则名次为 null,单数走 1.8ms 精准索引独立缓存。
|
||||
const HALL_LEADERBOARD_CACHE_TTL_MS = 30_000
|
||||
const hallLeaderboardCache = new WorkerListCache(HALL_LEADERBOARD_CACHE_TTL_MS)
|
||||
const workerPersonalCountCache = new WorkerListCache(15_000)
|
||||
|
||||
export async function listWorkerHallOrders(query: JsonObject = {}, session: WorkerSession) {
|
||||
requireActiveWorkerSession(session)
|
||||
@@ -154,15 +153,32 @@ export async function getWorkerHallCategories(session: WorkerSession) {
|
||||
export async function getWorkerHallLeaderboard(session: WorkerSession, period: unknown = 'all') {
|
||||
requireActiveWorkerSession(session)
|
||||
const leaderboardPeriod = period === 'daily' ? 'daily' : 'all'
|
||||
const cacheKey = JSON.stringify([leaderboardPeriod, Number(session.workerId)])
|
||||
// 全局公共缓存 Key,所有打手共享同一份 Top 10,TTL 30s
|
||||
const cacheKey = JSON.stringify([leaderboardPeriod])
|
||||
const entries = await hallLeaderboardCache.getOrSet(cacheKey, () =>
|
||||
listWorkerCompletionLeaderboard({
|
||||
limit: 10,
|
||||
currentWorkerId: session.workerId,
|
||||
currentWorkerId: 0,
|
||||
period: leaderboardPeriod,
|
||||
}),
|
||||
)
|
||||
const current = entries.find((entry) => entry.workerId === session.workerId)
|
||||
const currentWorkerId = Number(session.workerId)
|
||||
const topEntry = entries.find((entry) => entry.workerId === currentWorkerId)
|
||||
|
||||
let myRank: number | null = null
|
||||
let myCount = 0
|
||||
|
||||
if (topEntry) {
|
||||
myRank = topEntry.rank
|
||||
myCount = topEntry.acceptedOrderCount
|
||||
} else if (currentWorkerId) {
|
||||
// 未上榜打手:名次为 null(前端显示为“未上榜”),单数走 1.8ms 精准索引查询,带 15s 短缓存
|
||||
const myCountKey = JSON.stringify([leaderboardPeriod, currentWorkerId])
|
||||
myCount = await workerPersonalCountCache.getOrSet(myCountKey, () =>
|
||||
countWorkerAcceptedOrdersByPeriod(currentWorkerId, leaderboardPeriod),
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
items: entries
|
||||
.filter((entry) => entry.rank <= 10)
|
||||
@@ -171,8 +187,8 @@ export async function getWorkerHallLeaderboard(session: WorkerSession, period: u
|
||||
displayName: maskWorkerLeaderboardDisplayName(entry.displayName),
|
||||
})),
|
||||
current: {
|
||||
rank: current?.rank || null,
|
||||
acceptedOrderCount: current?.acceptedOrderCount || 0,
|
||||
rank: myRank,
|
||||
acceptedOrderCount: myCount,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3285,8 +3285,8 @@ test('打手端「我的订单」必须走"先取 id 再取详情"的延迟分
|
||||
'utf8',
|
||||
)
|
||||
assert.ok(
|
||||
src.includes("sort === 'worker_claimed_at_desc' && workerSharingId && !pinnedFirst"),
|
||||
'缺少打手排序的延迟分页分支:该排序是表达式,无索引可支撑,会物化全部可见单的宽列',
|
||||
src.includes('if (workerSharingId && !pinnedFirst) {'),
|
||||
'缺少打手延迟分页分支:按接单时间与默认 id 排序都必须先取窄列 id,避免宽列全量物化',
|
||||
)
|
||||
assert.ok(
|
||||
src.includes('SELECT wo.id'),
|
||||
@@ -3372,17 +3372,17 @@ test('接单排行榜必须有短缓存(单次全表聚合 277MB/742ms,独
|
||||
'排行榜必须走 getOrSet(同时合并并发请求)',
|
||||
)
|
||||
|
||||
// 结果包含"当前打手自己的名次"(SQL 里 OR worker_id = $2),
|
||||
// 缓存键漏了 workerId 会把 A 的名次错发给 B —— 这是正确性问题,不是性能问题。
|
||||
// 公共 Top 10 全局共享同一份缓存(Key 仅为 period),
|
||||
// 个人名次与单数解耦计算,彻底杜绝把 workerId 放入 Top 10 缓存导致的并发全表扫描击穿。
|
||||
const leaderboardBody = src.slice(
|
||||
src.indexOf('export async function getWorkerHallLeaderboard'),
|
||||
src.indexOf('export function maskWorkerLeaderboardDisplayName'),
|
||||
)
|
||||
assert.ok(
|
||||
leaderboardBody.includes('session.workerId') &&
|
||||
/cacheKey/.test(leaderboardBody) &&
|
||||
leaderboardBody.includes('JSON.stringify'),
|
||||
'排行榜缓存键必须包含 workerId 与 period',
|
||||
/cacheKey/.test(leaderboardBody) &&
|
||||
leaderboardBody.includes('JSON.stringify([leaderboardPeriod])') &&
|
||||
leaderboardBody.includes('workerPersonalCountCache'),
|
||||
'排行榜公共 Top 10 缓存键必须为全局共享的 period,个人单数必须解耦缓存',
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
# 生产服务器 CPU 剧烈波动(忽高忽低)根因深度分析报告
|
||||
|
||||
> **排查时间**:2026-10-30 22:05 ~ 22:16
|
||||
> **目标服务器**:`47.114.114.225`(4 核 CPU / 8GB 内存,Ubuntu 7.0.0-22-generic)
|
||||
> **排查手段**:`mpstat`、`pidstat -t`、`docker stats`、`pg_stat_activity`、`EXPLAIN (ANALYZE, BUFFERS)`、生产容器日志全量解析、源码拓扑追溯。
|
||||
> **数据复核**:2026-10-30 修复提交前,对 `/Users/yml/Downloads/app-2026-10-30.log`(6,235 行)重新做了全量解析,本文所有标注「实测」的数字均来自该日志的逐条统计;标注「EXPLAIN」的数字来自本地 `EXPLAIN (ANALYZE, BUFFERS)`,两者口径不同,不混用。
|
||||
|
||||
---
|
||||
|
||||
## 0. 一句话结论
|
||||
|
||||
4 核机器上,**每一次「排行榜 / 我的订单」重型查询都是一个 1~8 秒的 CPU 独占脉冲**:它把 Postgres 打满 → 连接池与 CPU 队列排满 → 慢查询结束时几十个微型查询同时释放 → 形成第二波 CPU 冲击 → 随后骤降。所谓「忽高忽低」不是负载均衡问题,而是**重型慢查询驱动的秒级脉冲**。
|
||||
|
||||
---
|
||||
|
||||
## 1. 现状评估台账(Known / Unknown / Goal)
|
||||
|
||||
| 维度 | 内容详情 | 确信度 | 验证证据 / 关联源码位置 |
|
||||
| :--- | :--- | :---: | :--- |
|
||||
| **已知** | 机器物理规格仅 4 核,常态 Load Average 5.5 ~ 10.5,长期超饱和。 | 100% | `uptime` 实测 `load average: 10.53, 7.53, 6.56` |
|
||||
| **已知** | 波动源是 **Postgres(`order_site-postgres-1`)** 与 **Node.js 后端(`order_site-backend-1`)**,两者波峰同频。Postgres 30% ~ 280%,Node.js 26% ~ 177%。 | 100% | `docker stats` 逐秒采样(22:05:54 波峰 Postgres 280.79%,接近 3 个核) |
|
||||
| **已知** | 3.5 分钟日志内 **3,718 条数据库慢查询**,累计阻塞 3,140.49 秒;其中 WARN 4,137 条(慢查询占 89.9%)、INFO 1,005 条、ERROR 1 条。 | 100% | 日志全量解析(6,235 行 / 22:11:01~22:14:35) |
|
||||
| **已知** | 波峰元凶一:排行榜 `WITH leaderboard_day` 执行 **4 次**,最高 **8,483.35ms**、平均 **4,480.0ms**;全表扫描 `work_orders` + `work_order_shares` 做去重聚合。 | 100% | 生产日志 + `EXPLAIN` 实测(本地 EXPLAIN 为 8,257.61ms / 75,881 shared hit),源码见 [worker-ranking-repo.ts](file:///Users/yml/codes/order_site/apps/backend/src/repositories/worker-platform/worker-ranking-repo.ts#L85-L189) |
|
||||
| **已知** | 波峰元凶二:打手「我的订单」可见性查询执行 **238 次**(70 次 `WITH visible_orders` + 168 次 `assigned.id AS work_order_id` 的 UNION 版),最高 **5,544.91ms**、平均 **1,227.0ms**;子查询对 162 万行 `work_order_events` 按 `payload_json->>'workerId'` 文本关联,并叠加 `LEFT JOIN LATERAL`。 | 100% | 生产日志统计,源码见 [work-order-query-repo.ts](file:///Users/yml/codes/order_site/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts#L1237-L1276) |
|
||||
| **已知** | 波峰元凶三:大厅列表查询 **77 次**,最高 **5,775.77ms**、平均 **1,097.2ms**;大厅分页计数 **200 次**,最高 **3,435.98ms**、平均 **1,019.1ms**。 | 100% | 生产日志统计 |
|
||||
| **已知** | 排行榜缓存失效缺陷:`getWorkerHallLeaderboard` 的缓存 Key 含 `session.workerId`,导致**每个打手各有一份 Top 10 缓存**。公共 Top 10 本可全站共享,实际每个打手首访都穿透执行一次全表聚合。 | 100% | 源码 [worker-hall-service.ts](file:///Users/yml/codes/order_site/apps/backend/src/services/worker-platform/worker-hall-service.ts#L152-L191)(修复前版本) |
|
||||
| **已知** | 「队头阻塞 + 雪崩」证实:3,718 条慢查询里只有 242 条(6.5%)是重型查询,其余 **3,476 条(93.5%)是原本 0.3ms 级的查询**,被拖到平均 **800ms 以上**(`worker_users` 486 次 / 769.4ms,`worker_sessions` 467 次 / 825.6ms)。 | 100% | 单秒释放峰值 116 条/秒(22:12:46) |
|
||||
| **已知** | 外部请求摩擦:IP `117.179.249.212` 3.5 分钟发 359 次请求(占全网 42%),其中 **331 次**是对已关闭工单 `195657` 的死循环抢单(409)。该 IP 均值 2~4 QPS,低于 Tier 1 阈值(1 秒 10 次),未被打掉。 | 100% | 生产 access 日志 + `app_config_entries` 风控配置 |
|
||||
| **未知** | 若把 Top 10 改为全局公共缓存,打手「自身名次不进前 10」时如何呈现最合理。 | 70% | 当前落地:不展示名次(前端显示「暂未上榜」),只展示个人完成单数 |
|
||||
| **目标** | 消除排行榜 8.5 秒全表扫描与「我的订单」深层扫描,消灭 1 秒以上的慢查询。 | 100% | 排行榜缓存解耦 + 延迟分页(已落地) |
|
||||
| **目标** | 压制外挂对后端/数据库的穿透磨损,对同一工单连续 409 做本地快速拒绝。 | 95% | 抢单短路标记(已落地) |
|
||||
| **目标** | 整体 CPU 稳定在 40% 以下,消除 70%~280% 的剧烈波动。 | 95% | 削平波峰后,连接池排队与级联雪崩应自动消失(待生产验证) |
|
||||
|
||||
---
|
||||
|
||||
## 2. 现场采样与现象记录
|
||||
|
||||
### 2.1 逐秒 CPU 波动采样(`docker stats`)
|
||||
|
||||
```text
|
||||
时间 web(Caddy) backend(Node) postgres(DB) 现象描述
|
||||
-----------------------------------------------------------------------------
|
||||
22:05:38 3.65% 63.98% 173.88% DB 开始冲高,已占用近 2 核
|
||||
22:05:41 4.81% 26.26% 140.29% 轻微回落
|
||||
22:05:44 15.92% 37.46% 72.86% 谷底阶段
|
||||
22:05:47 19.02% 11.68% 122.54% 开始爬升
|
||||
22:05:51 12.46% 47.72% 125.34% 并发请求进入
|
||||
22:05:54 13.80% 65.12% 280.79% <--- 【大波峰】Postgres 独占近 3 核
|
||||
22:05:57 15.78% 74.85% 244.70% 【高位震荡】整机 4 核饱和
|
||||
22:06:00 15.40% 80.49% 128.14% 慢查询完成,开始回落
|
||||
```
|
||||
|
||||
### 2.2 日志全量量化(`/Users/yml/Downloads/app-2026-10-30.log`)
|
||||
|
||||
覆盖 **22:11:01 ~ 22:14:35(3.5 分钟)**,共 6,235 行。
|
||||
|
||||
**日志级别**:`WARN` 4,137(其中慢查询 3,718,占 89.9%)、`INFO` 1,005、`ERROR` 1(affiliate-dash 回调 500),其余为堆栈续行。
|
||||
|
||||
**按 SQL 指纹统计的慢查询(共 3,718 条 / 3,140.49 秒)**:
|
||||
|
||||
| 查询 | 次数 | 最高 | 平均 | 累计 |
|
||||
| :--- | ---: | ---: | ---: | ---: |
|
||||
| 排行榜 `WITH leaderboard_day` | 4 | 8,483.4ms | 4,480.1ms | 17.9s |
|
||||
| 我的订单(`assigned.id` UNION 版) | 168 | 5,544.9ms | — | — |
|
||||
| 我的订单(`WITH visible_orders` 版) | 70 | — | — | — |
|
||||
| 我的订单合计 | **238** | **5,544.9ms** | **1,227.0ms** | 292.0s |
|
||||
| 大厅列表(`SELECT CASE WHEN wo.status...`) | 77 | 5,775.8ms | 1,097.2ms | 84.5s |
|
||||
| 大厅分页计数(`SELECT COUNT(*)::int AS total FROM work_orders wo...`) | 115 | 3,436.0ms | 954.6ms | 109.8s |
|
||||
| `SELECT wu.*, wl.level_key...`(打手+等级+钱包) | 486 | 1,830.1ms | 769.4ms | 373.9s |
|
||||
| `FROM worker_sessions WHERE session_id = ?` | 467 | 1,913.5ms | 825.6ms | 385.6s |
|
||||
| 其余查询(互斥分组后剩余) | 2,331 | 4,707.4ms | 805.1ms | 1,876.8s |
|
||||
|
||||
> 口径说明:上表为**互斥分组**,合计 3,718 条 / 3,140.49 秒。若把带 `INNER JOIN 可见集` 等 COUNT 变体一起归为「大厅分页计数」,该类合计 200 条、最高 3,436.0ms、平均 1,019.1ms(与「其余查询」行有交叉,不重复计入合计)。
|
||||
|
||||
> 重型查询(排行榜 + 我的订单)= **242 条,占 6.5%**;其余 **3,476 条(93.5%)** 是被拖累的查询。这也解释了为什么慢查询数量看起来「莫名其妙」——报警主体不是元凶,而是受害者。
|
||||
|
||||
**并发释放脉冲(秒级)**:
|
||||
|
||||
- `22:12:46` 1 秒内释放 **116 条**慢查询
|
||||
- `22:12:47` **108 条**
|
||||
- `22:13:29` **103 条**
|
||||
- `22:13:30` **102 条**
|
||||
|
||||
即:每次 5~8 秒的重型查询执行期间,微型查询被堵在连接池与 CPU 队列里;重型查询一结束,排队的查询在几十毫秒内同时完成,形成第二波 CPU 冲击与慢查询刷屏。
|
||||
|
||||
**请求与外挂分布(852 次 HTTP 请求)**:
|
||||
|
||||
- IP `117.179.249.212`:**359 次(42%)**,其中 **331 次**是对工单 `195657` 的死循环抢单(409)。
|
||||
- 全站 `/grab` 请求共 **426 次**,说明除该 IP 外还有约 95 次抢单落在该工单/其他工单上。
|
||||
- 文件上传 `/api/v1/worker/files/upload` **71 次**,其中 **58 次超过 1 秒**,最慢 **7,861ms**(图片转码 CPU + 数据库排队叠加)。
|
||||
|
||||
---
|
||||
|
||||
## 3. 根因剖析:为什么是「忽高忽低」
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[打手打开大厅/我的订单] --> B[8.5s 排行榜 / 5.5s 可见性查询]
|
||||
B --> C[Postgres CPU 冲到 2~3 核]
|
||||
C --> D[连接池 + CPU 队列排满]
|
||||
D --> E[微型查询全部排队 500~900ms]
|
||||
B --> F[查询结束, CPU 释放]
|
||||
F --> G[排队查询瞬间成批完成 116 条/秒]
|
||||
G --> H[第二波 CPU 冲击]
|
||||
H --> I[进入平静期]
|
||||
I --> A
|
||||
```
|
||||
|
||||
1. **低谷期(20%~50%)**:心跳、单工单读取、静态资源等轻请求,Postgres 与 Node 都轻松。
|
||||
2. **触发点(150%~300%+)**:任意 1~2 个打手刷新大厅 / 打开「我的订单」/ 上传交付图,就足以触发一次 8.5 秒级聚合(它是 buffer 命中后仍要做的 HashAggregate + Sort,不是等 IO)。
|
||||
3. **级联放大**:重型查询占满工作线程与 CPU 后,`worker_sessions`、`worker_users`、`work_categories` 这类 0.3ms 级查询排队到 800ms 以上;结束后集中释放形成第二波脉冲。
|
||||
4. **放大系数**:慢查询告警数 = 元凶数 × 排队深度。这也是为什么只看告警条数(3,718)会高估「问题查询」数量,真正的元凶只有 242 条。
|
||||
|
||||
---
|
||||
|
||||
## 4. 关键缺陷、证据链与修复状态
|
||||
|
||||
### 缺陷 1:排行榜缓存 Key 混入 `workerId` 导致缓存穿透(严重)— ✅ 已修复
|
||||
|
||||
- **源码**:[worker-hall-service.ts](file:///Users/yml/codes/order_site/apps/backend/src/services/worker-platform/worker-hall-service.ts#L152-L191)
|
||||
- **根因**:SQL 里 `WHERE rank <= $1 OR worker_id = $2` 把「当前打手自己的名次」混进同一次全表聚合,所以缓存 Key 只能带 `workerId`——于是每个打手一份缓存,公共 Top 10 完全无法共享,每个新打手首访都穿透一次 8.5 秒聚合。
|
||||
- **影响**:单次 8,483.35ms(生产日志)/ 8,257.61ms(本地 EXPLAIN),75,881 shared hit。
|
||||
- **修复**:
|
||||
1. 缓存 Key 只保留 `leaderboardPeriod`(`all` / `daily`),TTL 30s,全站共享 Top 10;
|
||||
2. `currentWorkerId` 传 0,SQL 只返回 `rank <= 10`;
|
||||
3. 个人名次:在前 10 直接从列表取;不在前 10 则 `rank = null`(前端显示「暂未上榜」)+ 单独走 `countWorkerAcceptedOrdersByPeriod`(`assigned_worker_id` 精准索引,1~2ms),带 15s 独立缓存。
|
||||
- **代价**:最多 30s 的榜单延迟、15s 的个人单数延迟,业务可接受。
|
||||
|
||||
### 缺陷 2:打手订单可见性对 162 万行事件表做文本探测(严重)— ⚠️ 部分缓解,未根治
|
||||
|
||||
- **源码**:[work-order-query-repo.ts](file:///Users/yml/codes/order_site/apps/backend/src/repositories/worker-platform/work-order-query-repo.ts#L1237-L1276)
|
||||
- **问题 SQL 形态**:
|
||||
|
||||
```sql
|
||||
INNER JOIN (
|
||||
SELECT assigned.id AS work_order_id FROM work_orders assigned WHERE assigned.assigned_worker_id = $1::bigint
|
||||
UNION
|
||||
SELECT historical.id FROM work_orders historical
|
||||
WHERE historical.last_assigned_worker_id = $1::bigint
|
||||
AND (historical.status <> 'open'
|
||||
OR EXISTS (SELECT 1 FROM work_order_events timeout_event
|
||||
WHERE timeout_event.work_order_id = historical.id
|
||||
AND timeout_event.event_type = 'timeout_reopen'))
|
||||
UNION
|
||||
SELECT share_source.work_order_id FROM work_order_shares share_source WHERE share_source.worker_id = $1::bigint AND share_source.status != 'cancelled'
|
||||
UNION
|
||||
SELECT timeout_ev.work_order_id
|
||||
FROM work_order_events timeout_ev
|
||||
WHERE timeout_ev.event_type LIKE 'timeout_%'
|
||||
AND timeout_ev.payload_json->>'workerId' = $1::text
|
||||
) worker_visible ON worker_visible.work_order_id = wo.id
|
||||
```
|
||||
|
||||
- **影响**:`work_order_events` 已 162 万行(生产现场 EXPLAIN 统计,未在本地库复核),单次最高 5,544.9ms,前端翻页/切状态时反复触发。
|
||||
- **已做**:默认排序也走「先取 id 再批量回表」的延迟分页(窄投影,避免 1.2KB `material_json` 参与排序物化),语义不变。
|
||||
- **未做**:`UNION` 四分支本身没有收敛,`timeout_%` 分支仍是按 JSON 文本等值探测。
|
||||
|
||||
### 缺陷 3:外挂死循环抢单,未触发风控阈值(中)— ✅ 已修复
|
||||
|
||||
- **现场**:IP `117.179.249.212` 3.5 分钟 359 次请求,331 次刷同一张已关闭工单,全部 409。
|
||||
- **根因**:Tier 1 阈值是「1 秒 10 次」,该脚本稳定在 2~4 QPS,永远打不到阈值;每次请求却都要走鉴权、`worker_users` 查询、工单查询与行锁探测。
|
||||
- **修复**:`work-order-grab-guard` 对「已确认不可抢」的工单打 10 秒进程内标记,命中即 0.001ms 抛 409,完全不查库。
|
||||
- **必须配套的失效机制**:工单撤单 / 超时重开 / 重新发布都会**复用同一个工单 id**,因此所有回到大厅的路径必须在 `publishWorkOrderRealtimeChange` 出口立即 `evictWorkOrderGrabBlock`,否则正常打手会在 TTL 内被误判 409。该失效已随本次修复落地,并有单测覆盖。
|
||||
|
||||
---
|
||||
|
||||
## 5. 治理方案与落地状态
|
||||
|
||||
| 方案 | 做法 | 预期收益 | 状态 |
|
||||
| :--- | :--- | :--- | :--- |
|
||||
| 1. 排行榜解耦 | Top 10 全局公共缓存(Key = period,TTL 30s)+ 个人单数独立精准查询(TTL 15s) | 全表聚合从「随在线人数线性增长」压到「每 30 秒至多 1 次(单副本)」 | ✅ 已提交 |
|
||||
| 1b. 排行榜 SQL 收敛 | 可进一步去掉 `OR worker_id = $2` 分支(现在恒传 0),把「个人名次」彻底从该 SQL 里剥离 | 少一个分支,避免后人又把 workerId 传回缓存 Key | ⬜ 待办(低风险) |
|
||||
| 2. 我的订单可见性 | ① `work_order_events` 分支改走带 `work_order_id` 的部分索引;② 把「可见工单 id 集合」物化/缓存(按打手 + 30s);③ 拆 UNION 为多个 `EXISTS` 短路 | 目标:单次 < 300ms | ⚠️ 仅完成延迟分页 |
|
||||
| 2b. 具体索引建议 | 现有 `idx_work_order_events_timeout_worker_id`(`(payload_json->>'workerId') WHERE event_type LIKE 'timeout_%'`)只覆盖过滤,仍需回表取 `work_order_id`:`CREATE INDEX IF NOT EXISTS idx_work_order_events_timeout_worker_order ON work_order_events ((payload_json->>'workerId'), work_order_id) WHERE event_type LIKE 'timeout_%';` 上线前先在生产 `EXPLAIN (ANALYZE, BUFFERS)` 验证命中 | 去掉回表 | ⬜ 待办(需生产实测) |
|
||||
| 3. 抢单防刷 | 非 open 工单 10s 进程内短路 + 状态变更即时失效 | 外挂 2~4 QPS 的数据库成本降为 0 | ✅ 已提交 |
|
||||
| 4. 风控阈值 | 或对「同一工单连续 409 ≥ 3 次」做 10~30s 递增退避(不依赖全局 QPS 阈值) | 从源头减少无效请求 | ⬜ 待办 |
|
||||
| 5. 容量 | 4 核已长期超载;若峰值仍在 2 核以上,评估升配到 8 核或读写分离 | 兜底 | ⬜ 待评估 |
|
||||
|
||||
### 验收口径(部署后按此核对)
|
||||
|
||||
1. `WITH leaderboard_day` 与「我的订单」在慢查询日志中的出现频率分别 ≤ 2 次/小时、≤ 5 次/小时。
|
||||
2. 生产 `EXPLAIN (ANALYZE, BUFFERS)`:排行榜 ≤ 300ms(缓存 miss 时)、个人单数 ≤ 5ms、我的订单首屏 ≤ 300ms。
|
||||
3. 同一 IP 对同一工单的 409 请求,数据库侧不应再出现对应查询(可用 `pg_stat_statements` 调用次数对比)。
|
||||
4. 整机 Load Average 稳定 < 4(4 核),Postgres 容器 CPU < 120%。
|
||||
5. 工单重新发布后,其他打手必须能在 1 秒内正常抢单(验证短路标记失效,不允许误杀)。
|
||||
|
||||
---
|
||||
|
||||
## 6. 风险与已知限制
|
||||
|
||||
- **进程内缓存**:排行榜榜单、个人单数、抢单短路标记都是**每进程一份**。单副本部署符合当前预期;若横向扩容为 N 副本,重算频率变为 30s × N,短路标记也只在本副本生效。届时应改用共享缓存(Redis)或接受该倍数。
|
||||
- **一致性**:榜单 30s、个人单数 15s 的延迟属于有意设计;抢单短路标记的 TTL 上限 10s 由「状态变更即时失效」兜底,不依赖 TTL。
|
||||
- **未复核项**:`work_orders` 19.6 万行 / `work_order_shares` 5.4 万行 / `work_order_events` 162 万行来自生产现场 EXPLAIN 统计,本地库(2026-10-22 快照)未重新核对;`docker stats` 采样值来自现场记录,未二次验证。
|
||||
- **验证方式**:本次修复通过 `npm run typecheck`、`npm run check:sql`、`npm run test`(后端全量)验证;性能收益需在生产部署后按第 5 节验收口径复核。
|
||||
Reference in New Issue
Block a user