ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

基于Redis+Lua的分布式限流实战:Spring Boot接口高可用保护

基于Redis+Lua的分布式限流实战:Spring Boot接口高可用保护 “问什么问再问停雾”这句话在问答社区里经常被当成玩笑意思是“问题太多再问就关服”。但把它放到服务端工程的场景里它描述的是非常真实的风险一个对外提供问答能力的接口如果在短时间内收到大量重复或恶意请求线程池、数据库连接、内存都会快速被打满最终表现就是响应超时、接口 429、甚至整体不可用。要避免“停雾”不能只靠硬件扩容还要在流量入口做好限流、降级和熔断。下面以 Java Spring Boot Redis 为技术栈从一个最小问答接口开始逐步实现基于 Redis Lua 的分布式限流再讨论滑动窗口、令牌桶、网关限流和生产落地给正在做 API 高可用建设的开发者一条可复现的路径。1. 先理解接口为什么会“停雾”限流是最后一道保护1.1 问答接口的流量模型与真正的风险问答类接口通常具备三个特点单次请求逻辑相对简单、实时性要求高、会被终端用户或外部系统高频调用。正常用户点击时QPS 往往不高但系统一旦上线面对流量并不总是“正常”。常见的压力来源包括爬虫批量抓取答案、自动化脚本循环调用、活动推广导致流量突增、上游服务重试机制在短时间内重复发送相同请求。当请求数量超过服务处理能力资源消耗会沿着调用链路逐层放大首先是 Web 容器线程排队然后是数据库连接池被占满最后是 Redis、消息队列等中间件连接数达到上限。如果不加任何保护问答接口在压力下的表现通常是“延迟逐步升高然后大量超时”而此时最怕的是重试风暴客户端看到超时后继续重发服务端线程继续堆积最终雪崩。因此限流的核心目标不是让正常用户可以无限访问而是在流量超过阈值时快速拒绝多余请求把资源保留给足以支撑的正常流量。这样虽然会牺牲掉一部分请求但整个服务不会被打挂。用一张对比表可以更直观地看出不同状态下接口的行为状态正常流量超过阈值但有限流超过阈值且无限流响应时间正常放行请求正常拒绝请求快速返回所有请求排队延迟上升服务可用性可用部分请求被拒绝但服务稳定可能雪崩数据库压力正常受控连接池耗尽用户可感知正常偶尔收到“请求过于频繁”超时、白屏、无法访问1.2 限流、熔断、降级各有分工不要混为一谈很多项目把限流、熔断、降级混在一起导致一个问题接口被打满时底层错误一路透传到前端用户看到 500但日志里没有明确业务提示。实际上这三个手段保护的是不同阶段。限流控制的是“请求进入速率”。当单位时间内的请求数超过阈值直接拒绝后续请求防止上游流量冲垮下游。熔断保护的是“依赖调用”。当下游服务连续失败率达到阈值调用方主动短路不再发起请求给下游恢复时间。降级解决的是“服务能力不足时的兜底”。当核心接口不可用或响应过慢系统返回一个简化的结果比如缓存数据、默认文案、空列表而不是抛异常。三者的关系可以这样理解限流是在入口处做减法熔断是在依赖处做隔离降级是在失败后做兜底。实际项目中问答接口的完整保护链路通常是“网关限流 - 应用层限流 - 依赖熔断 - 失败降级 - 统一异常返回”。一个接口如果只加限流不加降级用户被拒绝时只会看到冷冰冰的 429如果只加降级不加限流系统依然可能被高流量打垮。1.3 限流放在哪一层决定了它能挡住什么限流可以出现在多个位置Nginx 或网关、应用框架拦截器、业务方法内部、数据库或缓存层。不同位置的保护粒度不一样。位置保护对象示例优点局限网关/Nginx进入整个系统的流量按 IP、按 URL 限流前置保护成本低无法感知业务用户维度应用层拦截器单个服务实例按用户 ID、接口维度限流规则灵活多实例需要分布式协调业务方法内部核心业务逻辑按 Token、按操作频率限流精准控制关键路径侵入业务代码缓存/数据库层底层资源限制访问量、保护慢查询保护关键资源层内无法提供友好提示生产环境建议至少要保留两层网关层负责粗粒度全局限流应用层负责细粒度业务限流。不要把限流只写在某一台服务器内存里否则扩容后限流次数被分摊每个节点都允许同样的 QPS整体流量就失去约束。这也是为什么本文选择 Redis 来保存计数器多实例共享同一个 Redis 就能让限流规则全局生效。2. 搭建一个最小问答服务先看没有限流时的表现2.1 环境准备与依赖版本为了把限流讲清楚这里从一个 Spring Boot 项目开始。示例使用 Java 8、Spring Boot 2.7.x、Redis 6.x、Maven 3.6。具体版本不是铁律但如果本机 Redis 版本过低脚本中使用的命令差异可能影响执行结果落地前要先确认。推荐使用稳定版本的 Spring Boot示例中不引入额外框架。pom.xml 中需要包含 Web、Redis、AOP、Validation 依赖parent groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-parent/artifactId version2.7.18/version relativePath/ /parent properties java.version1.8/java.version /properties dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-aop/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-validation/artifactId /dependency /dependencies如果引入版本号后出现与本地 Redis 不兼容的情况需要根据 Redis 服务端版本和 Spring Data Redis 版本重新核对依赖。这里不把版本当作固定结论而是提供一个稳定的起点。2.2 创建问答接口项目结构在常见工程中可以按这个结构组织代码ask-service ├── pom.xml └── src/main/java/com/example/askservice ├── AskServiceApplication.java ├── controller │ └── AskController.java ├── limiter │ ├── RateLimit.java │ ├── RateLimitAspect.java │ └── LuaRateLimiter.java └── config └── RedisConfig.java这里只列了与限流相关的主要目录。实际项目还会有 entity、mapper、service 等分层。目录结构按自己团队规范调整即可但要保证 Spring Boot 的扫描路径能覆盖到切面类。AskServiceApplication 是启动类代码如下package com.example.askservice; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; SpringBootApplication public class AskServiceApplication { public static void main(String[] args) { SpringApplication.run(AskServiceApplication.class, args); } }这里没有特殊配置只要保证启动类放在包根路径控制器和切面都能被扫描到即可。2.3 写一个最简单的问答接口先创建一个 AskController。接口接收一个 question 参数根据输入返回一个固定答案。为了模拟真实场景在处理时加了一个 50 毫秒的延迟代表查询缓存或数据库的耗时package com.example.askservice.controller; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.util.concurrent.TimeUnit; RestController RequestMapping(/api) public class AskController { GetMapping(/ask) public String ask(RequestParam(question) String question) throws InterruptedException { // 模拟业务处理实际项目中可能是查询知识库或调用推荐服务 TimeUnit.MILLISECONDS.sleep(50); return answer for: question; } }这里故意让每个请求等待 50 毫秒。一台默认配置的本地 Tomcat 可以并发处理一定数量的请求但如果每秒请求量达到几百线程池和 CPU 会迅速出现压力。这样后续再验证限流效果时对比会更明显。2.4 先启动一次确认未限流时确实可以无限请求使用 Maven 启动项目mvn spring-boot:run启动成功后用 curl 测试接口curl http://localhost:8080/api/ask?question如何学习Java返回结果answer for: 如何学习Java在一台没有限流保护的服务上连续执行下面命令也能全部成功for i in $(seq 1 100); do curl -s -o /dev/null -w %{http_code}\n http://localhost:8080/api/ask?questiontest$i done输出会是一串 200。这说明接口本身可用但没有能力保护自己。一旦请求规模变大或请求来自自动化脚本服务只能被动承受。这也是后续需要接入限流的直接原因。3. 用 Redis Lua 实现分布式限流从脚本到注解3.1 为什么选择 Redis Lua而不是只写 Java 代码限流最简单的方式是在内存中维护一个计数器例如使用 ConcurrentHashMap 记录用户上次请求时间。这样做的好处是快速、零外部依赖但问题很明显应用部署多个实例时每个实例的计数器是独立的用户轮流访问不同实例时会绕过限制。要让限制全局生效计数器必须放到所有实例都能访问的地方Redis 是常用选择。选择 Lua 脚本是因为限流需要“判断当前计数、计数加一、超过阈值则拒绝”这三个操作原子性完成。如果不使用 Lua先用 GET 拿到计数再判断再 INCR在高并发下会发生竞态多个请求同时读到同一个值都认为没有超限最终放行量会明显超过配置。Redis 执行 Lua 脚本时是原子性的中间不会插入其他命令因此适合做这类计数判断。固定窗口限流的逻辑可以概括为在窗口时间内维护一个计数器每个请求先将计数器加一如果首次加一则设置过期时间接着判断计数是否超过阈值。窗口到期后 Redis 会自动删除 key相当于重新开始一个窗口。3.2 固定窗口 Lua 脚本与参数说明创建一个rate_limit.lua文件内容如下local key KEYS[1] local limit tonumber(ARGV[1]) local window tonumber(ARGV[2]) local current redis.call(INCR, key) if current 1 then redis.call(EXPIRE, key, window) end if current limit then return 0 end return 1这段脚本有四个要点key 是限流标识可以是接口名、用户 ID、IP 或它们的组合。limit 是窗口内允许的最大请求数。window 是窗口长度单位秒。返回值 1 表示放行0 表示拒绝。这里有一个隐藏细节只有执行 INCR 后 current 等于 1 时才设置过期时间。如果每次请求都执行 EXPIRE窗口会被不断续期计数永远无法通过过期清零。只设置一次则能保证窗口到期后重新计数。3.3 在 Spring Boot 中接入 Redis 与 Lua 限流器先配置 RedisTemplate指定 JSON 序列化器便于观察数据package com.example.askservice.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.data.redis.connection.RedisConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; Configuration public class RedisConfig { Bean public RedisTemplateString, Object redisTemplate(RedisConnectionFactory factory) { RedisTemplateString, Object template new RedisTemplate(); template.setConnectionFactory(factory); template.setKeySerializer(new StringRedisSerializer()); template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); template.afterPropertiesSet(); return template; } }然后创建一个 LuaRateLimiter 组件负责加载脚本并执行package com.example.askservice.limiter; import org.springframework.core.io.ClassPathResource; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.data.redis.core.script.DefaultRedisScript; import org.springframework.scripting.support.ResourceScriptSource; import org.springframework.stereotype.Component; import javax.annotation.PostConstruct; import java.util.Collections; Component public class LuaRateLimiter { private final RedisTemplateString, Object redisTemplate; private DefaultRedisScriptLong rateLimitScript; public LuaRateLimiter(RedisTemplateString, Object redisTemplate) { this.redisTemplate redisTemplate; } PostConstruct public void init() { rateLimitScript new DefaultRedisScript(); rateLimitScript.setScriptSource( new ResourceScriptSource(new ClassPathResource(rate_limit.lua))); rateLimitScript.setResultType(Long.class); } public boolean allow(String key, long limit, long windowSeconds) { Long result redisTemplate.execute( rateLimitScript, Collections.singletonList(key), limit, windowSeconds ); return result ! null result 1L; } }注意把 rate_limit.lua 放在 src/main/resources 目录下。RedisTemplate 的 execute 方法会在每次调用时把参数传给脚本。如果生产环境中 Redis 开启了 ACL 或集群模式需要确认 Lua 脚本涉及的命令都有执行权限。3.4 用注解加切面让限流不侵入业务代码在接口方法上直接写 if 判断也可以但会散落到各个接口。更清晰的方式是定义一个 RateLimit 注解再用 AOP 切面统一处理。注解定义package com.example.askservice.limiter; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.lang.annotation.Target; Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface RateLimit { String key() default ; long limit() default 10; long windowSeconds() default 60; }切面实现package com.example.askservice.limiter; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.reflect.MethodSignature; import org.springframework.stereotype.Component; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import javax.servlet.http.HttpServletRequest; import java.lang.reflect.Method; Aspect Component public class RateLimitAspect { private final LuaRateLimiter rateLimiter; public RateLimitAspect(LuaRateLimiter rateLimiter) { this.rateLimiter rateLimiter; } Around(annotation(com.example.askservice.limiter.RateLimit)) public Object around(ProceedingJoinPoint joinPoint) throws Throwable { MethodSignature signature (MethodSignature) joinPoint.getSignature(); Method method signature.getMethod(); RateLimit rateLimit method.getAnnotation(RateLimit
返回列表