电商小程序购物车系统设计:缓存与数据库双写一致性实战

购物车是电商小程序里访问频率最高、对延迟最敏感的模块之一。用户每点一次"加入购物车"都直接写数据库,高峰期数据库行锁竞争明显,接口耗时会被拉长;完全依赖缓存,又要面对缓存宕机、数据不一致的风险。本文记录我们在生产环境落地购物车模块时,采用"缓存为主、数据库兜底"双写方案的完整过程,包括数据结构选型、双写一致性处理,以及三个踩过的真实坑点。

一、需求拆解与基本约束

先明确购物车要支持的核心能力:

  • 加入商品、修改数量、删除商品、勾选结算;
  • 商品有多个 SKU(规格),同一 SKU 在购物车中只能有一条记录;
  • 用户多端登录(小程序、H5)时购物车数据要一致;
  • 商品下架、价格变动后,购物车里的展示要能感知。

基本约束有两个:一是读写比极高,读远多于写;二是购物车数据允许短暂不一致,但不允许丢失------用户明明加了货,结算时却找不到,是不可接受的事故。

二、数据结构设计

2.1 缓存层:Hash 结构

缓存选用 Redis,购物车用 Hash 存储,Key 为 cart:{userId},field 为 SKU 标识,value 为购物车项序列化内容:

java 复制代码
public void addToCart(Long userId, CartItemDTO item) {
    String key = "cart:" + userId;
    String field = "sku:" + item.getSkuId();
    // 先查缓存里是否已存在该SKU
    Object exist = redisTemplate.opsForHash().get(key, field);
    if (exist != null) {
        CartItem old = parse(exist);
        item.setQuantity(old.getQuantity() + item.getQuantity());
    }
    item.setUpdateTime(System.currentTimeMillis());
    redisTemplate.opsForHash().put(key, field, toJson(item));
    redisTemplate.expire(key, Duration.ofDays(30));
}

用 Hash 而不是把整个购物车序列化成一个 String,原因是修改单个 SKU 时不用读改写整个大对象,并发修改不同 SKU 也不会互相覆盖。

购物车项内容包含:SKU ID、SPU ID、数量、加入时快照价格、勾选状态、加入时间。注意数量在服务端合并,而不是信任小程序端传上来的最终数量------前端传"加几件",服务端取原值累加,可以挡住客户端重放导致的数量异常。

2.2 数据库层

数据库保留一张购物车表做兜底,结构很朴素:

sql 复制代码
CREATE TABLE cart_item (
  id           BIGINT PRIMARY KEY AUTO_INCREMENT,
  user_id      BIGINT NOT NULL,
  spu_id       BIGINT NOT NULL,
  sku_id       BIGINT NOT NULL,
  quantity     INT NOT NULL,
  checked      TINYINT NOT NULL DEFAULT 1,
  snapshot_price DECIMAL(10,2),
  create_time  DATETIME NOT NULL,
  update_time  DATETIME NOT NULL,
  UNIQUE KEY uk_user_sku (user_id, sku_id),
  KEY idx_user (user_id)
);

uk_user_sku 唯一约束是兜底防线,配合 INSERT ... ON DUPLICATE KEY UPDATE 保证同一用户同一 SKU 只有一行。

三、双写一致性:先更缓存,再异步落库

我们采用的策略是:写请求先更新缓存保证用户立刻看到,再通过消息队列异步写数据库,而不是同步双写。同步双写在缓存写成功、数据库写失败时会出现不一致,且数据库抖动会直接拖慢购物车接口。

java 复制代码
public void addToCart(Long userId, CartItemDTO item) {
    String key = "cart:" + userId;
    String field = "sku:" + item.getSkuId();
    // 1. 更新缓存(Lua脚本保证读合并写原子)
    cartRedisService.mergeAndPut(key, field, item);
    // 2. 发变更事件,由消费者写库
    CartChangeEvent event = new CartChangeEvent(userId, item.getSkuId(),
            item.getQuantity(), ChangeType.ADD);
    mqProducer.send("cart-change", event);
}

消费者侧写库做幂等处理,同一条消息重复投递不会产生脏数据:

java 复制代码
public void onMessage(CartChangeEvent event) {
    int rows = cartMapper.upsert(event.getUserId(), event.getSkuId(),
            event.getQuantity(), event.getSnapshotPrice());
    if (rows == 0) {
        log.warn("cart upsert affected 0 rows, uid={} sku={}",
                event.getUserId(), event.getSkuId());
    }
}

对应的 SQL:

sql 复制代码
INSERT INTO cart_item (user_id, sku_id, spu_id, quantity, snapshot_price, create_time, update_time)
VALUES (#{userId}, #{skuId}, #{spuId}, #{quantity}, #{price}, NOW(), NOW())
ON DUPLICATE KEY UPDATE
    quantity = VALUES(quantity),
    snapshot_price = VALUES(snapshot_price),
    update_time = NOW();

3.1 读路径:缓存未命中才回源

正常情况下购物车从缓存读;缓存未命中(比如 Redis 故障恢复后冷数据丢失)再查数据库并回填:

java 复制代码
public List<CartItem> getCart(Long userId) {
    String key = "cart:" + userId;
    Map<Object, Object> entries = redisTemplate.opsForHash().entries(key);
    if (!entries.isEmpty()) {
        return entries.values().stream().map(this::parse).toList();
    }
    // 回源数据库
    List<CartItem> dbItems = cartMapper.selectByUser(userId);
    if (!dbItems.isEmpty()) {
        Map<String, String> reload = dbItems.stream()
                .collect(Collectors.toMap(
                        i -> "sku:" + i.getSkuId(),
                        this::toJson));
        redisTemplate.opsForHash().putAll(key, reload);
        redisTemplate.expire(key, Duration.ofDays(30));
    }
    return dbItems;
}

四、三个实战踩坑点

坑1:并发加购同 SKU,数量被覆盖

最初实现是"先 get 再 put"两步走。压测时发现,两个请求几乎同时加入同一 SKU,各自读到旧值再写回,后写覆盖先写,数量少算。根因是读和写不是原子操作。

解决办法是把"读合并写"放进一段 Lua 脚本,在 Redis 单线程内一次执行完:

lua 复制代码
local key = KEYS[1]
local field = ARGV[1]
local payload = ARGV[2]
local addQty = tonumber(ARGV[3])
local old = redis.call('HGET', key, field)
if old then
  local decoded = cjson.decode(old)
  decoded['quantity'] = decoded['quantity'] + addQty
  decoded['updateTime'] = tonumber(ARGV[4])
  redis.call('HSET', key, field, cjson.encode(decoded))
else
  redis.call('HSET', key, field, payload)
end
redis.call('EXPIRE', key, 2592000)

这里还有个细节:cjson.encode 在某些环境会把中文转成 Unicode 转义序列,回端后需要统一解码,否则商品名显示异常。

坑2:消息消费失败,缓存与数据库长期漂移

异步落库依赖 MQ,如果消费者报错且没有重试,缓存里的新数据永远进不了库,一旦下次缓存未命中回源,用户会看到"旧购物车"。

我们的处理是三层保障:消费失败走消息中间件的重试;超过重试次数进入死信队列,由定时任务扫描死信并告警;另外加一个每日凌晨的对账任务,抽样比对缓存与数据库的数量差异,输出差异清单。实践中对账任务抓出过两起因历史代码异常导致的零星漂移,没有让它流到用户侧。

坑3:商品价格、下架状态在快照里"凝固"

购物车里存的是加入时的快照价格,如果商品后来调价或下架,购物车页面仍然显示旧信息,结算时才暴露问题,用户体验很差。

我们的做法是购物车查询返回前,批量拿 SKU ID 集合去查一次商品中心的实时数据(走本地缓存,TTL 设得很短),在响应里标记出三类状态:价格变动(展示现价与划线价)、已下架(置灰禁选)、库存不足(限制可结算数量)。购物车表中的快照价只用于后台追溯,不直接作为前台展示依据。批量查询而不是循环单个查,是为了避免几十条购物车项把商品接口打成 N 次调用。

五、结算勾选状态的一个补充设计

勾选状态存在购物车项里,但结算接口不能信任前端回传的勾选列表。正确做法是前端只传选中的 SKU ID,服务端重新从购物车缓存取数量、从商品中心取现价和库存,重算一遍金额。这样即使用户改了本地请求参数,结算金额也以服务端实时计算为准,防篡改、防超卖。

六、小结

购物车模块的设计可以归纳成三句话:

  1. 缓存用 Hash 承载高频读写,单 SKU 改动用 Lua 保证原子;
  2. 数据库通过唯一约束 + upsert 做兜底,异步双写配合重试、死信和对账守住一致性;
  3. 价格与库存状态在读取时实时校准,结算金额永远由服务端重算。

这套方案在我们的压测环境中,单节点购物车写入可以稳定支撑到数千 QPS,缓存与数据库的差异通过每日对账始终保持在可发现、可修复的范围内,上线后没有再出现过"加购丢失"类客诉。

相关推荐
java1234_小锋1 小时前
【技术专题】Mysql8 数据库 - Mysql8 客户端sqlyog安装以及配置
数据库·sqlyog
奇牙coding1 小时前
Codex C接 配置教程:的 字段从迁移时必须写完整后缀,填旧值或省略 会静默回退默认模型
java·c语言·数据库·ai
Nturmoils1 小时前
别急着 JOIN,子查询有些场景更顺手
数据库
喜欢的名字被抢了1 小时前
09-Redis 进阶原理篇:单线程、多线程、过期、LRU-LFU、Fork 与 Lua
数据库·redis·lua
卓怡学长1 小时前
w193基于springboot“乐通黄骅”电动车智能充电服务小程序
java·spring boot·spring·小程序·intellij-idea
Omics Pro1 小时前
计算虚拟扰动:网络毒理+虚拟敲除
数据库·人工智能·算法·机器学习·自然语言处理
白帽攻防录10 小时前
SRC 挖洞:Roundcube 预认证 SQL 注入深度复盘,CVE-2026-48842 preg_replace 转义绕过怎么打穿邮件系统
网络·数据库·sql·网络安全·sql注入
扶风ff10 小时前
新品知识更新太快?用练题簿在线刷题,安排企业培训的小测与复盘
前端·学习·小程序
꯭自꯭闭꯭10 小时前
达梦SQL优化相关
linux·运维·数据库·sql