Commit 2a02d9d1 by luoqi

fix: 自带 PG 的 /dev/shm 提到 1g + 画像版本号并发竞态(测试服全量回填暴露的两个问题)

昨夜测试服跑优化后的全量回填(35.7 万患者,2.2 小时跑完、2,694 人/分)暴露两个**既有**问题,
都不是优化本身造成的。

## ① /dev/shm 64MB → plan 全量重算当场崩

  ERROR: could not resize shared memory segment "/PostgreSQL.xxx" to 12615680 bytes:
         No space left on device

Docker 给 /dev/shm 的默认只有 64MB(inspect 确认 ShmSize=67108864),而 PG 的并行查询
worker 用共享内存段交换中间结果。召回的 selectHits 把 **10 个子场景 Promise.all 并行跑**,
每个都是 2500 万 patient_facts 上带 LATERAL 的重查询、各自还起 parallel worker,
并发的段远超 64MB。

崩在 selectHits → runSubScenario,那处 Promise.all 本次一个字没动 —— 与 worker pool 无关。
compose 里给 postgres 加 `shm_size: 1g`(PG 容器常规配法)。
️ 生产用阿里云托管 RDS、不启本服务,不受此问题影响,也不受本改动影响。

## ② CLI 与 stale-scan cron 抢版本号 → Unique constraint

全量回填跨过了 02:00 的 `PAC_STALE_SCAN_CRON`,两个**独立进程**同时给同一批患者建新版本:
各自在事务外读到同一个 latest.version、算出同一个 nextVersion →
`Unique constraint failed on the fields: (patient_id, version)`。
实测那一轮 CLI 挂 688 个、**定时任务自己挂 1,604 个**(它伤得更重)。

BullMQ processor 内部按 patient_id 串行,挡得住自己人,挡不住外面另起的 CLI 进程。
上一轮旧代码没撞,只是因为它在 2 点前就被我杀了 —— 运气,不是设计。

修法:两条写路径进事务先拿**按患者的 advisory 事务锁**
(`pg_advisory_xact_lock(hashtext(patientId))`,随事务自动释放、跨进程有效);
建版本那支再**在锁内重读**最高版本 —— 迟到者看到已经变大的版本号,顺序叠上去。
就地刷新那支也拿同一把锁,否则可能写到刚被并发写者 supersede 的版本上。

## 验证

本地真库对照:取同 300 个患者、删掉其画像(逼两个进程都算出 version=1)、两进程并发 --force

  旧代码   A: success 18  failed 216  |  B: success 282 failed 18   → **234 次 unique 冲突**
  新代码   A: success 186 failed 0    |  B: success 300 failed 0    → **0 次冲突**

498 单测通过(36 suites),tsc 干净。persona-watermark.spec 新增 4 例锁住:
建版本前必拿锁 / 版本号锁内重读 / 就地刷新那支也拿锁 / noop 不白拿锁。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
parent b9765cd8
Pipeline #3450 failed in 0 seconds
...@@ -400,6 +400,9 @@ export class PersonaService { ...@@ -400,6 +400,9 @@ export class PersonaService {
status = verdict === 'unchanged' ? 'unchanged' : 'refreshed'; status = verdict === 'unchanged' ? 'unchanged' : 'refreshed';
await this.prisma.$transaction( await this.prisma.$transaction(
async (tx) => { async (tx) => {
// 同下面建版本那支拿同一把患者级 advisory 锁:否则并发写者可能正在 supersede
// 这一行 / 建新版本,我们的就地刷新就写到了一个刚变成历史的版本上。
await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${patient.id}))`;
if (verdict === 'volatile') { if (verdict === 'volatile') {
// key 集合与语义指纹都已确认相同 → 按 (personaId,key) 逐条就地改,保住行 id。 // key 集合与语义指纹都已确认相同 → 按 (personaId,key) 逐条就地改,保住行 id。
for (const d of drafts) { for (const d of drafts) {
...@@ -424,12 +427,29 @@ export class PersonaService { ...@@ -424,12 +427,29 @@ export class PersonaService {
{ maxWait: 30000, timeout: 60000 }, { maxWait: 30000, timeout: 60000 },
); );
} else { } else {
const nextVersion = (latest?.version ?? 0) + 1; // ⭐ 版本号必须在**锁内重读**,不能用事务外那次 findFirst 的结果。
// 2026-07-27 测试服实测:CLI 全量回填跨过了 02:00 的 stale-scan cron,两个进程
// 同时给同一患者建新版本 —— 各自在事务外读到同一个 latest.version、算出同一个
// nextVersion → `Unique constraint failed on (patient_id, version)`。
// 那一轮 CLI 挂 688 个、定时任务自己挂 1,604 个。
// BullMQ 内部按 patient_id 串行挡得住自己人,挡不住外面另起的 CLI 进程。
// 修法:进事务先拿**按患者的 advisory 事务锁**(随事务自动释放,跨进程有效),
// 再重读当前最高版本 —— 迟到的那个会看到已经变大的版本号,顺序叠上去,不再冲突。
let nextVersion = (latest?.version ?? 0) + 1;
const created = await this.prisma.$transaction( const created = await this.prisma.$transaction(
async (tx) => { async (tx) => {
if (active) { await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtext(${patient.id}))`;
// 锁内重读:并发写者可能已经把版本推高,也可能已经把我们看到的 active 换掉了
const fresh = await tx.persona.findFirst({
where: { patientId: patient.id },
orderBy: { version: 'desc' },
select: { id: true, version: true, supersededAt: true },
});
nextVersion = (fresh?.version ?? 0) + 1;
const freshActive = fresh && !fresh.supersededAt ? fresh : null;
if (freshActive) {
await tx.persona.update({ await tx.persona.update({
where: { id: active.id }, where: { id: freshActive.id },
data: { supersededAt: new Date() }, data: { supersededAt: new Date() },
}); });
} }
......
...@@ -76,9 +76,25 @@ function makeHarness(o: Opts) { ...@@ -76,9 +76,25 @@ function makeHarness(o: Opts) {
featureUpdate: [] as Record<string, unknown>[], featureUpdate: [] as Record<string, unknown>[],
watermarkQueries: 0, watermarkQueries: 0,
txnFindFirst: 0, txnFindFirst: 0,
/// 事务内拿的患者级 advisory 锁(防 CLI 与 stale-scan cron 抢版本号,见 persona.service)
advisoryLocks: [] as string[],
/// 事务内重读最高版本的次数 —— 版本号必须锁内重读,不能用事务外那次
versionRereads: 0,
}; };
const tx = { const tx = {
$executeRaw: jest.fn().mockImplementation((strings: TemplateStringsArray) => {
calls.advisoryLocks.push(strings.join('?'));
return Promise.resolve(1);
}),
persona: { persona: {
findFirst: jest.fn().mockImplementation(() => {
calls.versionRereads++;
return Promise.resolve(
o.persona
? { id: 'persona-old', version: 3, supersededAt: null, ...o.persona }
: null,
);
}),
update: jest.fn().mockImplementation(({ data }) => { update: jest.fn().mockImplementation(({ data }) => {
calls.personaUpdate.push(data); calls.personaUpdate.push(data);
return Promise.resolve({}); return Promise.resolve({});
...@@ -293,3 +309,66 @@ describe('查询瘦身', () => { ...@@ -293,3 +309,66 @@ describe('查询瘦身', () => {
expect(calls.txnFindFirst).toBe(0); // 旧实现这里会再查一次 latest event_seq expect(calls.txnFindFirst).toBe(0); // 旧实现这里会再查一次 latest event_seq
}); });
}); });
/**
* 并发写版本号 —— 2026-07-27 测试服实测事故的回归。
*
* CLI 全量回填(concurrency=8)跨过了 02:00 的 stale-scan cron,两个**独立进程**同时给
* 同一批患者建新版本:各自在事务外读到同一个 latest.version、算出同一个 nextVersion →
* `Unique constraint failed on the fields: (patient_id, version)`
* 那一轮 CLI 挂 688 个、定时任务自己挂 1,604 个(它伤得更重)。
*
* BullMQ processor 内部按 patient_id 串行,挡得住自己人,挡不住外面另起的 CLI 进程。
* 修法:两条写路径进事务先拿**按患者的 advisory 事务锁**(pg_advisory_xact_lock,
* 随事务自动释放、跨进程有效),建版本那支再**在锁内重读**最高版本 —— 迟到者看到
* 已经变大的版本号,顺序叠上去,不再撞唯一约束。
*/
describe('并发安全 —— CLI 与 cron 抢版本号', () => {
test('⭐ 建新版本前必须拿患者级 advisory 锁', async () => {
const { svc, calls } = await build({
persona: { eventWatermark: 100n, factWatermark: T0 },
storedFeatures: [{ key: 'gender', description: '男性', score: null, data: null, evidence: {} }],
now: { eventSeq: 200n, factAt: T1 }, // 水位前进 → 走建版本那支
});
await svc.recompute({ patientId: 'pat-1', source: 'test' });
expect(calls.personaCreate).toBe(1);
expect(calls.advisoryLocks).toHaveLength(1);
expect(calls.advisoryLocks[0]).toContain('pg_advisory_xact_lock');
});
test('⭐ 版本号在**锁内重读**,不能用事务外那次查询的结果', async () => {
const { svc, calls } = await build({
persona: { eventWatermark: 100n, factWatermark: T0 },
storedFeatures: [{ key: 'gender', description: '男性', score: null, data: null, evidence: {} }],
now: { eventSeq: 200n, factAt: T1 },
});
await svc.recompute({ patientId: 'pat-1', source: 'test' });
expect(calls.versionRereads).toBe(1); // 锁拿到之后又查了一次最高版本
});
test('⭐ 就地刷新那支也要拿同一把锁(否则会写到刚被 supersede 的版本上)', async () => {
const { svc, calls } = await build({
persona: { eventWatermark: 100n, factWatermark: T0 },
now: { eventSeq: 100n, factAt: T1 },
// 只有天数漂移 → volatile → 就地刷新,不升版本(口径同上面 refreshed 那条)
storedFeatures: storedFrom(RFM()),
draft: RFM({
description: '重要价值 · 距上次 151 天 · 累计¥154,350',
data: { segment: 'important_value', recencyDays: 151, monetaryCents: 15435000 },
}),
});
await svc.recompute({ patientId: 'pat-1', source: 'test' });
expect(calls.personaCreate).toBe(0); // 确认走的是就地刷新
expect(calls.advisoryLocks).toHaveLength(1);
expect(calls.advisoryLocks[0]).toContain('pg_advisory_xact_lock');
});
test('noop(闸拦下)不该白拿锁 —— 压根没进事务', async () => {
const { svc, calls } = await build({
persona: { eventWatermark: 200n, factWatermark: T1 },
now: { eventSeq: 200n, factAt: T1 }, // 水位没动 → noop
});
await svc.recompute({ patientId: 'pat-1', source: 'test' });
expect(calls.advisoryLocks).toHaveLength(0);
});
});
...@@ -22,6 +22,13 @@ services: ...@@ -22,6 +22,13 @@ services:
image: postgres:16-alpine image: postgres:16-alpine
restart: always restart: always
env_file: ./apps/pac-service/.env # 读 POSTGRES_USER/PASSWORD/DB env_file: ./apps/pac-service/.env # 读 POSTGRES_USER/PASSWORD/DB
# ⭐ Docker 给 /dev/shm 的默认只有 64MB,PG 的并行查询 worker 用共享内存段交换中间结果,
# 一超就是 `could not resize shared memory segment ... No space left on device`。
# 2026-07-27 测试服实测踩到:召回 selectHits 把 10 个子场景 Promise.all 并行跑,
# 每个都是 2500 万 patient_facts 上带 LATERAL 的重查询、各自还起 parallel worker,
# 并发的段远超 64MB → plan 全量重算当场崩。
# 1g 是 PG 容器的常规配法(官方镜像文档亦如此建议);托管 DB 形态下本服务不启用,无影响。
shm_size: 1g
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
ports: ports:
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment