资讯动态

基于Redis+Lua的分布式限流器设计与Go实战

发布时间:2026/10/21 11:49:40 来源:尧图企业网站定制
1. 为什么要在网关层做分布式限流做过线上服务的人大概都有过这种经历某个接口突然被刷QPS 从几百飙到几万数据库连接池瞬间打满紧接着整个服务雪崩连正常的用户请求都进不来。事后复盘发现攻击者或者爬虫只是盯着一个查询接口猛打而你的服务没有任何入口级的保护。单机限流能挡住一部分但只要服务是多副本部署单机限流就变成了每个副本各限各的总量根本控不住。这时候就需要一个跨进程、跨节点的分布式限流器。分布式限流器的核心诉求其实就三条全局计数准确、判断延迟足够低、不能因为限流组件本身挂掉而拖垮业务。业界常见的方案有几种基于网关层如 Nginx、API Gateway做限流、基于 Sentinel 这类流量治理框架、基于 Redis 做集中式计数。前两种要么依赖特定基础设施要么引入较重的组件。而 Redis Lua 的组合几乎是中小团队落地成本最低、可控性最强的一条路。为什么是 Redis因为限流的本质是在时间窗口内对某个 key 做原子累加并判断阈值这天然就是一个计数器问题。Redis 的单线程模型保证了命令的原子性而 Lua 脚本又保证了读-判断-写这一整段逻辑的原子性不会被其他请求穿插。这两点加起来就构成了分布式限流最核心的基石。这篇文章我会把基于 Redis Lua 的分布式限流器从设计思路、算法选型、脚本实现、Go 客户端封装、到线上踩坑完整地拆一遍。适合已经有一定 Go 和 Redis 基础、正在为线上流量治理发愁的后端同学。如果你只是想知道限流怎么做网上搜一下能搜到一堆但如果你想搞清楚为什么这么设计、参数怎么定、线上会出什么问题那这篇应该能帮到你。2. 限流算法选型与整体设计思路2.1 四种常见限流算法的取舍在动手写代码之前先把算法选清楚否则后面全是返工。常见的限流算法有四种我逐个说说它们在分布式场景下的表现。固定窗口计数器是最简单的比如限制每分钟 100 次那就用key 接口名 当前分钟做计数每次请求INCR超过 100 就拒绝。优点是实现极简、内存占用小。但它有个致命问题叫临界突刺假设限制每分钟 100 次用户在 00:59 打了 100 次在 01:00 又打了 100 次那么在这两秒内实际通过了 200 次请求是阈值的两倍。对于防护型限流这个突刺可能直接把下游打挂。滑动窗口计数器是对固定窗口的改良。它把时间切成多个小格子比如一分钟切成 6 个 10 秒的格子统计时把当前窗口覆盖的所有格子加起来。这样临界问题被大幅缓解精度取决于格子粒度。代价是需要存储多个格子的计数内存和计算量都上去了。用 Redis 实现滑动窗口通常用 ZSET 存时间戳或者用多个 key 分别计数再求和。漏桶算法把请求想象成往桶里倒水桶底有个固定速率的洞往外漏。请求速率超过漏出速率时桶会满满了就拒绝。它的特点是输出速率绝对恒定适合需要平滑流量的场景比如对接第三方接口有严格的调用频率要求。但漏桶不允许突发流量哪怕你一秒内只用了 10 次配额也不能攒着后面用。令牌桶算法则是反过来以固定速率往桶里放令牌桶有容量上限请求来了要拿一个令牌拿不到就拒绝。它允许一定程度的突发——只要桶里攒了令牌就能一次性消耗掉。这是最符合真实业务场景的算法因为大部分服务的流量本来就是有波峰的。算法突发流量实现复杂度内存占用分布式友好度固定窗口不支持有临界问题极低极低高滑动窗口部分支持中中中漏桶不支持中低中令牌桶支持中高低高2.2 为什么最终选了令牌桶 Lua综合下来我选的是令牌桶算法用 Lua 脚本在 Redis 里实现。理由有三个。第一业务流量天然有波峰。比如整点秒杀、定时任务触发、消息推送这些场景下用户就是会在一瞬间打进来一批请求如果用过严的漏桶正常业务会被误伤。令牌桶允许桶内积累的令牌被一次性消耗能扛住这种合理突发。第二令牌桶的状态可以用极少的字段表示。一个桶只需要两个值当前令牌数和上次补充令牌的时间戳。相比滑动窗口要存一堆时间戳令牌桶在 Redis 里就是一个 Hash 或者两个字段内存开销极小。这对于需要维护几十万个限流 key 的场景非常关键。第三Lua 脚本能保证整个取令牌过程的原子性。如果分成多条 Redis 命令先读令牌数、再算补充、再写回在高并发下必然出现竞态两个请求同时读到 5 个令牌都以为够用结果都放行实际超发。Lua 脚本在 Redis 里是单线程串行执行的整段逻辑要么全做完要么不做天然免疫竞态。提示令牌桶的补充令牌不需要后台定时任务去跑而是在每次请求时根据当前时间 - 上次补充时间惰性计算。这样既省了一个定时器又避免了空桶时还在白白计算。2.3 整体架构分层整个限流器的架构我分成了三层职责清晰方便替换和测试。最底层是Redis 存储层负责存令牌桶状态。用 Hash 结构存tokens和last_refill_time两个字段key 的命名规则是rate_limit:{资源标识}。资源标识可以是接口路径、用户 ID、租户 ID甚至是它们的组合比如rate_limit:api_order_create:user_123。中间层是Lua 脚本层封装了令牌桶的核心算法。脚本接收限流参数速率、桶容量和当前时间戳返回是否放行以及剩余令牌数。这一层是纯逻辑不依赖任何 Go 代码可以独立测试。最上层是Go 客户端封装层负责参数校验、key 拼接、脚本加载缓存、错误处理、降级策略。业务代码只需要调用一个Allow(ctx, key)方法拿到 true/false 即可完全不用关心底层是 Redis 还是别的什么。这样分层的好处是如果哪天要把 Redis 换成别的存储只需要重写最底层如果要换算法只改 Lua 脚本业务代码一行不用动。3. 令牌桶 Lua 脚本的核心实现细节3.1 令牌桶的数学模型在写脚本之前先把数学关系理清楚否则参数很容易设错。令牌桶有三个核心参数rate令牌补充速率单位是个/秒。比如 rate 100表示每秒往桶里放 100 个令牌。capacity桶容量也就是最多能攒多少个令牌。它决定了能容忍多大的突发。tokens当前桶里的令牌数是动态变化的。每次请求到来时计算逻辑是这样的elapsed now - last_refill_time -- 距离上次补充过了多少秒 new_tokens elapsed * rate -- 这段时间应该补充多少令牌 tokens min(capacity, tokens new_tokens) -- 补充后不能超过桶容量 if tokens 1 then tokens tokens - 1 -- 消耗一个令牌 last_refill_time now return 放行 else return 拒绝 end这里有个细节要注意last_refill_time 只在成功消耗令牌时才更新。如果请求被拒绝时间戳不动这样下次请求来的时候elapsed 会继续累加令牌该补还是补。如果拒绝时也更新时间戳那被拒绝的请求就白白吃掉了一段时间会导致令牌补充变慢这是很多人写错的地方。还有一个精度问题elapsed 是浮点数tokens 也是浮点数。Redis 的 Lua 用的是 5.1 版本数字都是 double 类型浮点运算没问题。但要注意别用整数除法比如elapsed (now - last) / 1000如果两个整数相除在 Lua 5.1 里会得到浮点结果这个是对的但如果你的时间戳是毫秒记得除以 1000.0 而不是 1000避免精度丢失。3.2 Lua 脚本逐行拆解下面是我实际用的脚本我把它拆开讲。-- rate_limit.lua -- KEYS[1]: 限流 key -- ARGV[1]: rate 每秒补充令牌数 -- ARGV[2]: capacity 桶容量 -- ARGV[3]: now 当前时间戳秒浮点 -- ARGV[4]: requested 本次请求消耗的令牌数默认 1 local key KEYS[1] local rate tonumber(ARGV[1]) local capacity tonumber(ARGV[2]) local now tonumber(ARGV[3]) local requested tonumber(ARGV[4]) or 1 -- 读取当前桶状态 local bucket redis.call(HMGET, key, tokens, last_refill_time) local tokens tonumber(bucket[1]) local last_refill_time tonumber(bucket[2]) -- 初始化桶不存在时填满桶 if tokens nil then tokens capacity last_refill_time now end -- 惰性补充令牌 local elapsed math.max(0, now - last_refill_time) local new_tokens elapsed * rate tokens math.min(capacity, tokens new_tokens) local allowed 0 if tokens requested then tokens tokens - requested allowed 1 last_refill_time now end -- 写回状态 redis.call(HMSET, key, tokens, tokens, last_refill_time, last_refill_time) -- 设置过期时间避免冷 key 永久占用内存 -- 过期时间 桶从满到空所需时间 * 2至少 60 秒 local ttl math.max(60, math.ceil(capacity / rate) * 2) redis.call(EXPIRE, key, ttl) return {allowed, math.floor(tokens)}逐段说几个关键点。初始化逻辑当 key 不存在时HMGET返回的是{false, false}tonumber(false)得到 nil。这时候直接把 tokens 设为 capacity也就是新桶是满的。这个设计符合直觉——一个刚上线的接口应该允许它一开始就有完整的突发能力。如果你希望新桶是空的把tokens capacity改成tokens 0即可但一般不建议因为那样会导致服务刚启动时所有请求都被拒。elapsed 的 max(0, ...)为什么要取 max因为分布式环境下不同机器的时钟可能有微小偏差。如果某台机器的 now 比 Redis 里存的 last_refill_time 还小elapsed 就是负数new_tokens 也是负数会把 tokens 减没。取 max(0, ...) 保证 elapsed 永远非负这是防御性编程。requested 参数默认是 1但支持传入更大的值。这个设计是为了支持权重限流——比如查询接口权重是 1导出接口权重是 10那么一次导出就消耗 10 个令牌。这样可以用一个桶同时管理不同成本的接口。TTL 的计算capacity / rate是桶从满到空需要的时间乘以 2 是留足余量。为什么要设 TTL因为限流 key 的数量可能非常庞大比如按用户 ID 限流有百万用户如果不设过期Redis 内存会被慢慢吃光。设了 TTL 后冷用户长时间不访问的 key 会自动清理内存可控。返回值返回{allowed, remaining}allowed 是 0 或 1remaining 是剩余令牌数取整。业务方可以根据 remaining 做监控告警比如剩余令牌长期为 0说明限流阈值可能设小了。3.3 脚本加载与缓存策略Lua 脚本有两种执行方式EVAL直接传脚本内容EVALSHA传脚本的 SHA1 值。前者每次都要把脚本内容传到 Redis网络开销大后者只传 40 字节的 SHA1效率高得多。正确做法是服务启动时用SCRIPT LOAD把脚本加载到 Redis拿到 SHA1 缓存起来之后全部用EVALSHA。但要处理一种情况Redis 重启后脚本缓存会丢失此时EVALSHA会返回NOSCRIPT错误。所以客户端要捕获这个错误自动降级为EVAL重新加载一次。在 Go 里go-redis库已经帮我们封装好了这个逻辑。用redis.NewScript()创建脚本对象后调用Run或EvalSha时库会自动处理 NOSCRIPT 的情况先尝试 EVALSHA失败则 EVAL 并缓存。所以实际写代码时直接用script.Run(ctx, client, keys, args...)就行不用自己处理。注意虽然 go-redis 自动处理了 NOSCRIPT但在高并发下如果 Redis 刚重启大量请求同时触发 EVAL 重新加载会有短暂的性能抖动。更稳妥的做法是在服务启动时主动执行一次SCRIPT LOAD把脚本预热进去。4. Go 客户端封装与业务接入4.1 限流器的结构体设计先看整体结构。我把它设计成一个Limiter结构体持有 Redis 客户端和脚本对象对外暴露Allow方法。package ratelimit import ( context fmt time github.com/redis/go-redis/v9 ) type Config struct { Rate float64 // 每秒补充令牌数 Capacity float64 // 桶容量 } type Limiter struct { client *redis.Client script *redis.Script cfg Config } func NewLimiter(client *redis.Client, cfg Config) *Limiter { if cfg.Rate 0 { panic(rate must be positive) } if cfg.Capacity 1 { cfg.Capacity 1 } return Limiter{ client: client, script: redis.NewScript(luaScript), cfg: cfg, } }这里Config是每个限流器实例的配置。实际项目中不同接口的限流阈值不同所以通常会有一个LimiterManager根据资源标识返回对应的Limiter。但为了简化这里先聚焦单个限流器的实现。NewLimiter里做了参数校验rate 必须为正capacity 至少为 1。这些校验放在构造函数里避免运行时才发现配置错误。4.2 Allow 方法的完整实现type Result struct { Allowed bool Remaining int64 } func (l *Limiter) Allow(ctx context.Context, key string) (*Result, error) { return l.AllowN(ctx, key, 1) } func (l *Limiter) AllowN(ctx context.Context, key string, n int64) (*Result, error) { now : float64(time.Now().UnixNano()) / 1e9 res, err : l.script.Run(ctx, l.client, []string{key}, l.cfg.Rate, l.cfg.Capacity, now, n, ).Result() if err ! nil { return nil, fmt.Errorf(rate limit script failed: %w, err) } arr, ok : res.([]interface{}) if !ok || len(arr) 2 { return nil, fmt.Errorf(unexpected script result: %v, res) } allowed, _ : arr[0].(int64) remaining, _ : arr[1].(int64) return Result{ Allowed: allowed 1, Remaining: remaining, }, nil }几个细节值得说。时间戳用UnixNano() / 1e9这样得到的是带小数秒的浮点数精度到纳秒。为什么不用Unix()只取整秒因为如果 rate 很高比如每秒 10000 个令牌整秒精度下同一秒内的所有请求 elapsed 都是 0令牌补充会卡顿——要么一秒内一个都不补要么下一秒一次性补 10000 个。用浮点秒能让令牌补充更平滑。AllowN 支持权重Allow是AllowN的语法糖默认消耗 1 个令牌。需要权重限流时直接调AllowN。错误处理脚本执行失败时返回 error而不是直接放行或拒绝。这个决策权交给上层——因为限流器挂掉时是放行还是拒绝取决于业务。防护型接口应该拒绝fail-closed而核心业务接口应该放行fail-open避免限流组件故障导致业务不可用。4.3 业务接入的两种姿势接入方式我推荐两种看场景选。方式一中间件拦截。适合 HTTP 服务在 Gin 或 Echo 的中间件里统一限流。func RateLimitMiddleware(limiter *ratelimit.Limiter) gin.HandlerFunc { return func(c *gin.Context) { key : fmt.Sprintf(rate_limit:%s:%s, c.FullPath(), c.ClientIP()) res, err : limiter.Allow(c.Request.Context(), key) if err ! nil { // 限流器故障放行记录日志 log.Printf(rate limiter error: %v, err) c.Next() return } if !res.Allowed { c.Header(X-RateLimit-Remaining, 0) c.AbortWithStatusJSON(429, gin.H{ error: too many requests, }) return } c.Header(X-RateLimit-Remaining, fmt.Sprint(res.Remaining)) c.Next() } }这里 key 用了接口路径 客户端 IP的组合实现每个 IP 对每个接口的独立限流。返回 429 状态码并在响应头里带上剩余令牌数方便客户端做自适应退避。方式二手动埋点。适合非 HTTP 场景比如消息消费、定时任务、RPC 调用。func processOrder(ctx context.Context, orderID string) error { key : fmt.Sprintf(rate_limit:order_process:%s, orderID) res, err : limiter.Allow(ctx, key) if err ! nil { log.Printf(limiter error: %v, err) // 放行 } else if !res.Allowed { return ErrRateLimited } // 正常业务逻辑 return doProcess(ctx, orderID) }手动埋点更灵活但需要开发者自己记得加。我一般会在团队里约定所有对外部依赖的调用、所有可能被刷的接口都必须有限流保护。4.4 多维度限流的 key 设计实际业务中单一维度的限流往往不够。比如一个登录接口既要限制每个 IP 每分钟 20 次又要限制每个账号每分钟 5 次还要限制整个接口每秒 1000 次。这就需要多维度限流。我的做法是一个请求依次过多个限流器全部通过才放行。type MultiLimiter struct { limiters []*Limiter keyFuncs []func(*gin.Context) string } func (m *MultiLimiter) Allow(ctx context.Context, c *gin.Context) (bool, error) { for i, limiter : range m.limiters { key : m.keyFuncs[i](c) res, err : limiter.Allow(ctx, key) if err ! nil { continue // 单个限流器故障跳过 } if !res.Allowed { return false, nil } } return true, nil }key 的设计要遵循一个原则粒度从粗到细先过粗的。因为粗粒度限流器比如全局限流能挡住大部分异常流量让细粒度限流器的压力小很多。如果反过来先过细粒度每个请求都要查多个 Redis key延迟会上去。5. 线上踩坑与常见问题排查5.1 时钟回拨导致的令牌异常这是我在线上真实遇到过的问题。某台机器的 NTP 同步时发生了时钟回拨系统时间往后退了几秒。结果这台机器发出的请求now 比 Redis 里存的 last_refill_time 小elapsed 变成负数。虽然脚本里有math.max(0, ...)兜底但更隐蔽的问题是回拨期间这台机器发出的所有请求 elapsed 都是 0令牌不补充导致这段时间内该机器上的请求大量被拒。解决方案不要用业务机器的本地时间改用 Redis 服务器的时间。Redis 的TIME命令返回服务器时间戳可以在脚本里直接调用。local time_result redis.call(TIME) local now tonumber(time_result[1]) tonumber(time_result[2]) / 1000000这样所有请求都用同一个时间源彻底消除时钟偏差。代价是多一次 Redis 调用但在脚本内部调用不增加网络往返开销可以忽略。5.2 大 key 与热 key 问题限流 key 如果设计不当会产生热 key。比如用rate_limit:global做全局限流所有请求都打这一个 keyRedis 单分片的压力会非常大。实测下来单个 Redis 实例对单个 key 的 QPS 上限大概在 8 万到 10 万左右超过就会出现明显的延迟抖动。解决方案分片。把全局 key 拆成 N 个比如rate_limit:global:0到rate_limit:global:15每个请求随机选一个分片每个分片的阈值设为总量的 1/N。这样压力分散到 16 个 key 上总吞吐能提升一个数量级。代价是限流精度略有下降——因为各分片独立计数实际通过量可能在总量附近波动但误差在可接受范围内。func (l *Limiter) AllowSharded(ctx context.Context, baseKey string, shards int) (*Result, error) { shard : rand.Intn(shards) key : fmt.Sprintf(%s:%d, baseKey, shard) // 每个分片的阈值是总量的 1/shards return l.AllowN(ctx, key, 1) }5.3 限流器故障时的降级策略Redis 挂了怎么办这是必须提前想清楚的问题。我的策略是分级降级。故障场景降级策略理由Redis 短暂超时100ms重试一次仍失败则放行避免误伤正常请求Redis 持续不可用切换到本地单机限流至少挡住单机维度的异常本地限流也失效全部放行触发告警保业务可用性优先本地单机限流可以用 Go 的golang.org/x/time/rate包它实现了标准的令牌桶纯内存操作不依赖任何外部组件。当检测到 Redis 连续失败超过阈值时自动切换到本地限流等 Redis 恢复后再切回来。type FallbackLimiter struct { remote *Limiter local *rate.Limiter failCount int64 } func (f *FallbackLimiter) Allow(ctx context.Context, key string) (*Result, error) { if atomic.LoadInt64(f.failCount) 10 { // 降级到本地限流 if f.local.Allow() { return Result{Allowed: true}, nil } return Result{Allowed: false}, nil } res, err : f.remote.Allow(ctx, key) if err ! nil { atomic.AddInt64(f.failCount, 1) return Result{Allowed: true}, nil } atomic.StoreInt64(f.failCount, 0) return res, nil }5.4 常见问题速查表现象可能原因排查方法解决方案限流不生效请求全放行key 拼接错误每次 key 都不同打印实际 key看是否稳定检查 key 拼接逻辑确保同一资源 key 一致限流过严正常请求被拒rate 或 capacity 设太小看监控里 remaining 是否长期为 0调大 rate或增大 capacity 容忍突发Redis CPU 飙高热 key单分片压力过大用redis-cli --hotkeys找热 key分片或改用本地限流 定期同步令牌补充不平滑忽多忽少时间戳精度不够用了整秒检查 now 的计算方式改用浮点秒或 Redis TIME脚本执行报 NOSCRIPTRedis 重启脚本缓存丢失看错误日志go-redis 自动处理或启动时预热内存持续增长key 没设 TTLredis-cli info memory看内存曲线脚本里加 EXPIRE5.5 参数怎么定一个实操计算过程最后说说参数怎么定这是最多人问的。假设有个查询接口压测下来单机能扛 500 QPS线上部署了 4 个副本那么总承载能力是 2000 QPS。为了留 20% 余量限流阈值设为 1600 QPS。rate 1600每秒补充 1600 个令牌。capacity 怎么定capacity 决定了能容忍多大的突发。如果业务允许瞬间打进来 2 秒的量那 capacity 3200。但 capacity 太大会导致限流失效——桶里攒了 3200 个令牌一瞬间全放出去下游可能扛不住。我的经验是 capacity 设为 rate 的 1 到 2 倍即 1600 到 3200 之间。对于防护型接口取 1 倍对于业务型接口取 2 倍。再举个按用户限流的例子。假设每个用户每分钟最多查 60 次换算成 rate 就是 1 次/秒。capacity 设为 10意味着用户攒 10 秒不用可以一次性打 10 次。这个体验比较合理既限制了长期频率又允许短时突发。提示参数定完后一定要在预发环境压测验证。用wrk或vegeta打流量观察实际通过 QPS 是否接近设定值以及 P99 延迟是否可接受。限流器本身引入的延迟应该控制在 1ms 以内超过就说明 Redis 或脚本有问题。6. 性能优化与进阶玩法6.1 减少 Redis 往返的批量限流如果一个请求要过多个限流维度每个维度一次 Redis 调用延迟会累加。优化方法是用一个 Lua 脚本一次性检查多个 key。-- 批量限流一次检查多个桶 -- KEYS: 多个限流 key -- ARGV: 每个 key 对应的 rate, capacity, now, requested local results {} for i 1, #KEYS do local key KEYS[i] local rate tonumber(ARGV[(i-1)*4 1]) local capacity tonumber(ARGV[(i-1)*4 2]) local now tonumber(ARGV[(i-1)*4 3]) local requested tonumber(ARGV[(i-1)*4 4]) -- ... 同样的令牌桶逻辑 results[i] allowed end return results这样 N 个维度只需要一次网络往返。但要注意Lua 脚本执行时间不能太长否则会阻塞 Redis 的其他请求。一般建议单个脚本执行时间控制在 1ms 以内key 数量不超过 10 个。6.2 用 Pipeline 提升吞吐如果批量限流的脚本太复杂也可以退而求其次用 Pipeline 把多个 EVALSHA 打包发送。Pipeline 不保证原子性但能减少网络往返次数。对于限流这种各维度独立判断的场景原子性不是必须的Pipeline 完全够用。pipe : client.Pipeline() cmds : make([]*redis.Cmd, len(keys)) for i, key : range keys { cmds[i] limiter.script.Run(ctx, pipe, []string{key}, rate, capacity, now, 1) } _, err : pipe.Exec(ctx)实测下来Pipeline 批量 10 个 key总延迟从 10ms 降到 2ms 左右效果明显。6.3 监控指标怎么埋限流器上线后必须配套监控否则出了问题两眼一抹黑。我一般埋这几个指标限流触发次数按 key 维度统计突增说明有异常流量或阈值设小了。剩余令牌数长期为 0 说明限流一直在生效需要评估是否影响业务。脚本执行耗时P99 超过 5ms 要告警说明 Redis 有压力。降级触发次数一旦触发降级说明 Redis 出问题了立即告警。这些指标用 Prometheus 的 Counter 和 Histogram 就能实现接入 Grafana 做看板。关键是按 key 维度打标签这样才能快速定位是哪个接口、哪个用户出了问题。6.4 动态调整阈值限流阈值不应该写死在代码里而应该支持动态调整。我的做法是把配置放在配置中心比如 Nacos、Apollo限流器定期拉取最新配置。这样大促前可以临时调高阈值出问题时可以快速调低不用重新发版。type DynamicConfig struct { Rate float64 Capacity float64 } func (l *Limiter) watchConfig() { for { cfg : fetchConfigFromCenter() l.cfg.Store(cfg) time.Sleep(10 * time.Second) } }配置更新后新的请求立即用新参数已经在桶里的令牌不受影响。这个切换是平滑的不会造成流量抖动。7. 一些个人体会这套 Redis Lua 的限流器我在几个项目里都用过从日请求百万级到亿级都跑过。最大的体会是限流器的难点不在算法而在参数和降级。算法就那么几种网上一搜一大把但参数定多少、Redis 挂了怎么办、热 key 怎么处理这些才是真正决定线上稳定性的东西。另一个体会是不要过度设计。我见过有的团队一上来就搞滑动窗口 多级限流 动态配额结果代码复杂到没人敢改出了问题排查半天。其实对于 90% 的场景固定窗口或简单令牌桶就够了。先把基础版本跑通有了监控数据再根据实际情况优化比一上来就搞复杂架构靠谱得多。最后分享一个小技巧限流 key 的命名一定要规范建议用rate_limit:{业务}:{维度}:{值}的格式比如rate_limit:api_login:ip:1.2.3.4。这样在 Redis 里SCAN rate_limit:api_login:*就能快速找到某个业务的所有限流 key排查问题时非常方便。命名混乱的 key排查起来就是灾难。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑