基于 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:它把「检查是否有名额」和「占用名额」合并成一个原子操作,并且自带阻塞等待,等待中的客户端不会空转轮询。
三、数据结构
sem:{name}:permits List 元素是占位符,长度 = 剩余可用许可数
sem:{name}:holders ZSET member = 实例唯一标识,score = 租约到期时间戳(毫秒)初始化时向 List 中推入 N 个占位符:
DEL sem:cache-loader:permits
RPUSH sem:cache-loader:permits p p p ... # N 个初始化需要幂等:可以用一个带 NX 的标记 key 保证只初始化一次,或者交给运维脚本在部署前执行。更稳妥的做法是由补偿任务负责「把许可数补齐到容量」,这样即使初始化漏了,几十秒内也能自愈。
实例唯一标识要能区分同一个 Pod 的不同次获取,例如 podName:pid:timestamp:random。这样即使同一个 Pod 重启后再次获取许可,也不会和上一次的残留条目混淆。
四、三个核心操作
4.1 获取许可
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);
}实测三个实例依次获取:
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。这两步必须原子,否则中间崩溃会让许可凭空丢失(移除了但没放回)或凭空增加(放回了但没移除,补偿任务又补一次)。
-- 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 的返回值作为判断依据,天然防止了重复释放。实测:
pod-1 第一次释放:返回 1,剩余许可 1 → 2
pod-1 再次释放: 返回 0,许可数不变如果租约已经过期、条目被补偿任务清理掉了,这次释放同样返回 0,不会把许可重复放回。
Spring Data Redis 中执行:
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 补偿任务
由一个独立的、单实例运行的定时任务执行(用 ShedLock、XXL-Job 之类保证不并发):
-- 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 的租约被改成已过期(模拟崩溃),运行补偿脚本:
补偿回收数量: 1
最终:可用许可 = 2,持有者 = 1(pod-3 仍在运行)
pod-2 之后再释放:返回 0,许可数不变容量 3 的账目重新对上了。对「只 BRPOP 不 ZADD」的那个信号量运行同一个脚本,也补回了 1 个许可。
最后用 20 个线程各获取、释放 50 次(每次持有 2—5ms,每次释放后再重复释放一次)做压力测试:共获取 1,000 次,同时持有许可的实例最多 3 个,重复释放全部返回 0,结束时可用 3、持有者 0。
这个脚本超出容量时不做回收(missing 为负时什么都不做)。如果需要缩容,应当由运维显式操作,避免补偿任务在配置变更瞬间误删正在使用的许可。
五、使用方式与参数
@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 分钟、每分钟补偿一次 |
六、这个方案的边界
不是互斥锁。 租约到期后补偿任务会回收名额,但原实例可能还在跑(比如它只是卡住了)。实测时间线:
text2ms 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 记录租约,让崩溃的持有者可以被回收;用一个按容量补齐的补偿任务兜住所有丢失路径。剩下的都是参数选择——容量取决于下游能力,租约取决于任务耗时,两者都要能观测和调整。
缓存预热的整体设计,见 多级缓存、预热与热点数据。
配套实验
- codesphere-labs/cache/redis-distributed-semaphore:获取、超时、释放与重复释放、租约回收、BRPOP 与 ZADD 之间崩溃、并发压力与租约早于任务结束;用最小 RESP 客户端直接执行文中命令与 Lua,没有启动 Spring(验证记录)
参考资料