刚接触 Redis 时,最容易走进一个误区:把 String、Hash、List、Set、ZSet 的命令背得很熟,真正建模时却还是拿不准。用户详情到底该存一整段 JSON,还是拆进 Hash?邮件任务已经能用 List 排队,为什么还要上 Stream?统计独立访客时,用 Set 明明最准确,为什么有人愿意接受 HyperLogLog 的误差?
这些问题没有一张“结构—场景”对照表就能替你回答。选择数据结构,本质上是在决定四件事:业务要按什么粒度读写、要不要保留顺序或去重、数据怎样过期、一次更新必须保证到什么原子边界。同一个业务对象,因为访问方式不同,完全可能得到不同答案。
这一节继续使用同一套用户与活动服务:用户 u1001 会修改个人资料,文章 a100 会累计访问量与标签,邮件任务要异步发送,技术社区每周会生成热度榜。我们不按类型逐个念定义,而是从接口真正要做的动作反推结构。
假设产品经理只说一句“把用户数据放进 Redis”,这句话还不足以建模。你至少还要追问:详情接口总是整块读取吗?头像和简介会不会分别更新?是否需要让简介比昵称更早过期?是否按城市筛选用户?一条用户记录会不会膨胀到几千个字段?
前三个问题决定 String 与 Hash 的取舍,第四个问题已经超出了普通 key 查询能力,第五个问题则在提醒你防止大 Key。Redis 的原生数据结构擅长已知 key 下的精确操作,不等于它会自动提供关系型数据库那样的任意条件查询。
还要把删除方式问清楚。用户注销时,是删除整份资料,还是只清掉一个临时字段?活动结束时,是让整批 key 自然过期,还是必须立即撤销资格?结构一旦把不同生命周期的数据绑在同一个 key 下,后续清理就只能整块进行。反过来,把每个很小的字段都拆成独立 key,也会增加元数据、网络往返和运维复杂度。合理边界通常与业务一起创建、一起读取、一起失效的范围相同。

可以把常见选择压缩成一组判断:
下面的选型器把这些约束放到一起。故意切换“必须精确”“需要确认”“按分数排序”等条件,你会看到推荐结果如何变化。
选型时先写出接口动作,例如“按用户 ID 读取全部资料”“只把文章阅读量加一”“取本周热度最高的十篇文章”。如果一句需求里出现了多个完全不同的动作,通常意味着应该拆成多个 key,而不是寻找一个包办所有需求的万能结构。
GET /users/u1001 每次都返回完整详情,更新也由一个统一接口整块提交时,把 JSON 序列化成 String 很自然:
const profileKey = "user:profile:u1001";
await redis.set(
profileKey,
JSON.stringify({
id: "u1001",
name: "小林",
city: "杭州",
version: 1,
}),
"EX",
135,
);
const profile = JSON.parse(await redis.get(profileKey));这个模型的优点不是“String 比 Hash 高级”,而是读写边界与接口边界一致。一次 GET 拿到完整快照,一次 SET EX 同时替换内容并设置整键 TTL。缓存 miss 时从数据库重建也简单,应用只维护一种序列化格式。
代价同样直接:修改 city 时,应用需要先得到整份对象、改字段、重新序列化并覆盖整段值。若两个请求各自基于旧快照修改不同字段,后写入的一方可能把前一方的改动覆盖掉。SET 本身是原子的,但“读取 JSON—修改对象—再次 SET”是三步业务流程,并不会因为最后一条命令原子就自动避免丢失更新。可以通过数据库版本号、WATCH、Lua 或把权威写入留在数据库中处理,但不能假装问题不存在。
如果资料接口经常只读昵称、只更新头像,Hash 会更贴近访问方式:
const settingsKey = "user:settings:u1001";
await redis.hset(settingsKey, {
theme: "dark",
locale: "zh-CN",
});
const locale = await redis.hget(settingsKey, "locale");
const settings = await redis.hgetall(settingsKey);
console.log(JSON.stringify(settings));控制台打印:
{"theme":"dark","locale":"zh-CN"}Hash 的价值在于字段级读写,而不是笼统地说“更省内存”。HSET 修改一个字段时,其他字段不会被整块覆盖;HINCRBY 还能直接递增购物车某商品数量。不过,HGETALL 会返回全部字段,字段数量一旦失控,网络响应和主线程工作量也会一起放大。只要你的常用操作仍然是整块读取,Hash 未必比 JSON String 更省事。
更新链路也会影响选择。若用户资料是数据库记录的缓存副本,写接口通常先完成数据库事务,再删除或重建缓存;此时 String JSON 的整块快照容易理解。若 Redis 中的 Hash 本身承担短期状态,比如编辑草稿的若干选项,字段级修改可能更合适。不要因为 Hash 看起来像数据库的一行,就把它当成无需约束的业务主表。字段类型、必填关系、跨字段校验和历史版本仍要由应用负责。
内存比较必须用代表性数据测量。小 Hash 可能使用紧凑编码,但字段变多、字段名变长或值变大后,内部表示会发生变化;JSON String 又会重复保存字段名。哪一种更省,受对象数量、字段长度、编码阈值和 Redis 版本影响。正确做法是在接近生产的数据分布上比较 MEMORY USAGE,同时计算读取字节数和更新频率,而不是引用一条脱离条件的结论。

过去常见的说法是“Hash 只能整键过期,field 不能单独设置 TTL”。它对 Redis 7.2 等旧版本成立,但从 Redis 7.4 开始,HEXPIRE、HPEXPIRE、HTTL、HPTTL 等命令已经提供字段级过期能力。旧版本部署仍然需要拆成独立 key,或由应用记录到期时间并清理;新版本则可以让 bio 在 15 分钟后单独消失,而 name 继续保留。
await redis.hset("user:profile:u1001", {
name: "小林",
bio: "后端开发者",
});
// 使用原始命令形式,语法边界清楚,也便于核对服务端版本。
await redis.call(
"HEXPIRE",
"user:profile:u1001",
900,
"FIELDS",
1,
"bio",
);字段 TTL 带来的是更细的生命周期,不是“以后都应该用一个大 Hash”。整键 TTL 仍可能先到期;过期字段会被删除;对已有字段再次 HSET 会清除这个字段原有的过期时间,写入后若仍需过期,就要重新设置。客户端库能否直接暴露便捷方法也取决于版本,服务端不满 7.4 时更会直接报未知命令。上线前应检查 Redis 服务端版本、托管服务兼容性和客户端调用方式,而不是只看本机类型提示。
字段过期还会改变读取结果。今天 HGETALL 返回三个字段,几分钟后可能只剩两个;若应用把缺失字段直接理解成空字符串,业务语义就会混乱。为可过期字段定义默认值、缺失含义和回源逻辑,比会写 HEXPIRE 更重要。需要同时更新字段值与字段 TTL 时,两条命令也不是天然的一个业务原子步骤,应使用事务、Lua 或封装好的组合操作,并在失败后保留可重试条件。
最后还有一个很现实的边界:普通 Hash 能按 key 和 field 精确访问,却不会因为你存了 city: 杭州 就自动支持“找出所有杭州用户”。这种反向检索要么由数据库或搜索索引完成,要么另建索引结构并承担同步成本。结构选对了,只代表单条记录的访问模型合理,不代表查询系统已经完整。
文章详情接口需要记录 a100 的访问次数。用 String 存十进制整数,再用 INCR 更新,是比“GET 后在 Node.js 里加一再 SET”更可靠的模型:
const values = [];
for (let i = 0; i < 3; i += 1) {
values.push(await redis.incr("counter:article:a100:view"));
}
console.log(JSON.stringify(values));控制台打印:
[1,2,3]INCR 的一次命令执行是原子的,多个应用实例同时递增不会都读到同一个旧值再互相覆盖。但如果业务还要求“第一次计数时设置 TTL”,连续发送 INCR 和 EXPIRE 就又变成了两个步骤:进程可能在中间退出,留下永不过期的计数 key。需要把这两个动作放进 Lua,或选择已经封装该原子边界的实现。
原子计数也不等于精确业务统计。浏览器重试、网关重放、消息重复投递都可能让同一次行为被计数多次;若页面访问量允许近似,这不一定值得增加复杂度。若积分、优惠次数或结算数量必须精确,就要为事件准备唯一 ID,先判断是否处理过,再更新计数,并考虑去重记录保留多久。Redis 解决的是并发修改冲突,不能替业务识别“这是不是同一件事”。
计数放进 Hash 也可以,例如 HINCRBY article:stats:a100 views 1,它适合把点赞、收藏、评论数放在同一对象下按字段更新。选择 String 还是 Hash,仍取决于生命周期:如果不同计数独立过期、独立迁移或会成为热点,拆 key 往往更好管理;如果它们总是一起读取和过期,Hash 更集中。不要仅为了减少 key 数量,把所有文章计数塞进一个 article:stats 巨型 Hash。那会让清理、迁移和热点隔离都变得困难。
注册成功后发送欢迎邮件,最小实现只需要一条先进先出的队列:生产者 RPUSH,消费者从另一端弹出。List 保留插入顺序并允许重复,适合简单、短暂、由单一消费逻辑处理的工作队列。
await redis.rpush("queue:email", "mail-1", "mail-2");
const queued = await redis.lrange("queue:email", 0, -1);
console.log(JSON.stringify(queued));控制台打印:
["mail-1","mail-2"]问题发生在消费者弹出任务之后、邮件真正发出之前。若进程此时崩溃,普通 LPOP 已经把任务从队列移走,Redis 不知道它是否完成。可以用 LMOVE 或阻塞版 BLMOVE 把任务原子地从“待处理”List 搬到“处理中”List,成功后再 LREM;还要另写巡检逻辑,把超时任务搬回去。List 仍然能做可靠队列,但可靠性是你额外搭出来的。
当需求变成“多个 worker 分摊任务、失败任务可接管、要知道哪些消息尚未确认、审计时还要回看事件”,Stream 更符合问题。Stream 是按 ID 追加的记录序列,消费组会维护已投递但尚未确认的记录。下面这组操作把两条订单事件交给 worker-a,确认前 pending 为 2,XACK 后变为 0:
const streamKey = "stream:orders";
await redis.xgroup("CREATE", streamKey, "fulfillment", "0-0", "MKSTREAM");
await redis.xadd(streamKey, "1-0", "orderId", "o1001", "event", "paid");
await redis.xadd(streamKey, "2-0", "orderId", "o1002", "event",
控制台打印:
{"received":2,"beforeAck":2,"afterAck":0}
这里必须分清“Redis 已收到确认”和“业务绝不会重复”。如果邮件已经发送,worker 却在 XACK 前退出,消息可能再次交付;消费端仍要用订单 ID 或事件 ID 做幂等。Stream 也不会自动限制历史长度,应根据审计和恢复窗口设计裁剪策略。阻塞式读取最好使用专用连接,避免同一连接上的其他命令排在阻塞操作后面。
消费组还有两个常被遗漏的运维动作。第一,定期观察 pending 数量和最老消息停留时间,它们能区分“暂时积压”和“某个消费者已经失联”。第二,为接管设置明确条件,不能一看到 pending 就立即抢走,否则仍在处理长任务的消费者会与接管者并发执行同一消息。接管只是改变消息归属,最终仍要依靠幂等副作用和有限重试收口。若需要跨机房超长保留、复杂路由或大规模消息生态,应重新评估专门消息系统,而不是不断给一个 Stream 叠补丁。
文章 a100 可以被重复打上“redis”标签,但接口返回时只需要唯一标签。Set 的成员天然去重,SADD 的返回值还能告诉你本次是否真的新增了成员:
await redis.sadd("article:a100:tags", "redis", "backend", "redis");
const tags = (await redis.smembers("article:a100:tags")).sort();
console.log(JSON.stringify(tags));控制台打印:
["backend","redis"]Set 还适合“用户是否参加活动”“两个人共同关注了谁”这类成员关系,因为 SISMEMBER、交集、并集和差集都直接表达业务动作。它不保存顺序,也不为成员附带额外字段;一旦需求变成“按关注时间排序”,就要把时间变成 ZSet 的 score,或者另存关系详情。

统计独立访客时,Set 精确但会保存每一个访客 ID。如果业务既要精确人数,又要在事后列出这些用户,内存成本是必须承担的。若首页只展示“今日约有多少独立访客”,并不需要找回成员,HyperLogLog 更合适:它用固定且很小的空间估算基数,代价是结果存在小误差。选择它之前,要先让产品确认“近似”是否可接受;对结算人数、中奖名单、合规报表,就不能拿概率估算替代精确集合。
集合运算也要估算输入规模。求两个小兴趣集合的交集很自然,但对两个百万成员集合直接做交集,会让一次命令承担大量扫描和结果构造。线上接口更应该限制集合大小、缓存稳定结果,或把离线人群计算交给适合批处理的系统。SMEMBERS 会一次返回整个集合,管理后台想分页浏览时应使用渐进扫描,并接受扫描过程不是数据库快照这一事实。
“去重”不是一个足够完整的需求。Set 回答的是“哪些成员出现过”,HyperLogLog 回答的是“不同成员大约有多少”,Bitmap 回答的是“连续编号中的哪些位置为真”。三者都能碰到去重场景,但可查询内容、精度和内存模型完全不同。
技术社区周榜要求文章不能重复上榜,而且每次点赞、评论后都要重新计算名次。ZSet 的 member 用文章 ID,score 用本周热度,正好把“唯一成员”和“可排序数值”放进同一个结构。
const rankingKey = "rank:weekly:2026-W34";
await redis.zadd(
rankingKey,
1250, "u1001",
980, "u2001",
1420, "u3001",
);
await redis.zincrby(rankingKey, 300, "u2001");
const rows = await redis.zrevrange(rankingKey, 0, -
控制台打印:
{"rows":["u3001","1420","u2001","1280","u1001","1250"],"u1001Rank":3}
ZINCRBY 更新单个成员分数是原子命令,但“点赞加 2 分、评论加 5 分、同时写入关系型数据库”仍然跨越多个动作和系统。需要严格规则时,要继续设计幂等事件、Lua 或异步对账。另一个经常被忽略的细节是同分:只保存一个 score 时,同分成员会按成员字符串的字典序决定先后,它不会自动理解“先达到分数者优先”。业务若要求特定并列规则,必须在建模阶段明确,而不是等榜单投诉出现后再补。
分页规则同样要提前定。按排名区间读取第 1001~1020 名很方便,但榜单在用户翻页期间持续变化时,相邻两页可能重复或漏掉成员。这不是 ZSet 排序错误,而是数据在两次请求之间变了。对实时榜单可以接受这种现象;对需要稳定导出的榜单,应在结算时复制或归档为固定周期版本。周榜 key 也应带周期,例如 rank:weekly:2026-W34,避免清榜时对一个长期 key 做危险的整批重写。
ZSet 的范围查询也使它适合滑动窗口和按执行时间取任务,但“能按时间取出”不等于“完整消息队列”。延迟任务还要处理多消费者竞争、领取后宕机、重试次数和死信;如果这些状态逐渐增多,单个 ZSet 往往只够承担时间索引,任务正文和处理状态需要另存。
Bitmap 不是一个额外的容器类型,而是把 String 的字节按位操作。若用户 ID 已经映射为连续整数,SETBIT attendance:2026-08-19 1001 1 可以表示用户 1001 今天签到,BITCOUNT 可以统计签到位数。它在成员空间密集时非常省内存;如果 ID 最大值巨大而实际只出现几个成员,位图必须延伸到最高偏移,反而可能浪费空间。原始业务 ID 是 UUID 时,通常还需要稳定映射层。
HyperLogLog 用 PFADD 接收观察到的成员,用 PFCOUNT 返回近似基数。它不保存可枚举的用户集合,所以你不能在统计完成后问“这 100 万独立访客分别是谁”。它适合趋势看板、流量量级和允许误差的容量观察,不适合会员权益、库存、财务和抽奖。
Geo 保存经纬度与成员,可用 GEOSEARCH 查询半径或矩形范围内的门店。它适合“找到附近门店”这样的地理邻近查询,不负责复杂路线规划,也不会替你处理道路、通行时间和配送区域多边形。坐标顺序还要格外小心:写入命令使用经度在前、纬度在后。把普通二维业务坐标硬塞进 Geo 前,也应先确认它是否真的遵循地理距离语义。
这三类结构看起来都很“省”,但节省来自明确放弃一部分能力:Bitmap 依赖可映射的整数偏移,HyperLogLog 放弃精确成员,Geo 聚焦附近范围而不是任意空间分析。只要业务无法接受被放弃的那一部分,就应该退回 Set、ZSet、数据库或专门系统。
生命周期设计仍然存在。按天签到的 Bitmap 可以一天一个 key 并设置保留期,按月统计时再做位运算;独立访客的 HyperLogLog 也可按日期分桶后合并估算。Geo 中门店下线时必须及时删除成员,否则“附近门店”会返回已经停业的地点。把数据压得很小,只解决了存储体积,没有自动解决更新来源、过期清理和错误修正。尤其是概率结构,一旦把错误成员加入后无法像 Set 那样精确找出并删除,常见做法是从可信事件重新构建新周期数据。
因此,这类结构上线前还要准备一份校验样本:用小规模精确结果逐项对照返回值,确认 ID 映射、误差范围和坐标顺序都符合业务预期。
真正能落地的 Redis 建模,不应该只写“这里使用 Hash”。建议为每个 key 写下六项内容:key 模式、value 结构、主要读命令、主要写命令、TTL 归属、容量上限。例如用户资料可以写成:
key: user:profile:<userId>
type: String(JSON)
read: GET,详情接口整块读取
write: SET EX,数据库更新后删除或重建缓存
ttl: 每个用户 key 独立,基础值加抖动
limit: 单个 JSON 大小受控,不存无限增长列表邮件任务则应多写故障语义:任务什么时候算领取、什么时候算完成、worker 宕机后谁接管、重复处理怎样去重。排行榜要写同分规则和周期切换,Set 要写是否允许枚举全部成员,Hash 要写字段数量上限和服务端版本。这样做看似比“选一个结构”麻烦,却能提前暴露绝大多数生产问题。
评审时可以继续追问四个故障问题:写到一半进程退出会留下什么?重复请求会不会把结果算两次?key 到期后第一批请求会做什么?某个集合增长十倍时,最重的读命令会返回多少数据?如果答案只能靠“应该不会发生”,设计还不够完整。把这些问题写进 key 契约,后续才能据此设置监控、报警和容量阈值。
最后记住一个简单原则:数据结构不是按名词选的,而是按业务要执行的原子动作和查询方式选的。 String 的 INCR、Hash 的字段更新、Set 的成员去重、ZSet 的分数排序、Stream 的确认记录,价值都来自服务端已经提供了与你的业务动作接近的语义。选对结构,代码会自然变短;如果每一步都要拉回应用层重组、加锁、补状态,往往说明模型还没有对准问题。
下一节会继续处理 key 命名、生命周期、容量与 Cluster 同槽约束。到了那里,我们会看到:即使类型选对了,key 的边界设计错误,照样会制造大 Key、批量过期和难以迁移的问题。