Skip to content

基于 Redis 的分布式信号量:限制同时加载缓存的实例数 ​

一次灰度发布,121 个 Pod 几乎同时启动,每个都从数据库全量加载约 20 万条数据到本地缓存。单个 Pod 的连接数是受控的,但 121 份叠加在一起,数据库出现大量慢查询并触发了主从切换。问题不在单个实例的行为,而在于没有人限制「同时有多少实例在加载」。

需要的机制很清楚:全局最多允许 N 个实例同时加载,其余的等待并重试;实例在加载过程中崩溃时,它占用的名额要能被回收。约束是只能用 spring-boot-starter-data-redis,不引入 Redisson 等依赖。

本文给出一个用 Redis 的 List 和 ZSET 组合实现的分布式信号量,并在 Redis 8.10.1 上实测了获取、等待超时、正常释放、重复释放、崩溃补偿、并发压力和租约早于任务结束的全过程,脚本与原始输出见文末「配套实验」。

一、先说结论 ​

  • 用 List 存许可,用 ZSET 记录持有者。 List 的长度就是剩余许可数,BRPOP 天然支持阻塞等待;ZSET 的 score 存租约到期时间,供补偿任务回收。
  • BRPOP 比「查计数再判断」可靠。 取出即占用,是单条命令的原子操作,不需要自己处理竞争。
  • 释放必须原子。 「从 ZSET 移除」和「把许可放回 List」要写在同一个 Lua 脚本里,否则中间崩溃会导致许可凭空多出或永久丢失。
  • 崩溃回收靠租约,不靠心跳。 持有者在 ZSET 中带一个到期时间,补偿任务定期清理过期条目并补回许可,实测能准确回收崩溃实例占用的名额。
  • 信号量不是锁。 它限制的是并发数,不保证互斥。实测租约 500ms、任务 1500ms 时,补偿回收许可后另一个实例立刻进入,容量为 1 的信号量同时有 2 个持有者;被保护的操作必须能容忍这种超额,并且可以重复执行。

二、为什么不是别的方案 ​

方案问题
INCR 计数器 + 判断上限「加一再判断是否超限」需要回滚,实例崩溃后计数永远降不下来
分布式锁(一次只放一个)121 个实例串行加载,总耗时不可接受;本意是限流而不是互斥
应用侧配置错峰启动依赖部署节奏,扩容和重启时不可控
Redisson 的 RSemaphore功能完备,但当前不允许引入新依赖

选 List 的关键原因是 BRPOP:它把「检查是否有名额」和「占用名额」合并成一个原子操作,并且自带阻塞等待,等待中的客户端不会空转轮询。

三、数据结构 ​

实例 Pod启动加载BRPOP permits取到许可才继续执行加载释放RPUSHpermits(List)元素个数 = 剩余许可holders(ZSET)member=实例, score=租约到期补偿任务:ZREMRANGEBYSCORE 清理过期持有者,并补回许可
图 1 · List 的长度就是剩余许可数,BRPOP 天然支持阻塞等待;ZSET 记录持有者与租约到期时间,供补偿任务回收
text
sem:{name}:permits   List    元素是占位符,长度 = 剩余可用许可数
sem:{name}:holders   ZSET    member = 实例唯一标识,score = 租约到期时间戳(毫秒)

初始化时向 List 中推入 N 个占位符:

bash
DEL sem:cache-loader:permits
RPUSH sem:cache-loader:permits p p p ... # N 个

初始化需要幂等:可以用一个带 NX 的标记 key 保证只初始化一次,或者交给运维脚本在部署前执行。更稳妥的做法是由补偿任务负责「把许可数补齐到容量」,这样即使初始化漏了,几十秒内也能自愈。

实例唯一标识要能区分同一个 Pod 的不同次获取,例如 podName:pid:timestamp:random。这样即使同一个 Pod 重启后再次获取许可,也不会和上一次的残留条目混淆。

四、三个核心操作 ​

4.1 获取许可 ​

java
public Optional<String> acquire(Duration waitTimeout, Duration lease) {
    // BRPOP:取到占位符才算获得许可;超时返回 null
    String permit = redis.opsForList().rightPop(permitsKey, waitTimeout);
    if (permit == null) {
        return Optional.empty();                   // 等待超时,调用方决定重试还是放弃
    }
    String holder = instanceId + ":" + System.nanoTime();
    long expireAt = System.currentTimeMillis() + lease.toMillis();
    redis.opsForZSet().add(holdersKey, holder, expireAt);
    return Optional.of(holder);
}

实测三个实例依次获取:

text
pod-1 获取: [p]  剩余许可=2  持有者=1
pod-2 获取: [p]  剩余许可=1  持有者=2
pod-3 获取: [p]  剩余许可=0  持有者=3
pod-4 等待 2 秒后返回空,共等待 2053ms

第四个实例阻塞到超时后返回空,这正是需要的行为:调用方可以按退避策略重试,而不是直接失败。

这里有一个窗口:BRPOP 成功之后、ZADD 之前如果进程崩溃,许可会「丢失」——它既不在 List 里,也没有对应的持有者记录。实测只执行 BRPOP 不执行 ZADD 后,账目变成「可用 2、持有者 0」。这正是下面补偿任务要解决的问题之一,所以补偿逻辑要按「容量 - 可用 - 持有中」来补齐,而不是只清理过期条目。

4.2 释放许可 ​

释放要做两件事:从 ZSET 移除自己、把许可放回 List。这两步必须原子,否则中间崩溃会让许可凭空丢失(移除了但没放回)或凭空增加(放回了但没移除,补偿任务又补一次)。

lua
-- KEYS[1]=permits  KEYS[2]=holders  ARGV[1]=holder
if redis.call('ZREM', KEYS[2], ARGV[1]) == 1 then
  redis.call('RPUSH', KEYS[1], 'p')
  return 1
end
return 0

以 ZREM 的返回值作为判断依据,天然防止了重复释放。实测:

text
pod-1 第一次释放:返回 1,剩余许可 1 → 2
pod-1 再次释放:  返回 0,许可数不变

如果租约已经过期、条目被补偿任务清理掉了,这次释放同样返回 0,不会把许可重复放回。

Spring Data Redis 中执行:

java
private static final RedisScript<Long> RELEASE = RedisScript.of(
        new ClassPathResource("redis/semaphore-release.lua"), Long.class);

public boolean release(String holder) {
    Long released = redis.execute(RELEASE, List.of(permitsKey, holdersKey), holder);
    return Long.valueOf(1L).equals(released);
}

4.3 补偿任务 ​

容量 3 个许可的实测过程pod-1 正常释放Lua 返回 1,重复释放返回 0pod-2 崩溃ZSET 中留下过期条目pod-3 仍在运行持有许可租约到期score < 当前时间补偿任务回收 1 个许可最终:可用 2 个持有 1 个(pod-3)
图 2 · 正常释放走 Lua 脚本,保证「移除持有者」和「归还许可」原子完成;崩溃的实例靠租约过期被补偿回收

由一个独立的、单实例运行的定时任务执行(用 ShedLock、XXL-Job 之类保证不并发):

lua
-- KEYS[1]=permits KEYS[2]=holders ARGV[1]=当前时间戳 ARGV[2]=容量
local expired = redis.call('ZRANGEBYSCORE', KEYS[2], '-inf', ARGV[1])
if #expired > 0 then
  redis.call('ZREM', KEYS[2], unpack(expired))
end
local capacity  = tonumber(ARGV[2])
local available = redis.call('LLEN', KEYS[1])
local held      = redis.call('ZCARD', KEYS[2])
local missing   = capacity - available - held
if missing > 0 then
  for _ = 1, missing do redis.call('RPUSH', KEYS[1], 'p') end
end
return missing

它做的是「把总数补齐到容量」,而不是「每清理一个条目就补一个许可」。这样无论许可是因为持有者崩溃、还是因为 BRPOP 与 ZADD 之间崩溃而丢失,都能被补回来。

实测:pod-2 的租约被改成已过期(模拟崩溃),运行补偿脚本:

text
补偿回收数量: 1
最终:可用许可 = 2,持有者 = 1(pod-3 仍在运行)
pod-2 之后再释放:返回 0,许可数不变

容量 3 的账目重新对上了。对「只 BRPOP 不 ZADD」的那个信号量运行同一个脚本,也补回了 1 个许可。

最后用 20 个线程各获取、释放 50 次(每次持有 2—5ms,每次释放后再重复释放一次)做压力测试:共获取 1,000 次,同时持有许可的实例最多 3 个,重复释放全部返回 0,结束时可用 3、持有者 0。

这个脚本超出容量时不做回收(missing 为负时什么都不做)。如果需要缩容,应当由运维显式操作,避免补偿任务在配置变更瞬间误删正在使用的许可。

五、使用方式与参数 ​

java
@Bean
ApplicationRunner cacheWarmupRunner(CacheLoader loader, RedisSemaphore semaphore) {
    return args -> {
        String holder = null;
        for (int attempt = 1; attempt <= 30 && holder == null; attempt++) {
            holder = semaphore.acquire(Duration.ofSeconds(10), Duration.ofMinutes(10)).orElse(null);
            if (holder == null) {
                Thread.sleep(backoffWithJitter(attempt));   // 退避 + 抖动,避免同时重试
            }
        }
        if (holder == null) {
            log.warn("等待加载许可超时,降级为懒加载");        // 关键:不要因为拿不到许可就启动失败
            return;
        }
        try {
            loader.loadAll();
        } finally {
            semaphore.release(holder);
        }
    };
}

参数怎么定:

参数依据
容量 N数据库能承受的并发加载数。用单个实例加载时的数据库负载反推,留出余量
租约时长明显大于加载耗时的 p99,例如 p99 是 2 分钟就设 10 分钟。太短会导致仍在加载时被回收,出现超出容量的并发
等待超时单次 BRPOP 的阻塞时间,几秒到十几秒;配合外层重试与退避
补偿周期小于租约时长,例如租约 10 分钟、每分钟补偿一次

六、这个方案的边界 ​

  • 不是互斥锁。 租约到期后补偿任务会回收名额,但原实例可能还在跑(比如它只是卡住了)。实测时间线:

    text
    2ms      pod-a 获取许可,租约 500ms,任务要跑 1500ms
    709ms    补偿任务回收了 pod-a 的许可
    712ms    pod-b 获取许可,此时 pod-a 仍在运行 → 同时 2 个持有者
    1503ms   pod-a 结束,释放返回 0

    所以被保护的操作必须能容忍「偶尔超出 N 个并发」,并且本身是可重复执行的。缓存加载符合这个条件,转账、扣库存不符合。需要真正互斥时,下游要能拒绝过期的持有者,做法见 Redis 原子性边界。

  • Redis 不可用时要能降级。 拿不到许可不应该让应用启动失败,降级为懒加载或直接跳过预热。

  • Redis 主从切换可能丢数据。 异步复制下,主从切换的瞬间可能丢失少量许可状态(丢失窗口的实测见 Redis Sentinel 故障切换),补偿任务会在下一个周期把账目补齐——这也是必须有补偿的原因之一。

  • 容量变更要同时更新配置和 Redis 中的许可数,扩容可以直接 RPUSH,缩容要谨慎。

  • 可观测性:至少暴露「等待许可的耗时」「重试次数」「补偿回收次数」三个指标。补偿回收次数突然升高,通常意味着实例在加载过程中崩溃,或者租约设短了。

七、常见误区 ​

  • 「用计数器加判断就能限流」:加一再判断不是原子的,崩溃后计数无法回落。
  • 「释放时先 ZREM 再 RPUSH 也一样」:两条命令之间崩溃就会丢许可,必须用 Lua 合成一步。
  • 「租约设短一点更安全」:设得比实际耗时短,会在任务还在跑的时候回收名额,并发数反而超标。
  • 「补偿任务清理过期条目就够了」:BRPOP 与 ZADD 之间崩溃丢失的许可没有对应条目,必须按容量补齐。
  • 「有了信号量就不用限流了」:它只限制加载任务的并发数,单个任务内部的批量读取仍然要分批。

小结 ​

这个方案的三个要点可以迁移到任何「限制分布式并发数」的场景:用 BRPOP 把「判断 + 占用」变成一次原子操作并自带等待;用 ZSET 的 score 记录租约,让崩溃的持有者可以被回收;用一个按容量补齐的补偿任务兜住所有丢失路径。剩下的都是参数选择——容量取决于下游能力,租约取决于任务耗时,两者都要能观测和调整。

缓存预热的整体设计,见 多级缓存、预热与热点数据。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权