4. Redis数据结构选型与建模

在实时数据平台里,Redis 的价值往往不在于“能存”,而在于“用对结构”。同一个业务需求,用 String 和用 Hash 的差别可能只是内存与更新粒度;但用 List 和用 Stream 的差别,则直接决定消息是否会丢、能否重放、能否多消费组并行。选型的核心不是背 API,而是先判断数据的访问形态:是单值读写、字段级更新、有序排名、集合运算、去重计数,还是需要持久化消费位点的消息流。

先看访问形态,再定结构

实时计数场景通常表现为“高频自增、低频读取、偶尔重置”。例如统计某活动页面的曝光量,键可以设计为 metric:exposure:20240520:activity_1001,写入使用 INCR,读取使用 GET。如果还需要按小时、按渠道拆分,不要把所有维度塞进一个键,而应让键名承载维度,值保持为纯数字。这样单键操作是 O(1),也便于用 SCAN 按前缀聚合。若计数需要过期,使用 EXPIRE 设置 TTL,避免历史键无限增长。

当计数对象带有多个字段时,Hash 更合适。例如一个商品实时看板需要同时维护曝光、点击、加购、下单四个计数,键为 stats:item:88001,字段为 exposure、click、cart、order。写入用 HINCRBY,读取用 HMGET。相比四个 String 键,Hash 把同一对象的字段聚合在一起,减少了键数量,也便于一次性读取整个对象。但要注意,Hash 不适合字段数量无限增长的场景,例如把每个用户 ID 都作为字段写入同一个 Hash,会导致大键和操作阻塞。

排行榜与去重:ZSet、Set、Bitmap、HyperLogLog 的分工

排行榜是 ZSet 的典型场景。以主播热度榜为例,键为 rank:anchor:20240520,成员为主播 ID,分数为热度值。更新用 ZINCRBY,取 Top N 用 ZREVRANGE,查排名用 ZREVRANK。ZSet 的分数是双精度浮点数,适合热度、积分、时间戳等可比较数值。若需要同分按时间先后排序,可以把分数设计为“主分数 + 时间戳小数位”,例如 score = heat * 10^10 + (MAX_TS - ts),但要注意浮点精度边界,必要时改用两个 ZSet 或额外字段辅助。

去重场景要区分“判断存在”和“统计数量”。判断某用户是否已参与活动,用 Set 的 SISMEMBER 最直接,键为 dedup:activity:1001,成员为用户 ID。若用户量达到千万级,Set 内存会很高,此时可用 Bitmap:把用户 ID 映射为偏移量,键为 bitmap:activity:1001,用 SETBIT 标记、GETBIT 判断。Bitmap 的偏移量必须是非负整数,且要控制最大偏移,否则会分配大量零字节。若只需要统计“大约多少人参与”,不要求精确判断单个用户,则用 HyperLogLog:PFADD 添加、PFCOUNT 估算,标准误差约 0.81%,内存固定约 12KB。它不能判断单个元素是否存在,这是与 Set、Bitmap 的关键区别。

实时场景 → Redis 结构选型决策图 实时计数 单值高频自增 对象多字段计数 字段级更新 排行榜 / 排序 按分数取 Top N 去重 / 存在判断 精确或近似 消息队列 / 消费 多消费组、可重放 String INCR / GET / EXPIRE Hash HINCRBY / HMGET ZSet ZINCRBY / ZREVRANGE Set / Bitmap / HLL SISMEMBER / SETBIT / PFADD List / Stream LPUSH+BRPOP / XADD+XREADGROUP 键:metric:exposure:日期:活动 值:整数,TTL 控制生命周期 键:stats:item:88001 字段:exposure/click/cart/order 键:rank:anchor:20240520 成员:主播ID,分数:热度 Set:精确去重,内存高 Bitmap:位图,需控偏移;HLL:约12KB估算 List:简单队列,无消费位点 Stream:消费组、ACK、可重放

会话与消息队列:Hash、List、Stream 的取舍

会话数据适合 Hash。以登录会话为例,键为 session:token:abc123,字段包括 uid、login_ts、device、last_active。读取时用 HGETALL 一次取回,更新 last_active 用 HSET,续期用 EXPIRE。相比把整个会话序列化成 String,Hash 支持字段级读写,避免每次更新都要反序列化整个对象。若会话字段很少且整体读写,String 存 JSON 也可以,但要注意并发更新时的覆盖问题。

消息队列要区分“任务队列”和“事件流”。简单任务队列可用 List:生产者 LPUSH queue:task,消费者 BRPOP queue:task 5。它的优点是简单、阻塞等待;缺点是消息一旦被弹出就从 List 中移除,消费者处理失败时无法自动重试,也没有消费组概念。若需要多消费组、消费位点、失败重试和消息回溯,应使用 Stream:XADD stream:order * order_id 1001 amount 99.5 写入,XGROUP CREATE 创建消费组,XREADGROUP 读取,处理成功后 XACK。未 ACK 的消息会留在 Pending Entries List 中,可通过 XPENDING 查看并重新认领。Stream 的代价是结构更复杂、内存占用更高,适合订单、支付、审计等不能丢的事件。

建模时的键设计与容量控制

键名建议采用“业务:对象:标识:维度”的分层格式,例如 rank:anchor:20240520、dedup:activity:1001、stream:order。冒号分层便于 SCAN 按前缀遍历,也便于运维按业务清理。不要使用过长的键名,也不要把可变值拼进键名导致键数量爆炸。对于 Set、ZSet、Hash、Stream 这类可能增长的键,要设置上限或定期归档:ZSet 可用 ZREMRANGEBYRANK 保留 Top 10000;Stream 可用 XTRIM 或 MAXLEN 控制长度;Bitmap 要评估最大偏移量,避免单个键占用几十 MB。

选型速记:单值计数用 String;对象多字段用 Hash;排序排名用 ZSet;精确去重用 Set;海量存在判断用 Bitmap;近似去重计数用 HyperLogLog;简单队列用 List;可靠事件流用 Stream。先问“数据怎么访问”,再问“内存和可靠性要求”,最后才写命令。

一个可执行的选型检查清单

场景首选结构关键命令需要警惕
页面曝光计数StringINCR、GET、EXPIRE键数量膨胀,需按日期分片
商品多指标看板HashHINCRBY、HMGET字段无限增长形成大键
主播热度榜ZSetZINCRBY、ZREVRANGE同分排序与浮点精度
活动参与去重Set / BitmapSISMEMBER / SETBITSet 内存高;Bitmap 偏移量上限
UV 近似统计HyperLogLogPFADD、PFCOUNT不能判断单个元素是否存在
登录会话HashHSET、HGETALL、EXPIRE并发覆盖与 TTL 续期
简单任务队列ListLPUSH、BRPOP无 ACK、失败难重试
订单事件流StreamXADD、XREADGROUP、XACKPending 堆积与内存增长

实际建模时,建议先用小批量真实数据做一次内存与延迟验证。例如写入 10 万个用户 ID 到 Set,观察 MEMORY USAGE;再用同样数据写入 Bitmap 和 HyperLogLog,对比内存与 PFCOUNT 误差。示例中若 Set 占用约 4MB、Bitmap 约 12.5KB、HyperLogLog 约 12KB,就能直观看出近似结构的收益。最终选型不是追求“最省内存”,而是在精确性、可靠性、内存和开发复杂度之间找到与业务匹配的平衡点。