跳到主内容

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 原子主流购物车
每商品独立 StringSET 单 KeyKey 元数据膨胀INCR 可行需要独立 TTL

Hash 的关键优势在于:小 Hash 在 Redis 7 中使用 listpack,旧版本使用 ziplist,把字段紧凑排列;超过阈值才转为开销更大的哈希表。购物车通常展示几十个商品,正好落在压缩编码区间。

Key: cart:1001用户购物车field: sku:2001value: 2field: sku:2002value: 1field: sku:2003value: 5小 Hashlistpack 压缩

实操步骤:字段级购物车命令 ​

以下命令可直接在 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相对结果
每商品独立 String20embstr/int约 2.10 GB基准
Redis Hash 默认20listpack约 820 MB下降约 61%
Hash 阈值调优后20listpack约 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
HSET/HINCRBY字段级写入字段数 ≤ 阈值且字段值较短超过任一阈值转为 hashtablelistpack紧凑省内存hashtable读写快但更占内存

避坑总结:生产环境至少注意这 5 点 ​

  1. 把整个 Value 写成大 JSON,导致 Hash 压缩失效。 常见现象是 OBJECT ENCODING 从 listpack 变成 hashtable,内存突然上涨。原因通常是 Value 超过 hash-max-listpack-value,或字段数超过阈值。修正方法是 Value 只存数量、选中态等短字段,标题、图片、价格通过商品缓存按 SKU 批量查询。

  2. 先 HGETALL 再在客户端 HINCRBY,引发并发覆盖。 大促时两个请求同时读到数量 1,各自加一后写回 2,实际应为 3。修正方法是数量变化一律使用 HINCRBY;涉及“不能超卖、不能为负、勾选与数量要一致”的复合逻辑,用 Lua 脚本或版本号校验。

  3. 无界购物车拖垮 HGETALL。 有些爬虫或异常账号不断加购,单 Key 达到数千字段,转成 hashtable 后执行 HGETALL 还可能阻塞 Redis。修正方法是加购接口限制 SKU 种类,展示端用 HSCAN 分批读取,并监控 HLEN 大 Key;超过阈值只保留有效商品或提示用户整理购物车。

  4. 只调配置不迁移存量 Key。 CONFIG SET hash-max-listpack-entries 后发现内存没有下降,是因为已经是 hashtable 的 Key 不会自动回退。修正方法是低峰用脚本 HGETALL → DEL → HSET 重建,或借助影子 Key 双写后切换;重建期间要处理并发写入,避免丢数量。

  5. 盲目删除重建造成缓存击穿。 购物车被删除的瞬间大量请求回源数据库。修正方法是重建过程使用新 Key(如 cart:new:{userId}),完成后再 RENAME;对未登录用户设置 TTL,登录时做一次性合并,并给合并接口加幂等令牌。

综上,Redis Hash 非常适合电商购物车:字段级命令解决高频改数量,紧凑编码解决海量用户内存成本。但收益来自「短字段、有限字段数、原子更新、只存必要数据」四个前提;一旦把 Hash 当 JSON 仓库或无界集合使用,省内存与低延迟都会被反噬。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。