前言
幂等性是防止重复操作的关键机制。很多线上问题都是因为没有做幂等:用户连点两次创建了两个订单、弱网重试导致重复扣款、重复发送短信。这篇给你5个常见场景的幂等性设计方法。
一、什么是幂等性
定义:同一个请求执行多次,结果和执行一次一样。
举例:
- 查询操作:天然幂等(查10次结果一样)
- 删除操作:幂等(删除已删除的记录,结果还是删除)
- 创建操作:不幂等(创建10次会有10条记录)❌
- 扣款操作:不幂等(扣10次会扣10次钱)❌
二、5个常见场景详解
场景1:创建订单(防止重复创建)
场景描述:用户点击"提交订单"按钮,因网络慢连点了3次,或者前端自动重试导致重复提交。
问题:创建了3个订单,用户投诉,客服工作量增加。
解决方案:
- 前端防护:按钮防抖(500ms),提交后立即禁用按钮,显示"提交中..."
- 后端幂等:幂等键 = 用户ID + 购物车ID + 时间戳(前端生成UUID)
- 实现逻辑:
- 收到请求时,先查询幂等键是否存在(Redis或数据库)
- 如存在,返回原订单号和订单状态,HTTP 200
- 如不存在,创建订单,保存幂等键(有效期24小时)
- 返回新订单号
PRD写法:
接口名称:创建订单 幂等性要求:必须支持幂等 幂等键:userId_cartId_uuid(前端生成UUID) 幂等逻辑: 1. 收到请求时,先查询幂等键是否存在 2. 如存在,返回原订单号,HTTP 200,isDuplicate=true 3. 如不存在,创建订单,保存幂等键到Redis(有效期24小时) 4. 返回新订单号,isDuplicate=false 幂等键有效期:24小时 重复请求返回: { "code": 200, "message": "订单已创建", "data": { "orderId": "ORD20250101001", "isDuplicate": true, "createdAt": "2025-01-01 10:00:00" } }技术实现示例:
// 伪代码 function createOrder(userId, cartId, idempotencyKey) { // 1. 检查幂等键 const existingOrder = redis.get(idempotencyKey); if (existingOrder) { return { orderId: existingOrder.orderId, isDuplicate: true }; } // 2. 创建订单 const order = db.createOrder({ userId, cartId }); // 3. 保存幂等键 redis.set(idempotencyKey, { orderId: order.id }, 24 * 3600); return { orderId: order.id, isDuplicate: false }; }场景2:支付扣款(防止重复扣款)
场景描述:用户支付时网络超时,前端或支付网关自动重试3次,导致重复扣款。
问题:扣了3次钱,用户投诉,需要退款,影响用户体验和公司信誉。
解决方案:
- 幂等键:订单号(唯一标识)
- 实现逻辑:
- 支付前先查询订单支付状态
- 如已支付,直接返回成功,不调用支付接口
- 如未支付,调用支付接口,成功后更新订单状态
- 支付接口内部也要做幂等(支付网关通常支持)
PRD写法:
接口名称:支付扣款 幂等性要求:必须支持幂等 幂等键:订单号(orderId) 幂等逻辑: 1. 支付前先查询订单支付状态 2. 如已支付,直接返回成功,HTTP 200 3. 如未支付,调用支付接口,成功后更新订单状态为"已支付" 4. 支付接口内部也要做幂等(使用支付流水号) 幂等键有效期:永久(订单支付状态不可逆) 重复支付请求返回: { "code": 200, "message": "订单已支付", "data": { "orderId": "ORD20250101001", "payStatus": "paid", "paidAt": "2025-01-01 10:00:00" } }注意事项:
- 支付接口必须支持幂等,使用支付流水号作为幂等键
- 支付成功后,订单状态必须立即更新,避免并发问题
- 支付失败时,不要更新订单状态,允许用户重试
场景3:发送短信(防止重复发送)
场景描述:系统故障导致短信发送接口被调用多次,或者用户快速点击"发送验证码"按钮。
问题:用户收到多条相同短信,浪费短信费用,影响用户体验。
解决方案:
- 幂等键:手机号 + 短信模板ID + 业务ID(如订单号)
- 实现逻辑:
- 发送前先查询幂等键是否存在
- 如存在且在有效期内(如1分钟),直接返回成功,不发送
- 如不存在或已过期,发送短信,保存幂等键(有效期1分钟)
- 返回发送结果
PRD写法:
接口名称:发送短信 幂等性要求:必须支持幂等 幂等键:手机号_模板ID_业务ID(如:13800138000_VERIFY_ORD001) 幂等逻辑: 1. 发送前先查询幂等键是否存在 2. 如存在且在有效期内(1分钟),直接返回成功,不发送 3. 如不存在或已过期,发送短信,保存幂等键到Redis(有效期1分钟) 4. 返回发送结果 幂等键有效期:1分钟(防止短时间内重复发送) 重复请求返回: { "code": 200, "message": "短信已发送", "data": { "isDuplicate": true, "sentAt": "2025-01-01 10:00:00" } }扩展场景:
- 验证码短信:1分钟内相同手机号+模板只发送一次
- 通知短信:相同业务ID(如订单号)只发送一次
- 营销短信:相同手机号+活动ID每天只发送一次
场景4:库存扣减(防止超卖)
场景描述:两个用户同时购买最后1件商品,或者秒杀活动时大量并发请求。
问题:库存变成-1,超卖问题,用户下单后无法发货,影响用户体验。
解决方案:
- 使用乐观锁:版本号机制
- 实现逻辑:
- 查询库存时,同时获取版本号(version)
- 扣减库存时,使用版本号作为条件:UPDATE stock SET qty = qty - 1, version = version + 1 WHERE id = ? AND version = ?
- 如果更新影响行数=0,说明版本号不匹配(已被其他请求修改),返回"库存不足"
- 如果更新影响行数>0,说明扣减成功
PRD写法:
接口名称:库存扣减 幂等性要求:必须支持幂等(使用乐观锁) 幂等键:订单号(防止重复扣减) 并发控制:乐观锁(版本号) 实现方式: 1. 查询库存时,返回版本号(version) 2. 扣减库存时,使用版本号作为条件: UPDATE stock SET qty = qty - 1, version = version + 1 WHERE id = ? AND version = ? AND qty > 0 3. 如果更新影响行数=0,说明版本号不匹配或库存不足,返回"库存不足" 4. 如果更新影响行数>0,说明扣减成功 用户提示:库存不足,请选择其他商品 恢复路径:无(库存已售罄)技术实现示例:
// 伪代码 function deductStock(productId, orderId, quantity) { // 1. 检查幂等键(防止重复扣减) const existingDeduction = redis.get(`deduct:${orderId}`); if (existingDeduction) { return { success: true, isDuplicate: true }; } // 2. 查询库存和版本号 const stock = db.query("SELECT qty, version FROM stock WHERE id = ?", productId); if (stock.qty < quantity) { return { success: false, message: "库存不足" }; } // 3. 使用乐观锁扣减库存 const affectedRows = db.execute( "UPDATE stock SET qty = qty - ?, version = version + 1 WHERE id = ? AND version = ? AND qty >= ?", [quantity, productId, stock.version, quantity] ); if (affectedRows === 0) { return { success: false, message: "库存不足,请重试" }; } // 4. 保存幂等键 redis.set(`deduct:${orderId}`, { productId, quantity }, 3600); return { success: true, isDuplicate: false }; }注意事项:
- 乐观锁适合读多写少的场景,性能高
- 如果冲突频繁,可以考虑悲观锁(SELECT FOR UPDATE)
- 秒杀场景建议使用Redis分布式锁或消息队列削峰
场景5:数据导入(防止重复导入)
场景描述:用户上传Excel导入数据,因网络问题重复上传,或者用户误操作重复点击"导入"按钮。
问题:数据重复,需要人工清理,影响数据准确性。
解决方案:
- 幂等键:文件MD5(文件内容唯一标识)
- 实现逻辑:
- 上传文件后,计算文件MD5
- 导入前先查询文件MD5是否已导入
- 如已导入,返回"该文件已导入"和导入记录
- 如未导入,执行导入,保存文件MD5和导入记录
PRD写法:
接口名称:数据导入 幂等性要求:必须支持幂等 幂等键:文件MD5(文件内容唯一标识) 幂等逻辑: 1. 上传文件后,计算文件MD5 2. 导入前先查询文件MD5是否已导入 3. 如已导入,返回"该文件已导入"和导入记录(导入时间、导入条数等) 4. 如未导入,执行导入,保存文件MD5和导入记录到数据库 幂等键有效期:永久(文件内容不变,MD5不变) 重复导入返回: { "code": 200, "message": "该文件已导入", "data": { "isDuplicate": true, "importedAt": "2025-01-01 10:00:00", "importedCount": 100 } }扩展场景:
- 批量导入:相同文件MD5只导入一次
- 增量导入:相同文件MD5+导入时间只导入一次
- 数据更新:相同文件MD5+业务ID只更新一次
三、幂等键设计原则
幂等键的设计直接影响幂等性的有效性,需要遵循以下原则:
设计原则
- 唯一性:幂等键必须能唯一标识一次业务操作
- 稳定性:相同业务操作,幂等键必须相同
- 可读性:幂等键最好包含业务信息,便于排查问题
- 长度限制:幂等键长度要适中,避免过长影响性能
| 场景 | 幂等键设计 | 生成方式 | 有效期 | 存储位置 |
|---|---|---|---|---|
| 创建订单 | userId_cartId_uuid | 前端生成UUID | 24小时 | Redis |
| 支付扣款 | 订单号 | 系统生成 | 永久 | 数据库 |
| 发送短信 | 手机号_模板ID_业务ID | 系统生成 | 1分钟 | Redis |
| 库存扣减 | 订单号(防重复)+ 版本号(防并发) | 系统生成 | 订单生命周期 | Redis + 数据库 |
| 数据导入 | 文件MD5 | 系统计算 | 永久 | 数据库 |
常见错误
- 错误1:使用时间戳作为幂等键(时间戳会变化,无法保证唯一性)
❌ 错误:幂等键 = userId + timestamp ✅ 正确:幂等键 = userId + cartId + uuid - 错误2:幂等键包含随机数(随机数每次不同,无法保证幂等)
❌ 错误:幂等键 = userId + random() ✅ 正确:幂等键 = userId + cartId + uuid(前端生成,重试时保持不变) - 错误3:幂等键有效期设置过长(占用存储空间)
❌ 错误:发送短信幂等键有效期24小时 ✅ 正确:发送短信幂等键有效期1分钟(业务需求决定) - 错误4:幂等键只存Redis,不存数据库(Redis故障时丢失)
❌ 错误:支付幂等键只存Redis ✅ 正确:支付幂等键存数据库(永久有效,不能丢失)
四、PRD模板与最佳实践
标准PRD模板
接口名称:创建订单 接口路径:POST /api/orders 幂等性要求:必须支持幂等 【幂等键设计】 幂等键:userId_cartId_uuid 生成方式:前端生成UUID,重试时保持不变 组成规则:{userId}_{cartId}_{uuid} 示例:12345_67890_a1b2c3d4-e5f6-7890-abcd-ef1234567890 【幂等逻辑】 1. 收到请求时,先查询幂等键是否存在(Redis) 2. 如存在,返回原订单号和订单状态,HTTP 200,isDuplicate=true 3. 如不存在,执行业务逻辑(创建订单) 4. 业务逻辑成功后,保存幂等键到Redis(有效期24小时) 5. 返回新订单号,isDuplicate=false 【幂等键存储】 存储位置:Redis 有效期:24小时 Key格式:idempotency:order:{userId}_{cartId}_{uuid} Value格式:{"orderId": "ORD001", "status": "created", "createdAt": "2025-01-01 10:00:00"} 【重复请求返回】 HTTP状态码:200 响应体: { "code": 200, "message": "订单已创建", "data": { "orderId": "ORD20250101001", "isDuplicate": true, "status": "created", "createdAt": "2025-01-01 10:00:00" } } 【异常处理】 1. Redis故障:降级到数据库查询(性能较低,但保证可用性) 2. 幂等键冲突:返回"请求处理中,请稍候"(防止并发问题) 3. 业务逻辑失败:不保存幂等键,允许重试最佳实践
- 前端生成幂等键:前端生成UUID,重试时保持不变,这样前端重试时可以带上相同的幂等键
- 幂等键包含业务信息:幂等键最好包含业务信息(如userId、cartId),便于排查问题
- 幂等键有效期合理:根据业务需求设置有效期,不要过长或过短
- 幂等键存储选择:
- 临时性幂等键(如创建订单):存Redis,设置过期时间
- 永久性幂等键(如支付):存数据库,永久有效
- 幂等键查询优化:使用Redis的SETNX命令,保证原子性
- 幂等键降级方案:Redis故障时,降级到数据库查询,保证可用性
- 幂等键监控:监控幂等键命中率,评估幂等性效果
实现检查清单
- [ ] 幂等键设计合理(唯一性、稳定性、可读性)
- [ ] 幂等键生成方式正确(前端生成UUID,重试时保持不变)
- [ ] 幂等键存储位置正确(临时性存Redis,永久性存数据库)
- [ ] 幂等键有效期合理(根据业务需求设置)
- [ ] 幂等逻辑实现正确(先查询,存在则返回,不存在则执行)
- [ ] 重复请求返回正确(HTTP 200,isDuplicate=true)
- [ ] 异常处理完善(Redis故障降级、幂等键冲突处理)
- [ ] 前端防抖实现(按钮防抖,提交后禁用)
- [ ] 日志记录完善(记录幂等键、请求参数、处理结果)
- [ ] 监控告警配置(监控幂等键命中率、异常情况)
五、常见错误与陷阱
错误1:幂等键设计不合理
问题:使用时间戳作为幂等键,导致每次请求幂等键都不同,无法保证幂等性。
❌ 错误示例: 幂等键 = userId + timestamp 问题:时间戳每次不同,无法保证幂等性 ✅ 正确示例: 幂等键 = userId + cartId + uuid(前端生成,重试时保持不变)错误2:幂等键只存Redis,不存数据库
问题:Redis故障时,幂等键丢失,导致重复操作。
❌ 错误示例: 支付幂等键只存Redis 问题:Redis故障时,幂等键丢失,可能导致重复扣款 ✅ 正确示例: 支付幂等键存数据库(永久有效,不能丢失) 临时性幂等键(如创建订单)可以只存Redis错误3:幂等键有效期设置不合理
问题:幂等键有效期过长,占用存储空间;有效期过短,导致正常重试失败。
❌ 错误示例: 发送短信幂等键有效期24小时(过长,占用空间) 创建订单幂等键有效期1分钟(过短,正常重试可能失败) ✅ 正确示例: 发送短信幂等键有效期1分钟(业务需求决定) 创建订单幂等键有效期24小时(业务需求决定)错误4:幂等逻辑实现不正确
问题:先执行业务逻辑,再保存幂等键,导致并发时重复执行。
❌ 错误示例: 1. 执行业务逻辑(创建订单) 2. 保存幂等键 问题:并发时,两个请求都执行了业务逻辑 ✅ 正确示例: 1. 查询幂等键是否存在 2. 如存在,返回原结果 3. 如不存在,执行业务逻辑,保存幂等键(使用SETNX保证原子性)错误5:重复请求返回错误状态码
问题:重复请求返回4xx错误,导致前端认为请求失败,继续重试。
❌ 错误示例: 重复请求返回 HTTP 400 Bad Request 问题:前端认为请求失败,继续重试,导致无限循环 ✅ 正确示例: 重复请求返回 HTTP 200 OK,isDuplicate=true 前端根据isDuplicate判断是否重复,不再重试错误6:前端没有防抖处理
问题:用户快速点击按钮,发送多个请求,虽然后端做了幂等,但浪费资源。
❌ 错误示例: 前端没有防抖,用户快速点击发送多个请求 问题:虽然后端做了幂等,但浪费资源,增加服务器压力 ✅ 正确示例: 前端按钮防抖(500ms),提交后立即禁用按钮,显示"提交中..." 减少不必要的请求,提升用户体验六、FAQ
Q1:幂等键由前端生成还是后端生成?
答:建议前端生成(如UUID),这样前端重试时可以带上相同的幂等键。如果后端生成,前端重试时无法获取相同的幂等键,导致幂等性失效。
前端生成的优势:
- 前端重试时可以带上相同的幂等键
- 减少后端压力(不需要生成幂等键)
- 更好的用户体验(前端可以控制重试逻辑)
Q2:幂等键存在哪里?
答:根据业务需求选择存储位置:
- 临时性幂等键(如创建订单):存Redis,设置过期时间,性能高
- 永久性幂等键(如支付):存数据库,永久有效,不能丢失
- 混合方案:Redis + 数据库,Redis作为缓存,数据库作为持久化
Q3:所有接口都要做幂等吗?
答:不是。需要做幂等的接口:
- 创建操作:创建订单、创建用户、创建商品等
- 修改操作:支付扣款、库存扣减、积分扣减等
- 发送操作:发送短信、发送邮件、发送推送等
不需要做幂等的接口:
- 查询操作:查询订单、查询用户、查询商品等(天然幂等)
- 删除操作:删除订单、删除用户等(天然幂等,删除已删除的记录结果还是删除)
Q4:幂等键和分布式锁有什么区别?
答:幂等键和分布式锁是两种不同的机制:
- 幂等键:防止重复操作,允许并发请求,但只执行一次
- 分布式锁:防止并发操作,同一时间只允许一个请求执行
使用场景:
- 幂等键:适合创建订单、支付扣款等场景(允许并发,但只执行一次)
- 分布式锁:适合库存扣减、秒杀等场景(不允许并发,必须串行执行)
Q5:幂等键冲突了怎么办?
答:幂等键冲突是正常情况,表示重复请求,应该返回原结果,而不是报错。如果使用Redis的SETNX命令,可以保证原子性,避免并发问题。
Q6:Redis故障时,幂等性怎么保证?
答:需要降级方案:
- 临时性幂等键:降级到数据库查询(性能较低,但保证可用性)
- 永久性幂等键:直接存数据库,不依赖Redis
- 混合方案:Redis + 数据库,Redis作为缓存,数据库作为持久化
Q7:幂等键有效期怎么设置?
答:根据业务需求设置:
- 创建订单:24小时(订单创建后24小时内不会重复创建)
- 发送短信:1分钟(1分钟内不会重复发送)
- 支付扣款:永久(支付状态不可逆)
- 数据导入:永久(文件内容不变,MD5不变)
Q8:如何监控幂等性效果?
答:监控以下指标:
- 幂等键命中率:重复请求占比,评估幂等性效果
- 幂等键存储量:Redis/数据库中的幂等键数量,评估存储压力
- 幂等键查询耗时:查询幂等键的耗时,评估性能影响
- 重复操作次数:没有幂等键的重复操作次数,评估业务影响
工具入口
生成幂等性设计思维导图