Score 设计
最直观的方案是直接使用时间戳作为 score:
这个方案看起来没问题,但实际使用时会遇到一些坑。
挑战 1:时间精度
用秒级时间戳做 score,最直接的问题是同一秒内的任务无法区分顺序。订单取消、自动确认、自动评价这几类任务经常落在同一秒里,而 ZSet 的 member 是唯一的、score 相同时按 member 的字典序排列——也就是说,任务的执行顺序变成了字符串的字典序,和真实到期顺序毫无关系。
score = 1710505800(秒级)
同一个 score 下的任务:
"task_100" ← 字典序在前
"task_99" ← 字典序在后,但可能先到期
"task_101"
看起来只是”顺序不完美”,实际后果要严重得多:
- “先到期的先执行”被破坏。 同一秒内,优惠券过期任务和订单取消任务乱序执行,可能导致重复发券。
- 批量取任务时会被随机截断。
ZRANGEBYSCORE key 0 <now> LIMIT 0 200取出来的 200 条里顺序是乱的,边界上的任务可能”看起来还没到期”却被一起取走。
把 score 换成毫秒级时间戳是成本最低的修法,精度从 1 秒提升到 1 毫秒,绝大多数业务场景到这一步就够了。但毫秒也不是无限的——同一毫秒内到达的请求(比如一次批量下单产生的 500 个任务)依然会撞车,这引出了后面「Score 设计方案」一节里的序列号方案。
还有一层容易被忽略的约束:Redis 的 score 是 IEEE 754 双精度浮点数,只能精确表示 2^53 以内的整数(约 9.007e15)。当前毫秒时间戳约 1.7e12,还剩大约 5000 倍的余量;但如果继续往高位上移(比如把序列号放到 16 位),score 就会逼近上限,精度开始丢失。
| score 精度 | 同一时间单位内的任务数 | 冲突概率 | 额外处理 |
|---|---|---|---|
| 秒 | 极高 | 🔴 几乎必然 | 顺序错乱 |
| 毫秒 | 中 | 🟡 有一定 | 高并发下仍可能 |
| 微秒 | 低 | 🟢 很低 | 需要更高精度的时间源 |
| 毫秒 + 序列号 | 无上限 | 🟢 无 | 需要维护序列号 |
挑战 2:任务去重
ZSet 的 member 唯一,这既是优点也是陷阱。
ZADD 一个已存在的 member,行为不是”插入第二条”,而是更新它的 score:
ZADD delay:queue 1710505800000 "task_1001"
ZADD delay:queue 1710505900000 "task_1001" # 不是新增,是把 score 改成了新值
后果是:重复提交同一个任务会悄悄推迟它的执行时间。 用户点了两次”取消订单”,第二次提交把 deadline 从 30 分钟推迟到 60 分钟,订单会多被扣 30 分钟的库存。
而这恰恰是延时队列里最常见的重复来源:
| 重复来源 | 触发条件 | 后果 |
|---|---|---|
| 用户重复点击 | 网络慢,用户连点两次 | deadline 被推迟 |
| 客户端自动重试 | 提交超时后重发 | deadline 被推迟 |
| 消息重复投递 | MQ 的”至少一次”语义 | deadline 被推迟 |
| 补偿任务重复跑 | 定时校验发现”没执行”就补一次 | deadline 被推迟 |
解法是把 member 设计成幂等键。不要用”业务 id”这种可复用的值,而是用”业务 id + 本次提交的唯一标识”:
// member = 业务键 + 本次提交的唯一 token
String member = task.getBizKey() + ":" + task.getSubmitToken();
zset.add(member, deadline);
这样重复提交会生成两个不同的 member,两条任务都会执行——如果业务上要求”只执行一次”,就必须在执行侧做幂等(唯一索引 + 状态机),而不是指望队列层面拦截。
另一种做法是先查再写,用 ZSCORE 判断是否已存在:
if (zset.score(member) == null) {
zset.add(member, deadline);
}
这在单实例下有效,但多实例下有竞态:两个实例可能同时查到”不存在”然后都写入。要真正保证幂等,得靠 Redis 侧的原子性——用 Lua 脚本把”查 + 写”合成一步:
-- 只有 score 不存在时才写入,已存在则返回 0 表示幂等命中
if redis.call('ZSCORE', KEYS[1], ARGV[1]) then
return 0
end
redis.call('ZADD', KEYS[1], ARGV[2], ARGV[1])
return 1
最后还要区分两种”重复”:
- 真重复:同一次业务提交被投递多次,应该去重。
- 合法重复:业务上就是要发两条(比如”下单后 30 分钟未支付则取消”和”下单后 30 分钟发提醒”),member 不同,本来就该都执行。
member 的设计必须能区分这两者。 一律用 bizKey:token 的形式,把”算不算重复”这件事交还给业务语义去定义。
挑战 3:并发竞争
多个调度线程同时从一个 ZSet 里取到期任务时,经典问题是”取”和”删”之间不是原子的:
// 有问题的写法
List<String> members = zset.rangeByScore(0, now, 0, 100); // 线程 A 和 B 同时执行到这里
for (String m : members) {
zset.remove(m); // 各自删一次
handle(m); // 同一个任务被执行了两遍
}
线程 A 和线程 B 都读到了 task_1001,然后都去执行它。Redis 不会报错,ZSet 的删除也”成功”了(第二次删返回 0),但业务副作用已经发生了两次。
正确的做法是让”取出并删除”成为一个原子操作。Redis 有两条路:
路线 1:Lua 脚本(推荐)
-- 原子地从 ZSet 中取出并删除到期的任务
local members = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[1], 'LIMIT', 0, tonumber(ARGV[2]))
if #members > 0 then
redis.call('ZREM', KEYS[1], unpack(members))
end
return members
Lua 脚本在 Redis 里是串行执行的,执行期间不会有其他命令插进来,所以不存在”两个线程读到同一批”的窗口。
路线 2:ZPOPMIN(Redis 5.0+)
ZPOPMIN delay:queue 100
它只弹出”最小 score 的 N 个”,而不是”所有已到期的”,所以适合”取最早的任务”,不适合”取所有到期的”。要做”取所有到期的”,Lua 更合适。
还有一个容易被忽略的点:取出和执行业务逻辑之间仍然有窗口。Lua 保证了”取走”是原子的,但任务从 Redis 取出到业务执行完成这段时间里,进程可能崩溃,任务就丢了。解决办法是先标记再删除——取出的任务先挪进一个”处理中”集合,执行成功后再真正删除,失败则挪回重试集合。这正是《Redis ZSet 基础》里”双层存储”要解决的问题。
方案 1:毫秒级时间戳
优点:
- ✅ 精度提高到毫秒级
- ✅ 同一秒内的任务不会冲突
缺点:
- ❌ 同一毫秒内的任务仍可能冲突
- ❌ 如果并发很高,仍可能有问题
方案 2:时间戳 + 随机数
示例:
原始时间戳:1710505800000 (毫秒)
score 设计:
├─ 时间戳部分(高 46 位):1710505800000 << 6
└─ 随机数部分(低 6 位):0 ~ 63
示例 score:
1710505800000 << 6 + 0 = 109543571200000
1710505800000 << 6 + 1 = 109543571200001
...
1710505800000 << 6 + 63 = 109543571200063
优点:
- ✅ 同一毫秒内的任务不会冲突(最多 64 个)
- ✅ 保持时间顺序
缺点:
- ❌ 如果并发 > 64,仍可能冲突
- ❌ 需要合理的随机位数选择
方案 3:时间戳 + 序列号(推荐)
示例:
score 设计:
├─ 时间戳部分(高 42 位)
└─ 序列号部分(低 10 位)
同一秒内的任务:
1710505800000 << 10 + 0 = 1752904332800000
1710505800000 << 10 + 1 = 1752904332800001
...
1710505800000 << 10 + 1023 = 1752904332801023
可以支持同一毫秒内 1024 个任务
优点:
- ✅ 完全避免冲突(序列号唯一)
- ✅ 保持时间顺序
- ✅ 支持高并发
缺点:
- ❌ 序列号会一直增长,需要定期重置
方案 4:时间戳 + UUID(适用于分布式)
优点:
- ✅ 分布式环境下也能保证唯一性
- ✅ 不需要序列号管理
缺点:
- ❌ 随机性可能导致顺序不完全按时间
| 方案 | 冲突概率 | 顺序保证 | 分布式友好 | 复杂度 |
|---|---|---|---|---|
| 秒级时间戳 | 🔴 高 | ✅ 完全 | ✅ 友好 | ⭐ 简单 |
| 毫秒级时间戳 | 🟡 中 | ✅ 完全 | ✅ 友好 | ⭐ 简单 |
| 时间戳 + 随机数 | 🟢 低 | ✅ 完全 | ✅ 友好 | ⭐⭐ 中等 |
| 时间戳 + 序列号 | 🟢 无 | ✅ 完全 | ⚠️ 需要协调 | ⭐⭐⭐ 复杂 |
| 时间戳 + UUID | 🟢 无 | ⚠️ 近似 | ✅ 友好 | ⭐⭐ 中等 |
推荐方案:时间戳 + 序列号
综合前面三个挑战,推荐的 score 结构是”毫秒时间戳 + 序列号”:
score = (timestamp_ms << 10) | sequence
├─ 高位:毫秒时间戳,保证全局时间顺序
└─ 低位:10 位序列号(0~1023),保证同一毫秒内不冲突
为什么是 10 位? 这是精度和取值范围之间的取舍:10 位序列号意味着同一毫秒内最多容纳 1024 个任务,足够绝大多数业务场景;而序列号占的位越少,高位时间戳能保留的位数就越多。
为什么这样能防冲突? 序列号不是随机的,而是由 Redis 侧的原子自增分配。Lua 脚本里用 INCR 拿到一个全局递增的序列号:
local seq = redis.call('INCR', KEYS[2]) % 1024
local score = tonumber(ARGV[1]) * 1024 + seq
redis.call('ZADD', KEYS[1], score, ARGV[2])
return score
因为 INCR 是原子的,同一毫秒内并发进来的任务会拿到不同的序列号,score 天然不冲突,而且 score 的大小顺序就是真实的入队顺序。
取任务时把序列号还原成时间戳:
long timestamp = score / 1024; // 高位
long sequence = score % 1024; // 低位
用之前要确认三件事:
- score 是否超出 double 的精确整数范围。 Redis 的 score 是双精度,能精确表示的整数上限是 2^53 ≈ 9.007e15。当前毫秒时间戳约 1.7e12,左移 10 位后约 1.75e15,仍在范围内;但再左移 4 位(序列号占 16 位)就会逼近上限。左移 10 位是安全的选择。
- 序列号的容量。 序列号取模 1024 意味着它是”循环”的。如果同一毫秒内真的来了超过 1024 个任务,就会发生覆盖。监控里应该记录”单毫秒最大任务数”这个指标,超过 800 就要考虑扩大序列号位数。
- 序列号不需要跨毫秒重置。 因为高位时间戳已经保证了全局顺序,低位序列号只要在同一毫秒内唯一即可。真正需要”重置”的是
INCR计数器本身——它会一直增长,Lua 里取模之后就无所谓了,但如果要持久化这个计数器,得注意它溢出后的回绕。
「Score 设计对比」一节那张表列出了五种方案的取舍,这里补一句实践建议:延时跨度短、并发不高的场景直接用毫秒时间戳就够了,省掉序列号这一层复杂度;只有当监控确实看到”同一毫秒内多任务”并因此出现顺序问题时,再升级到序列号方案。方案 3 的复杂度是要用实测数据来偿还的,不要提前付。
思考 1
如何处理 score 溢出的问题?
解题思路:
ZSet 的 score 是 double,能存的整数上限远大于当前时间戳,但直接存绝对毫秒时间戳时,随着系统长时间运行加上更远的未来时间点,会慢慢逼近精度边界。所以要比较几种表示法:相对时间、周期性重置、浮点。
详细步骤:
问题分析:
解决方案:
方案 1:使用相对时间
优点:
- ✅ 避免绝对时间戳溢出
- ✅ 可以支持任意长的延时
缺点:
- ❌ 系统重启后需要重新计算 base_time
- ❌ 需要持久化 base_time
方案 2:周期性重置序列号
优点:
- ✅ 序列号不会无限增长
- ✅ 延长了可使用时间
缺点:
- ⚠️ 需要管理周期号
- ⚠️ 实现复杂度增加
方案 3:使用浮点数
优点:
- ✅ 简单直接
- ✅ 不需要管理序列号
缺点:
- ❌ Redis 使用 double 存储,精度有限
- ❌ 可能有浮点数精度问题
最佳实践:
- ✅ 短期使用:直接使用毫秒级时间戳
- ✅ 长期使用:使用相对时间或周期性重置
- ✅ 避免溢出:定期检查 score 是否接近上限
- ✅ 监控告警:score 接近上限时发出告警
最终答案:
短期使用直接用毫秒级绝对时间戳最简单;长期使用应改用相对时间基准或周期性重置序列号,避免绝对时间戳无限增长逼近 double 的精度上限;用浮点数表示相对时间虽然简单,但 Redis 以 double 存储、精度有限,不推荐。落地时要定期检查 score 是否接近上限,并在接近时发出告警。
发现内容有问题?
告诉我们哪里需要改进。
