「防重复提交」和「接口限流」听起来都是小事,真正做到并发正确却不容易。我们的表单接口在压测里连续暴露了三个并发问题,每个的根因都和 PostgreSQL 的事务语义有关。本文给出错误写法、根因、修法,以及我们最终固化的并发测试方式。
坑一:幂等重试,读到的还是旧快照
接口用「提交键」防重复,自然的写法是"先查后写":
existing = search([('key', '=', key)])
if existing:
return existing.reference # 重试:直接返回既有单号
record = create(...) # 首次:建档
单线程下完全正确。但在可重复读隔离级别下,同一个事务里再查一次,看到的还是事务开始时的快照。序列变成:请求 A 建了档但还没提交,请求 B(重试)在快照里怎么查都看不到 A,于是 B 也走建档——最后靠唯一约束兜底报错。错误码对了,但"先查后写"的优化完全失效,还多走了一次失败路径。
修法是两条:
- 正确性下沉到数据库:给提交键建唯一约束,"先查后写"只当快路径;冲突时读出既有记录返回,这才是幂等性的最终保证;
- 要读新数据就换连接:需要"再查一次看到别人刚提交的"时,用独立连接/新事务重读——在 RR 事务里再查多少次都是旧快照。
坑二:限流计数 upsert 的序列化失败
限流用一张计数表,按「键 + 时间窗」做 INSERT ... ON CONFLICT 自增:
INSERT INTO rate_bucket (key, window_start, count)
VALUES (%s, %s, 1)
ON CONFLICT (key, window_start)
DO UPDATE SET count = rate_bucket.count + 1;
单线程没问题,并发下开始出现数据库抛出的序列化失败——可重复读里两个事务更新同一行,晚到的一方可能被要求回滚重来。处理分三层:
- 计数事务独立提交:限流计数不能挂在业务事务里,否则业务回滚会把已消费的次数"退还",限流形同虚设;
- 有限重试:序列化失败是瞬时错误,捕获后做少量重试即可收敛;
- 口径明确:宁可多放行一次,不可把限流失败变成请求失败——计数异常按放行处理并记录告警。
坑三:同键并发双请求,谁赢都对
即使有唯一约束,仍要回答一个问题:两个同键请求几乎同时到达,"后来者"该收到什么?
我们的答案:200 + 既有单号,直接依赖唯一约束——插入冲突时读出既有记录返回,"谁先到"不参与结果。
并发测试怎么写
这三个坑有一个共同点:单线程测试全都发现不了。我们的做法是把并发场景固化成测试:
| 场景 | 并发方式 | 期望结果 |
|---|---|---|
| 同键同内容双请求 | 两个连接同时提交 | 201 + 200,返回同一单号 |
| 同键异内容双请求 | 两个连接同时提交 | 201 + 409,明确冲突 |
| 限流阈值边界 | 并发送超阈值请求 | 不超过阈值放行,其余 429 |
| 幂等重试读新数据 | A 提交后 B 立即重试 | B 看到 A 的记录,不重复建档 |
关键是真的用两个数据库连接同时打——在同一个事务/连接里模拟并发,等于什么都没测。
小结
把三件事连起来看,规律很清楚:并发正确性靠数据库约束与事务语义兜底,应用层只负责体验。先查后写、进程内锁、事务内重读这些"看起来更省"的做法,在可重复读下都可能是错的。每条约束配一个真并发的测试,比事后修线上数据便宜得多。
