☰
电商小程序购物车系统设计:缓存与数据库双写一致性实战
2026/10/3 6:52:18 网站建设 项目流程

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

一、需求拆解与基本约束

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

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

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

二、数据结构设计

2.1 缓存层:Hash 结构

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

publicvoidaddToCart(LonguserId,CartItemDTOitem){Stringkey="cart:"+userId;Stringfield="sku:"+item.getSkuId();// 先查缓存里是否已存在该SKUObjectexist=redisTemplate.opsForHash().get(key,field);if(exist!=null){CartItemold=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 数据库层

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

CREATETABLEcart_item(idBIGINTPRIMARYKEYAUTO_INCREMENT,user_idBIGINTNOTNULL,spu_idBIGINTNOTNULL,sku_idBIGINTNOTNULL,quantityINTNOTNULL,checkedTINYINTNOTNULLDEFAULT1,snapshot_priceDECIMAL(10,2),create_timeDATETIMENOTNULL,update_timeDATETIMENOTNULL,UNIQUEKEYuk_user_sku(user_id,sku_id),KEYidx_user(user_id));

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

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

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

publicvoidaddToCart(LonguserId,CartItemDTOitem){Stringkey="cart:"+userId;Stringfield="sku:"+item.getSkuId();// 1. 更新缓存(Lua脚本保证读合并写原子)cartRedisService.mergeAndPut(key,field,item);// 2. 发变更事件,由消费者写库CartChangeEventevent=newCartChangeEvent(userId,item.getSkuId(),item.getQuantity(),ChangeType.ADD);mqProducer.send("cart-change",event);}

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

publicvoidonMessage(CartChangeEventevent){introws=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:

INSERTINTOcart_item(user_id,sku_id,spu_id,quantity,snapshot_price,create_time,update_time)VALUES(#{userId}, #{skuId}, #{spuId}, #{quantity}, #{price}, NOW(), NOW())ONDUPLICATEKEYUPDATEquantity=VALUES(quantity),snapshot_price=VALUES(snapshot_price),update_time=NOW();

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

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

publicList<CartItem>getCart(LonguserId){Stringkey="cart:"+userId;Map<Object,Object>entries=redisTemplate.opsForHash().entries(key);if(!entries.isEmpty()){returnentries.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));}returndbItems;}

四、三个实战踩坑点

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

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

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

localkey=KEYS[1]localfield=ARGV[1]localpayload=ARGV[2]localaddQty=tonumber(ARGV[3])localold=redis.call('HGET',key,field)ifoldthenlocaldecoded=cjson.decode(old)decoded['quantity']=decoded['quantity']+addQty decoded['updateTime']=tonumber(ARGV[4])redis.call('HSET',key,field,cjson.encode(decoded))elseredis.call('HSET',key,field,payload)endredis.call('EXPIRE',key,2592000)

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

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

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

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

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

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

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

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

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

六、小结

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

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

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询