Java后端服务中如何设计幂等性机制防止外卖API重复下单问题

📅 2026/7/14 7:12:02
Java后端服务中如何设计幂等性机制防止外卖API重复下单问题
Java后端服务中如何设计幂等性机制防止外卖API重复下单问题在美团外卖、饿了么等开放平台的API对接中由于网络抖动、客户端误操作或支付回调的重试机制极易出现重复下单请求。如果后端服务不具备幂等性会导致用户扣款多次但订单只生成一份或者生成多份重复订单引发严重的资损和客诉。本文将深入探讨在Java后端开发中如何结合Redis、数据库约束及分布式锁技术设计一套稳健的幂等性方案确保在高并发场景下“同一下单请求”无论执行多少次结果都是一致的。1. 什么是幂等性Idempotency在HTTP协议中幂等性是指对同一资源的操作无论执行多少次其对资源状态的影响都与执行一次相同。对于外卖下单接口幂等性意味着同一个用户在短时间内提交了相同的订单请求系统应当只创建一笔有效的订单其余请求应被拦截或返回已存在的订单信息。2. 基于唯一索引的数据库层面防护硬防最基础也是最可靠的幂等手段是利用数据库的唯一约束。我们应当在订单表中建立一个“业务唯一键”通常是商户ID时间戳随机数或客户端传入的幂等Token。数据库表设计示例CREATETABLEmt_order(idBIGINTNOTNULLAUTO_INCREMENT,out_trade_noVARCHAR(64)NOTNULLCOMMENT商户系统内部的订单号必须唯一,mt_order_idVARCHAR(64)DEFAULTNULLCOMMENT美团返回的订单ID,amountDECIMAL(10,2)NOTNULL,statusTINYINTDEFAULT0,PRIMARYKEY(id),UNIQUEKEYuk_out_trade_no(out_trade_no)-- 核心唯一索引)ENGINEInnoDBDEFAULTCHARSETutf8mb4;Java实体与MapperMyBatispackagecom.baodanbao.cn.model;publicclassMtOrder{privateLongid;privateStringoutTradeNo;// 对应数据库唯一索引字段privateStringmtOrderId;privateBigDecimalamount;// getter/setter...}packagecom.baodanbao.cn.mapper;importcom.baodanbao.cn.model.MtOrder;importorg.apache.ibatis.annotations.Insert;importorg.apache.ibatis.annotations.Mapper;MapperpublicinterfaceOrderMapper{Insert(INSERT INTO mt_order (out_trade_no, amount, status) VALUES (#{outTradeNo}, #{amount}, #{status}))intinsertOrder(MtOrderorder);}3. 基于RedisToken的预校验机制软防数据库唯一索引虽然能防止脏数据但当并发极高时大量重复请求直接打到数据库会导致唯一索引冲突抛出异常消耗数据库性能。因此我们需要在业务逻辑执行前利用Redis进行前置拦截。核心流程客户端下单前先请求获取一个幂等Token。提交订单时携带该Token。后端通过Lua脚本原子性地校验Token是否存在存在则删除并放行否则拦截。Redis配置与工具类packagecom.baodanbao.cn.config;importorg.springframework.context.annotation.Bean;importorg.springframework.context.annotation.Configuration;importorg.springframework.data.redis.connection.RedisConnectionFactory;importorg.springframework.data.redis.core.RedisTemplate;importorg.springframework.data.redis.serializer.StringRedisSerializer;ConfigurationpublicclassRedisConfig{BeanpublicRedisTemplateString,ObjectredisTemplate(RedisConnectionFactoryfactory){RedisTemplateString,ObjecttemplatenewRedisTemplate();template.setConnectionFactory(factory);template.setKeySerializer(newStringRedisSerializer());template.setValueSerializer(newGenericJackson2JsonRedisSerializer());returntemplate;}}4. 幂等切面与Lua脚本实现为了不污染业务代码我们使用AOP切面来处理幂等校验。幂等注解定义packagecom.baodanbao.cn.annotation;importjava.lang.annotation.ElementType;importjava.lang.annotation.Retention;importjava.lang.annotation.RetentionPolicy;importjava.lang.annotation.Target;Target(ElementType.METHOD)Retention(RetentionPolicy.RUNTIME)publicinterfaceIdempotent{Stringmessage()default请勿重复下单;}AOP切面实现关键代码packagecom.baodanbao.cn.aspect;importcom.baodanbao.cn.annotation.Idempotent;importorg.aspectj.lang.annotation.Aspect;importorg.aspectj.lang.annotation.Before;importorg.aspectj.lang.annotation.Pointcut;importorg.redisson.api.RScript;importorg.redisson.api.RedissonClient;importorg.springframework.beans.factory.annotation.Autowired;importorg.springframework.data.redis.core.RedisTemplate;importorg.springframework.stereotype.Component;importorg.springframework.web.context.request.RequestContextHolder;importorg.springframework.web.context.request.ServletRequestAttributes;importjavax.servlet.http.HttpServletRequest;importjava.util.Collections;AspectComponentpublicclassIdempotentAspect{AutowiredprivateRedisTemplateString,ObjectredisTemplate;Pointcut(annotation(com.baodanbao.cn.annotation.Idempotent))publicvoididempotentPointCut(){}Before(idempotentPointCut())publicvoidbefore()throwsException{HttpServletRequestrequest((ServletRequestAttributes)RequestContextHolder.currentRequestAttributes()).getRequest();// 从请求头或参数中获取TokenStringtokenrequest.getHeader(Idempotent-Token);if(tokennull||token.isEmpty()){thrownewIllegalArgumentException(缺少幂等Token);}// Lua脚本保证原子性GETDEL// 如果Key存在则获取并删除防止下次重复提交不存在则返回nilStringscriptif redis.call(get, KEYS[1]) then return redis.call(del, KEYS[1]) else return 0 end;Booleanresult(Boolean)redisTemplate.execute((RedisCallbackBoolean)connection-{ObjectnativeConnectionconnection.getNativeConnection();if(nativeConnectioninstanceofJedisCluster){return(Long)((JedisCluster)nativeConnection).eval(script,Collections.singletonList(token),Collections.emptyList())1;}else{return(Long)((Jedis)nativeConnection).eval(script,Collections.singletonList(token),Collections.emptyList())1;}});if(!result){thrownewRuntimeException(重复请求操作失败);}}}5. 业务Controller的整合在Controller的方法上添加Idempotent注解即可开启幂等保护。packagecom.baodanbao.cn.controller;importcom.baodanbao.cn.annotation.Idempotent;importcom.baodanbao.cn.model.MtOrder;importcom.baodanbao.cn.service.OrderService;importorg.springframework.beans.factory.annotation.Autowired;importorg.springframework.web.bind.annotation.PostMapping;importorg.springframework.web.bind.annotation.RequestBody;importorg.springframework.web.bind.annotation.RestController;RestControllerpublicclassOrderController{AutowiredprivateOrderServiceorderService;/** * 下单接口 * 1. 客户端先获取Token * 2. 调用此接口Header携带Token */PostMapping(/submit/order)Idempotent(message订单正在处理中请勿重复提交)publicStringsubmitOrder(RequestBodyMtOrderorder){// 1. 校验库存等业务逻辑...// 2. 保存订单此时数据库唯一索引作为兜底try{orderService.createOrder(order);return下单成功;}catch(Exceptione){// 如果是唯一索引冲突尝试查询是否已存在订单if(e.getMessage().contains(Duplicate entry)){return订单已存在;}throwe;}}}6. 客户端获取Token接口客户端在进入下单页时应先请求获取Token。packagecom.baodanbao.cn.controller;importorg.springframework.data.redis.core.RedisTemplate;importorg.springframework.web.bind.annotation.GetMapping;importorg.springframework.web.bind.annotation.RestController;importjava.util.UUID;importjava.util.concurrent.TimeUnit;RestControllerpublicclassTokenController{AutowiredprivateRedisTemplateString,ObjectredisTemplate;GetMapping(/idempotent/token)publicStringgetIdempotentToken(){StringtokenUUID.randomUUID().toString();// 设置有效期例如5分钟redisTemplate.opsForValue().set(token,1,5,TimeUnit.MINUTES);returntoken;}}本文著作权归 俱美开放平台 转载请注明出处