4. Redis 数据结构选型:String、Hash、Set、Sorted Set 在任务状态缓存中的适用场景分析
任务状态缓存是调度平台的核心数据平面。选择错误的数据结构会导致内存膨胀、查询延迟飙升、索引命中率下降。本章从任务状态的生命周期出发,逐一分析四种常用 Redis 数据结构的适用边界与反模式。
4.1 任务状态缓存的典型数据特征
在调度平台中,每个任务实例通常包含以下字段:
- taskId:全局唯一标识(如 UUID 或雪花 ID)
- status:当前状态(pending / running / success / failed / timeout)
- priority:优先级(1-10,数值越小优先级越高)
- submitTime:提交时间戳
- workerId:执行节点标识
- retryCount:重试次数
- payload:任务参数(JSON 字符串)
调度平台对缓存的访问模式主要有三类:
- 点查:根据 taskId 获取单个任务的全部状态
- 范围扫描:按优先级或提交时间获取待处理任务列表
- 集合运算:获取某 worker 上所有 running 状态的任务
选型第一原则: 数据结构的选择必须与访问模式对齐。用 String 存全量 JSON 虽然简单,但在范围查询场景下会完全丧失索引能力,导致调度器全量扫描。
4.2 四种数据结构的适用场景对比
| 数据结构 | 点查性能 | 范围查询 | 集合运算 | 内存效率 | 典型场景 |
|---|---|---|---|---|---|
| String | O(1) 极优 | 不支持 | 不支持 | 低(全量序列化) | 任务详情快照、分布式锁 |
| Hash | O(1) 极优 | 不支持 | 不支持 | 高(字段级存储) | 任务状态字段读写、部分更新 |
| Set | O(1) 成员判断 | 不支持 | O(N) 交并差 | 中(成员存储) | 去重集合、状态分组 |
| Sorted Set | O(log N) 较优 | O(log N + M) 极优 | 有限支持 | 中(含 score 开销) | 优先级队列、时间窗口扫描 |
关键结论: 没有一种数据结构能覆盖所有场景。实际生产环境通常采用 Hash + Sorted Set 的组合模式——Hash 负责点查与字段更新,Sorted Set 负责范围扫描与优先级排序。
4.3 String:适合快照缓存,不适合状态索引
String 是最直观的选择:将任务状态序列化为 JSON 字符串,以 taskId 为 key 存储。优点是读写简单,适合任务详情快照场景。
// 反模式:用 String 存全量状态,然后尝试 SCAN 匹配
SET task:12345 '{"status":"running","priority":3,"workerId":"w1"}'
// 调度器需要扫描所有 running 任务时,只能 KEYS 或 SCAN 全量,再反序列化过滤
// 时间复杂度 O(N),N 为任务总数,索引命中率趋近于 0
适用场景:
- 任务详情展示(前端点查)
- 分布式锁(SETNX)
- 计数器(INCR)
不适用场景:
- 按状态过滤任务列表
- 按优先级排序
- 按时间范围查询
4.4 Hash:字段级读写,状态更新的首选
Hash 将任务的不同字段存储为独立的 field-value,支持部分更新而不需要反序列化整个对象。这是调度平台中最常用的点查数据结构。
// 推荐:用 Hash 存储任务状态字段
HSET task:12345 status running priority 3 workerId w1 submitTime 1700000000
// 更新单个字段
HSET task:12345 status success
// 获取全部字段
HGETALL task:12345
优势:
- 更新单个字段时网络开销小(仅传输变更字段)
- 内存效率高(ziplist 编码下小 Hash 极省内存)
- 支持字段级 TTL(通过 field 过期策略配合)
局限: Hash 本身不支持范围查询。如果需要按 priority 排序获取任务列表,必须借助其他数据结构。
4.5 Set:状态分组与去重
Set 适合维护同一状态下的任务 ID 集合。例如,将所有 running 状态的任务 ID 放入一个 Set,可以快速判断某个任务是否处于 running 状态,或获取所有 running 任务。
// 用 Set 维护状态分组
SADD status:running task:12345 task:12346
SADD status:pending task:12347
// 判断任务是否 running
SISMEMBER status:running task:12345 // 返回 1
// 获取所有 running 任务
SMEMBERS status:running
适用场景:
- 状态分组(running / pending / failed 各一个 Set)
- 去重校验(防止重复提交)
- 集合运算(某 worker 上 running 任务 = worker:1 ∩ status:running)
注意: Set 的 SMEMBERS 在集合较大时(>5000 成员)会阻塞 Redis 单线程。生产环境应使用 SSCAN 分批迭代,或改用 Sorted Set 配合分页。
4.6 Sorted Set:优先级排序与时间窗口扫描的核心
Sorted Set 是调度平台实现索引命中率提升的关键数据结构。通过将 score 设置为优先级或时间戳,可以实现 O(log N + M) 的范围查询,避免全量扫描。
// 用 Sorted Set 实现优先级队列
ZADD task:queue 3 task:12345 // score=3 表示优先级
ZADD task:queue 1 task:12346 // score=1 优先级更高
// 获取优先级最高的 10 个任务
ZRANGEBYSCORE task:queue -inf +inf WITHSCORES LIMIT 0 10
// 获取提交时间在最近 5 分钟内的任务
ZADD task:time 1700000000 task:12345
ZRANGEBYSCORE task:time 1700000000 1700000300
核心优势:
- 支持按 score 范围扫描(优先级、时间窗口)
- 支持分页(LIMIT offset count)
- 支持排名操作(ZRANK、ZREVRANK)
- 内存效率优于 List(跳表 + 压缩列表)
典型组合模式:
| 数据结构 | Key 设计 | 用途 |
|---|---|---|
| Hash | task:{taskId} | 存储任务全部字段,支持点查与部分更新 |
| Sorted Set | task:priority | 按优先级排序,调度器取最高优先级任务 |
| Sorted Set | task:submitTime | 按提交时间排序,支持时间窗口扫描 |
| Set | status:running | 快速判断任务状态,支持集合运算 |
4.7 数据结构选型决策流程
4.8 实战选型检查清单
| 场景 | 推荐结构 | 反模式 | 原因 |
|---|---|---|---|
| 根据 taskId 获取任务详情 | Hash | String(全量 JSON) | Hash 支持字段级读取,减少网络传输 |
| 获取优先级最高的 100 个待处理任务 | Sorted Set | List + 排序 | Sorted Set 原生支持按 score 排序和分页 |
| 判断某任务是否处于 running 状态 | Set(SISMEMBER) | Hash(HGET 后判断) | Set 的 SISMEMBER 时间复杂度 O(1),且语义清晰 |
| 更新任务的重试次数 | Hash(HINCRBY) | String(先 GET 再 SET) | HINCRBY 原子操作,避免竞态条件 |
| 获取某 worker 上所有 running 任务 | Set(SINTER) | SCAN 全量过滤 | Set 交集运算 O(N),远优于全量扫描 |
| 任务详情快照(不变数据) | String | Hash(字段过多) | String 序列化后存储,适合一次性读取的静态数据 |
索引命中率提升关键: 调度器在扫描待处理任务时,如果使用 Sorted Set 的 ZRANGEBYSCORE 代替 KEYS/SCAN,索引命中率从 O(N) 降为 O(log N + M),其中 M 为返回结果数。在 10 万级任务量下,单次扫描延迟从 50ms 降至 0.1ms,提升约 500 倍。
4.9 组合模式下的数据一致性
当使用 Hash + Sorted Set + Set 组合时,需要保证跨数据结构的原子更新。推荐使用 Lua 脚本或 Redis 事务:
-- Lua 脚本:原子更新任务状态
-- KEYS[1] = task:hash:{taskId}
-- KEYS[2] = task:priority
-- KEYS[3] = status:oldStatus
-- KEYS[4] = status:newStatus
-- ARGV[1] = taskId
-- ARGV[2] = newStatus
-- ARGV[3] = priority
redis.call('HSET', KEYS[1], 'status', ARGV[2])
redis.call('ZADD', KEYS[2], ARGV[3], ARGV[1])
redis.call('SREM', KEYS[3], ARGV[1])
redis.call('SADD', KEYS[4], ARGV[1])
return 1
通过 Lua 脚本保证:Hash 状态更新、Sorted Set 优先级写入、Set 状态分组迁移在同一个原子操作中完成,避免调度器读到不一致的状态。
选型总结: 调度平台的任务状态缓存没有银弹。String 用于快照,Hash 用于点查,Set 用于分组,Sorted Set 用于排序扫描。根据访问模式选择合适的数据结构,并通过组合模式与 Lua 脚本保证一致性,是提升索引命中率的根本路径。