fix: 免审验收容错与成本中心归一回填超时修复
- worker:等级升级计数移出验收事务,瞬时错误重试一次,最终失败转人工待办且不再返回 500 - worker:待验收阶段修改验收资料时保留 reviewMode/evidenceStatus/evidenceDueAt - cost-center:迁移 107 新增人工待归一覆盖部分索引(生产实测候选聚合 19.6s → 391ms) - cost-center:归一人工商品/按工单回填商品/模板预览前端超时放宽到 300s - cost-center:批处理接口语句超时放宽到 60s(SET LOCAL,在线接口仍为 15s)
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
import { AsyncLocalStorage } from 'node:async_hooks'
|
||||
|
||||
import { Pool } from 'pg'
|
||||
import type { PoolClient, QueryResult, QueryResultRow } from 'pg'
|
||||
|
||||
@@ -7,6 +9,28 @@ import { assertSqlParamArity } from './sql-arity.js'
|
||||
|
||||
let poolInstance: Pool | null = null
|
||||
|
||||
/**
|
||||
* 后台批处理(回填/归一)单条语句的时间上限。
|
||||
*
|
||||
* 在线接口沿用 DATABASE_STATEMENT_TIMEOUT_MS(默认 15s);这些批处理要扫
|
||||
* 十几万行,单批给 60s,避免库稍微抖一下就整批 500。真正的解法是分批 + 覆盖索引,
|
||||
* 这里只是兜底。
|
||||
*/
|
||||
export const BATCH_STATEMENT_TIMEOUT_MS = 60_000
|
||||
|
||||
/** 当前异步调用链上的批处理语句超时(不设置则走连接池默认值)。 */
|
||||
const statementTimeoutOverride = new AsyncLocalStorage<number>()
|
||||
|
||||
/**
|
||||
* 在这个异步调用链内把单条语句的 statement_timeout 放宽到 timeoutMs。
|
||||
*
|
||||
* 仅在批处理路由里使用;只影响本次请求,不会污染连接池里的其它连接。
|
||||
*/
|
||||
export function runWithStatementTimeout<T>(timeoutMs: number, fn: () => Promise<T>): Promise<T> {
|
||||
const normalized = Math.max(1, Math.trunc(Number(timeoutMs) || 0))
|
||||
return statementTimeoutOverride.run(normalized, fn)
|
||||
}
|
||||
|
||||
export function getDb(): Pool {
|
||||
if (!poolInstance) {
|
||||
const pool = new Pool({
|
||||
@@ -40,6 +64,9 @@ export async function query<T extends QueryResultRow>(
|
||||
text: string,
|
||||
params: unknown[] = [],
|
||||
): Promise<QueryResult<T>> {
|
||||
const batchTimeoutMs = statementTimeoutOverride.getStore()
|
||||
if (batchTimeoutMs) return queryWithStatementTimeout<T>(text, params, batchTimeoutMs)
|
||||
|
||||
const pool = getDb()
|
||||
const startedAt = process.hrtime.bigint()
|
||||
try {
|
||||
@@ -52,6 +79,35 @@ export async function query<T extends QueryResultRow>(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 批处理单条语句:独立事务 + SET LOCAL 放宽 statement_timeout。
|
||||
*
|
||||
* SET LOCAL 只在本事务内生效,COMMIT/ROLLBACK 后自动恢复,连接还给池子时
|
||||
* 仍然是默认的 15s;同时补上慢查询/失败日志(事务内 client.query 不会记录)。
|
||||
*/
|
||||
async function queryWithStatementTimeout<T extends QueryResultRow>(
|
||||
text: string,
|
||||
params: unknown[],
|
||||
timeoutMs: number,
|
||||
): Promise<QueryResult<T>> {
|
||||
const client = await getDb().connect()
|
||||
const startedAt = process.hrtime.bigint()
|
||||
try {
|
||||
await client.query('BEGIN')
|
||||
await client.query(`SET LOCAL statement_timeout = ${Math.trunc(timeoutMs)}`)
|
||||
const result = await client.query<T>(text, params)
|
||||
await client.query('COMMIT')
|
||||
logSlowQuery(text, startedAt, result.rowCount ?? 0)
|
||||
return result
|
||||
} catch (error) {
|
||||
logSlowQuery(text, startedAt, 0, error)
|
||||
await client.query('ROLLBACK').catch(() => undefined)
|
||||
throw error
|
||||
} finally {
|
||||
client.release()
|
||||
}
|
||||
}
|
||||
|
||||
function logSlowQuery(text: string, startedAt: bigint, rowCount: number, error?: unknown) {
|
||||
const thresholdMs = Number(runtimeConfig.database?.slowQueryThresholdMs ?? 500)
|
||||
if (thresholdMs <= 0) return
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
-- 107_order_items_manual_pending_cover_index.sql
|
||||
--
|
||||
-- 背景(2026-10-29 生产实测):
|
||||
-- 「商品与成本中心 → 归一人工商品」的候选 SKU 聚合,以及「从接单模板初始化」里
|
||||
-- 的归一数量回填,都在 15s statement_timeout 上直接 500:
|
||||
-- · 候选聚合(30 天窗口)19.6s;去掉日期联表 25.1s;换 merge join 27.5s
|
||||
-- · 执行计划是 Bitmap Heap Scan on order_items,命中 102,779 行 / 59,313 个堆块
|
||||
-- (约 460MB 随机读)——现有 idx_order_items_manual_sku_key_stored 只带
|
||||
-- manual_sku_key,sku_name / canonical_product_id / order_id 仍需回表。
|
||||
--
|
||||
-- 这个覆盖部分索引只存「待归一的人工订单项」,让候选聚合与按 id 分批回填都能走
|
||||
-- index-only scan,预计 20 秒级降到 1–2 秒。
|
||||
--
|
||||
-- 迁移在 singleTransaction 里执行(见 migrate.ts),所以这里用普通 CREATE INDEX;
|
||||
-- 生产按 086 的惯例先执行一次 CREATE INDEX CONCURRENTLY 预建,
|
||||
-- IF NOT EXISTS 会直接跳过,部署时不锁表。
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_order_items_manual_pending_cover
|
||||
ON order_items (id)
|
||||
INCLUDE (order_id, manual_sku_key, sku_name)
|
||||
WHERE fulfillment_mode = 'manual'
|
||||
AND canonical_product_id IS NULL
|
||||
AND manual_sku_key IS NOT NULL;
|
||||
|
||||
COMMENT ON INDEX idx_order_items_manual_pending_cover IS
|
||||
'人工商品归一/回填:待归一人工订单项的覆盖索引(2026-10-29 性能修复)';
|
||||
Reference in New Issue
Block a user