朕喜技术(ZhenxiJS)
2026.09.22
1 分钟阅读

Redis 集群限流不生效?读写 key 落点算法不一致,8 次请求全 200 一次没拦

日期:2026-09-22 16:00 | 第 76 题 关键词:Redis 限流不生效、限流形同虚设、Redis Cluster 分片、CRC16 % 16384、客户端分片、 读写 key 落点不一致、RedisCluster 落点算法、Flask 限流 429 不生效、 redis-py NoScriptError、Lua INCR 原子限流、gunicorn 多 worker 计数不一致、 docker compose 复现限流问题、滑动窗口/固定窗口限流 技术栈:Flask 3.0 + redis-py 5.0 + Redis 7 + docker compose(3 redis 分片 / 3 worker / 1 网关)

一、问题背景

一个按会员 ID 做「60 秒窗口内计数」的限流网关服务:

  • free 档 60 秒最多 3 次,basic 10 次,pro 50 次;
  • 线上形态是「1 个网关 → 轮询到 3 个 gunicorn worker → 3 个 redis 分片(自称 Redis Cluster)」;
  • 网关轮询分发,不保证同一用户落同一 worker。

线上投诉:「同一个账号 1 秒内刷了几百次接口,一次都没被拦。」

但所有能看的指标都是正常态:

  • /rate 全程 HTTP 200,/healthz 200,没有任何 5xx;
  • 3 个 worker 的 /stats 都打 cluster-mode enabled、version: 1.4.0-era;
  • redis INFO 没有报错、没有 MOVED、没有 CROSSSLOT,连接数正常。

服务看起来一切正常,限流就是不起作用。 这是最典型的「静默失效」—— 没有任何报错,只有业务语义错。

复现输出(问题态):

member_id = 10000000201   tier = free   limit = 3 / 60s
  #  worker HTTP  status   used  read@  write@
  0  app_1   200   OK       1     2      1
  1  app_2   200   OK       1     2      1
  ...(8 次全是 200)
redis_1  type=list     value_or_len=3
HTTP 200 = 8   HTTP 429 = 0   (limit=3,期望 429 >= 5)
RESULT: LIMITED-NOT-ENFORCED  ← 限流形同虚设

眼睛要抓的 3 个反常细节:

  1. used 永远是 1 —— 每次读到的都是「还没开始计数」;
  2. read@ 与 write@ 不是同一个号(2 和 1)—— 读一个地方、写另一个地方;
  3. 计数落成 list 类型,不是字符串计数器。

二、复现步骤

git clone <repo> && cd <repo>
git checkout 2026-09-22-1600
cd scenario/2026-09-22-1600

docker compose up -d --build          # 3 redis + 3 app + 1 网关
./run.sh repro                        # 看现象:全 200,无限流
./run.sh health_report                # 摊开看分片/埋点/网关
./run.sh verify                       # 判定:PASS=3 FAIL=10

直接用一条命令核对「key → 实例」落点。 注意:不要 import naive_key_hash —— 它在修复后已被删除, 那样写会直接报 ImportError: cannot import name 'naive_key_hash' from 'routes'。 下面这条命令在问题态和修复态都能跑,用的是读写两条路径各自的真实入口: 读走 crc16_slot(),写走 RedisCluster.shard_of_naive()(修复后它已收敛到同一套算法)。

docker compose exec -T edge python -c "
import sys; sys.path.insert(0,'/srv/app')
from routes import crc16_slot, RedisCluster
r = RedisCluster(['redis://redis_0:6379','redis://redis_1:6379','redis://redis_2:6379'], {})
for m in ['10000000201','10000000202','10000000204','10000000209']:
    k = 'rate:' + m
    print(k, '读%3=' + str(crc16_slot(k) % 3), '写%3=' + str(r.shard_of_naive(k)))
"

问题态输出(读、写落在不同实例 —— 这就是 bug):

rate:10000000201 读%3=2 写%3=1   ← 分叉
rate:10000000202 读%3=0 写%3=2
rate:10000000204 读%3=1 写%3=1
rate:10000000209 读%3=2 写%3=0

修复态输出(同一 key 的读写落点全部相等 —— 这就是修好了):

rate:10000000201 读%3=2 写%3=2
rate:10000000202 读%3=0 写%3=0
rate:10000000204 读%3=1 写%3=1
rate:10000000209 读%3=2 写%3=2

若想直接看问题态的老函数,先回退代码再执行: git checkout cc6fc7a -- scenario/2026-09-22-1600/app/routes.py, 此时 from routes import naive_key_hash 才可用。

三、根本原因

根因 1(核心):同一个 key,读和写用了两套不同的落点算法

代码里有两个「key → redis 实例」的函数,读路径用 A,写路径用 B:

函数算法用在哪
shard_of_cluster()CRC16(key) % 16384,再 % 3读路径
shard_of_naive()逐字节 ASCII 求和,再 % 3写路径

于是每个请求变成:

  1. 读:按 CRC16 落点去 redis_2 查 rate:10000000201 → 不存在 → used = 0;
  2. 判定:0 >= 3 不成立 → 放行(永远放行);
  3. 写:按 naive 落点把计数写到 redis_1。

每次请求都从 0 开始。计数器在另一个实例上自己涨自己,判定路径永远看不到它。used 恒为 1、read@≠write@ 就是这么来的。

一句话记住:「读一个分片、写另一个分片」= 限流永远不触发,且没有任何报错。

根因 2:读 STRING、写 LIST,数据类型对不上

写路径用 lpush + ltrim 维护一张 LIST 当「剩余配额表」, 读路径用 get 读 STRING。即使落点修对了,两边类型也对不上,照样读不出计数。

根因 3:/stats 的 counter 是进程内局部变量

state = {"counter": 0} 是每个 worker 一份,/stats 报的是「本进程处理了几个请求」。 3 个 worker 各报各的(12/12/13),汇总口径不自洽 —— 正是这个「看起来正常」的埋点,掩盖了真实的限流失效。

四、解决办法

只改 app/routes.py 一个文件。三步对应三个根因。

① 落点算法收敛成唯一一份

def shard_of(self, key: str) -> int:
    """读、写路径共用的落点算法:CRC16(key) % 16384 再 % 实例数。"""
    return crc16_slot(key) % self.n_pools

def shard_of_cluster(self, key):   # 兼容旧调用点,指向同一份算法
    return self.shard_of(key)

def shard_of_naive(self, key):     # 老算法已删除,防止再分叉
    return self.shard_of(key)

关键原则:「key → 分片」只能有一份实现,读和写都必须调它。 顺手补上 Redis Cluster 的 {hash tag} 语义(CRC16 只算 {} 内的内容), 保证同一业务实体的多个 key 能落同槽:

def crc16_slot(key: str) -> int:
    if "{" in key:
        start = key.index("{") + 1
        end = key.find("}", start)
        if end != -1 and end > start:
            key = key[start:end]
    return crc16(key.encode()) % 16384

② 计数器统一 STRING + 原子 Lua 脚本

-- 先自增、再判定、超限回补
local n = redis.call('INCR', KEYS[1])
if n == 1 then redis.call('EXPIRE', KEYS[1], ARGV[2]) end
if n > tonumber(ARGV[1]) then
  redis.call('DECR', KEYS[1])
  return {0, n}
end
return {1, n}

调用:

key = f"rate:{member_id}"
idx = r.shard_of(key)              # 读写同一落点
conn = r.get_client(idx)
admitted, used = admit(keys=[key], args=[limit, window], client=conn)

为什么不用「先 GET 再 INCR」:在 3 个 worker 之间不是原子的。 并发下会出现「两个请求都读到 2、都判定放行」,实际放行数超过配额。 Lua 脚本让 INCR + 判定 + DECR 成为一个原子操作; 超限时 DECR 回补,使存储值恰好等于被放行的次数(total == ok200 == limit)。

③ 全局计数放 redis

stats_conn = _new_redis(settings["redis_urls"][0])
STATS_KEY = "stats:rate_requests"
stats_conn.incr(STATS_KEY)     # 固定 key,不参与分片,3 个 worker 读同一个值

五、验证

for i in 0 1 2; do docker compose exec -T redis_$i redis-cli FLUSHALL; done
./run.sh verify
  10000000201: HTTP 200=3 429=5 total=3 kinds=['none','none','string']
  10000000202: HTTP 200=3 429=5 total=3 kinds=['string','none','none']
  basic(12次)  : 2 个 429
  single worker: 5 个 429
  worker 埋点  : [36, 36, 36]
PASS=13 FAIL=0
RESULT: OK

read@ == write@、key 只有一种 STRING 类型、3 个 worker 计数一致。

并发下不超发(verify 是顺序的,测不出竞态)

docker compose exec -T edge python -c "
import sys, concurrent.futures as cf
sys.path.insert(0,'/srv/app/ops')
from _common import send, wait_ready
wait_ready()
with cf.ThreadPoolExecutor(max_workers=40) as ex:
    codes=list(ex.map(lambda _: send('10000000999','free')[0].status_code, range(40)))
print('200 =', codes.count(200), ' 429 =', codes.count(429))
"
200 = 3  429 = 37       ← 配额 3,40 线程并发下恰好放行 3 个

六、经验与避坑

排查思路

  1. 「没有 429」不等于「服务正常」。限流这类业务语义失效可以全程 200 且零报错, 不能只看 HTTP 状态码和 5xx 告警。
  2. 先核对「读的 key」和「写的 key」是否在同一个地方。 在多分片/多节点架构里,返回体里同时打印 read@ 与 write@ 是极高性价比的埋点。
  3. 警惕「看起来正常」的自报指标。cluster-mode enabled 只是代码里的一句字符串, counter 是进程内变量 —— 埋点自己算错,比没有埋点更危险。
  4. 直接看存储层:key 落在哪个实例、是什么数据类型、值是多少。 type=list 而代码读 get,一眼就能看出类型不匹配。

通用的坑

  1. 客户端分片最大的陷阱就是「落点算法复制粘贴出两份」。 一旦读写分叉,故障模式是「静默不生效」而非报错。 修法不是「把两个函数改成一样」,而是删掉一份,只留一个入口。
  2. READ + INCR 不是原子操作,多 worker 下必然超发。 计数型限流要用 INCR 判定,并用 Lua 保证原子性。 若业务要求「存储值 = 放行数」,就 INCR 后超限 DECR 回补。
  3. redis-py 的 Script 手动 evalsha 会踩 NoScriptError:
redis.exceptions.NoScriptError: No matching script. Please use EVAL.
# ❌ 手动 evalsha:脚本未加载就 500
conn.evalsha(script.sha, 1, key, limit, window)
# ✅ 调 Script 对象:内部自动 EVAL / EVALSHA 回退
script(keys=[key], args=[limit, window], client=conn)
  1. 改完代码要 --build,不是 restart。 docker compose restart 不会重新 COPY 源码进镜像,改动不生效会让人怀疑人生。
  2. 判定/测试前先 FLUSHALL。窗口计数器有 60s TTL, 上一轮的残留会串到下一轮,used 不是从 1 开始,结论就跑偏了。
  3. 顺序测试测不出竞态。修完限流务必补一个并发用例 (ThreadPoolExecutor 打同一用户),否则「原子性」只是纸面结论。

可复现命令速查

cd scenario/2026-09-22-1600
docker compose up -d --build
./run.sh repro          # 问题态 RESULT: LIMITED-NOT-ENFORCED
./run.sh verify         # 问题态 PASS=3 FAIL=10
# --- 应用修复后 ---
docker compose up -d --build app_0 app_1 app_2 edge
for i in 0 1 2; do docker compose exec -T redis_$i redis-cli FLUSHALL; done
./run.sh verify         # PASS=13 FAIL=0
docker compose down -v

完整操作过程见同分支 scenario/2026-09-22-1600/SOLUTION.md。

版权声明

本文为博主原创文章,遵循 CC BY-NC-SA 4.0 许可协议。 转载请注明出处:http://localhost/blogs/2026-09-22-1600-redis

分享这篇文章