尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

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

电商小程序购物车系统设计:缓存与数据库双写一致性实战 购物车是电商小程序里访问频率最高、对延迟最敏感的模块之一。用户每点一次加入购物车都直接写数据库高峰期数据库行锁竞争明显接口耗时会被拉长完全依赖缓存又要面对缓存宕机、数据不一致的风险。本文记录我们在生产环境落地购物车模块时采用缓存为主、数据库兜底双写方案的完整过程包括数据结构选型、双写一致性处理以及三个踩过的真实坑点。一、需求拆解与基本约束先明确购物车要支持的核心能力加入商品、修改数量、删除商品、勾选结算商品有多个 SKU规格同一 SKU 在购物车中只能有一条记录用户多端登录小程序、H5时购物车数据要一致商品下架、价格变动后购物车里的展示要能感知。基本约束有两个一是读写比极高读远多于写二是购物车数据允许短暂不一致但不允许丢失——用户明明加了货结算时却找不到是不可接受的事故。二、数据结构设计2.1 缓存层Hash 结构缓存选用 Redis购物车用 Hash 存储Key 为cart:{userId}field 为 SKU 标识value 为购物车项序列化内容publicvoidaddToCart(LonguserId,CartItemDTOitem){Stringkeycart:userId;Stringfieldsku:item.getSkuId();// 先查缓存里是否已存在该SKUObjectexistredisTemplate.opsForHash().get(key,field);if(exist!null){CartItemoldparse(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){Stringkeycart:userId;Stringfieldsku:item.getSkuId();// 1. 更新缓存Lua脚本保证读合并写原子cartRedisService.mergeAndPut(key,field,item);// 2. 发变更事件由消费者写库CartChangeEventeventnewCartChangeEvent(userId,item.getSkuId(),item.getQuantity(),ChangeType.ADD);mqProducer.send(cart-change,event);}消费者侧写库做幂等处理同一条消息重复投递不会产生脏数据publicvoidonMessage(CartChangeEventevent){introwscartMapper.upsert(event.getUserId(),event.getSkuId(),event.getQuantity(),event.getSnapshotPrice());if(rows0){log.warn(cart upsert affected 0 rows, uid{} sku{},event.getUserId(),event.getSkuId());}}对应的 SQLINSERTINTOcart_item(user_id,sku_id,spu_id,quantity,snapshot_price,create_time,update_time)VALUES(#{userId}, #{skuId}, #{spuId}, #{quantity}, #{price}, NOW(), NOW())ONDUPLICATEKEYUPDATEquantityVALUES(quantity),snapshot_priceVALUES(snapshot_price),update_timeNOW();3.1 读路径缓存未命中才回源正常情况下购物车从缓存读缓存未命中比如 Redis 故障恢复后冷数据丢失再查数据库并回填publicListCartItemgetCart(LonguserId){Stringkeycart:userId;MapObject,ObjectentriesredisTemplate.opsForHash().entries(key);if(!entries.isEmpty()){returnentries.values().stream().map(this::parse).toList();}// 回源数据库ListCartItemdbItemscartMapper.selectByUser(userId);if(!dbItems.isEmpty()){MapString,StringreloaddbItems.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 单线程内一次执行完localkeyKEYS[1]localfieldARGV[1]localpayloadARGV[2]localaddQtytonumber(ARGV[3])localoldredis.call(HGET,key,field)ifoldthenlocaldecodedcjson.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服务端重新从购物车缓存取数量、从商品中心取现价和库存重算一遍金额。这样即使用户改了本地请求参数结算金额也以服务端实时计算为准防篡改、防超卖。六、小结购物车模块的设计可以归纳成三句话缓存用 Hash 承载高频读写单 SKU 改动用 Lua 保证原子数据库通过唯一约束 upsert 做兜底异步双写配合重试、死信和对账守住一致性价格与库存状态在读取时实时校准结算金额永远由服务端重算。这套方案在我们的压测环境中单节点购物车写入可以稳定支撑到数千 QPS缓存与数据库的差异通过每日对账始终保持在可发现、可修复的范围内上线后没有再出现过加购丢失类客诉。
返回列表