1. 项目概述:为什么API限流不是“加个注解就完事”的事
在某次线上压测复盘会上,我亲眼看着一个日均调用量不到5万的内部管理后台,在促销活动预热阶段被前端轮询接口拖垮——数据库连接池耗尽、线程阻塞、响应延迟飙升到8秒以上,最终触发熔断,整个运营看板瘫痪了23分钟。事后排查发现,问题根源不是数据库性能瓶颈,也不是代码存在N+1查询,而是一个被所有人忽略的细节:那个每3秒自动刷新一次的实时数据接口,没有任何访问约束,单个用户页面打开后,10分钟内就发出了200多次请求;当几百个运营人员同时操作时,瞬时并发直接冲垮了服务边界。这让我彻底意识到,SpringBoot实战中所谓“高效实现API限流策略”,从来不是教科书里一行@RateLimiter就能解决的工程问题,而是一场关于流量认知、系统韧性与业务语义的深度博弈。
限流这件事,表面看是技术选型问题,实则是系统设计哲学的投射。你用Redis+Lua写个令牌桶,能扛住突发流量,但挡不住恶意爬虫对登录接口的暴力试探;你配个Sentinel全局QPS阈值,能保护核心链路,却可能误杀高价值用户的重试请求;你给所有接口统一加100QPS限制,看似安全,实则让报表导出这类长耗时接口永远无法完成。真正的“高效”,必须同时满足三个硬指标:第一,响应快——限流决策必须在毫秒级完成,不能成为新瓶颈;第二,精度高——能区分用户、IP、设备、业务场景等多维身份,支持动态阈值调整;第三,可运维——限流生效状态、触发原因、拦截明细必须可观测、可追溯、可干预。这不是SpringBoot框架自带功能能覆盖的,它需要你亲手把限流逻辑嵌进请求生命周期的关键切口,用真实业务数据反哺策略迭代。这篇文章,就是我把过去三年在多个中大型系统中落地限流方案踩过的坑、验证过的路径、沉淀下来的配置模板和判断逻辑,全部摊开来讲清楚。无论你是刚接触限流概念的初级开发者,还是正在为线上稳定性焦头烂额的架构师,都能在这里找到可直接抄作业的方案,以及比代码更重要的——为什么这么选的底层逻辑。
2. 整体设计思路与方案选型解析:从“能用”到“好用”的四层演进
2.1 为什么拒绝“全量注解式”限流?——直面Spring Boot生态的三大认知误区
很多开发者一上来就想找“最简单的限流starter”,比如spring-boot-starter-rate-limiter或某些第三方封装库。我试过至少7种主流方案,最终全部弃用,原因很现实:
误区一:“注解即限流”掩盖了策略失效风险
@RateLimiter(key = "user", rate = 10)这类写法,看似优雅,实则把限流逻辑锁死在方法签名里。当业务要求“同一用户对订单创建接口限5次/分钟,但对订单查询不限制”时,你得为每个方法单独加注解;当运营临时要求“今晚20:00-22:00对所有未登录用户降级为1次/分钟”时,你得改代码、发版本、重启服务——而此时流量高峰已过。限流策略必须脱离代码编译期,变成运行时可配置的规则引擎。误区二:“内存限流”在集群环境下形同虚设
基于ConcurrentHashMap或Guava RateLimiter的本地限流,单机测试完美,一上K8s集群立刻崩盘。A节点放行的请求,B节点完全不知情,结果总流量翻倍突破阈值。我曾在一个电商结算服务上见过惨痛案例:本地令牌桶设为100QPS,4个Pod实例,理论峰值400QPS,实际压测时因流量分配不均,单个Pod瞬间被打到180QPS,直接OOM。分布式系统下,限流状态必须强一致、低延迟共享,Redis不是可选项,而是必选项。误区三:“一刀切QPS”违背业务真实水位
把所有接口都设成“100QPS”,就像给汽车油门装固定限位器——跑高速时动力不足,堵车时又毫无意义。真实业务中,接口负载差异极大:用户登录接口平均耗时120ms,但峰值QPS可能达2000;商品详情页渲染耗时800ms,峰值QPS却只有300。如果按相同QPS限流,前者可能被误杀,后者永远达不到瓶颈。必须引入“权重系数”概念:将QPS换算为“资源消耗单位”,用“每秒允许消耗X个单位”替代“每秒最多Y次调用”。
提示:我们最终放弃所有基于注解或本地内存的方案,转向“中心化规则引擎 + 分布式令牌桶 + 动态权重计算”的混合架构。这不是过度设计,而是线上事故倒逼出的生存法则。
2.2 四层限流架构设计:从网关到应用层的纵深防御
我们落地的方案不是单一技术点,而是一套分层防御体系,每一层解决不同维度的问题,且层层之间有明确的职责边界和降级策略:
| 层级 | 位置 | 核心能力 | 典型阈值 | 失效影响 |
|---|---|---|---|---|
| L1:网络层限流 | Nginx / API网关 | IP维度基础防护,防扫描、防CC攻击 | 单IP 500req/min | 网关层拦截,应用无感知 |
| L2:网关层限流 | Spring Cloud Gateway | 用户ID/设备ID/APPKey多维识别,支持动态规则 | 按用户组分级(VIP 500QPS,普通 100QPS) | 请求在网关返回429,不触达后端服务 |
| L3:服务层限流 | SpringBoot应用内 | 接口级精细化控制,结合业务上下文(如“下单接口仅对余额>0用户放宽阈值”) | 按接口+参数组合动态计算(如“创建订单:10QPS + 0.5权重/金额万元”) | 应用内快速失败,返回业务友好提示 |
| L4:DB/缓存层限流 | MyBatis拦截器 / Redis客户端 | 防止慢SQL或热点Key击穿,保护下游存储 | 单SQL执行超时>500ms自动熔断,单Key QPS>5000触发告警 | 数据库连接池保底,避免雪崩 |
这个设计的关键在于各层不重复、不遗漏、可独立开关。比如L1和L2负责“广度防御”(拦住大部分无效流量),L3负责“精度控制”(保障核心业务体验),L4则是最后的“兜底保险”。当L2网关因配置错误失效时,L3服务层仍能守住底线;当L3因代码Bug未生效时,L1的IP限流至少能阻止脚本攻击。这种纵深设计,让我们在最近一次全站压测中,即使故意关闭网关限流模块,系统依然保持可用,只是响应时间从200ms升至600ms——这正是“高效”的本质:不是追求绝对零故障,而是让故障影响可控、可预期。
2.3 技术栈选型逻辑:为什么是Redis+Lua+Spring AOP,而不是Sentinel或Resilience4j?
面对Sentinel、Resilience4j、Bucket4j等成熟方案,我们最终选择手写Redis+Lua+Spring AOP组合,决策依据非常务实:
Redis的选择理由:
- 性能:单Redis实例轻松支撑5万+ QPS限流检查(实测P99<2ms);
- 一致性:Redis Cluster的hash slot机制天然支持分布式状态分片,无需额外协调;
- 运维成熟:公司已有完善Redis监控告警体系,限流指标可直接复用;
- 成本:相比部署独立Sentinel Dashboard,复用现有Redis集群零新增成本。
Lua脚本的核心价值:
限流本质是“读-判-写”原子操作(读当前令牌数→判断是否足够→扣减令牌数)。若用Java代码分三步操作Redis,必然存在竞态条件。而Redis Lua脚本在服务端原子执行,我们写的acquire_token.lua脚本仅43行,却能保证100%精确性。更重要的是,Lua可直接访问Redis内置时间函数(redis.call('TIME')),无需依赖客户端时间同步,彻底规避NTP时钟漂移导致的令牌桶异常。这点在跨机房部署时尤为关键——我们曾因客户端服务器时间误差300ms,导致令牌桶提前耗尽,而Lua方案完全免疫此问题。Spring AOP的不可替代性:
为什么不用Filter或Interceptor?因为它们无法获取方法参数。而我们的限流策略需动态解析参数,例如:@RateLimit(key = "order:{#order.userId}", weight = "#order.amount/10000") public Order createOrder(@RequestBody Order order) { ... }这里的
#order.userId和#order.amount必须在AOP环绕通知中通过ProceedingJoinPoint解析SpEL表达式才能拿到。Filter只能看到原始HTTP请求体,解析JSON再反序列化,性能损耗大且易出错。AOP在方法执行前介入,参数已是强类型对象,精准高效。
注意:我们严格禁止在Lua脚本中做任何业务逻辑判断(如“if userId startsWith 'vip' then...”)。所有业务规则判断必须在Java层完成,Lua只做纯粹的令牌桶操作。这是为了保障Lua脚本的极致轻量和可测试性——我们为
acquire_token.lua写了127个单元测试用例,覆盖所有边界条件。
3. 核心细节解析与实操要点:从令牌桶原理到生产级配置
3.1 令牌桶算法的工程化改造:为什么标准实现不适用于真实业务?
标准令牌桶算法(Token Bucket)公式为:当前令牌数 = min(最大容量, 上次填充量 + 速率 × 时间差)
这个公式在教科书里很美,但放到生产环境会暴露三个致命缺陷:
缺陷一:时间精度陷阱
标准实现依赖系统时钟,而JVM GC停顿、容器CPU节流都会导致System.currentTimeMillis()跳变。我们曾在线上观察到GC后时间戳突增200ms,导致令牌桶瞬间“灌满”,引发短时流量洪峰。解决方案是改用单调时钟(Monotonic Clock):Redis的TIME命令返回的是Redis服务端的单调递增时间戳(秒+微秒),完全不受客户端影响。我们在Lua脚本中始终使用redis.call('TIME')获取时间,确保时间差计算绝对可靠。缺陷二:权重缺失导致资源错配
标准令牌桶对所有请求一视同仁,但现实中1次支付接口调用消耗的数据库连接、CPU周期,可能是10次用户查询接口的总和。我们引入动态权重系数:实际消耗令牌 = 基础令牌 × (1 + 业务权重 × 参数因子)例如:
- 订单创建接口基础令牌=1,但
weight = #order.amount/10000(每万元订单加1令牌); - 文件上传接口基础令牌=1,但
weight = #file.size/1024/1024(每MB加1令牌);
这样,一个10万元的订单请求实际消耗2个令牌,而一个100元订单只消耗1.01个令牌,资源分配与业务价值严格对齐。
- 订单创建接口基础令牌=1,但
缺陷三:冷启动抖动
新服务上线或流量低谷后,令牌桶长期空置,一旦突发流量涌入,桶内令牌瞬间耗尽,导致大量请求被拒。我们增加预热填充机制:在服务启动时,主动向Redis写入初始令牌(maxCapacity × 0.3),并设置TTL略大于填充周期,确保服务刚启动就有缓冲空间。这个值经压测验证:0.3是平衡“防抖动”和“防误放行”的黄金比例。
3.2 Redis Key设计规范:如何避免Key爆炸与热点问题?
限流Key的设计,直接决定Redis集群的负载均衡效果。我们踩过太多坑,最终形成铁律:
禁止使用URL路径作为Key:
/api/v1/orders/{id}中的{id}是变量,会导致Key无限膨胀(如limit:api:v1:orders:123456、limit:api:v1:orders:123457…),Redis内存暴涨且无法有效驱逐。正确做法是提取业务主键并标准化:// 错误示例(Key爆炸) limit:api:v1:orders:123456 // 正确示例(Key收敛) limit:order:create:userId:1001 limit:order:query:userId:1001强制添加命名空间前缀:
所有Key必须以limit:开头,便于Redis监控时按前缀聚合统计。我们曾因未加前缀,导致运维无法区分限流Key和业务缓存Key,误删过生产数据。热点Key防护:对超高频用户做分片:
VIP用户日均调用10万次,其限流Key(如limit:user:1000001)会成为Redis单分片热点。解决方案是对用户ID哈希分片:-- Lua中计算分片 local shardId = math.fmod(tonumber(userId), 16) + 1 local key = "limit:user:" .. shardId .. ":" .. userId这样1000001号用户会被分散到16个Key中(
limit:user:1:1000001~limit:user:16:1000001),流量均匀打散。Key TTL必须精确匹配限流周期:
1分钟限流,Key TTL必须设为60秒,而非EXPIRE key 60。因为EXPIRE是相对时间,而PEXPIREAT(毫秒级绝对过期时间)才能保证所有节点Key在同一毫秒失效,避免因Redis主从同步延迟导致的限流窗口偏移。
3.3 Spring AOP限流切面的健壮性设计:如何让拦截器不成为新故障点?
一个限流切面如果本身不稳定,比不限流更可怕。我们为RateLimitAspect做了五层加固:
超时熔断:
所有Redis操作强制设置timeout=50ms,若50ms内未返回,则跳过限流直接放行(宁可误放,不可误杀)。配置通过JedisPoolConfig注入,避免全局超时影响其他Redis操作。降级开关:
通过@Value("${rate.limit.enabled:true}")控制开关,支持运行时curl -X POST http://localhost:8080/actuator/feature-toggle?rateLimit=false动态关闭,无需重启。异常隔离:
Redis连接异常、Lua执行报错等,全部捕获并记录WARN日志,绝不抛出异常中断业务流程。我们定义了RateLimitException作为限流拒绝专用异常,其他异常一律吞掉。异步日志:
每次限流拦截都记录详细日志(用户ID、接口、消耗令牌数、剩余令牌),但采用AsyncAppender异步写入,避免I/O阻塞主线程。日志格式严格遵循ELK规范,方便Kibana聚合分析。缓存穿透防护:
对非法用户ID(如非数字字符串)的请求,先走本地Caffeine缓存(1000条,5分钟过期)判断是否黑名单,避免海量恶意请求直击Redis。
实操心得:我们曾在线上发现一个BUG——当用户ID为空字符串时,Lua脚本中
tonumber("")返回nil,导致math.fmod(nil, 16)报错,整个切面崩溃。修复方案是在Java层增加StringUtils.isNumeric(userId)校验,并为""生成固定分片ID。这个细节提醒我们:限流组件的输入校验,必须比业务代码更严格。
4. 实操过程与核心环节实现:从零搭建可落地的限流系统
4.1 环境准备与依赖配置:精简到只有3个必要依赖
摒弃所有“全家桶”式starter,我们的pom.xml中限流相关依赖仅保留最精简的3个:
<!-- Redis客户端,选用Jedis而非Lettuce,因Lettuce的异步模式在限流场景下增加复杂度 --> <dependency> <groupId>redis.clients</groupId> <artifactId>jedis</artifactId> <version>4.4.3</version> </dependency> <!-- Spring AOP核心,无需额外starter --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency> <!-- SpEL表达式解析,用于动态提取参数 --> <dependency> <groupId>org.springframework</groupId> <artifactId>spring-expression</artifactId> </dependency>为什么不用Spring Boot官方的spring-boot-starter-data-redis?
因为它默认集成Lettuce,而Lettuce的连接池配置与Jedis差异巨大,且其异步API在限流这种强实时性场景下,回调地狱会让代码难以维护。Jedis的同步阻塞模型反而更契合“快速决策、快速返回”的限流诉求。我们为Jedis配置了严苛的连接池参数:
@Bean public JedisPool jedisPool() { JedisPoolConfig config = new JedisPoolConfig(); config.setMaxTotal(100); // 最大连接数 config.setMaxIdle(20); // 最大空闲连接 config.setMinIdle(5); // 最小空闲连接(保活用) config.setBlockWhenExhausted(true); // 连接池耗尽时阻塞等待 config.setMaxWaitMillis(50); // 最大等待50ms,超时则降级 config.setTestOnBorrow(true); // 取连接时测试有效性 return new JedisPool(config, "redis-host", 6379, 5000); }关键参数解读:
setMaxWaitMillis(50)是生命线——当Redis响应慢时,Jedis最多等50ms,超时立即返回null,触发降级逻辑。这个值经过2000次压测验证:50ms是用户体验可接受的上限,也是限流组件自身不拖慢业务的底线。
4.2 Lua脚本详解:43行代码如何承载所有限流逻辑?
acquire_token.lua是整个系统的灵魂,我们把它拆解为可验证的原子模块:
-- acquire_token.lua -- 输入参数:KEYS[1]=令牌桶Key, ARGV[1]=最大容量, ARGV[2]=填充速率(令牌/秒), -- ARGV[3]=本次请求消耗令牌数, ARGV[4]=当前时间戳(毫秒) local key = KEYS[1] local capacity = tonumber(ARGV[1]) local rate = tonumber(ARGV[2]) local cost = tonumber(ARGV[3]) local now = tonumber(ARGV[4]) -- 1. 获取桶内当前状态:last_time(上次填充时间戳), tokens(当前令牌数) local bucket = redis.call('HMGET', key, 'last_time', 'tokens') local last_time = tonumber(bucket[1]) or 0 local tokens = tonumber(bucket[2]) or capacity -- 2. 计算应填充令牌数:rate * 时间差,但不超过capacity local time_diff = (now - last_time) / 1000.0 -- 转换为秒 local fill_tokens = math.min(capacity, tokens + rate * time_diff) -- 3. 判断是否足够:足够则扣减,不足则拒绝 if fill_tokens >= cost then -- 原子更新:设置新令牌数和新时间戳 redis.call('HMSET', key, 'tokens', fill_tokens - cost, 'last_time', now) redis.call('PEXPIREAT', key, now + 60000) -- 绝对过期时间:1分钟后 return 1 -- 允许 else -- 桶空,但需确保Key存在且有TTL,避免缓存穿透 if tokens == 0 then redis.call('HMSET', key, 'tokens', 0, 'last_time', now) redis.call('PEXPIREAT', key, now + 60000) end return 0 -- 拒绝 end这个脚本的精妙之处在于:
- 绝对时间驱动:所有时间计算基于
ARGV[4](Java层传入的System.currentTimeMillis()),而非Redis服务端TIME,确保Java层能精确控制窗口起始点; - 幂等性保障:即使同一请求因网络重试多次执行Lua,
HMSET和PEXPIREAT都是幂等操作,不会导致令牌重复扣减; - 内存友好:
HMGET/HMSET只操作两个字段,避免HGETALL全量加载带来的内存压力; - TTL精准:
PEXPIREAT设置绝对过期时间,彻底解决EXPIRE相对时间在Redis主从同步下的窗口漂移问题。
我们在Java层调用时,严格校验返回值:
Long result = (Long) jedis.evalsha(sha1, Collections.singletonList(key), Arrays.asList(String.valueOf(capacity), String.valueOf(rate), String.valueOf(cost), String.valueOf(System.currentTimeMillis()))); if (result == 0) { throw new RateLimitException("Request rejected by rate limit"); }4.3 注解驱动的限流规则定义:让业务开发像写SQL一样写限流策略
我们设计的@RateLimit注解,目标是让业务同学无需理解Redis或Lua,就能精准表达业务意图:
@Target({ElementType.METHOD}) @Retention(RetentionPolicy.RUNTIME) public @interface RateLimit { String key() default ""; // SpEL表达式,如 "user:#userId" double capacity() default 100.0; // 桶容量 double rate() default 10.0; // 填充速率(令牌/秒) String weight() default "1.0"; // SpEL权重表达式,如 "#order.amount/10000" String fallback() default ""; // 降级方法名(可选) }关键实现细节:
key()和weight()都支持完整SpEL语法,通过StandardEvaluationContext解析:EvaluationContext context = new StandardEvaluationContext(); context.setVariable("userId", 1001); context.setVariable("order", orderObj); String resolvedKey = parser.parseExpression(key).getValue(context, String.class); Double resolvedWeight = parser.parseExpression(weight).getValue(context, Double.class);- 权重计算前置校验:在调用Lua前,先计算
resolvedWeight,若为null或NaN,直接抛出IllegalArgumentException,避免无效参数传入Redis导致脚本异常。 - fallback机制:当限流拒绝时,自动反射调用同Controller内指定的降级方法,该方法必须返回
ResponseEntity<?>,且参数列表与原方法一致(支持@PathVariable等注解注入)。
一个真实业务场景的使用示例:
@RestController public class OrderController { // VIP用户下单:500QPS,普通用户:50QPS,且订单金额每万元加1令牌 @RateLimit( key = "order:create:userId:#userId", capacity = "#isVip ? 500 : 50", rate = "#isVip ? 500 : 50", weight = "#order.amount/10000" ) public ResponseEntity<Order> createOrder(@RequestBody Order order, @RequestHeader("X-Is-Vip") boolean isVip) { // 业务逻辑 } // 降级方法:返回兜底订单 public ResponseEntity<Order> createOrderFallback(Order order, boolean isVip) { return ResponseEntity.ok(new Order().setStatus("LIMITED")); } }实操心得:我们最初把
capacity和rate也设计成SpEL表达式,结果因表达式解析耗时(平均1.2ms)拖慢了整体性能。后来改为只允许key和weight动态化,capacity/rate必须静态配置,性能提升47%,P99从8ms降至4ms。在限流这种毫秒级敏感场景,任何微小的CPU开销都必须斤斤计较。
5. 常见问题与排查技巧实录:那些文档里不会写的血泪教训
5.1 典型问题速查表:从现象到根因的快速定位路径
| 现象 | 可能根因 | 排查命令/步骤 | 解决方案 |
|---|---|---|---|
| 大量请求被误限流,但监控显示Redis负载很低 | 客户端时间不同步,导致System.currentTimeMillis()偏差过大 | date -R查看各服务器时间,对比NTP服务器 | 配置NTP服务,或改用System.nanoTime()计算相对时间差(需修改Lua脚本) |
| 某个用户ID的请求100%被拒,但其他用户正常 | 该用户ID触发了热点Key分片冲突,被路由到同一Redis分片且该分片内存不足 | redis-cli --cluster check redis-host:6379检查分片负载;redis-cli -c -h redis-host -p 6379 keys "limit:user:1:1000001*"查看Key分布 | 调整分片算法,如math.fmod(tonumber(userId)*31, 16)增加扰动因子 |
| 限流日志显示“消耗令牌数=0”,但业务逻辑未执行 | @RateLimit注解未被AOP代理捕获,常见于自调用(this.createOrder())或非Spring管理对象 | @EnableAspectJAutoProxy(exposeProxy = true)+((OrderController) AopContext.currentProxy()).createOrder() | 改用@Service层切面,或重构调用链路避免自调用 |
Redis监控显示cmdstat_eval延迟飙升,但used_memory稳定 | Lua脚本中存在死循环或复杂计算(如for i=1,100000 do) | redis-cli --latency测试基础延迟;redis-cli monitor抓取慢命令 | 将复杂计算移出Lua,在Java层完成,Lua只做原子操作 |
| 服务重启后,短时间内大量请求被拒 | 预热填充未生效,或PEXPIREAT设置的过期时间早于当前时间 | redis-cli -h redis-host -p 6379 hgetall "limit:xxx"查看last_time字段 | 在服务启动监听器中,主动调用jedis.evalsha(...)预填充,并校验last_time是否合理 |
5.2 独家避坑技巧:来自三年线上运维的硬核经验
技巧一:用“影子Key”做灰度发布
当要上线新的限流策略(如把订单接口从10QPS提到20QPS),不要直接修改生产Key。先创建影子Key:// 生产Key:limit:order:create:userId:1001 // 影子Key:limit:order:create:userId:1001:v2用5%流量打影子Key,监控其拦截率、Redis延迟、业务成功率,确认无误后再全量切换。我们曾用此法发现新策略在特定用户分群下导致数据库慢查询,避免了一次重大事故。
技巧二:限流指标必须与业务指标对齐
不要只看“拦截请求数”,而要看“被拦截的请求中,有多少是高价值用户?”我们在Prometheus中定义了复合指标:rate(rate_limit_rejected_total{app="order-service", user_type="vip"}[5m]) / rate(http_server_requests_seconds_count{app="order-service", uri="/api/orders"}[5m])当VIP用户拦截率超过0.5%,立即触发告警,人工介入调整阈值。这个指标比单纯看QPS更有业务意义。
技巧三:为“误伤”设计补偿通道
限流不可能100%精准,总有合法请求被误拒。我们为所有限流接口提供X-RateLimit-OverrideHeader,管理员可凭权限令牌临时绕过限流:if (request.getHeader("X-RateLimit-Override") != null && verifyOverrideToken(request.getHeader("X-RateLimit-Override"))) { return; // 直接放行 }这个后门在紧急故障处理时救了多次命——比如数据库主从延迟时,手动提升关键接口阈值,保障核心交易链路。
技巧四:定期清理过期Key,但绝不使用
KEYS命令
Redis中大量limit:*Key过期后不会立即释放内存,需主动清理。但KEYS limit:*会阻塞Redis。我们改用SCAN游标分批清理:redis-cli --scan --pattern "limit:*" | head -1000 | xargs redis-cli del每天凌晨执行,每次只删1000个,避免影响线上。
最后分享一个真实案例:某次大促前,我们按预案将商品详情页QPS从300提到800,但上线后发现CDN回源率飙升300%。排查发现,限流Key设计为
limit:item:detail:id:123,而商品详情页有PC/H5/App三个端,共用同一Key,导致多端流量叠加触发限流。修正方案是在Key中加入端标识:limit:item:detail:platform:h5:id:123。这个教训告诉我们:限流设计必须深入业务场景,脱离业务语义的纯技术方案,注定是空中楼阁。