本文目录导读:

幂等设计在Java后端开发中非常常见,核心目的是防止重复提交、重复处理导致的数据不一致或资源浪费。
以下列出最适合做幂等设计的Java案例,从业务场景到技术实现进行分类:
支付与交易类(最典型、最严格)
案例:支付扣款 / 订单创建
- 场景:用户在支付页面点击“确认支付”按钮,由于网络波动或用户手抖,请求被发送了多次,如果服务端不处理幂等,用户会被扣两次款,产生两个订单。
- 业务要求:无论请求多少次,最终只扣一次款,只生成一个订单。
- 核心矛盾:支付成功后需要回调通知、或用户主动查询状态时,系统必须能识别“这是一笔已经处理过的请求”。
Java实现方案
- 利用数据库唯一索引:
- 在订单或流水表中,将
order_id或transaction_id设为唯一键。 - 插入前先尝试插入,如果报
DuplicateKeyException(主键/唯一键冲突),说明已处理。
- 在订单或流水表中,将
- Redis分布式锁 + 防重Token:
- 前端请求前先获取一个Token(如:
pay:token:{userId}:{orderId})。 - 后端处理时,使用
SET key uuid NX EX 30(只在不存在时设置,30秒过期)。 - 只有成功获取锁的请求才能继续处理,处理完立即删除锁。
- 注意:需要结合业务状态(如订单状态已为“已支付”)做最终兜底。
- 前端请求前先获取一个Token(如:
消息队列消费(最易忽略、影响面广)
案例:订单支付成功的回调消息处理
- 场景:支付服务发送“支付成功”消息到MQ(消息队列),消费者收到后执行发货或积分兑换,由于MQ支持至少一次投递(At Least Once),消息可能重复消费(例如消费者处理完后宕机,消息未ack,MQ重试)。
- 业务要求:同一笔订单的支付成功消息,无论消费多少次,只发一次货,只加一次积分。
Java实现方案
- 业务主键去重表:
- 在数据库中创建一张去重表,主键或唯一键为
message_id或business_key(如orderId+eventType)。 - 消费逻辑:
INSERT INTO dedup_table(business_key) VALUES(?),若成功则执行业务逻辑;若失败(唯一键冲突),则直接ack消息并跳过。
- 在数据库中创建一张去重表,主键或唯一键为
- Redis Set/布隆过滤器:
- 消费前
SADD dedup:msg:set {messageId},若返回1(新添加)则处理,返回0则忽略,适合高并发且允许小概率误判的场景。
- 消费前
表单提交与接口调用(最常见、用户体验)
案例:创建投票、提交表单、确认发货
- 场景:用户快速双击“提交”按钮,或前端防抖失效,导致请求被连续发送,虽然前端做了按钮置灰,但总有绕过(如F5刷新、浏览器回退重发)。
- 业务要求:同一用户、同一表单内容,多次提交只产生一条记录。
Java实现方案:Token + 一次消费
- 生成Token:后端生成一个唯一的Token(如UUID),存入Redis(Key:
submit:token:{userId}:{formId}),并返回给前端。 - 提交校验:前端提交时必须携带该Token。
- 后端消费:使用
DEL submit:token:{userId}:{formId}并检查返回值。- 若返回1(删除成功),说明是首次请求,执行插入。
- 若返回0(Key不存在或已删除),说明重复请求,直接返回“处理中”或“已提交”的提示。
外部系统回调通知
案例:第三方支付回调、短信/邮件发送回调
- 场景:支付宝/微信回调你的
notify_url,它们会重试通知(3-7次),且通知顺序可能乱序。 - 业务要求:同一笔交易的回调,只做一次状态更新(如将订单从未支付改为已支付)。
Java实现方案:CAS(Compare And Set)+ 数据库乐观锁
- SQL原生幂等:
UPDATE orders SET status = 'paid', update_time = NOW() WHERE id = ? AND status = 'unpaid';
- 影响行数
= 1:成功处理,这是第一次。 - 影响行数
= 0:说明订单状态已经不是 unpaid(已被其他回调处理过),忽略此次回调。
- 影响行数
- 配合唯一约束:在订单状态变更流水表中,设置
(order_id, target_status)为唯一联合索引。
文件上传或大对象操作
案例:上传用户头像、上传商品图片
- 场景:用户上传同一个文件(相同MD5),或上传时网络重试。
- 业务要求:的文件,只存储一份,不浪费存储空间;相同标识的附件,只关联一次。
Java实现方案:内容Hash + 唯一索引
- 计算文件MD5:上传完成后计算文件的MD5(或SHA-256)。
- 去重存储:
- 在文件存储表(OSS或本地文件系统记录表)中,设置
file_md5为唯一键。 - 存储前查询:若存在相同MD5的文件,直接复用原文件路径/ID,不重新上传。
- 插入时捕获唯一键冲突即可。
- 在文件存储表(OSS或本地文件系统记录表)中,设置
定时任务与批量处理
案例:每日报表生成、用户积分结算
- 场景:凌晨的定时任务,可能因分布式节点重复触发(例如两台服务器同时执行 cron job)。
- 业务要求:同一日期、同一业务范围的报表,只生成一份。
Java实现方案:分布式调度 + 任务标识幂等
- 使用Quartz/Elastic-Job的
@DisallowConcurrentExecution或分片机制:保证同一时间只有一个节点执行。 - 数据库任务状态锁:
- 记录一个任务执行记录表,主键为任务名+任务日期(如
report:2023-10-01)。 - 执行前
INSERT时,若冲突则说明已执行过,直接跳过。 - 配合
SELECT ... FOR UPDATE可精确控制。
- 记录一个任务执行记录表,主键为任务名+任务日期(如
选择方案的决策树
| 业务场景 | 推荐幂等方案 | 核心原因 |
|---|---|---|
| 支付扣款 | 数据库唯一索引 + 状态机乐观锁 | 强一致性,绝不允许重复扣款 |
| MQ消费 | 去重表(DB)或Redis Set | 处理海量消息,去重代价低 |
| 表单提交 | Redis Token(一次删除) | 高并发,低延迟,性能最优 |
| 回调通知 | 数据库乐观锁(update where status=unpaid) | 利用数据库行锁,天然幂等 |
| 文件上传 | 文件MD5唯一索引 | 业务特征(相同内容)适合哈希去重 |
| 定时任务 | 分布式锁或任务记录表唯一键 | 跨域一致性要求不高,防止重复生产 |
关键原则:
- 优先选择数据库唯一键/乐观锁:这是最基础、最可靠的幂等实现,多数MySQL/PostgreSQL场景都可以兜底。
- Redis适合做第一道拦截:性能极高,但在极端宕机或网络分区时可能有漏网之鱼,需要DB做最后的SC(最终一致性)保障。
- 接口设计上,天然提供幂等性:
PUT /order/{id}天然就是幂等的(多次更新相同内容结果相同),而POST /order不是,需要额外处理。
在Java开发中,推荐将幂等逻辑封装成注解 + AOP(如 @Idempotent),通过切面统一实现Token校验或状态判断,避免业务代码中侵入大量判断逻辑。