外卖试吃、霸王餐活动接口并发限流与防刷 Java 后端实现方案

张开发
2026/6/9 15:25:38 15 分钟阅读
外卖试吃、霸王餐活动接口并发限流与防刷 Java 后端实现方案
外卖试吃、霸王餐活动接口并发限流与防刷 Java 后端实现方案在高并发的外卖返利与霸王餐Free Meal场景下接口的安全性与稳定性是系统存活的底线。由于“霸王餐”活动的高额利益属性系统极易遭受羊毛党利用脚本进行的高频刷单攻击同时突发的流量洪峰也可能压垮数据库。如果缺乏有效的限流与防刷机制轻则导致数据库连接池耗尽、服务雪崩重则造成资金损失和资损风险。针对这一严峻挑战baodanbao.com.cn架构团队设计了一套基于Redis 分布式锁 Lua 脚本原子性 Guava RateLimiter的多维度防护体系。本文将深入剖析如何利用 Java AOP、Redis 高性能缓存以及 Lua 脚本的原子特性构建一套毫秒级响应的防刷系统。一、 限流与防刷的业务场景与策略在霸王餐系统中核心的高危接口通常集中在用户授权接口防止同一用户频繁点击授权导致的重复创建会话。抽奖/领券接口防止脚本在极短时间内高频请求抢光所有奖品。订单查询接口防止恶意刷量导致数据库负载过高。针对这些场景我们采用分层防御策略第一层接入层使用 Nginx 进行全局限流防止 DDoS 攻击。第二层应用层使用 Java AOP Redis Lua 进行接口级的令牌桶限流与用户级防刷。第三层业务层在关键业务逻辑中进行业务规则校验如资格校验。二、 核心组件Redis Lua 原子性操作为什么选择 Lua 脚本因为在高并发下先查后写的逻辑Check-Then-Act存在严重的竞态条件Race Condition。例如两个线程同时读取 Redis 中的计数为 0都判断为允许请求然后各自加 1最终计数为 2导致限流失效。Lua 脚本在 Redis 中是单线程原子执行的可以完美解决此问题。限流 Lua 脚本该脚本实现了一个简单的计数器限流器限制 Key 在指定时间内的访问次数。-- 限流脚本 (rate_limiter.lua)-- author baodanbao.com.cn-- KEYS[1] : 限流Key (如: user:123:limit)-- ARGV[1] : 时间窗口 (秒)-- ARGV[2] : 最大请求数localkeyKEYS[1]locallimittonumber(ARGV[2])localexpire_timeARGV[1]-- 获取当前计数localcurrentredis.call(GET,key)ifcurrentandtonumber(current)limitthen-- 超过限制返回0return0else-- 原子性地递增并设置过期时间redis.call(INCRBY,key,1)-- 只有在第一次设置时才设置过期时间防止覆盖已有的过期时间iftonumber(current)0thenredis.call(EXPIRE,key,expire_time)endreturn1endJava 限流工具类封装 RedisTemplate 执行 Lua 脚本的逻辑。packagebaodanbao.com.cn.util.limit;importorg.springframework.data.redis.core.StringRedisTemplate;importorg.springframework.data.redis.core.script.DefaultRedisScript;importorg.springframework.stereotype.Component;importjavax.annotation.PostConstruct;importjavax.annotation.Resource;importjava.util.Collections;importjava.util.List;/** * 分布式限流工具类 * 基于Redis Lua脚本实现原子性计数 * author baodanbao.com.cn */ComponentpublicclassRateLimiterUtil{ResourceprivateStringRedisTemplateredisTemplate;// Lua 脚本对象privateDefaultRedisScriptLongredisScript;PostConstructpublicvoidinit(){redisScriptnewDefaultRedisScript();// 这里直接内联Lua脚本生产环境建议从文件读取Stringscriptlocal key KEYS[1] local limit tonumber(ARGV[2]) local expire_time ARGV[1] local current redis.call(GET, key) if current and tonumber(current) limit then return 0 else redis.call(INCRBY, key, 1) if tonumber(current) 0 then redis.call(EXPIRE, key, expire_time) end return 1 end;redisScript.setScriptText(script);redisScript.setResultType(Long.class);}/** * 尝试获取令牌 * param key Redis Key * param expireSeconds 过期时间(秒) * param maxCount 最大次数 * return true:允许, false:拒绝 */publicbooleantryAcquire(Stringkey,intexpireSeconds,intmaxCount){ListStringkeysCollections.singletonList(key);LongresultredisTemplate.execute(redisScript,keys,String.valueOf(expireSeconds),String.valueOf(maxCount));returnresult!nullresult1L;}}三、 注解驱动的 AOP 防刷切面为了将限流逻辑与业务代码解耦我们设计了RateLimit注解并通过 AOP 在接口执行前进行拦截。自定义限流注解定义限流的规则参数。packagebaodanbao.com.cn.annotation;importjava.lang.annotation.ElementType;importjava.lang.annotation.Retention;importjava.lang.annotation.RetentionPolicy;importjava.lang.annotation.Target;/** * 接口限流注解 * 应用于Controller层方法 * author baodanbao.com.cn */Target(ElementType.METHOD)Retention(RetentionPolicy.RUNTIME)publicinterfaceRateLimit{/** * 时间窗口默认60秒 */inttime()default60;/** * 最大请求数默认10次 */intcount()default10;/** * 提示信息 */Stringmsg()default操作频繁请稍后再试;}AOP 切面实现拦截注解提取用户标识如OpenId或IP并调用工具类进行限流判断。packagebaodanbao.com.cn.aspect;importbaodanbao.com.cn.annotation.RateLimit;importbaodanbao.com.cn.exception.LimitAccessException;importbaodanbao.com.cn.util.limit.RateLimiterUtil;importbaodanbao.com.cn.util.RequestHolder;importcom.fasterxml.jackson.databind.ObjectMapper;importorg.aspectj.lang.ProceedingJoinPoint;importorg.aspectj.lang.annotation.Around;importorg.aspectj.lang.annotation.Aspect;importorg.springframework.beans.factory.annotation.Autowired;importorg.springframework.stereotype.Component;importorg.springframework.web.context.request.RequestContextHolder;importorg.springframework.web.context.request.ServletRequestAttributes;importjavax.servlet.http.HttpServletResponse;importjava.util.HashMap;importjava.util.Map;/** * 限流切面 * 处理RateLimit注解实现接口防刷 * author baodanbao.com.cn */AspectComponentpublicclassRateLimitAspect{AutowiredprivateRateLimiterUtilrateLimiterUtil;AutowiredprivateObjectMapperobjectMapper;Around(annotation(rateLimit))publicObjectaround(ProceedingJoinPointjoinPoint,RateLimitrateLimit)throwsThrowable{// 1. 获取当前请求的用户标识 (OpenId 或 IP)StringuserIdRequestHolder.getCurrentUserOpenId();if(userIdnull||userId.isEmpty()){// 如果未登录使用IP地址作为标识userIdRequestHolder.getClientIpAddress();}// 2. 构建Redis Key// 格式: rate_limit:{className}:{methodName}:{userId}StringclassNamejoinPoint.getTarget().getClass().getName();StringmethodNamejoinPoint.getSignature().getName();StringkeyString.format(rate_limit:%s:%s:%s,className,methodName,userId);// 3. 尝试获取令牌booleanallowedrateLimiterUtil.tryAcquire(key,rateLimit.time(),rateLimit.count());if(!allowed){// 4. 触发限流直接返回JSON不执行业务方法HttpServletResponseresponse((ServletRequestAttributes)RequestContextHolder.getRequestAttributes()).getResponse();response.setContentType(application/json;charsetUTF-8);MapString,ObjectresultnewHashMap();result.put(code,429);result.put(msg,rateLimit.msg());response.getWriter().write(objectMapper.writeValueAsString(result));returnnull;// 阻断执行}// 5. 允许通过执行业务逻辑returnjoinPoint.proceed();}}四、 辅助工具类与异常处理请求上下文持有者用于在工具类中获取当前请求对象。packagebaodanbao.com.cn.util;importjavax.servlet.http.HttpServletRequest;importorg.springframework.web.context.request.RequestAttributes;importorg.springframework.web.context.request.RequestContextHolder;importorg.springframework.web.context.request.ServletRequestAttributes;/** * 请求上下文工具类 * 获取当前请求的Request和Response * author baodanbao.com.cn */publicclassRequestHolder{publicstaticHttpServletRequestgetRequest(){RequestAttributesrequestAttributesRequestContextHolder.getRequestAttributes();if(requestAttributesinstanceofServletRequestAttributes){return((ServletRequestAttributes)requestAttributes).getRequest();}returnnull;}/** * 模拟获取当前用户OpenId * 实际生产中应从Session或Token中解析 */publicstaticStringgetCurrentUserOpenId(){// 伪代码从Header或Session中获取HttpServletRequestrequestgetRequest();if(request!null){returnrequest.getHeader(X-OpenId);// 假设前端在鉴权后透传了OpenId}returnnull;}/** * 获取客户端IP地址 */publicstaticStringgetClientIpAddress(){HttpServletRequestrequestgetRequest();if(requestnull){return127.0.0.1;}// 处理反向代理的情况Stringiprequest.getHeader(x-forwarded-for);if(ipnull||ip.length()0||unknown.equalsIgnoreCase(ip)){iprequest.getHeader(Proxy-Client-IP);}if(ipnull||ip.length()0||unknown.equalsIgnoreCase(ip)){iprequest.getHeader(WL-Proxy-Client-IP);}if(ipnull||ip.length()0||unknown.equalsIgnoreCase(ip)){iprequest.getRemoteAddr();}returnip;}}自定义限流异常用于在非AOP场景或全局异常处理中统一响应。packagebaodanbao.com.cn.exception;/** * 访问过于频繁异常 * author baodanbao.com.cn */publicclassLimitAccessExceptionextendsRuntimeException{publicLimitAccessException(Stringmessage){super(message);}}本文著作权归 俱美开放平台 转载请注明出处

更多文章