高并发场景下如何避免重复下单?基于 PostgreSQL 的接口幂等性设计与实现
高并发场景下如何避免重复下单?基于 PostgreSQL 的接口幂等性设计与实现
一、问题背景
在业务系统中,重复请求是一个非常常见但又容易被忽略的问题。
例如,用户在提交订单时,由于网络延迟,点击按钮后没有立即收到响应,于是再次点击提交。
此时服务端可能收到两次内容完全相同的请求。
如果接口没有进行特殊处理,就可能创建两笔订单。
更复杂的情况是:第一次请求实际上已经成功,但由于网络异常,客户端没有收到响应,于是自动重试。
这种情况下,即使前端做了按钮防重复点击,也无法彻底解决问题。
核心问题在于:如何保证同一个业务操作,即使被请求多次,也只产生一次预期的业务结果?
这就是接口幂等性需要解决的问题。
本文以 Spring Boot 和 PostgreSQL 为例,介绍一种不依赖 Redis 分布式锁的幂等性实现方案。
二、为什么简单的查询判断不可靠?
初学者比较容易想到一种实现:
public void createOrder(OrderRequest request) {
// 检查订单是否存在
if (orderRepository.existsByRequestId(request.getRequestId())) {
return;
}
// 创建订单
orderRepository.save(buildOrder(request));
}
这种方式在单线程场景下似乎没有问题,但在并发环境中存在明显缺陷。
假设请求 A 和请求 B 几乎同时执行:
| 时间 | 请求 A | 请求 B |
|---|---|---|
| T1 | 查询订单不存在 | |
| T2 | 查询订单不存在 | |
| T3 | 创建订单 | |
| T4 | 创建订单 | |
| T5 | 提交成功 | 提交成功 |
两个请求都通过了存在性检查。
这是一种典型的 TOCTOU(Time-of-check to Time-of-use)竞态条件。
也就是说,在检查条件与实际执行操作之间,系统状态可能已经发生变化。
仅靠应用层的 if 判断,无法保证并发操作的原子性。
对于这种问题,更可靠的方案是让数据库参与并发控制。
三、利用 PostgreSQL 唯一约束实现幂等性
1. 引入幂等键
首先,需要为每一次业务操作生成一个唯一标识。
例如,客户端提交订单时携带请求头:
POST /api/orders
Content-Type: application/json
Idempotency-Key: 8d7f9a21-3c52-4b6e-9f18-28d940c17a31
{
"productId": 1001,
"quantity": 2,
"amount": 19900
}
这里的 Idempotency-Key 就是幂等键。
它需要满足两个条件:
- 同一次业务操作的所有重试,使用相同的幂等键。
- 不同业务操作,使用不同的幂等键。
例如用户购买两次同样的商品,虽然请求参数完全一致,但应该生成两个不同的幂等键。
这意味着,不能简单地根据请求参数是否相同来判断是否属于重复请求。
2. 设计数据库表
创建订单表:
CREATE TABLE orders (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL,
idempotency_key VARCHAR(128) NOT NULL,
request_hash VARCHAR(64) NOT NULL,
amount BIGINT NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT uk_order_idempotency
UNIQUE (user_id, idempotency_key)
);
这里有两个需要注意的地方。
第一,使用 (user_id, idempotency_key) 作为联合唯一约束。
这样可以将幂等键的作用范围限制在用户维度,避免不同用户之间发生不必要的冲突。
第二,增加 request_hash 字段。
它用于记录请求内容的摘要。
原因是同一个幂等键原则上不允许对应不同的业务参数。
例如第一次请求购买商品 A,第二次却使用相同的幂等键购买商品 B,应该直接拒绝,而不是返回第一次请求的结果。
实际实现中,可以对规范化后的关键业务参数计算 SHA-256 摘要。
3. 使用 ON CONFLICT 处理并发插入
PostgreSQL 提供了 ON CONFLICT 语法,可以非常方便地处理唯一约束冲突。
INSERT INTO orders (
user_id,
idempotency_key,
request_hash,
amount,
status
)
VALUES (
10001,
'request-001',
'a1b2c3d4',
19900,
'PENDING'
)
ON CONFLICT (user_id, idempotency_key)
DO NOTHING
RETURNING id;
这条 SQL 的行为是:
情况一:订单不存在
数据库正常插入订单,并返回新订单 ID。
情况二:订单已经存在
触发唯一约束冲突,不执行插入操作,也不会返回新订单 ID。
这样,就可以利用 PostgreSQL 的唯一约束解决并发创建问题。
与应用层先查询再插入相比,数据库约束能够提供更可靠的并发保护。
需要注意,这里只是保证订单记录不会重复创建,并不意味着整个下单流程中的所有外部操作都已经具备幂等性。
四、在 Spring Boot 中实现
下面使用 Spring JDBC 展示核心实现思路。
假设订单创建服务提供如下方法:
@Transactional
public Long createOrder(
Long userId,
String idempotencyKey,
String requestHash,
Long amount) {
String insertSql = """
INSERT INTO orders (
user_id,
idempotency_key,
request_hash,
amount
)
VALUES (?, ?, ?, ?)
ON CONFLICT (user_id, idempotency_key)
DO NOTHING
RETURNING id
""";
List<Long> ids = jdbcTemplate.query(
insertSql,
(rs, rowNum) -> rs.getLong("id"),
userId,
idempotencyKey,
requestHash,
amount
);
// 插入成功,返回新订单
if (!ids.isEmpty()) {
return ids.get(0);
}
// 已有订单,查询原始请求信息
String selectSql = """
SELECT id, request_hash
FROM orders
WHERE user_id = ?
AND idempotency_key = ?
""";
return jdbcTemplate.queryForObject(
selectSql,
(rs, rowNum) -> {
String oldHash = rs.getString("request_hash");
if (!oldHash.equals(requestHash)) {
throw new IllegalArgumentException(
"幂等键已被不同请求使用"
);
}
return rs.getLong("id");
},
userId,
idempotencyKey
);
}
这里使用 Spring 的 @Transactional 管理事务。
当插入成功时,直接返回新订单 ID。
当唯一键冲突时,读取已有订单的请求摘要。
如果摘要相同,说明属于同一次业务操作的重复提交,返回原来的订单 ID。
如果摘要不同,则拒绝本次请求。
实际项目中,可以进一步将摘要不一致映射为 HTTP 409 Conflict。
需要注意,这个示例基于 PostgreSQL 默认的 READ COMMITTED 隔离级别。发生并发唯一键冲突时,插入语句可能等待另一个事务结束,随后执行的查询才能读取已提交记录。
如果项目采用其他事务隔离级别,或者订单可能被其他流程立即删除,需要单独处理可见性和并发异常。
五、为什么不直接使用 Redis 分布式锁?
Redis 分布式锁也经常被用于防止重复提交。
例如:
SET order:user10001:request001 1 NX EX 30
这条命令可以尝试获取一个有效期为 30 秒的锁。
如果获取成功,说明当前请求获得了执行权限。
但这里有一个重要问题:
分布式锁提供的是一段时间内的互斥控制,并不等于永久性的业务幂等保证。
假设出现以下执行过程:
- 请求 A 成功获取 Redis 锁。
- 请求 A 创建订单。
- 数据库事务提交成功。
- 服务在释放锁之前异常退出。
- Redis 锁到期。
- 请求 B 使用相同参数再次提交。
如果没有数据库唯一约束,请求 B 依然可能创建重复订单。
因此,即使使用 Redis 分布式锁,也不应该完全依赖它保证核心业务数据的唯一性。
对于订单创建场景,更合理的职责划分是:
| 技术手段 | 主要作用 |
|---|---|
| 前端按钮防重复点击 | 减少非必要请求 |
| Idempotency-Key | 标识同一次业务操作 |
| PostgreSQL 唯一约束 | 保证数据库记录唯一 |
| Redis | 缓存结果、减少重复请求压力 |
| 数据库事务 | 保证事务内数据库操作一致性 |
不一定需要同时引入所有技术。
如果业务规模不大,PostgreSQL 唯一约束加事务通常就能解决订单记录重复创建的问题。
六、订单不会重复,是否就意味着支付不会重复?
答案是否定的。
假设订单创建成功后,需要调用第三方支付接口。
系统可能执行如下操作:
创建订单
↓
调用支付接口
↓
支付平台扣款成功
↓
本地服务发生异常
↓
未能更新订单支付状态
此时,数据库中可能仍然显示订单未支付。
如果业务系统直接重新调用支付接口,就可能造成重复扣款。
因此,订单幂等和支付幂等是两个不同层面的问题。
对于包含外部支付调用的业务,建议进一步引入以下机制。
1. 使用稳定的支付请求标识
同一个订单的同一次支付操作,应使用稳定的支付请求标识。
如果第三方支付平台支持幂等键,那么重试时必须复用该标识。
这样可以降低由于网络超时、响应丢失等问题造成重复支付的风险。
具体保证范围需要以支付平台的接口约定为准。
2. 不要在数据库事务中直接等待外部支付
数据库事务无法直接回滚第三方支付平台已经完成的扣款。
如果在事务中执行长时间的外部 HTTP 请求,还可能造成数据库连接和行锁被长时间占用。
可以考虑通过 Transactional Outbox(事务发件箱)模式 处理。
例如,在创建订单的同一个数据库事务中写入待处理事件:
CREATE TABLE outbox_events (
id BIGSERIAL PRIMARY KEY,
event_type VARCHAR(64) NOT NULL,
aggregate_id BIGINT NOT NULL,
payload JSONB NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
业务事务中执行:
BEGIN;
-- 创建订单
INSERT INTO orders (
user_id,
idempotency_key,
request_hash,
amount
)
VALUES (
10001,
'request-002',
'e5f6g7h8',
19900
);
-- 同事务记录待处理事件
INSERT INTO outbox_events (
event_type,
aggregate_id,
payload
)
VALUES (
'ORDER_CREATED',
currval(pg_get_serial_sequence('orders', 'id')),
'{"source":"order-service"}'::jsonb
);
COMMIT;
这里的 SQL 用于说明订单记录和事件记录在同一事务内提交的原理。
实际工程中,应该直接使用订单插入时 RETURNING id 得到的 ID,同时妥善处理唯一键冲突,确保只有首次成功创建订单时才写入对应事件。
后续由独立 Worker 消费这些事件,再执行支付准备、通知等操作。
这样可以避免订单已经提交,但对应的待处理事件没有写入数据库的问题。
不过,Outbox 本身不能保证外部事件只执行一次。Worker 在执行成功后、更新事件状态前仍然可能崩溃。
因此,事件消费端仍然需要幂等处理,支付请求也必须具备独立的去重或对账机制。
七、如何验证幂等性是否真正生效?
实现完成后,不能只通过普通接口调用验证。
建议至少覆盖以下四类测试。
测试一:相同请求重复提交
连续发送两次相同请求,使用相同的幂等键。
预期结果:
- 两次请求返回相同订单 ID。
- 数据库中只有一条对应订单记录。
测试二:并发请求
使用压测工具同时发送 100 个相同幂等键的请求。
预期结果:
- 最终只有一条订单记录。
- 不发生重复创建。
- 不出现未处理的数据库唯一键异常。
这里需要验证的是最终数据库状态,而不仅仅是接口返回成功的数量。
测试三:相同幂等键,不同业务参数
第一次请求:
{
"productId": 1001,
"amount": 19900
}
第二次请求:
{
"productId": 1002,
"amount": 29900
}
两次使用相同的幂等键。
预期结果:
第二次请求返回业务冲突错误,不能直接返回第一次订单,也不能创建新订单。
测试四:数据库事务回滚
模拟订单创建过程中发生数据库异常。
确认事务回滚后,再次使用相同幂等键提交。
预期结果:
新的请求能够正常创建订单,不会因为之前失败的操作而永久失去创建权限。
如果系统同时使用 Redis 缓存幂等结果,还需要额外验证缓存状态与数据库事务结果是否一致。
八、实际工程中还需要考虑什么?
除了上述实现,还有三个容易被忽略的问题。
第一,幂等键的有效期。
对于有过期需求的业务,应明确幂等记录的保留时间,以及过期后是否允许重新执行。
不能简单地给 Redis Key 设置过期时间,就认为整个业务的幂等生命周期已经结束。
第二,幂等结果不一定只是订单 ID。
有些 API 要求重复请求返回与首次请求一致的响应。
这种场景可能需要独立的幂等记录表,保存请求摘要、处理状态和最终响应结果。
尤其当原始订单可能被归档或删除时,不能完全依赖订单表保存历史幂等信息。
第三,并发下的处理中状态。
如果创建订单后还需要异步支付、库存预留或其他操作,那么重复请求可能发生在原始操作尚未完成时。
此时应该根据接口契约返回当前处理状态,而不是简单地将订单存在视为全部业务成功。
对于复杂的异步操作,还可以设计 PROCESSING、SUCCEEDED、FAILED 等明确的状态,并提供查询接口。
九、总结
接口幂等性看起来只是防止重复请求,但真正实现时涉及数据库并发控制、事务管理以及跨系统的一致性问题。
本文介绍的 PostgreSQL 方案有几个主要特点:
- 利用唯一约束处理并发冲突,不依赖应用层先查后写。
- 通过幂等键区分不同业务操作。
- 通过请求摘要防止幂等键被错误复用。
- 通过数据库事务保证订单和本地业务事件的一致性。
- 通过外部接口幂等和事件消费去重进一步控制跨系统重复执行的风险。
需要强调的是,幂等性不等于整个分布式业务流程能够自动实现 Exactly Once(恰好执行一次)。
在涉及数据库、消息队列和第三方服务的系统中,更现实的工程实践往往是:
至少一次投递 + 幂等处理 + 状态追踪 + 异常对账。
对于普通业务系统,没有必要一开始就引入复杂的分布式锁或消息中间件。
可以先从数据库唯一约束、明确的业务状态和事务边界入手,再根据实际需求逐步扩展。
- 点赞
- 收藏
- 关注作者
评论(0)