redis zset在score相同时不按插入时间排序,而是严格按member的二进制字典序升序排列,导致“task_10”排在“task_2”前等反直觉结果;根本原因是其底层采用跳跃表+字典结构,且官方明确约定二级排序仅依赖member字节级字典序,与时间无关。

为什么ZSet里Score重复会导致排序乱序?
Redis ZSet在Score相同时,**不按插入时间排,而是严格按Member的二进制字典序升序排**。比如插入顺序是 "task_10"、"task_2"、"task_1",但最终 ZRANGE 返回一定是 "task_1" → "task_10" → "task_2",因为字节比较时 "1" "10" "2"。这不是Bug,是设计行为——但业务上常误以为“同分就该按提交先后”,结果发现任务执行顺序和预期不符。
用毫秒级时间戳 + 随机后缀构造唯一Score
单纯靠时间戳防重不靠谱:高并发下毫秒内仍可能撞车;而直接拼UUID或随机数又会破坏时间序。推荐组合方式:
- 基础值用
System.currentTimeMillis()(Java)或time.Now().UnixMilli()(Go),保证主序 - 低位补 3~4 位随机整数,如
score = timestamp * 10000 + rand.Intn(10000) - 避免用字符串拼接(如
"1720841940123_task_abc"),Redis Score 是 double 类型,超 16 位有效数字会丢失精度
禁止客户端“查后再删”,必须用Lua原子封装
多消费者场景下,如果先 ZRANGEBYSCORE 拿到一批任务,再 ZREM 删除,中间必然存在竞态:两个Worker同时查到同一组任务,只有一个能删成功,另一个仍会处理已出队任务,造成重复消费。
正确做法是把查询和删除压进一个Lua脚本:
Redis 缓存和数据结构管理技能。通过自然语言操作 Redis,支持 String、Hash、List、Set、ZSet、Stream 等数据结构操作。当用户提到 Redis、缓存、消息队列、会话存储时使用此技能。
local key = KEYS[1]
local now = ARGV[1]
local limit = ARGV[2]
local tasks = redis.call('ZRANGEBYSCORE', key, '-inf', now, 'LIMIT', 0, limit)
if #tasks > 0 then
redis.call('ZREM', key, unpack(tasks))
end
return tasks
调用时传入当前毫秒时间戳(System.currentTimeMillis())、队列名、每次取多少条。Redis 执行该脚本期间会锁住整个key,彻底杜绝竞争。
ZSet本身不解决“业务去重”,它只保证Member唯一
很多人混淆概念:ZSet 的“去重”是指 Member 字符串不能重复——不是 Score 不能重复,更不是业务逻辑去重。比如你存订单超时任务,member 必须是唯一订单ID(如 "order_123456"),而不是固定字符串 "timeout"。否则第二次添加同个订单,会覆盖前一次的Score,导致延迟时间被篡改。
真正要防业务重复,得靠上层:订单创建时先 ZSCORE 检查是否已存在该 member,存在则跳过添加;或用 zadd(..., nx=True)(Python redis client)参数强制只新增不更新。
最易忽略的一点:ZSet 的排序稳定性依赖 Member 唯一性 + 字典序规则,但它不保存任何时间戳元数据。想按“同分时谁先加谁在前”,只能靠Score编码携带顺序信息,且必须控制好 double 精度边界——超过 2^53 就开始丢精度了。










