bigywater.tech
微服务 · 11

分布式限流:Redis 令牌桶的原子性

多个实例共用一个令牌桶怎么保证原子?速度能到多少?时间戳该用谁的?Redis 卡住的时候是放行、拒绝还是退回本地?这篇用本机的 Redis 8.10.2 和 Lua 脚本量出来。

3200 对 1000
桶里只有 1000 个令牌时,读-改-写实现与 Lua 脚本各放行的数量(3 轮都一样)
1500 对 509
3 个客户端各 100/s、时钟偏差 0 / +300 / −300 ms 时,用客户端时间戳与用 Redis TIME 的 5 秒放行总数(限额应约 510)
199 / 0 / 188
Redis 暂停的第 2 秒,fail-open、fail-closed、退回本地令牌桶各放行的数量(限额 100/s)

原子性

实测 · 本机执行输出
  实现                     | 3 轮各放行多少(应为 1000)  | 3 轮结束时桶里剩余令牌(应为 0) | 3 轮 WATCH 冲突重试次数   | 尝试次数/秒(中位数)
  -----------------------+--------------------+--------------------+--------------------+------------
  (a) 读-改-写(不原子)         | 3200 / 3200 / 3200 | 315 / 336 / 338    | 0 / 0 / 0          | 8585       
  (b) WATCH + MULTI/EXEC | 1000 / 1000 / 1000 | 0 / 0 / 0          | 3899 / 3891 / 3893 | 3066       
  (c) Lua 脚本             | 1000 / 1000 / 1000 | 0 / 0 / 0          | 0 / 0 / 0          | 16021      

读-改-写会让所有尝试都过;WATCH 靠冲突重试保证,开销大;Lua 脚本在 Redis 里整体原子。

速度

实测 · 本机执行输出
  场景                  | 吞吐        | 延迟中位数   | p99    
  --------------------+-----------+---------+--------
  热点:16 个线程共用 1 个 key | 16291/秒   | 0.85 ms | 2.87 ms
  分散:每个线程一个 key       | 16364/秒   | 0.84 ms | 2.86 ms
  对照:进程内的令牌桶(一把锁)     | 4157005/秒 | -       | -      

热点和分散差别不大;跨进程往返比进程内慢两个数量级。

时间来源

实测 · 本机执行输出
  “现在”的来源                                        | 5 秒内放行总数 | 三个客户端各放行       
  -----------------------------------------------+----------+----------------
  (a) Redis 的 TIME                               | 509      | [170, 165, 174]
  (b) 客户端时间戳(偏差 0 / +300 / −300 ms),ts 直接记成传入的时间 | 1500     | [500, 500, 500]
  (c) 客户端时间戳(同样的偏差),ts 只增不减(math.max)            | 508      | [72, 382, 54]  

客户端时钟有偏差时,令牌会被反复补满;用 Redis 的 TIME 最稳。

Redis 不可用

实测 · 本机执行输出
  策略          |         | 第 0 秒 | 1   | 2   | 3   | 4   | 5   | 6   | 7  
  ------------+---------+-------+-----+-----+-----+-----+-----+-----+----
  fail-open   | 放行/秒    | 105   | 100 | 199 | 200 | 200 | 105 | 100 | 100
              | 平均耗时 ms | 1     | 1   | 37  | 2   | 2   | 1   | 1   | 1  
  fail-closed | 放行/秒    | 105   | 100 | 4   | 0   | 0   | 96  | 101 | 100
              | 平均耗时 ms | 1     | 1   | 41  | 2   | 2   | 1   | 1   | 1  
  退回本地令牌桶     | 放行/秒    | 105   | 100 | 188 | 96  | 104 | 96  | 100 | 100
              | 平均耗时 ms | 1     | 1   | 37  | 2   | 2   | 1   | 1   | 0  
  fail-open 这一轮里客户端遇到的错误(第 2 秒起;键 = (是否已过第 3 秒, 错误类型与信息前 40 个字符)): {(False, 'TimeoutError:Timeout reading from socket'): 136, (False, 'ConnectionError:Error 54 connecting to localhost:9151. C'): 27, (False, 'ConnectionError:Error 32 while writing to socket. Broken'): 18, (False, 'ConnectionError:Error while reading from localhost:9151 '): 15, (True, 'ConnectionError:Error 54 connecting to localhost:9151. C'): 214, (True, 'ConnectionError:Error 32 while writing to socket. Broken'): 90, (True, 'ConnectionError:Error while reading from localhost:9151 '): 102, (True, 'ConnectionError:Error 54 while writing to socket. Connec'): 2}
  Redis 暂停的第 2 到第 5 秒:fail-open 把请求几乎全放行(接近 200 个/秒,限额形同虚设),fail-closed 几乎全部拒绝,退回本地令牌桶则按各线程的本地额度放行(8 个线程的额度加起来约 100;切换那一秒因为本地桶是满的,会多放行一些);只有切换那一秒每次判断平均慢几十毫秒(等 socket 超时),之后很快就报连接错误

fail-open 没有限额,fail-closed 全部拒绝,退回本地桶最接近限额。

分步演示

分布式限流
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Python 3.14.8 / Go / nginx / Envoy / etcd / Consul / Nacos / OpenTelemetry / Prometheus(按各篇注明用到哪些及版本);所有服务都是本机自己起的进程,端口 9150 以上,只监听 127.0.0.1,不连任何外部服务,不碰本机已有的实例;故障用 kill -9 进程、让连接断开或在代码里注入延迟与错误来制造;计时用 time.perf_counter,数字每次运行会有小幅抖动。Redis 8.10.2(Homebrew 的 redis-server,本机自己起的实例,端口 9151 附近)与 redis-py 8.1.0(关掉了默认的重试与退避:Retry(NoBackoff(), 0));令牌桶 Lua 脚本是我写的;第四节用 SIGSTOP / SIGCONT 暂停和恢复 redis-server 进程;客户端是 Python 线程,吞吐受客户端限制
    • 没有验证:Redis Cluster / 主从切换时脚本的行为;redis-py 默认的重试与退避(已关掉)在真实服务里的影响;多个 Redis 分片上的限额;滑动窗口日志(ZSET)实现的开销;Redis 通过网络访问时的延迟(这里是环回)
    京ICP备2026067502号-1京公网安备11010502063487号