在电商业务里,购物车是连接用户意图与下单转化的核心环节。MySQL作为最常用的关系型存储,承担购物车持久化时面临多端同步、促销叠算和突发流量等挑战。设计购物车表不能只画几个字段,而要综合考虑读写比例、数据生命周期以及后期运营查询的便利。

购物车表的基础范式与反范式权衡
最直观的设计是建一张cart_item表,字段包含id、user_id、sku_id、quantity、selected、created_at和updated_at。这种接近第三范式的结构把商品信息完全交给商品主表,购物车行只保存引用和数量,优势在于商品改价、下线时无需批量更新购物车,数据一致性容易保证。但每次渲染购物车都要关联商品表并查库存,高并发下关联查询可能成为瓶颈。
为了提升读性能,不少团队会在购物车行内冗余商品名称、主图、单价快照。这就是一种有意的反范式设计,代价是用户加购后若商家改价,快照不会自动变,需要在结算时重新校验线上价。实践中更稳妥的做法是:购物车表只冗余不可变的展示字段(如商品标题、规格值),价格始终以实时查询或缓存为准,避免资损。下面是一段建表参考:
CREATE TABLE cart_item ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, user_id BIGINT UNSIGNED NOT NULL, sku_id BIGINT UNSIGNED NOT NULL, quantity INT NOT NULL DEFAULT 1, selected TINYINT NOT NULL DEFAULT 1, title_snapshot VARCHAR(255) NOT NULL DEFAULT '', spec_snapshot VARCHAR(255) NOT NULL DEFAULT '', is_deleted TINYINT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_user_sku (user_id, sku_id, is_deleted) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
上面的uk_user_sku唯一索引把已删除行通过is_deleted区分开,既防止同一用户对同款重复插入,又支持软删除后重新加购。相比物理删除,软删除能保留行为日志,方便追溯刷单或异常清空。需要注意的是,如果is_deleted放进唯一键,那么已删除的相同user_id与sku_id组合可以再次插入新行,业务层要确认这种语义符合预期。
索引规划与高并发下的分片思路
购物车查询绝大多数都以user_id为起点,例如拉取某用户全部有效购物车项。因此主键虽是自增ID,但必须建立以user_id为首的二级索引,否则每次都要全表扫描。推荐建立INDEX idx_user_selected (user_id, selected, is_deleted),这样筛选“某用户已勾选且未删除”的SQL可以直接索引覆盖,减少回表。
当单表行数过亿,即便有索引,B+树层级和缓冲池争用也会让RT升高。此时可按user_id取模做分库分表,或利用MySQL 8.0的哈希分区。对于中小业务,更轻量的方案是冷热分离:超过三十天未操作的购物车归入历史表,主表只留活跃数据。以下示例展示按user_id哈希分八个分区的写法:
CREATE TABLE cart_item_part ( id BIGINT UNSIGNED AUTO_INCREMENT, user_id BIGINT UNSIGNED NOT NULL, sku_id BIGINT UNSIGNED NOT NULL, quantity INT NOT NULL DEFAULT 1, selected TINYINT NOT NULL DEFAULT 1, is_deleted TINYINT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (id, user_id), UNIQUE KEY uk_user_sku (user_id, sku_id, is_deleted) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 PARTITION BY HASH(user_id) PARTITIONS 8;
分区后,单用户的读写请求会落到一个固定分区,运维扩容也只需调整分区数并迁移。需要警惕的是,如果业务里存在“根据sku_id反查被哪些用户加购”的运营需求,哈希分区会让这类跨分区查询变慢,此时应另建异构索引表或使用搜索引擎承接。高并发加购时还要避免行锁升级,建议应用层合并同一用户的批量加购请求,用INSERT ... ON DUPLICATE KEY UPDATE减少事务次数。
软删除、匿名购物车与多端同步方案
未登录用户的购物车通常存在本地或服务端以设备标识暂存。等用户登录后,需要把匿名购物车合并进账号购物车。MySQL侧可设计一张anon_cart表,结构与cart_item类似,但用device_id代替user_id。合并时开启事务,将anon_cart中相同sku_id的数量累加进cart_item,再清空匿名行。该过程要处理并发登录导致的重复合并,可通过device_id维度的分布式锁控制。
软删除除了前面说的is_deleted标记,还可以用独立字段deleted_at记录时间,便于定时物理清理。但要注意,若唯一索引只包含is_deleted而不含时间,用户删除后马上重新加购会复用已删行,此时created_at被重置可能干扰推荐算法统计。因此有的团队放弃唯一键复用,改由应用层先查后插,虽然多一次查询,但语义更清晰。
// 合并匿名购物车示例(伪代码)
function mergeAnonCart($userId, $deviceId) {
$list = $db->query("SELECT sku_id, quantity FROM anon_cart WHERE device_id = ? AND is_deleted = 0", [$deviceId]);
foreach ($list as $row) {
$db->execute("INSERT INTO cart_item (user_id, sku_id, quantity, selected, is_deleted)
VALUES (?, ?, ?, 1, 0)
ON DUPLICATE KEY UPDATE quantity = quantity + VALUES(quantity)",
[$userId, $row['sku_id'], $row['quantity']]);
}
$db->execute("UPDATE anon_cart SET is_deleted = 1 WHERE device_id = ?", [$deviceId]);
}
多端同步方面,如果Web、App、小程序共用一套MySQL购物车表,要在应用层统一user_id体系,并给updated_at加版本号或时间戳,客户端拉取时传上次最大时间,只增量返回变更。这样即便某端漏合并,也能在下次打开时补齐。整体来看,MySQL购物车表设计没有万能模板,核心是在范式清晰、索引高效与业务柔性之间找到适合自己流量的平衡点。