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 字符串)

调度平台对缓存的访问模式主要有三类:

  1. 点查:根据 taskId 获取单个任务的全部状态
  2. 范围扫描:按优先级或提交时间获取待处理任务列表
  3. 集合运算:获取某 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 数据结构选型决策流程

Redis 数据结构选型决策流程 任务状态缓存需求 是否需要按优先级/时间范围查询? 是 Sorted Set(score=优先级/时间戳) 否 是否需要集合运算? 是 Set(状态分组/去重) 否 是否需要部分更新? 是 Hash(字段级读写) 否 String(全量快照) 生产推荐组合:Hash(点查)+ Sorted Set(范围扫描)+ Set(状态分组) 避免单一数据结构承载所有访问模式,通过多 key 冗余实现索引命中率最大化

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 脚本保证一致性,是提升索引命中率的根本路径。