先给结论:不要从 P50/P99 公式推阈值,直接从「成功率 vs remaining」的经验分布曲线里找拐点——拐点就是阈值;拐点两侧用不同动作,而不是一个阈值一刀切。
你试了 80ms / 100ms / 120ms 三个点,成功率分别是 31% / 18% / 误杀,说明拐点大概率落在 100~150ms 之间,但你没有足够密的数据点来精确定位。下面给你一套可复现的方法。
一、从数据里反推阈值(可复现方法)
1. 取数:按 remaining 分桶统计成功率
在结算服务入口(或网关层)埋一个指标:每个请求到达末跳时的 remaining_budget_ms 和最终 success/fail。用 Prometheus / 自建 histogram:
// 结算服务入口,拿到 deadline 后
long remaining = Math.max(0, deadline - System.nanoTime() / 1_000_000);
// 分桶:10ms 粒度,0~200ms 区间
int bucket = (int) Math.min(remaining, 200) / 10;
Metrics.histogram("settlement.remaining_budget", bucket);
// 请求结束后
Metrics.counter("settlement.outcome", outcome == OK ? "success" : "fail");
对应 PromQL(按桶聚合成功率):
# 每个 10ms 桶的成功率
sum(rate(settlement.outcome_total{result="success"}[5m])) by (bucket)
/
sum(rate(settlement.outcome_total[5m])) by (bucket)
如果你用 MySQL 记录日志,等价 SQL:
SELECT
FLOOR(remaining_ms / 10) * 10 AS bucket_ms,
COUNT(*) AS total,
SUM(CASE WHEN outcome='success' THEN 1 ELSE 0 END) AS ok,
ROUND(SUM(CASE WHEN outcome='success' THEN 1 ELSE 0 END) / COUNT(*) * 100, 1) AS success_pct
FROM t_request_log
WHERE dt >= DATE_SUB(CURDATE(), INTERVAL 7 DAY)
AND service = 'settlement'
GROUP BY bucket_ms
HAVING total >= 30 -- 样本量 < 30 的桶不采信
ORDER BY bucket_ms;
2. 找拐点:一阶差分最大处
把成功率按桶画出来,你会看到类似:
remaining: 0 10 20 30 40 50 60 70 80 90 100 110 120 130 140 150 ...
success%: 5 12 18 25 33 42 55 68 78 85 90 93 96 98 99 99.6
拐点 = 一阶差分(Δsuccess%)从大到小突变的边界。上例中 100→110ms 的 Δ 从 5% 跳到 3%,这就是拐点。阈值取拐点左侧第一个桶:T = 100ms。
为什么不用
P50 + 本地计算 P99 + 安全系数?因为那个公式假设耗时是正态分布的,但你的实际分布是「DB 锁等待 + GC 暂停」的长尾,公式会系统性低估尾部风险。直接用经验分布,不引入分布假设。
3. 安全边际
拐点阈值 T 再往左退 10%~20% 作为硬阈值(防止 P99 漂移):
hard_threshold = T × 0.85 // 上例 → 85ms
soft_threshold = T × 1.15 // 上例 → 115ms
二、三档动作判据(不是二选一)
不要只分「发起 / 不发起」,分三档:
| remaining 区间 | 动作 | 理由 |
|---|---|---|
| remaining < hard_threshold (85ms) | 快速失败:返回 503 + Retry-After: 2s,不触碰 MySQL | 成功率 < 35%,发起 = 浪费连接 + 拖慢后续请求(你实测的二次恶化) |
| hard_threshold ≤ remaining < soft_threshold (85~115ms) | 降级执行:只写流水(1 条 DML),跳过风控结果落库(省 2 条 DML + 1 条查询),事务耗时从 ~45ms 降到 ~15ms | 成功率 35%~90%,赌一把但有兜底 |
| remaining ≥ soft_threshold (≥115ms) | 照常执行:完整 3 DML + 1 查询 | 成功率 > 90%,正常路径 |
降级执行的一致性边界
这是你问的第三点,给具体做法:
// 结算服务入口
long remaining = deadline - elapsed();
if (remaining < HARD_THRESHOLD_MS) {
// 快速失败:不碰 DB
return Result.retryable(503, "insufficient budget");
}
if (remaining < SOFT_THRESHOLD_MS) {
// 降级:只写流水,标记待补全
settlementRepo.writeLedgerOnly(orderId, amount, /* riskResult = */ null);
// 写入补偿标记
compensationRepo.mark(orderId, COMPENSATION_TYPE.RISK_BACKFILL,
Instant.now().plus(Duration.ofMinutes(30))); // 30min 窗口
return Result.ok(DegradedMode.LEDGER_ONLY);
}
// 正常路径
settlementRepo.writeFull(orderId, amount, riskResult);
补偿任务:
- 扫描
t_compensation表,type = RISK_BACKFILL AND created_at BETWEEN now-30min AND now - 补全风控结果落库(1 条 UPDATE)
- 对账口径:T+1 对账时,
t_settlement中risk_result IS NULL AND mode = 'DEGRADED'的行视为「待补全」,不视为异常 - 补偿超时(> 2h 未补全)→ 告警 + 人工介入
关键原则:降级路径的 DML 必须是幂等的(用 INSERT ... ON DUPLICATE KEY UPDATE 或 INSERT IGNORE),这样补偿任务重试不会重复写入。
三、你提到的「二次恶化」怎么防
你观察到超时请求把 HikariCP active 从 12 顶到 48,拖慢了后续正常请求。快速失败(hard_threshold 以下不碰 DB)直接切断了这个回路。但还需要一层保险:
# HikariCP 配置
maximumPoolSize=30 # 从 20 提到 30,留 50% 余量
leakDetectionThreshold=1000 # 连接持有 > 1s 打 warn 日志
配合应用层的并发闸门(Semaphore):
// 结算服务:同一时刻最多 N 个请求在途
private static final Semaphore DB_GATE = new Semaphore(20);
public Result settle(OrderOrder order) {
if (!DB_GATE.tryAcquire()) {
return Result.retryable(503, "db gate full");
}
try {
// 正常执行
} finally {
DB_GATE.release();
}
}
这样即使有漏网的低预算请求进来,也不会把所有连接占满。
四、开放问题(想听社区讨论)
你们团队在串行链路里遇到「末跳预算饥饿」时,有没有人试过让上游把 deadline 传递方式从「固定比例切分」改成「末跳保底 + 上游弹性」?即:末跳保底 120ms(= P99 × 1.3),剩余 680ms 由前 3 跳按比例分配、谁慢谁吃——而不是每跳各留 40%。这个方案的好处是末跳永远有「够用的」预算,代价是前 3 跳的超时概率会上升。你们觉得哪个方向更合理?