Java后端服务中如何设计幂等性机制防止外卖API重复下单问题
在美团外卖、饿了么等开放平台的API对接中,由于网络抖动、客户端误操作或支付回调的重试机制,极易出现重复下单请求。如果后端服务不具备幂等性,会导致用户扣款多次但订单只生成一份,或者生成多份重复订单,引发严重的资损和客诉。本文将深入探讨在Java后端开发中,如何结合Redis、数据库约束及分布式锁技术,设计一套稳健的幂等性方案,确保在高并发场景下“同一下单请求”无论执行多少次,结果都是一致的。
1. 什么是幂等性(Idempotency)
在HTTP协议中,幂等性是指对同一资源的操作,无论执行多少次,其对资源状态的影响都与执行一次相同。
对于外卖下单接口,幂等性意味着:同一个用户在短时间内提交了相同的订单请求,系统应当只创建一笔有效的订单,其余请求应被拦截或返回已存在的订单信息。
2. 基于唯一索引的数据库层面防护(硬防)
最基础也是最可靠的幂等手段是利用数据库的唯一约束。我们应当在订单表中建立一个“业务唯一键”,通常是商户ID+时间戳+随机数或客户端传入的幂等Token。
数据库表设计示例:
CREATETABLE`mt_order`(`id`BIGINTNOTNULLAUTO_INCREMENT,`out_trade_no`VARCHAR(64)NOTNULLCOMMENT'商户系统内部的订单号,必须唯一',`mt_order_id`VARCHAR(64)DEFAULTNULLCOMMENT'美团返回的订单ID',`amount`DECIMAL(10,2)NOTNULL,`status`TINYINTDEFAULT'0',PRIMARYKEY(`id`),UNIQUEKEY`uk_out_trade_no`(`out_trade_no`)-- 核心:唯一索引)ENGINE=InnoDBDEFAULTCHARSET=utf8mb4;Java实体与Mapper(MyBatis):
packagecom.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. 基于Redis+Token的预校验机制(软防)
数据库唯一索引虽然能防止脏数据,但当并发极高时,大量重复请求直接打到数据库会导致唯一索引冲突,抛出异常,消耗数据库性能。因此,我们需要在业务逻辑执行前,利用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{@BeanpublicRedisTemplate<String,Object>redisTemplate(RedisConnectionFactoryfactory){RedisTemplate<String,Object>template=newRedisTemplate<>();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)public@interfaceIdempotent{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;@Aspect@ComponentpublicclassIdempotentAspect{@AutowiredprivateRedisTemplate<String,Object>redisTemplate;@Pointcut("@annotation(com.baodanbao.cn.annotation.Idempotent)")publicvoididempotentPointCut(){}@Before("idempotentPointCut()")publicvoidbefore()throwsException{HttpServletRequestrequest=((ServletRequestAttributes)RequestContextHolder.currentRequestAttributes()).getRequest();// 从请求头或参数中获取TokenStringtoken=request.getHeader("Idempotent-Token");if(token==null||token.isEmpty()){thrownewIllegalArgumentException("缺少幂等Token");}// Lua脚本保证原子性:GETDEL// 如果Key存在则获取并删除(防止下次重复提交),不存在则返回nilStringscript="if redis.call('get', KEYS[1]) then return redis.call('del', KEYS[1]) else return 0 end";Booleanresult=(Boolean)redisTemplate.execute((RedisCallback<Boolean>)connection->{ObjectnativeConnection=connection.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{@AutowiredprivateRedisTemplate<String,Object>redisTemplate;@GetMapping("/idempotent/token")publicStringgetIdempotentToken(){Stringtoken=UUID.randomUUID().toString();// 设置有效期,例如5分钟redisTemplate.opsForValue().set(token,"1",5,TimeUnit.MINUTES);returntoken;}}本文著作权归 俱美开放平台 ,转载请注明出处!