1. 为什么我们需要关注幂等性问题
上周我们系统上线后遇到一个诡异的问题:用户投诉说充值了两次,但实际只到账了一次。查日志发现是因为网络抖动导致请求重发,而我们的支付接口没有做幂等处理。这个事故让我深刻意识到,在高并发系统中,幂等性设计不是可选项,而是必选项。
幂等性(Idempotence)原本是数学概念,指的是对同一个参数多次执行某个操作,产生的结果与执行一次相同。在分布式系统中,这意味着无论客户端调用多少次,服务端的状态都保持一致。对于支付、订单创建这类关键业务,幂等性设计能有效避免重复扣款、重复下单等问题。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 四种主流幂等方案深度解析
2.1 唯一约束方案
这是最直观的实现方式。我们给每个业务请求分配唯一标识(比如订单号),在数据库层面建立唯一索引。当重复请求到来时,数据库会抛出唯一键冲突异常。
sql复制-- 订单表设计示例
CREATE TABLE orders (
id BIGINT PRIMARY KEY,
order_no VARCHAR(32) UNIQUE, -- 唯一订单号
status TINYINT,
amount DECIMAL(10,2)
);
实战经验:唯一索引字段建议使用业务无关的UUID,而不是自增ID。我们曾经用自增ID做幂等键,在分库分表时遇到了大麻烦。
适用场景:
- 创建类操作(如订单创建)
- 数据写入场景
- 事务相对简单的业务
2.2 乐观锁方案
通过版本号机制实现,特别适合更新操作。我们在数据表中增加version字段,更新时校验版本号:
java复制// Java代码示例
@Transactional
public void updateOrder(Long orderId, BigDecimal amount) {
Order order = orderDao.selectById(orderId);
if (order.getStatus() != 0) {
throw new RuntimeException("订单状态异常");
}
int affected = orderDao.update(
"update orders set amount=?, version=version+1 where id=? and version=?",
amount, orderId, order.getVersion()
);
if (affected == 0) {
throw new ConcurrentUpdateException("并发更新冲突");
}
}
性能对比:
| 方案类型 | TPS | 锁冲突率 | 实现复杂度 |
|---|---|---|---|
| 乐观锁 | 高 | 低 | 中 |
| 悲观锁 | 中 | 高 | 低 |
2.3 分布式锁方案
在分布式环境下,我们需要跨JVM保证操作的原子性。Redis分布式锁是常见选择:
java复制// Redisson实现示例
public void deductStock(String productId, int quantity) {
String lockKey = "stock:" + productId;
RLock lock = redisson.getLock(lockKey);
try {
boolean locked = lock.tryLock(5, 10, TimeUnit.SECONDS);
if (!locked) {
throw new BusinessException("系统繁忙,请重试");
}
// 业务处理
stockService.reduceStock(productId, quantity);
} finally {
lock.unlock();
}
}
踩坑记录:一定要设置合理的锁超时时间!我们曾经因为没设置超时,导致系统死锁。建议采用"锁续期"机制。
2.4 状态机方案
通过业务状态流转控制幂等性,适合有明确状态变迁的业务:
code复制待支付 → 已支付 → 已发货 → 已完成
代码实现示例:
python复制def pay_order(order_id):
order = Order.get(order_id)
if order.status != 'PENDING':
return {'code': 200, 'msg': '重复请求已忽略'}
# 支付处理逻辑
process_payment(order)
order.status = 'PAID'
order.save()
3. 高并发场景下的特殊处理
3.1 热点数据优化
对于秒杀类场景,可以采用以下策略组合:
- 库存预热:提前将库存加载到Redis
- 分段扣减:将100个库存拆分为10个段,降低锁粒度
- 异步落库:先扣Redis库存,再异步更新数据库
3.2 幂等令牌设计
前端方案示例:
javascript复制// 生成幂等token
async function generateIdempotentToken() {
const res = await fetch('/api/token');
return res.data.token;
}
// 请求时携带token
async function submitOrder() {
const token = await generateIdempotentToken();
fetch('/api/order', {
method: 'POST',
headers: {
'X-Idempotent-Token': token
}
});
}
服务端校验流程:
- 客户端获取token(有效期5分钟)
- 服务端Redis记录token(setnx操作)
- 业务处理完成后删除token
- 重复请求会触发token校验失败
4. 生产环境问题排查实录
4.1 典型问题汇总
| 问题现象 | 根本原因 | 解决方案 |
|---|---|---|
| 重复扣款 | 网络重试+无幂等 | 增加唯一订单号 |
| 库存超卖 | 并发更新未加锁 | 乐观锁+Redis限流 |
| 状态不一致 | 先更新后校验 | 状态机严格流转 |
4.2 性能压测数据
我们在4核8G服务器上对四种方案进行压测(JMeter 500并发):
| 方案 | 平均响应时间 | 错误率 | 适用QPS |
|---|---|---|---|
| 唯一约束 | 23ms | 0.1% | 4500 |
| 乐观锁 | 18ms | 0.3% | 5200 |
| 分布式锁 | 35ms | 0.5% | 3800 |
| 状态机 | 15ms | 0.05% | 6000 |
5. 架构选型建议
根据业务特征选择方案:
- 金融支付:唯一约束+分布式锁(强一致性)
- 电商订单:乐观锁+状态机(高并发)
- 配置更新:CAS机制(轻量级)
混合方案示例:
java复制// 支付接口幂等设计
public PaymentResult pay(PaymentRequest request) {
// 第一重防护:唯一订单号
if (paymentDao.exists(request.getOrderNo())) {
return getExistingResult(request.getOrderNo());
}
// 第二重防护:分布式锁
Lock lock = redisLockManager.obtainLock(request.getOrderNo());
try {
// 第三重防护:事务隔离
return transactionTemplate.execute(status -> {
Payment payment = createPayment(request);
paymentDao.insertWithUniqueCheck(payment);
return processPayment(payment);
});
} finally {
lock.unlock();
}
}
最后分享一个实用技巧:在MySQL的RR隔离级别下,配合SELECT FOR UPDATE可以实现类似乐观锁的效果,但要注意锁范围控制,避免锁表风险。我们曾经有个接口因为没加索引导致全表被锁,这个教训值得大家警惕。
