Score 设计

最简单的 Score 设计

最直观的方案是直接使用时间戳作为 score:

这个方案看起来没问题,但实际使用时会遇到一些坑。


Score 设计的挑战

挑战 1:时间精度

用秒级时间戳做 score,最直接的问题是同一秒内的任务无法区分顺序。订单取消、自动确认、自动评价这几类任务经常落在同一秒里,而 ZSet 的 member 是唯一的、score 相同时按 member 的字典序排列——也就是说,任务的执行顺序变成了字符串的字典序,和真实到期顺序毫无关系。

score = 1710505800(秒级)

同一个 score 下的任务:
  "task_100"   ← 字典序在前
  "task_99"    ← 字典序在后,但可能先到期
  "task_101"

看起来只是”顺序不完美”,实际后果要严重得多:

  1. “先到期的先执行”被破坏。 同一秒内,优惠券过期任务和订单取消任务乱序执行,可能导致重复发券。
  2. 批量取任务时会被随机截断。 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 基础》里”双层存储”要解决的问题。


Score 设计方案

方案 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(适用于分布式)

优点:

  • ✅ 分布式环境下也能保证唯一性
  • ✅ 不需要序列号管理

缺点:

  • ❌ 随机性可能导致顺序不完全按时间

Score 设计对比

方案冲突概率顺序保证分布式友好复杂度
秒级时间戳🔴 高✅ 完全✅ 友好⭐ 简单
毫秒级时间戳🟡 中✅ 完全✅ 友好⭐ 简单
时间戳 + 随机数🟢 低✅ 完全✅ 友好⭐⭐ 中等
时间戳 + 序列号🟢 无✅ 完全⚠️ 需要协调⭐⭐⭐ 复杂
时间戳 + 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;    // 低位

用之前要确认三件事:

  1. score 是否超出 double 的精确整数范围。 Redis 的 score 是双精度,能精确表示的整数上限是 2^53 ≈ 9.007e15。当前毫秒时间戳约 1.7e12,左移 10 位后约 1.75e15,仍在范围内;但再左移 4 位(序列号占 16 位)就会逼近上限。左移 10 位是安全的选择。
  2. 序列号的容量。 序列号取模 1024 意味着它是”循环”的。如果同一毫秒内真的来了超过 1024 个任务,就会发生覆盖。监控里应该记录”单毫秒最大任务数”这个指标,超过 800 就要考虑扩大序列号位数。
  3. 序列号不需要跨毫秒重置。 因为高位时间戳已经保证了全局顺序,低位序列号只要在同一毫秒内唯一即可。真正需要”重置”的是 INCR 计数器本身——它会一直增长,Lua 里取模之后就无所谓了,但如果要持久化这个计数器,得注意它溢出后的回绕。

「Score 设计对比」一节那张表列出了五种方案的取舍,这里补一句实践建议:延时跨度短、并发不高的场景直接用毫秒时间戳就够了,省掉序列号这一层复杂度;只有当监控确实看到”同一毫秒内多任务”并因此出现顺序问题时,再升级到序列号方案。方案 3 的复杂度是要用实测数据来偿还的,不要提前付。


想一想

思考 1

如何处理 score 溢出的问题?

ZSet

解题思路:

ZSet 的 score 是 double,能存的整数上限远大于当前时间戳,但直接存绝对毫秒时间戳时,随着系统长时间运行加上更远的未来时间点,会慢慢逼近精度边界。所以要比较几种表示法:相对时间、周期性重置、浮点。

详细步骤:

问题分析:

解决方案:

方案 1:使用相对时间

优点:

  • ✅ 避免绝对时间戳溢出
  • ✅ 可以支持任意长的延时

缺点:

  • ❌ 系统重启后需要重新计算 base_time
  • ❌ 需要持久化 base_time

方案 2:周期性重置序列号

优点:

  • ✅ 序列号不会无限增长
  • ✅ 延长了可使用时间

缺点:

  • ⚠️ 需要管理周期号
  • ⚠️ 实现复杂度增加

方案 3:使用浮点数

优点:

  • ✅ 简单直接
  • ✅ 不需要管理序列号

缺点:

  • ❌ Redis 使用 double 存储,精度有限
  • ❌ 可能有浮点数精度问题

最佳实践:

  1. ✅ 短期使用:直接使用毫秒级时间戳
  2. ✅ 长期使用:使用相对时间或周期性重置
  3. ✅ 避免溢出:定期检查 score 是否接近上限
  4. ✅ 监控告警:score 接近上限时发出告警

最终答案:

短期使用直接用毫秒级绝对时间戳最简单;长期使用应改用相对时间基准或周期性重置序列号,避免绝对时间戳无限增长逼近 double 的精度上限;用浮点数表示相对时间虽然简单,但 Redis 以 double 存储、精度有限,不推荐。落地时要定期检查 score 是否接近上限,并在接近时发出告警。

发现内容有问题?

告诉我们哪里需要改进。