跳转至

Redis 数据结构与选择

单元 05|难度:中级|前置:数据库|目标:根据访问模式选择数据结构,而不是背命令

学习 Redis 的重点不是背命令,而是从访问模式反推数据结构。

先问五个问题

  1. 保存的是单值、对象、队列、无序集合还是有序集合?
  2. 需要按单个成员过期吗?
  3. 需要批量读取吗?
  4. 重复写入是否应该自动去重?
  5. 数据丢失后能否重建?

String:单值和独立 TTL

SET device:last_seen:10001 1753761600 EX 90
GET device:last_seen:10001

适合:

  • 一个 key 对应一个值;
  • 每个设备需要独立 TTL;
  • 计数器;
  • 简单缓存和分布式锁的基础。

批量读取使用 MGET:

MGET device:last_seen:10001 device:last_seen:10002

它的主要收益是减少客户端与 Redis 之间的网络往返;Redis 仍然需要逐个查找这些 key,时间复杂度随 key 数量增长。因此不能把“单次命令”误解为“可以无成本读取十万个对象”。

批量不等于无限量

一次 MGET 十万个大 value 会阻塞 Redis 处理其他命令,并产生巨大的网络响应和应用内存占用。Web 列表应分页,离线任务应分批。

Hash:一个对象或一组字段

先不要背 HSET。先看 Redis 眼里的层级:

Redis keyspace
└── key: device:10001
    └── value 类型: Hash
        ├── field: status  → value: online
        └── field: battery → value: 80

这里有三种不同的东西:

层级 例子 含义
Redis key device:10001 Redis 顶层定位一个对象的名字
Hash field status 对象内部的字段
field value online 字段保存的值

命令的含义是:

# 在 device:10001 这个 Hash 中,同时写入两个 field
HSET device:10001 status online battery 80

# 只读取 status
HGET device:10001 status

# 一次读取 status 和 battery
HMGET device:10001 status battery

执行以后,可以把它想成:

{
  "device:10001": {
    "status": "online",
    "battery": "80"
  }
}

注意:JSON 只是帮助理解,并不是 Redis 实际保存的 JSON 字符串。

同样是 Hash,也可以采用另一种建模

也可以用一个 Hash 保存一组设备的简短状态:

HSET tenant:42:device_status 10001 online 10002 offline
HMGET tenant:42:device_status 10001 10002

这时层级发生了变化:

key: tenant:42:device_status
└── Hash
    ├── field: 10001 → value: online
    └── field: 10002 → value: offline

前一个例子是“一个设备一个 Hash,设备属性作为 field”;后一个例子是“一个租户一个 Hash,设备 ID 作为 field”。二者都用了 Hash,但数据模型和扩展边界不同。

适合:

  • 一组相关字段;
  • 需要按字段读取;
  • 希望减少大量小 key 的元数据开销。

TTL 到底挂在哪里

TTL 表示“还能存活多久”。在传统 Redis 模型中,EXPIRE 的对象是顶层 Redis key:

EXPIRE device:10001 90

意思是:

让整个 device:10001 key 在 90 秒后过期。

它不是只让 status 过期,也不是只让 battery 过期,而是整个 Hash 一起消失。

为什么一个大 Hash 无法表示不同设备的过期时间

假设 Redis 7.4 以前采用:

key: tenant:42:device_status
├── field 10001 → online
└── field 10002 → online

设备 10001 在 10:01:00 收到最后一次心跳,希望它在 10:02:30 过期;设备 10002 在 10:00:00 收到最后一次心跳,希望它在 10:01:30 过期。

设备 10001 的截止时间:10:02:30
设备 10002 的截止时间:10:01:30

但 EXPIRE 只能写在共同的顶层 key 上:

EXPIRE tenant:42:device_status 90

这个 key 只有一个过期时间,无法同时表达两个截止时间。

如果设备 10001 每次心跳都刷新整个 Hash 的 TTL:

10001 持续心跳
→ 整个 tenant:42:device_status 一直不过期
→ 已经断电的 10002 也一直残留为 online

如果不刷新:

整个 Hash 到期
→ 10001 和 10002 一起被删除
→ 明明仍在心跳的 10001 也被误判离线

这才是“一个大 Hash 不适合直接实现每台设备独立 TTL”的完整原因:

多台设备共用一个顶层 key,但传统 TTL 只属于这个 key;每台设备需要不同截止时间,数据模型表达不了。

Redis 7.4 以后:Hash field 已支持独立 TTL

Redis 7.4 增加了 HEXPIRE。现在可以给指定 field 单独设置 TTL:

HSET tenant:42:device_status 10001 online
HEXPIRE tenant:42:device_status 90 FIELDS 1 10001

HSET tenant:42:device_status 10002 online
HEXPIRE tenant:42:device_status 90 FIELDS 1 10002

此时:

field 10001 有自己的 90 秒
field 10002 也有自己的 90 秒

可以使用 HTTL 查询 field 的剩余时间。

为什么还要知道旧模型

许多生产环境仍使用 Redis 6、Redis 7.0 或兼容实现;客户端库也不一定支持 HEXPIRE。做方案前必须先确认服务器和客户端版本,不能只依据最新命令。

有了 HEXPIRE,是否就能解决所有在线状态问题

不能。至少还有四个工程问题。

1. 刷新 field 值时要重新设置 TTL

官方行为是:HSET 覆盖 field 内容时会清除该 field 原有的过期时间。因此一次心跳至少包含:

HSET 更新状态
HEXPIRE 重新设置 90 秒

如果进程在两条命令之间崩溃,field 可能被更新却没有 TTL。需要根据客户端和一致性要求使用事务或脚本把它们作为一个不可分割的操作。

2. field 消失只适合回答“现在是否存在”

field 过期后:

HGET tenant:42:device_status 10001

返回空,可以作为“当前不在线”的查询视图。

但业务还可能要求:

  • 记录准确的离线时间;
  • 只生成一次离线告警;
  • 给下游发送可靠事件;
  • 区分超时离线、主动断开和平台故障。

“field 不存在”本身无法保存这些历史事实。

3. 过期通知不是可靠消息队列

Redis 可以发布过期通知,但它基于 Pub/Sub。消费者断线期间的事件可能丢失,而且事件发生时间可能晚于 TTL 理论归零时间。因此关键离线告警不能只依赖过期通知。

4. 一个超大 Hash 可能成为热点和分片边界

在 Redis Cluster 中,一个顶层 key 只属于一个 Hash Slot,也就落在一个分片上。把所有设备放进一个巨大 Hash,无法把这个 Hash 内部的 field 自动分散到多个节点。

更常见的是按租户、区域或固定桶拆分:

tenant:42:device_status:00
tenant:42:device_status:01
...

是否需要拆分,要根据设备量、吞吐和访问方式压测,不能仅凭“Hash 更省内存”决定。

三种在线状态建模怎样选择

方案 能否独立过期 适合 主要限制
每设备一个 String key + TTL 可以 兼容旧版本、模型直观 小 key 多;批量读取需 MGET
Redis 7.4+ Hash field + HEXPIRE 可以 同组短状态、减少顶层 key 版本要求;需重设 TTL;大 Hash 热点
ZSet 保存过期时间 由 Worker 主动扫描 需要明确处理超时迁移和产生事件 需要离线 Worker、并发版本校验

如果需求只是“页面快速判断当前是否在线”,TTL key 或 HEXPIRE 都可能合适。

如果需求是“可靠地产生一次离线迁移、告警并保存历史”,通常还需要状态服务、ZSet/时间轮、幂等事件和持久化记录。

Set:自动去重的无序集合

SADD tenant:42:online_devices 10001
SREM tenant:42:online_devices 10001
SCARD tenant:42:online_devices
  • SADD:把成员加入集合;已存在时不重复添加。
  • SREM:移除成员;不存在时也不会产生额外副作用。
  • SCARD:返回集合成员数量。

适合维护在线设备集合,因为重复的“上线”事件不会导致同一设备被计算两次。

但 Set 本身也没有成员级 TTL。设备突然断电时不会主动执行 SREM,所以必须配合超时检测和离线事件。

计数器:INCR / DECR

INCR tenant:42:online_count
DECR tenant:42:online_count

它们是 Redis 对数值字符串执行的原子加一和减一。

优点是查询极快、占用小;缺点是重复事件、丢失事件或错误状态迁移会使计数漂移。更可靠的做法是:

  • 只有确认状态发生 offline → online 时才增加;
  • 只有确认状态发生 online → offline 时才减少;
  • 保留可重建的状态事实;
  • 定期校准统计值。

选择速查

需求 候选结构 关键原因
每台设备独立过期,兼容旧版本 String key + TTL 每个 key 有自己的 TTL
Redis 7.4+ 中同组 field 独立过期 Hash + HEXPIRE 每个 field 可设置 TTL
批量读取一页设备状态 MGET / HMGET 减少网络往返
保存设备多个状态字段 Hash 字段化读写
在线设备自动去重 Set 成员唯一
获取在线设备数 SCARD 或缓存计数 避免全量扫描
按 last_seen 找超时设备 ZSet / 时间轮 按时间排序和扫描

自测

  1. 为什么 MGET 不能代替分页?
  2. Redis 7.4 以前,为什么一个大 Hash 无法实现每台设备独立 TTL?
  3. Redis 7.4+ 有了 HEXPIRE 后,还要考虑哪些工程问题?
  4. 重复的上线事件对 INCR 和 SADD 分别有什么影响?
  5. 如果 Redis 丢失,在线状态能否从设备心跳重新建立?
参考答案要点
  1. MGET 只减少网络往返;Redis 仍需查找所有 key,结果还要经过网络、应用内存和序列化。一次读取十万条仍可能阻塞和占用大量内存,所以面向用户的列表仍应分页。
  2. 多台设备只是同一顶层 key 内的不同 field,而传统 EXPIRE 只能设置在共同的顶层 key 上。设备各自的最后心跳不同,需要不同过期时间,一个 key 的单一 TTL 表达不了。
  3. 要确认 Redis/客户端版本;HSET 覆盖 field 后要重新设置 TTL并处理两步之间的故障;过期通知不可靠;大 Hash 在 Cluster 中仍落在单个分片;可靠离线事件还需要状态迁移和持久化。
  4. 重复执行 INCR 会把计数增加多次;重复 SADD 同一个成员不会产生多个成员,因此 Set 天然去重。
  5. 当前在线查询视图通常能随新心跳逐步重建,但离线历史、告警、操作记录等业务事实不能只依赖心跳恢复,必须持久化。

完成标准

  • 能从访问模式选择 String、Hash、Set 或 ZSet
  • 能解释 MGET 快在哪里、边界在哪里
  • 能画出 Redis key、Hash field 和 field value 三层关系
  • 能解释 Redis 7.4 前后的 Hash field TTL 差异
  • 能说明“过期查询视图”和“可靠离线事件”的区别
  • 完成 Redis 在线状态实验

延伸阅读:Redis 数据类型选择、HEXPIRE 官方说明、Keyspace Notifications

下一单元:消息队列