您现在的位置是:亿华云 > IT科技类资讯
从零搭建开发脚手架 保证服务的幂等性和防止重复请求
亿华云2025-10-02 18:41:46【IT科技类资讯】9人已围观
简介本文转载自微信公众号「Java大厂面试官」,作者laker。转载本文请联系Java大厂面试官公众号。 什么是幂等? 重复请求原因 解决方案 方案

什么是防止幂等?
多次执行的结果和一次执行的结果相同,例如查询操作天然就是请求幂等的。
重复请求原因
我们以电商场景中的从零下单来举例,造成下单重复一般有以下几个原因:
用户手抖点快了,搭建导致多次重复下单。脚手架保 网络抖动导致失败或者超时重传,证服重复例如nginx、性和Fegin、防止RPC框架等解决方案
方案一:前端同步阻塞按钮置灰
前端同步阻塞按钮置灰,请求用户点击“发布”按钮后,从零在网络请求没有返回,或者超时之前,用户都不可以继续点击“发布按钮”,界面可以将按钮置灰或者转圈。
优点:实现成本极低
缺点:
只能防御用户手抖的误操作。 确防不住远程调用的重试以及恶意重放。方案二:前后端搭配干活,预生成订单号
可以通过预先生成订单号(在进入下单页面的时候生成订单号),然后利用数据库中订单号的站群服务器唯一约束这个特性,避免重复写入订单。
时序图如下:
细节如下:
订单号生成时机
是在进入订单页面,而不是提交订单的时候 。
订单号生成规则
小规模系统完全可以用MySQL的Sequence或者Redis来生成。大规模系统也可以采用类似雪花算法之类的方式分布式生成GUID。 订单号中最好包含一些品类、时间等信息,便于业务处理,它不能是一个单纯自增的ID,否则别人很容易根据订单号计算出你大致的销量,所以订单号的生产算法在保证不重复的前提下,一般都会加入很多业务规则在里面。订单号是否是主键
方式一:使用订单号做主键
如果订单号不是递增的可能造成频繁页分裂,导致并发高的时候性能降低,所以要保证订单号全局递增。
方式二:有自增主键和订单号列并设置唯一索引
因为订单号不是主键,云服务器所以根据订单号查询会多一次回表操作,且如果订单号不递增二级订单号索引也会有页分裂。
订单号可以由前端生成吗
不可以,订单号一定是在后端生成,后端生成可以保证全局唯一,且可以用于做安全认证,不是后端颁发的订单号不予处理。
提交订单的时候,一种是先拿着订单号去查库,让业务代码校验是否存在,另一种是直接利用库表主键唯一约束抛异常,这两种处理方式哪种性能更好?
选后者,等查完库确定不存在再插入的时候,可能数据已经变化了,订单存在了,还是要抛异常,检查意义不大。
方案三:通用方案,锁模式
使用锁来控制一段时间内的重复请求,注意: 锁的粒度为用户+业务。
请求流程如下:
实现
针对方案三实现如下:
自定义注解限制重复提交
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) @Documented @Inherited public @interface RepeatSubmitLimit { /** * 业务key,例如下单业务 order */ String businessKey(); /** * 业务参数,用于做更细粒度锁,例如锁到具体 订单id #orderId */ String businessParam() default ""; /** * 是否用户隔离,默认启用 */ boolean userLimit() default true; /** * 锁时间 默认10s */ int time() default 10; }自定义切面拦截过滤处理
@Component @Aspect @Slf4j public class LimitSubmitAspect { LFUCache<Object, Object> LFUCACHE = CacheUtil.newLFUCache(100, 60 * 1000); @Pointcut("@annotation(RepeatSubmitLimit)") private void pointcut() { } @Around("pointcut()") public Object handleSubmit(ProceedingJoinPoint joinPoint) throws Throwable { Method method = ((MethodSignature) joinPoint.getSignature()).getMethod(); //获取注解信息 RepeatSubmitLimit repeatSubmitLimit = method.getAnnotation(RepeatSubmitLimit.class); int limitTime = repeatSubmitLimit.time(); String key = getLockKey(joinPoint, repeatSubmitLimit); Object result = LFUCACHE.get(key, false); if (result != null) { throw new BusinessException("请勿重复访问!"); } LFUCACHE.put(key, StpUtil.getLoginId(), limitTime * 1000); try { Object proceed = joinPoint.proceed(); return proceed; } catch (Throwable e) { log.error("Exception in { }.{ }() with cause = \{ }\ and exception = \{ }\", joinPoint.getSignature().getDeclaringTypeName(), joinPoint.getSignature().getName(), e.getCause() != null ? e.getCause() : "NULL", e.getMessage(), e); throw e; } finally { LFUCACHE.remove(key); } } private static final ParameterNameDiscoverer NAME_DISCOVERER = new DefaultParameterNameDiscoverer(); private static final ExpressionParser PARSER = new SpelExpressionParser(); private String getLockKey(ProceedingJoinPoint joinPoint, RepeatSubmitLimit repeatSubmitLimit) { String businessKey = repeatSubmitLimit.businessKey(); boolean userLimit = repeatSubmitLimit.userLimit(); String businessParam = repeatSubmitLimit.businessParam(); if (userLimit) { businessKey = businessKey + ":" + StpUtil.getLoginId(); } if (StrUtil.isNotBlank(businessParam)) { Method method = ((MethodSignature) joinPoint.getSignature()).getMethod(); EvaluationContext context = new MethodBasedEvaluationContext(null, method, joinPoint.getArgs(), NAME_DISCOVERER); String key = PARSER.parseExpression(businessParam).getValue(context, String.class); businessKey = businessKey + ":" + key; } return businessKey; } }使用示例
@RepeatSubmitLimit(businessKey = "tokenInfo", businessParam = "#name") @GetMapping("/api/v1/tokenInfo") public Response tokenInfo(String name) { }请求示例:http://localhost:8080/api/v1/tokenInfo?name=123
锁粒度为:taokeninfo:1:123
防重效果:
{ code: "500", msg: "请勿重复访问!" }参考:
后端存储实践课
很赞哦!(92738)