背景:生产 32 天内 >500ms 慢查询从 2.4k/天涨到 1.3 万/天(09-26 尖峰 14.3 万), pg_stat_statements 显示后台工单列表族占 57%、打手端 visible_orders 占 27%; 同时 backend 容器十天从 194MB 涨到 754MB(768MB 限额的 98%),贴顶后整体变慢。 1. 新增迁移 083:work_orders 增加派生列 source_order_amount_fen,并从 material_json.source.orderAmountFen 回填(只认 15 位以内整数,与 JS/SQL 口径一致)。 统计查询原来每行要解析 1.2KB JSON(全表约 132MB)且同一表达式求值两次, 生产实测 3.5~4.4 秒;改走派生列后 COALESCE 短路,未命中列的行才回退 JSON/orders.total_amount, 口径与改造前完全一致。写入点:createWorkOrder / updateWorkOrder 各一处,并加静态守卫。 2. worker_sessions.last_seen_at 从"每个请求都写"改为"5 分钟才写一次": 32 天被写了 3300 万次(占 DB 总时间 2.8%,并堆出 14% 死元组)。仍用一条语句返回鉴权有效性 (EXISTS target AS valid),不能因为不写就让上层 401。 3. 保留策略补上 work_order_events(71.7 万行 / 269MB,此前完全没纳入清理,只增不减), 新增配置 retention.workOrderEventDays(默认 180 天)与 WORK_ORDER_EVENT_RETENTION_DAYS 出口。 4. 内存治理:compose 后端限额 768m→1g 并显式设置 V8 老生代上限(--max-old-space-size=768), 避免贴 cgroup 上限后 GC 抖动;新增每 10 分钟的进程内存快照日志(rss/heapUsed/heapLimit), 用于区分"正常 GC 曲线"与"真泄漏"。 5. 限流桶加 5 万条硬上限:key 里含请求体字段(collect 的 orderNo)时可被构造成高基数, 只按过期清理挡不住内存增长。 验证:后端 530 项单测全绿(新增 11 项,含派生列/会话/保留的静态守卫与解析函数单测)。
147 lines
3.7 KiB
TypeScript
147 lines
3.7 KiB
TypeScript
import test from 'node:test'
|
|
import assert from 'node:assert/strict'
|
|
import type { NextFunction, Request, Response } from 'express'
|
|
|
|
import { createRateLimitMiddleware, resetRateLimitBucketsForTest } from './rate-limit.js'
|
|
|
|
test('createRateLimitMiddleware allows requests within the window', () => {
|
|
resetRateLimitBucketsForTest()
|
|
|
|
const limiter = createRateLimitMiddleware({
|
|
scope: 'test:allow',
|
|
windowMs: 60_000,
|
|
max: 2,
|
|
})
|
|
const req = createMockRequest()
|
|
const res = createMockResponse()
|
|
let nextCount = 0
|
|
const next: NextFunction = () => {
|
|
nextCount += 1
|
|
}
|
|
|
|
limiter(req, res, next)
|
|
limiter(req, res, next)
|
|
|
|
assert.equal(nextCount, 2)
|
|
assert.equal(res.statusCode, 200)
|
|
})
|
|
|
|
test('createRateLimitMiddleware blocks requests over the limit', () => {
|
|
resetRateLimitBucketsForTest()
|
|
|
|
const limiter = createRateLimitMiddleware({
|
|
scope: 'test:block',
|
|
windowMs: 60_000,
|
|
max: 1,
|
|
})
|
|
const req = createMockRequest()
|
|
const res = createMockResponse()
|
|
let nextCount = 0
|
|
const next: NextFunction = () => {
|
|
nextCount += 1
|
|
}
|
|
|
|
limiter(req, res, next)
|
|
limiter(req, res, next)
|
|
|
|
assert.equal(nextCount, 1)
|
|
assert.equal(res.statusCode, 429)
|
|
assert.equal(res.headers['Retry-After'], '60')
|
|
assert.equal(res.body.errorCode, 'rate_limited')
|
|
})
|
|
|
|
test('createRateLimitMiddleware supports custom limit responses', () => {
|
|
resetRateLimitBucketsForTest()
|
|
|
|
const limiter = createRateLimitMiddleware({
|
|
scope: 'test:custom',
|
|
windowMs: 60_000,
|
|
max: 1,
|
|
onLimit: (_req, res) => {
|
|
res.status(200).json({ code: 429, message: 'limited' })
|
|
},
|
|
})
|
|
const req = createMockRequest()
|
|
const res = createMockResponse()
|
|
|
|
limiter(req, res, () => {})
|
|
limiter(req, res, () => {})
|
|
|
|
assert.equal(res.statusCode, 200)
|
|
assert.deepEqual(res.body, { code: 429, message: 'limited' })
|
|
})
|
|
|
|
test('createRateLimitMiddleware can cap an IP across different accounts', () => {
|
|
resetRateLimitBucketsForTest()
|
|
|
|
const limiter = createRateLimitMiddleware({
|
|
scope: 'test:ip-wide',
|
|
windowMs: 60_000,
|
|
max: 2,
|
|
})
|
|
const first = createMockRequest()
|
|
const second = createMockRequest()
|
|
const third = createMockRequest()
|
|
const response = createMockResponse()
|
|
let nextCount = 0
|
|
const next: NextFunction = () => {
|
|
nextCount += 1
|
|
}
|
|
|
|
limiter(first, response, next)
|
|
limiter(second, response, next)
|
|
limiter(third, response, next)
|
|
|
|
assert.equal(nextCount, 2)
|
|
assert.equal(response.statusCode, 429)
|
|
})
|
|
|
|
function createMockRequest(): Request {
|
|
return {
|
|
ip: '127.0.0.1',
|
|
originalUrl: '/test',
|
|
headers: {},
|
|
socket: {
|
|
remoteAddress: '127.0.0.1',
|
|
},
|
|
} as Request
|
|
}
|
|
|
|
function createMockResponse(): Response & {
|
|
statusCode: number
|
|
headers: Record<string, string>
|
|
body: any
|
|
} {
|
|
const res = {
|
|
statusCode: 200,
|
|
headers: {} as Record<string, string>,
|
|
body: null as any,
|
|
setHeader(name: string, value: string) {
|
|
this.headers[name] = value
|
|
return this
|
|
},
|
|
status(statusCode: number) {
|
|
this.statusCode = statusCode
|
|
return this
|
|
},
|
|
json(body: any) {
|
|
this.body = body
|
|
return this
|
|
},
|
|
}
|
|
|
|
return res as Response & {
|
|
statusCode: number
|
|
headers: Record<string, string>
|
|
body: any
|
|
}
|
|
}
|
|
|
|
test('限流桶有数量硬上限:key 含用户可控字段时不能无限膨胀', async () => {
|
|
const { readFile } = await import('node:fs/promises')
|
|
const src = await readFile(new URL('./rate-limit.ts', import.meta.url), 'utf8')
|
|
// collect 路由的限流 key 里带请求体 orderNo(可被构造),只按过期清理挡不住高基数灌键
|
|
assert.ok(src.includes('MAX_RATE_LIMIT_BUCKETS'), '缺少限流桶数量上限')
|
|
assert.ok(src.includes('buckets.size > MAX_RATE_LIMIT_BUCKETS'), '必须在清理时按上限淘汰最老的桶')
|
|
})
|