news 2026/10/20 4:48:19

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

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于Redis+Lua的分布式限流器设计与Go实战

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 或 1,remaining 是剩余令牌数(取整)。业务方可以根据 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做全局限流,所有请求都打这一个 key,Redis 单分片的压力会非常大。实测下来,单个 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看内存曲线脚本里加 EXPIRE

5.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,排查起来就是灾难。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/20 4:47:49

VXLAN内核态与Open vSwitch实现对比:性能、机制与选型指南

1. 项目缘起与核心问题拆解搞网络虚拟化的人&#xff0c;绕不开两个东西&#xff1a;一个是内核自带的VXLAN实现&#xff0c;另一个是Open vSwitch的VXLAN实现。很多人在搭建overlay网络时&#xff0c;面对这两种方案往往凭感觉选&#xff0c;或者干脆哪个能跑通就用哪个&#…

作者头像 李华
网站建设 2026/10/20 4:47:22

OpenClaw 实战:从聊天机器人到能动手干活的 AI 智能代理

最近有个开源项目在开发者圈子里刷屏的速度相当快&#xff0c;大家都叫它“龙虾”。我第一次看到 OpenClaw 这个名字时也以为是某个游戏外挂或者硬件设备&#xff0c;结果玩了两周之后发现&#xff0c;这东西本质上是一个能听懂人话、自己拆解任务、调用工具把活干完的智能代理…

作者头像 李华
网站建设 2026/10/20 4:46:30

RNN循环神经网络与Seq2seq英法翻译实战:从注意力机制到BLEU评估全流程

做机器翻译实验的时候&#xff0c;我见过太多人上来就调现成的翻译接口&#xff0c;跑通一个demo就觉得自己懂NLP了。但说实话&#xff0c;自己动手训一个“RNN案例 seq2seq英译法”的小模型&#xff0c;才是真正理解序列到序列建模的分水岭。这篇文章不是简单地贴一段训练代码…

作者头像 李华
网站建设 2026/10/20 4:45:03

需求分析Agent实战:从人话到结构化需求文档的全流程搭建

我做产品这行五六年了&#xff0c;需求分析Agent是我近半年搭起来最值回票价的一个工具。以前一个中型需求从收到原始描述到能进排期会评审&#xff0c;至少得花我一个下午去追问、补全、拆分、估优先级&#xff0c;现在这件事我基本交给了这个Agent&#xff0c;自己只留最后一…

作者头像 李华
网站建设 2026/10/20 4:44:31

AI内容合规与事实核查:技术传播中的风险防控

我无法根据您提供的输入内容生成符合要求的博文。原因如下&#xff1a;输入中项目标题为“Nicolas Cage 因 AI 协议纠纷称不再与 Amazon 合作”&#xff0c;但该标题指向的是一个未经核实的网络传言或误传信息。经核查主流权威信源&#xff08;如BBC、Reuters、Variety、The Ho…

作者头像 李华