购物车服务设计:基于 Redis Hash 的高效实现

购物车服务设计:基于 Redis Hash 的高效实现

在电商系统中,购物车是用户操作最频繁的模块之一。它需要支持高并发读写实时数量更新商品信息动态变化等特性。本文将深入剖析如何利用 Redis 的 Hash 数据结构来构建购物车服务,涵盖数据模型设计、核心操作流程、命令详解、并发控制以及常见问题(如大 Key、合并购物车)的解决方案,并辅以流程图与代码示例。


一、购物车服务核心流程概览

购物车通常提供以下功能:

  • 添加商品到购物车(若已存在则增加数量)
  • 修改商品数量
  • 删除购物车中的商品
  • 选中/取消选中商品
  • 查询购物车列表
  • 清空购物车

整体业务流如下:
添加/修改数量
删除
查询
清空
用户操作购物车
操作类型
Add/Update
Delete
Query
Clear
更新 Redis Hash
HDEL 删除字段
HGETALL 获取全车
DEL 删除整个 Key
返回最新购物车


二、为什么选择 Redis Hash?

2.1 常见方案对比

数据结构 存储方式 优点 缺点
String(JSON) 整个购物车序列化为一个字符串 实现简单 修改一个商品需全量读写,存在并发覆盖风险
List 每个商品作为一个元素 可有序存储 修改/删除需遍历,效率低
Hash 用户 ID 为 Key,SKU ID 为 Field,商品详情为 Value 可单独操作 Field,原子性支持增减 单 Key 下 Field 过多可能导致大 Key
Set 商品 ID 为元素 去重方便 无法存储数量、选中状态等附加信息

结论 :Hash 结构在灵活性(支持单字段操作)、性能(O(1) 的读写)、原子性(HINCRBY)上表现最佳,是购物车服务的首选。

2.2 Redis Hash 关键概念

  • 大 Key(Big Key) :Redis 中的 Key,对应一个用户的购物车。

    命名规范:cart:user:{userId}

    大 Key 可能包含成百上千个 Field(商品),需注意性能问题(详见第五部分)。

  • 小 Key(Field) :Hash 内部的字段,对应商品的 SKU ID (库存单位唯一标识)。

    使用 SKU ID 作为 Field 可以快速定位商品。

  • Value:存储该商品的购物项详情,通常是一个 JSON 字符串或 Hash 结构(推荐 JSON),包含:

    • skuId(商品 ID)
    • skuName(商品名称)
    • price(单价)
    • quantity(数量)
    • selected(是否选中,1/0)
    • image(图片 URL)
    • stock(库存状态,可实时查商品服务)

实际存储示例(Redis 命令行):

bash 复制代码
> HSET cart:user:1001 123456 '{"skuId":123456,"skuName":"iPhone 14","price":5999,"quantity":2,"selected":1}'
(integer) 1
> HSET cart:user:1001 123457 '{"skuId":123457,"skuName":"AirPods Pro","price":1899,"quantity":1,"selected":0}'
(integer) 1

三、核心 CRUD 操作及 Redis 命令

假设我们使用 Spring Data Redis(或 Jedis/Lettuce)进行操作,以下为典型命令映射。

3.1 添加商品到购物车(Add / Increment)

场景:用户点击"加入购物车",若商品已存在,则增加数量;否则新增商品项。

Redis 命令HINCRBYHSET + 读后写。

推荐使用 HINCRBY 对数量进行原子增减,再更新其他字段。

java 复制代码
public void addToCart(Long userId, Long skuId, Integer delta) {
    String cartKey = "cart:user:" + userId;
    String field = skuId.toString();
    
    // 1. 原子增加数量(如果 field 不存在,则从 0 开始)
    Long newQuantity = redisTemplate.opsForHash()
        .increment(cartKey, field, delta);
    
    // 2. 如果数量 <= 0,则删除该商品项
    if (newQuantity <= 0) {
        redisTemplate.opsForHash().delete(cartKey, field);
        return;
    }
    
    // 3. 若是首次添加(增量前为0),需要补充商品详情
    if (newQuantity == delta) {
        // 远程调用商品服务获取 SKU 详情
        SkuInfo sku = productService.getSkuInfo(skuId);
        CartItem item = CartItem.builder()
            .skuId(skuId)
            .skuName(sku.getSkuName())
            .price(sku.getPrice())
            .quantity(newQuantity.intValue())
            .selected(1)
            .image(sku.getImage())
            .build();
        // 将完整对象存入 Hash
        redisTemplate.opsForHash().put(cartKey, field, JSON.toJSONString(item));
    } else {
        // 已存在:只更新数量字段,其他字段保留
        String oldJson = (String) redisTemplate.opsForHash().get(cartKey, field);
        CartItem item = JSON.parseObject(oldJson, CartItem.class);
        item.setQuantity(newQuantity.intValue());
        redisTemplate.opsForHash().put(cartKey, field, JSON.toJSONString(item));
    }
}

对应 Redis 命令组合

  • HINCRBY cart:user:1001 123456 1 → 数量 +1
  • HSET cart:user:1001 123456 '{"..."}' → 首次写入完整对象
  • HDEL cart:user:1001 123456 → 数量减到 0 时删除

3.2 修改商品数量(Update)

与添加逻辑类似,只需传入正或负的 delta,复用上述方法。

3.3 删除购物车商品(Delete)

java 复制代码
public void removeCartItem(Long userId, Long skuId) {
    String cartKey = "cart:user:" + userId;
    redisTemplate.opsForHash().delete(cartKey, skuId.toString());
}

命令:HDEL cart:user:1001 123456

3.4 设置选中/取消选中(Update selected flag)

需要读取当前 JSON,修改 selected 字段后写回。

java 复制代码
public void selectCartItem(Long userId, Long skuId, boolean selected) {
    String cartKey = "cart:user:" + userId;
    String field = skuId.toString();
    String oldJson = (String) redisTemplate.opsForHash().get(cartKey, field);
    if (oldJson != null) {
        CartItem item = JSON.parseObject(oldJson, CartItem.class);
        item.setSelected(selected ? 1 : 0);
        redisTemplate.opsForHash().put(cartKey, field, JSON.toJSONString(item));
    }
}

3.5 查询购物车列表(Query)

java 复制代码
public List<CartItem> getCartList(Long userId) {
    String cartKey = "cart:user:" + userId;
    Map<Object, Object> entries = redisTemplate.opsForHash().entries(cartKey);
    List<CartItem> cartItems = new ArrayList<>();
    for (Object value : entries.values()) {
        cartItems.add(JSON.parseObject((String) value, CartItem.class));
    }
    // 可按添加时间排序(若需顺序,可额外维护 ZSet)
    return cartItems;
}

命令:HGETALL cart:user:1001

3.6 清空购物车(Clear)

java 复制代码
public void clearCart(Long userId) {
    String cartKey = "cart:user:" + userId;
    redisTemplate.delete(cartKey);
}

命令:DEL cart:user:1001


四、添加与查询购物车的详细流程图

4.1 添加购物车流程图

存在
不存在
用户请求添加商品
从 ThreadLocal 获取当前用户 ID
构建 cart:user:userId
Redis Hash 中

是否存在该 skuId?
HINCRBY 增加数量
更新数量字段并写回 Hash
返回成功
远程调用商品服务获取 SKU 详情
组装 CartItem JSON
HSET 写入 Hash

4.2 查询购物车流程图



用户查看购物车
获取用户 ID
执行 HGETALL 命令
解析 JSON 字符串为 CartItem 对象
需要实时验价/验库存?
批量调用商品服务获取最新价格/库存
合并到购物车数据
直接返回
返回购物车列表


五、关键问题与最佳实践

5.1 大 Key 问题

问题 :一个用户的购物车可能包含数百个 SKU。HGETALL 会一次性拉取所有 Field,占用网络带宽和内存,甚至导致 Redis 阻塞(单线程模型)。

解决方案

策略 说明 适用场景
分页查询 使用 HSCAN 命令或维护一个辅助 ZSet(按时间排序)分页获取 Field 购物车商品较多(>100)
限制最大容量 限制单个购物车最多 50 件商品,超出提示 大部分电商采用
异步加载 前端先展示骨架,后台分批加载 体验优先
压缩 Value 使用 Protobuf 等压缩格式减小 Value 体积 节省内存

推荐做法:在添加时检查当前购物车商品数量,超过阈值(如 99)后拒绝新增并给出提示。

5.2 数量增减的并发安全

HINCRBY 是原子操作,天然解决并发增减的数量覆盖问题。但注意:首次添加时,如果并发两个请求同时执行 HINCRBY(field 不存在),Redis 会正确累加,但两个请求都可能触发远程调用并 HSET 覆盖,导致商品详情被重复写入(不会错,但浪费资源)。可通过分布式锁或检查 newQuantity == delta 时再次确认进行优化。

5.3 购物车过期时间

用户登录后,购物车应持久保存,但未登录用户的临时购物车可以设置过期时间(如 30 天)。对于已登录用户,不推荐设置 TTL(用户可能数月后再次购买)。可借助 Redis 的 EXPIRE 命令设置非活跃淘汰:

java 复制代码
redisTemplate.expire(cartKey, Duration.ofDays(30));

5.4 未登录购物车与登录后合并

常见流程:

  1. 未登录时,购物车存储在 前端 LocalStorage后端临时 Key(基于设备 ID 或 Token)。
  2. 用户登录后,后端将临时购物车与 Redis 中的登录购物车合并(按 SKU 累加数量,删除临时车)。
  3. 合并使用 Lua 脚本保证原子性。

5.5 商品信息变更(价格、上下架)

商品价格变动后,购物车中显示的价格应如何处理?有两种策略:

  • 实时拉取:每次查询购物车时,批量请求商品服务获取最新价格(增加服务调用压力,但保证准确)。
  • 异步更新:商品服务通过 MQ 发布价格变更消息,购物车服务监听后,遍历所有用户购物车中该 SKU 的 field,更新价格(大规模更新代价高,可采用延迟批量更新)。

推荐折中方案:购物车中存储商品快照价格,同时在展示时提供"价格已更新"提示,并允许用户点击刷新。实际企业多采用实时拉取(配合多级缓存)。


六、完整代码示例(Spring Boot + RedisTemplate)

java 复制代码
@Service
public class CartService {
    @Autowired
    private RedisTemplate<String, Object> redisTemplate;
    @Autowired
    private ProductServiceClient productServiceClient;

    private static final String CART_KEY_PREFIX = "cart:user:";

    public void addItem(Long userId, Long skuId, Integer delta) {
        String key = CART_KEY_PREFIX + userId;
        String field = String.valueOf(skuId);
        Long newQty = redisTemplate.opsForHash().increment(key, field, delta);
        if (newQty <= 0) {
            redisTemplate.opsForHash().delete(key, field);
            return;
        }
        // 首次添加需要补全信息
        if (newQty == delta) {
            SkuDTO sku = productServiceClient.getSku(skuId);
            CartItem item = new CartItem(skuId, sku.getTitle(), sku.getPrice(),
                    newQty.intValue(), 1, sku.getImage());
            redisTemplate.opsForHash().put(key, field, JsonUtils.toJson(item));
        } else {
            // 更新数量
            String json = (String) redisTemplate.opsForHash().get(key, field);
            CartItem item = JsonUtils.fromJson(json, CartItem.class);
            item.setQuantity(newQty.intValue());
            redisTemplate.opsForHash().put(key, field, JsonUtils.toJson(item));
        }
    }

    public List<CartItem> getCartList(Long userId) {
        String key = CART_KEY_PREFIX + userId;
        Map<Object, Object> entries = redisTemplate.opsForHash().entries(key);
        List<CartItem> list = new ArrayList<>();
        for (Object v : entries.values()) {
            list.add(JsonUtils.fromJson((String) v, CartItem.class));
        }
        // 可选:批量填充最新价格
        batchFillRealTimePrice(list);
        return list;
    }

    private void batchFillRealTimePrice(List<CartItem> items) {
        // 调用商品服务批量获取价格
    }
}

七、总结

要素 推荐方案 关键命令/技术
数据结构 Redis Hash HSET, HGET, HINCRBY, HDEL, HGETALL
添加商品 原子增减 + 首次补全详情 HINCRBY, HSET
修改数量 HINCRBY 支持正负数 无需额外查询
查询购物车 HGETALL 注意大 Key 问题 加数量限制或分页
数量并发安全 Redis 原子操作 HINCRBY
登录合并 临时车 + 永久车合并 Lua 脚本遍历累加
商品信息同步 实时批量查询 + 本地缓存 多级缓存 + 消息队列

基于 Redis Hash 的购物车实现,简单高效,满足了电商购物车高并发、低延迟的核心要求。在实际生产环境中,还需结合监控(大 Key 检测)、降级(商品服务超时走快照数据)、分页查询等优化手段,才能支撑大规模用户稳定运行。

相关推荐
观测云2 小时前
观测云4月产品升级报告 | 统一目录、Obsy AI 全新上线,基础设施、场景、监控告警、管理多项能力升级
数据库·人工智能·可观测性·产品迭代·观测云
Mike117.2 小时前
GBase 8c schema 和 search_path 引发的对象定位问题
数据库·sql·oracle
ITyunwei09873 小时前
数字化转型与遗留系统:如何为老旧的IT系统“减负“并注入新活力?
运维·网络·数据库
SelectDB3 小时前
强行拍平?全表扫描? AI Agent 动态 JSON 的观测分析
数据库·人工智能·数据分析
万邦科技Lafite3 小时前
如何通过 item_search_img API 接口获取淘宝商品信息
java·前端·数据库
雨辰AI3 小时前
面试题:人大金仓事务隔离级别、MVCC 机制详解(与MySQL差异对比)
数据库·后端·mysql·面试·政务
丑八怪大丑3 小时前
SQL新特性
数据库·sql
磊 子3 小时前
cpu是如何执行程序的?
数据库·操作系统·cpu
赵渝强老师3 小时前
【赵渝强老师】金仓数据库的运行日志文件
数据库·postgresql·oracle·国产数据库