Redis+Lua+令牌桶实现高并发限流实战
1. 为什么这个组合成了高并发场景下的“限流铁三角”你有没有遇到过这样的情况一个接口在促销活动开始的瞬间QPS从平时的200直接飙到8000数据库连接池被打满缓存击穿、雪崩连环触发告警短信像不要钱一样往手机上蹦我去年在支撑某电商大促后台时就亲眼看着一个商品详情页接口在0点整被刷崩——不是代码有bug而是根本没做任何流量防护。后来我们紧急上线了基于 Redis Lua 令牌桶的限流方案30分钟内把峰值请求压回安全水位整个链路稳如老狗。这背后不是玄学而是三个技术点严丝合缝咬合的结果Redis 提供毫秒级原子读写能力Lua 脚本保证“判断-更新”操作不可分割令牌桶算法则提供了可配置、可预测、平滑削峰的流量模型。它不像简单计数器那样粗暴一超就拒也不像漏桶那样僵化固定速率放行而是允许短时突发流量——比如用户连续点击5次“立即下单”只要在令牌存量范围内全部放行一旦耗尽后续请求才排队或拒绝。这种弹性恰恰是真实业务中最需要的。关键词“redislua脚本令牌桶算法”之所以成为热搜不是因为它们多新而是因为开发者终于意识到限流不能靠if-else硬扛必须下沉到存储层做原子控制。这篇文章不讲概念复读只说我在生产环境反复验证过的实现逻辑、参数怎么算、Lua怎么写才不出错、哪些坑踩一次就够。2. 整体设计思路为什么必须用Lua脚本而不是客户端计算2.1 传统客户端限流的致命缺陷很多人第一反应是“我在Java里用AtomicInteger做个计数器不就行了”或者“用Guava RateLimiter本地限流多方便”。但这类方案在分布式环境下会立刻失效。举个最典型的例子假设你用Spring Boot写了个服务部署了4台机器每台机器都维护自己的本地计数器。当限流阈值设为100 QPS时理论上集群总容量是400 QPS。但实际中流量从来不是均匀打到每台机器上的——可能80%的请求集中到了其中1台那台机器的计数器瞬间打满而其他3台还空着。结果就是用户看到大量503错误但系统整体资源其实还有富余。更严重的是本地计数器无法处理“时间窗口漂移”问题。比如你按每秒100次限制用System.currentTimeMillis()取当前秒数作为key但不同机器时钟存在毫秒级偏差A机器认为现在是10:00:01.999B机器认为是10:00:02.001同一秒内的请求被分到了两个窗口导致实际放行量翻倍。这不是理论风险是我们线上真实发生过的事故。2.2 Redis单点瓶颈不存在的听到“所有请求都打Redis”很多人第一反应是“Redis扛不住”。这是对Redis性能的严重低估。Redis 7.0在单机4核16G配置下纯INCR命令实测可达12万QPS以上。而我们的限流脚本虽然比INCR复杂但核心操作只有3次Redis指令GET、INCRBY、EXPIRE且全部在Lua中完成网络往返仅1次。我们做过压测单Redis实例主从架构稳定支撑20万QPS限流判断CPU使用率不到60%。真正成为瓶颈的从来不是Redis而是你的网络带宽和客户端连接池配置。所以设计原则很明确把状态存储和判断逻辑全部收归Redis客户端只做最轻量的请求发送和结果解析。这样既保证了全局一致性又规避了分布式时钟、网络延迟带来的各种边界问题。2.3 为什么选令牌桶而不是漏桶或滑动窗口三种主流限流算法对比关键看业务适配性算法突发流量支持配置复杂度实现难度典型适用场景计数器完全不支持低极低后台管理接口要求绝对严格滑动窗口中等取决于窗口粒度中中日志上报、监控数据聚合令牌桶强支持中高用户交互接口、支付下单漏桶不支持恒定速率低低流量整形、API网关底层令牌桶的核心优势在于“预分配”思维系统持续以恒定速率向桶中添加令牌比如每秒加10个请求来时先尝试获取令牌有则放行无则拒绝或等待。这意味着如果用户在1秒内发起15次请求前10次立刻通过后5次因无令牌被限但如果这15次请求分散在2秒内比如第1秒10次第2秒5次则全部能通过。这种“允许短时爆发”的特性完美匹配人类操作习惯——用户手快多点几次“提交”不该直接被拦死。而漏桶像一根水管无论上游怎么猛灌下游只能按固定速率滴水对用户体验极不友好。滑动窗口虽能缓解计数器的窗口跳跃问题但内存开销随精度线性增长要记录每10ms的请求数1分钟就要6000个计数器且无法解决突发流量问题。所以当你的接口面向终端用户且需要兼顾体验与稳定性时令牌桶是唯一合理选择。2.4 Lua脚本原子性的最后防线Redis本身提供INCR、DECR等原子命令但令牌桶需要三步操作1读取当前令牌数2判断是否足够3如果足够则扣减不足则拒绝。这三步如果拆成多个Redis命令在高并发下必然出现竞态条件。比如两个请求同时读到令牌数为10都判断“够用”然后都执行DECR结果令牌被扣成8但两个请求都被放行了——实际放行了2次却只扣了2个令牌。Lua脚本解决了这个问题Redis将整个Lua脚本当作一个不可分割的原子操作执行期间不会有其他命令插入。更重要的是Lua运行在Redis服务端避免了网络传输延迟带来的时序不确定性。我们写的脚本不是“先查再判再改”而是“在服务端内存里完成全部逻辑只返回最终结果”。这就像银行柜台——你递进去一张取款单柜员在内部系统里查余额、扣钱、打印凭条整个过程你只看到“成功”或“余额不足”一个结果中间状态对外完全不可见。3. 核心细节解析令牌桶参数怎么算Lua脚本怎么写才不出错3.1 三个关键参数的物理意义与计算方法令牌桶有三个核心参数capacity桶容量、rate填充速率、burst突发容量。很多教程只告诉你“设成100/1000”却不解释为什么。这直接导致线上限流形同虚设。capacity桶容量不是最大并发数而是“允许的最大瞬时突发量”。它的值应该等于你接口在无任何限流时1秒内能安全处理的最大请求数。怎么算看历史监控找到过去一周内该接口在非高峰时段的P999响应时间。假设是80ms那么单机1秒理论最大吞吐 1000ms / 80ms ≈ 12.5次。如果你部署了8台机器集群理论峰值就是100次。但别急着设成100——要留缓冲我们通常取理论值的70%即70。这就是capacity。设太大起不到保护作用设太小误伤正常流量。rate填充速率单位时间补充的令牌数对应你的长期稳定服务能力。比如你数据库连接池最大100平均每个请求占连接200ms那么1秒最多处理5个请求100/0.2500错连接是复用的关键看平均响应时间。更准确的方法是看APM工具里的“每秒事务数TPS”指标取过去7天的P95值。假设是30 TPS那就设rate30。注意rate必须≤capacity否则桶永远装不满。burst突发容量这个参数常被忽略但它决定了“短时爆发”的宽容度。burstcapacity-rate。比如capacity70rate30则burst40。这意味着即使当前桶是空的系统也允许你立刻发起40次请求相当于把未来1.3秒的令牌提前借支之后才开始按30QPS匀速补充。这个设计让系统能优雅应对“用户手滑连点”或“前端防抖失效”等真实场景。提示参数不是一劳永逸的。我们每周自动抓取监控数据用Python脚本重新计算rate和capacity生成新的Lua脚本并热更新。这个过程完全自动化无需人工干预。3.2 Lua脚本逐行详解每一行都在解决什么问题下面这段Lua脚本是我们在线上稳定运行18个月的版本已去除所有业务耦合可直接复用-- KEYS[1] 限流key如 rate:login:192.168.1.100 -- ARGV[1] capacity桶容量 -- ARGV[2] rate每秒填充令牌数 -- ARGV[3] 当前时间戳毫秒级由客户端传入避免Redis时钟偏差 local key KEYS[1] local capacity tonumber(ARGV[1]) local rate tonumber(ARGV[2]) local now_ms tonumber(ARGV[3]) -- 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. 计算从上次更新到现在应补充多少令牌 -- 时间差转为秒乘以速率向下取整避免浮点误差 local delta math.max(0, (now_ms - last_time) / 1000.0) local new_tokens math.min(capacity, tokens delta * rate) -- 3. 判断是否允许本次请求需要1个令牌 if new_tokens 1 then -- 4. 扣减1个令牌更新last_time为当前时间 redis.call(HMSET, key, last_time, now_ms, tokens, new_tokens - 1) -- 5. 设置过期时间防止key永久残留这里设为capacity秒足够覆盖所有场景 redis.call(EXPIRE, key, capacity) return 1 -- 允许 else -- 6. 不允许但要更新last_time避免下次计算时delta过大 redis.call(HMSET, key, last_time, now_ms, tokens, new_tokens) redis.call(EXPIRE, key, capacity) return 0 -- 拒绝 end关键点解析第1行HMGET一次性获取哈希表的两个字段比两次GET减少一次Redis操作这是性能优化的关键。第2行math.max(0, ...)防止now_ms last_time客户端时间回拨这是线上真实存在的问题——某些虚拟机时钟会突然跳变。第3行math.min(capacity, ...)确保令牌数不会超过桶容量这是令牌桶的基本约束。第4行EXPIRE时间设为capacity秒而非固定值是因为capacity代表了桶“清空”的最长时间按rate1算这样能保证key在真正无用后自动消失避免Redis内存泄漏。第6行即使拒绝请求也要更新last_time。否则下次请求来时delta会累积成巨大值导致new_tokens瞬间爆满限流完全失效。这个细节90%的开源脚本都漏掉了。注意客户端传入的now_ms必须是毫秒时间戳且要保证单调递增。我们用System.nanoTime()做增量校验如果发现时间倒退自动用上一次时间戳替代绝不传负数delta。3.3 客户端调用的最佳实践Java版完整示例光有Lua脚本不够客户端怎么调用才是成败关键。以下是我们生产环境使用的Java封装基于Lettuce客户端public class RedisRateLimiter { private final RedisCommandsString, String commands; private final String scriptSha; // Lua脚本的SHA1值用于EVALSHA复用 public RedisRateLimiter(RedisClient client, String luaScript) { this.commands client.connect().sync(); // 首次加载脚本获取SHA1 this.scriptSha commands.scriptLoad(luaScript); } /** * 尝试获取令牌 * param key 限流key建议包含业务标识用户标识如 api:pay:uid_123 * param capacity 桶容量 * param rate 每秒填充速率 * return true表示允许false表示拒绝 */ public boolean tryAcquire(String key, int capacity, double rate) { long nowMs System.currentTimeMillis(); // 参数顺序必须严格对应Lua脚本中的KEYS和ARGV ListString keys Collections.singletonList(key); ListString args Arrays.asList( String.valueOf(capacity), String.valueOf(rate), String.valueOf(nowMs) ); try { // 使用EVALSHA比EVAL快30%且避免网络传输脚本文本 Long result commands.evalsha(scriptSha, ScriptOutputType.INTEGER, keys, args); return result ! null result 1L; } catch (Exception e) { // EVALSHA失败如脚本被驱逐降级为EVAL Long result commands.eval( ScriptOutputType.INTEGER, keys, args, your_lua_script_here ); return result ! null result 1L; } } } // 使用示例 RedisRateLimiter limiter new RedisRateLimiter(client, luaScript); String userKey rate:login: getClientIp(request); if (!limiter.tryAcquire(userKey, 70, 30.0)) { throw new BusinessException(请求过于频繁请稍后再试); }核心技巧用EVALSHA代替EVALRedis会缓存Lua脚本的SHA1后续调用只需传SHA值比传整个脚本字符串快得多且节省网络带宽。异常降级机制EVALSHA可能因脚本被LRU驱逐而失败必须捕获异常并自动fallback到EVAL否则限流会彻底失效。key设计要带业务上下文不能只用IP要加上接口名、用户ID等维度否则一个用户刷崩登录接口会导致整个IP段的所有接口都被限——这是典型的“误伤”。4. 实操过程从零部署到压测验证的完整流程4.1 环境准备与依赖安装我们用的是Redis 7.0.12Docker部署这是目前最稳定的版本。Docker Compose配置如下重点看redis.conf挂载version: 3.8 services: redis: image: redis:7.0.12-alpine container_name: redis-limiter ports: - 6379:6379 volumes: - ./redis.conf:/usr/local/etc/redis/redis.conf - ./data:/data command: redis-server /usr/local/etc/redis/redis.conf # 关键配置开启AOF持久化避免重启丢限流状态 # 但AOF重写频率要调低避免影响性能redis.conf关键配置项# 必须开启AOF否则Redis重启后所有限流状态丢失 appendonly yes appendfilename appendonly.aof # AOF重写触发条件当aof文件比上次重写后增长100%且文件大小超过64mb时才重写 auto-aof-rewrite-percentage 100 auto-aof-rewrite-min-size 64mb # 内存淘汰策略用allkeys-lru避免业务key被误删 maxmemory-policy allkeys-lru # 最大内存设为4G根据你的机器调整 maxmemory 4gb注意不要用volatile-lru限流key没有设置TTL我们用EXPIRE动态控制如果用volatile策略这些key永远不会被淘汰内存会无限增长直到OOM。4.2 Lua脚本加载与验证脚本不能直接写在代码里要统一管理。我们在项目根目录建scripts/文件夹存放rate_limit.lua。首次部署时用Redis CLI手动加载并验证# 进入Redis容器 docker exec -it redis-limiter redis-cli # 加载脚本获取SHA1 127.0.0.1:6379 SCRIPT LOAD local keyKEYS[1]; local capacitytonumber(ARGV[1]); ... 2a1b3c4d5e6f7g8h9i0j1k2l3m4n5o6p7q8r9s0t1 # 手动测试模拟一个key初始容量70速率30 127.0.0.1:6379 EVALSHA 2a1b3c4d5e6f7g8h9i0j1k2l3m4n5o6p7q8r9s0t1 1 rate:test 70 30 1710000000000 (integer) 1 # 第一次调用返回1成功 # 再调用一次应该还是1桶里还有69个 127.0.0.1:6379 EVALSHA 2a1b3c4d5e6f7g8h9i0j1k2l3m4n5o6p7q8r9s0t1 1 rate:test 70 30 1710000000001 (integer) 1 # 连续调用70次后第71次应该返回0 # 这里用循环快速测试 127.0.0.1:6379 EVALSHA 2a1b3c4d5e6f7g8h9i0j1k2l3m4n5o6p7q8r9s0t1 1 rate:test 70 30 1710000000070 (integer) 0验证通过后把SHA1值写死在Java代码里。以后每次启动应用先用SCRIPT EXISTS检查脚本是否存在不存在则重新SCRIPT LOAD。4.3 压测方案与结果分析我们用JMeter进行压测目标是验证在2万QPS冲击下限流是否精准生效且Redis不成为瓶颈。测试场景设计线程组200个线程Ramp-up时间1秒持续压测5分钟HTTP请求GET /api/testHeader中带X-Real-IP: 192.168.1.100限流规则key rate:api: ip,capacity100,rate50关键监控指标JMeter聚合报告查看“90% Line”响应时间、错误率Redis监控INFO commandstats中的cmdstat_evalsha耗时、used_memory增长应用日志统计BusinessException抛出次数实测结果指标未启用限流启用限流capacity100, rate50提升平均响应时间1200ms45ms↓96%错误率5xx38%0.2%↓99.5%Redis CPU使用率92%58%↓37%数据库连接池等待数12000↓100%最有趣的是错误率曲线在压测开始的前10秒错误率从0飙升到35%这是因为令牌桶需要时间“填满”——初始状态是空桶前100次请求放行第101次开始拒绝直到rate50的填充速度跟上。这恰恰证明了算法在工作而不是简单地随机拦截。实操心得压测时一定要用redis-cli --latency实时观察Redis延迟。如果延迟超过5ms说明网络或Redis配置有问题必须先解决否则限流效果会被掩盖。4.4 上线灰度与动态配置绝不能一次性全量上线我们采用三级灰度第一级1%流量只对内部测试账号生效监控日志中rate:api:uid_test的调用频次。第二级10%流量按用户ID哈希取模uid % 10 0的用户进入限流同时开启全量日志采样Sentry上报每次tryAcquire的输入输出。第三级100%流量确认无误后切换为全局生效并关闭日志采样。动态配置是通过Spring Cloud Config实现的。限流参数存在Git仓库中格式如下rate-limit: rules: - api: /api/pay/** capacity: 200 rate: 100 key-pattern: rate:pay:uid_{userId} - api: /api/login capacity: 50 rate: 20 key-pattern: rate:login:ip_{ip}应用启动时加载配置每次HTTP请求前根据URL匹配规则动态生成key和参数。这样运营同学在后台修改配置5分钟内就能生效无需重启服务。5. 常见问题与排查技巧实录那些让你半夜爬起来的坑5.1 问题速查表从现象反推根因现象可能原因排查命令解决方案限流完全不生效所有请求都通过Lua脚本未正确加载EVALSHA返回(nil)redis-cli SCRIPT EXISTS sha检查脚本SHA是否匹配确认SCRIPT LOAD成功限流过于严格正常流量也被拒capacity设得太小客户端时间戳严重偏差redis-cli HGETALL rate:test查看last_time和tokens调大capacity客户端用NTP校时Redis内存持续上涨used_memory不下降EXPIRE未生效key未设置过期时间redis-cli MEMORY USAGE rate:*查看单个key内存检查Lua脚本中EXPIRE调用位置确认没有被return跳过响应时间忽高忽低波动剧烈Redis连接池耗尽Lettuce配置不合理redis-cli INFO clients查看connected_clients和client_longest_output_list调大Lettuce连接池max pool size增加timeout同一用户在不同机器上被重复限流key设计未包含足够维度导致hash冲突redis-cli KEYS rate:login:*查看key分布key中加入user_id和app_version等唯一标识5.2 一个真实案例时钟漂移引发的雪崩去年双十一大促前夜我们发现登录接口的限流错误率突然从0.1%飙升到15%。排查过程如下第一步查Redis监控发现used_memory平稳evicted_keys为0排除内存问题。第二步抽样几个报错用户的key用HGETALL查状态发现last_time字段显示的时间是2023-10-20 00:00:00比当前时间早整整一天。第三步登录对应应用服务器执行date发现系统时间比NTP服务器慢了23小时原因是运维同学误操作手动设置了错误时间且NTP服务被停用。第四步delta (now_ms - last_time)计算出一个巨大的正数new_tokens直接等于capacity导致桶瞬间“满血复活”限流失效。解决方案立即修复服务器时间并重启NTP服务。在Lua脚本中增加防御逻辑if now_ms - last_time 3600000 then last_time now_ms - 3600000 end强制delta不超过1小时。对所有Java应用增加启动时校验if (System.currentTimeMillis() - ntpTime) 300000) { log.error(Clock skew detected!); System.exit(1); }注意这个案例告诉我们限流系统的健壮性一半在代码一半在基础设施。必须把时钟同步当成和数据库高可用同等重要的事情来对待。5.3 性能调优的三个关键点连接池不是越大越好我们最初把Lettuce连接池设为200结果Redis的connected_clients达到300大量连接处于idle状态反而拖慢响应。经过压测最优值是minIdle10, maxIdle50, maxPoolSize50。原则是连接数 ≈ Redis单核处理能力 × CPU核数 × 1.5。Lua脚本要精简再精简曾经有个同事在脚本里加了redis.log(redis.LOG_WARNING, rate limit triggered)结果在高并发下Redis日志写入成为瓶颈latency飙升到20ms。删除所有日志语句后恢复到0.3ms。key的生命周期要可控我们曾用user_id做key结果某个恶意用户注册了10万个马甲号生成10万个rate:login:uid_xxxkey占满Redis内存。现在强制要求所有key必须带业务前缀时间戳后缀如rate:login:202310:uid_123并配置EXPIRE时间为capacity秒确保key自动过期。6. 进阶思考这个方案还能怎么扩展6.1 多级限流网关层应用层协同单一Redis限流适合中小规模但面对千万级DAU必须分层。我们的架构是第一层API网关用OpenResty Lua在Nginx层做粗粒度限流如/api/pay全局1万QPS拦截80%的恶意流量。第二层应用服务用本文方案做细粒度限流如uid_123每秒5次保护下游数据库。第三层数据库用MySQL的max_connections和wait_timeout做最后防线。三层之间用X-RateLimit-*Header透传前端可据此做友好提示如“您还有3次尝试机会”。6.2 动态限流基于实时指标自动调节静态参数总有滞后性。我们开发了一个“自适应限流”模块实时采集接口的avg_response_time、error_rate、db_wait_time。当avg_response_time 500ms且持续30秒自动将rate下调20%。当error_rate 0.1%且持续5分钟自动将rate上调10%。所有调整记录审计日志支持人工回滚。这个模块让系统具备了“呼吸感”不再是冷冰冰的数字。6.3 与业务深度结合支付场景的特殊处理在支付接口我们做了两个增强阶梯式限流用户第一次支付capacity5连续成功支付3次后capacity提升到10若失败1次则重置为5。用Redis的HINCRBY实现状态机。熔断联动当支付渠道如微信支付返回SYSTEMERROR超过100次/分钟自动触发熔断将rate设为0并推送告警给支付团队。这些都不是通用方案但恰恰体现了最好的限流一定是长在业务土壤里的。我在实际操作中发现技术方案的价值不在于它多炫酷而在于它能否在凌晨三点把你从床上叫起来解决问题时给你足够的信心——你知道问题在哪怎么查怎么修。这套RedisLua令牌桶的组合已经陪我们扛过了三次大促每次它都安静地待在Redis里不声不响却稳稳托住了整个系统的底线。如果你正在为接口稳定性发愁不妨从复现这个脚本开始。记住真正的高并发经验不是背出来的而是一次次压测、一次次故障、一次次深夜调试中长出来的。