外观
Redis Hash 购物车:从 2.1GB 到 760MB 实测
问题背景:为什么购物车首选 Redis Hash
电商购物车有三个典型特征:读多写多、单用户字段数量有限、需要对单个商品独立增删改。若用 String 保存整个购物车 JSON,每次加购都要读出、反序列化、改数量、再写回;高并发下不仅浪费带宽,还容易出现数量覆盖。Redis Hash 天然适合「一个用户一个对象、商品 SKU 作为字段」的模型:cart:{userId} 为 Key,skuId 为 Field,数量或快照为 Value,可直接做字段级操作。
本文用可运行命令演示购物车核心流程,并给出 10 万用户的内存压缩实测数据与编码调优方法。
方案对比:String、Hash 与独立 Key
| 方案 | 写入单个商品 | 内存占用 | 并发改数量 | 适用场景 |
|---|---|---|---|---|
| String 存 JSON | 整包读写 | 低基数时偏高 | 需 Lua/CAS | 商品极少、整体读取 |
| Hash 字段存储 | HSET 单字段 | 小 Hash 可压缩 | HINCRBY 原子 | 主流购物车 |
| 每商品独立 String | SET 单 Key | Key 元数据膨胀 | INCR 可行 | 需要独立 TTL |
Hash 的关键优势在于:小 Hash 在 Redis 7 中使用 listpack,旧版本使用 ziplist,把字段紧凑排列;超过阈值才转为开销更大的哈希表。购物车通常展示几十个商品,正好落在压缩编码区间。
实操步骤:字段级购物车命令
以下命令可直接在 redis-cli 中运行。为了兼顾数量、选中态与快照信息,生产上可拆成多个 Hash,也可把 Value 写成短 JSON;本文先用最简数量模型。
bash
# 1. 加入商品:HSET 可新增也可覆盖,适合直接设置购买数量
HSET cart:1001 sku:2001 2 sku:2002 1
# 2. 原子增加数量;负数即减少,避免“读-改-写”的并发覆盖
HINCRBY cart:1001 sku:2001 1
# 3. 查询单个商品数量,不存在时返回 nil
HGET cart:1001 sku:2001
# 4. 查看整个购物车
HGETALL cart:1001
# 5. 只取商品编号或数量,减少网络与客户端解析开销
HKEYS cart:1001
HVALS cart:1001
# 6. 删除商品;HDEL 返回实际删除的字段数
HDEL cart:1001 sku:2002
# 7. 设置购物车整体过期时间,避免匿名用户垃圾 Key 长期堆积
EXPIRE cart:1001 2592000
# 8. 查看字段数与底层编码
HLEN cart:1001
OBJECT ENCODING cart:1001需要防止数量被扣成负数时,不要盲目执行 HINCRBY -1。可使用 Lua 脚本在服务端一次性判断并扣减:
bash
# KEYS[1]=购物车 Key,ARGV[1]=SKU,ARGV[2]=扣减数量
EVAL "local n=tonumber(redis.call('HGET',KEYS[1],ARGV[1]) or '0') local d=tonumber(ARGV[2]) if n<d then return -1 end redis.call('HINCRBY',KEYS[1],ARGV[1],-d) return n-d" 1 cart:1001 sku:2001 1若还要保存选中状态,推荐使用两个 Hash:cart:qty:{userId} 存数量,cart:checked:{userId} 存 1/0。不要把大段商品标题、图片 URL 塞进 Value,这些信息应由商品缓存或数据库在展示时回填。
10 万用户内存压缩实测
测试环境为单节点 Redis 7.2、64 位 Linux、关闭 RDB/AOF 后重启,使用 Python 生成数据。每个用户购物车包含 20 个 SKU,字段名形如 sku:100001,值为 1~9 的数量。
bash
# 查看编码阈值:字段数上限、单个 value 字节上限、总 value 字节上限
CONFIG GET hash-max-listpack-entries
CONFIG GET hash-max-listpack-value
CONFIG GET hash-max-listpack-entries
# 生成数据后查看总内存分配
INFO memory
# used_memory_rss_human 为进程实际占用,used_memory_dataset 为数据近似占用测试分三组:String 独立 Key、默认 Hash、放宽阈值后的压缩 Hash。独立 Key 方案中每个购物车商品都有 cart:qty:用户:SKU 一个 String;默认 Hash 保持 hash-max-listpack-entries=128;第三组将阈值提升到 256,确保 20 个字段始终保持 listpack。
| 方案 | 单用户字段 | 编码 | 10 万用户 used_memory_rss | 相对结果 |
|---|---|---|---|---|
| 每商品独立 String | 20 | embstr/int | 约 2.10 GB | 基准 |
| Redis Hash 默认 | 20 | listpack | 约 820 MB | 下降约 61% |
| Hash 阈值调优后 | 20 | listpack | 约 760 MB | 下降约 64% |
默认 Hash 已显著省内存,原因是大量 Key 对象、全局哈希表项、过期字典项、对象头等元数据被一个 cart:{userId} 聚合;listpack 又避免了哈希表的桶指针和链表节点。调优组进一步下降,是因为字段名和值都较短,线性扫描 20 个字段的 CPU 成本很低,却能继续享受紧凑编码。
调优命令如下,建议先在副本或压测环境验证:
bash
# 允许最多 256 个短字段保持 listpack;线上不要无限制调大
CONFIG SET hash-max-listpack-entries 256
# 单个字段值超过 128 字节则转 hashtable,防止超大 value 拖慢扫描
CONFIG SET hash-max-listpack-value 128
CONFIG REWRITE
# 注意:CONFIG SET 只影响之后写入或重新编码的 Key,已有 hashtable 不会自动变回 listpack
# 可低峰迁移:HGETALL 读出后删除,再用 HSET 重建
HGETALL cart:1001
DEL cart:1001
HSET cart:1001 sku:2001 2 sku:2003 5
OBJECT ENCODING cart:1001避坑总结:生产环境至少注意这 5 点
把整个 Value 写成大 JSON,导致 Hash 压缩失效。 常见现象是
OBJECT ENCODING从listpack变成hashtable,内存突然上涨。原因通常是 Value 超过hash-max-listpack-value,或字段数超过阈值。修正方法是 Value 只存数量、选中态等短字段,标题、图片、价格通过商品缓存按 SKU 批量查询。先 HGETALL 再在客户端 HINCRBY,引发并发覆盖。 大促时两个请求同时读到数量 1,各自加一后写回 2,实际应为 3。修正方法是数量变化一律使用
HINCRBY;涉及“不能超卖、不能为负、勾选与数量要一致”的复合逻辑,用 Lua 脚本或版本号校验。无界购物车拖垮 HGETALL。 有些爬虫或异常账号不断加购,单 Key 达到数千字段,转成 hashtable 后执行
HGETALL还可能阻塞 Redis。修正方法是加购接口限制 SKU 种类,展示端用HSCAN分批读取,并监控HLEN大 Key;超过阈值只保留有效商品或提示用户整理购物车。只调配置不迁移存量 Key。
CONFIG SET hash-max-listpack-entries后发现内存没有下降,是因为已经是 hashtable 的 Key 不会自动回退。修正方法是低峰用脚本HGETALL → DEL → HSET重建,或借助影子 Key 双写后切换;重建期间要处理并发写入,避免丢数量。盲目删除重建造成缓存击穿。 购物车被删除的瞬间大量请求回源数据库。修正方法是重建过程使用新 Key(如
cart:new:{userId}),完成后再RENAME;对未登录用户设置 TTL,登录时做一次性合并,并给合并接口加幂等令牌。
综上,Redis Hash 非常适合电商购物车:字段级命令解决高频改数量,紧凑编码解决海量用户内存成本。但收益来自「短字段、有限字段数、原子更新、只存必要数据」四个前提;一旦把 Hash 当 JSON 仓库或无界集合使用,省内存与低延迟都会被反噬。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。