基于Redis+Lua的分布式限流实战:Spring Boot接口高可用保护
2026/8/31 10:01:17 网站建设 项目流程

“问什么问,再问停雾!”这句话在问答社区里经常被当成玩笑,意思是“问题太多,再问就关服”。但把它放到服务端工程的场景里,它描述的是非常真实的风险:一个对外提供问答能力的接口,如果在短时间内收到大量重复或恶意请求,线程池、数据库连接、内存都会快速被打满,最终表现就是响应超时、接口 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> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>2.7.18</version> <relativePath/> </parent> <properties> <java.version>1.8</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-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?question=test$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 RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) { RedisTemplate<String, 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 RedisTemplate<String, Object> redisTemplate; private DefaultRedisScript<Long> rateLimitScript; public LuaRateLimiter(RedisTemplate<String, 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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询