PHP 购物车怎么实现?Session 与数据库方案、价格计算与防超卖
购物车是电商系统里最“麻雀虽小五脏俱全”的模块:涉及会话状态、价格计算、库存校验、并发下单。本文用原生 PHP + MySQL 走一遍完整实现,重点讲清哪些数据放 Session、哪些必须放数据库,以及金额计算为什么不能用 float。

一、三种存储方案的取舍
- Session 存:未登录游客用。实现最简单,缺点是换设备/清 Cookie 就丢、无法跨端同步、并发下 Session 文件会互相阻塞。
- Cookie 存:把商品 id 与数量写进 Cookie。省服务器资源,但客户端可篡改(必须服务端重算价格),且有 4KB 大小与 URL 编码限制。
- 数据库存(购物车表):登录后必须用它。以
user_id为主键的一部分,天然支持多端同步与“加购后三天再回来还在”。
成熟做法是混合方案:游客阶段用 Session,登录后一次性把 Session 购物车合并进数据库(merge 后清空 Session)。
二、表结构
CREATE TABLE cart ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, user_id BIGINT UNSIGNED NOT NULL, sku_id BIGINT UNSIGNED NOT NULL, qty INT UNSIGNED NOT NULL DEFAULT 1, added_at INT UNSIGNED NOT NULL, UNIQUE KEY uk_user_sku (user_id, sku_id), KEY idx_user (user_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE sku ( id BIGINT UNSIGNED PRIMARY KEY, goods_id BIGINT UNSIGNED NOT NULL, price INT UNSIGNED NOT NULL, -- 单位:分 stock INT UNSIGNED NOT NULL, status TINYINT NOT NULL DEFAULT 1 ) ENGINE=InnoDB;
两个设计要点:价格字段用 INT 存“分”,绝不用 FLOAT/DOUBLE;购物车只存 sku_id + qty,不冗余存价格,展示时实时关联取价(否则改价后金额与商品详情页不一致,客诉就是这么来的)。
三、加购与合并
$stmt = $pdo->prepare("INSERT INTO cart (user_id, sku_id, qty, added_at)
VALUES (?,?,?,?)
ON DUPLICATE KEY UPDATE qty = qty + VALUES(qty)");
$stmt->execute([$uid, $skuId, $qty, time()]);
ON DUPLICATE KEY UPDATE 靠 uk_user_sku 唯一索引实现“存在则累加”,比先 SELECT 再判断少一次往返,也不会有并发覆盖问题。
游客登录后合并:
$pdo->beginTransaction();
try {
foreach ($_SESSION['cart'] as $skuId => $qty) {
$pdo->prepare("INSERT INTO cart(user_id,sku_id,qty,added_at) VALUES(?,?,?,?)
ON DUPLICATE KEY UPDATE qty=GREATEST(qty, VALUES(qty))")
->execute([$uid, $skuId, $qty, time()]);
}
unset($_SESSION['cart']);
$pdo->commit();
} catch (Throwable $e) { $pdo->rollBack(); throw $e; }
合并策略要提前定:取较大值(GREATEST)还是相加,两者都会让用户困惑,一般用“取较大值”更稳。
四、金额计算:只用整数
PHP 里 0.1 + 0.7 === 0.7999999999999999,浮点误差在金额场景不可接受。规范做法:
- 数据库、Session、接口传输都用分为单位的整数;
- 展示层再
sprintf('%.2f', $cents / 100); - 涉及百分比折扣时先乘后除,避免中间小数:
$pay = intdiv($price * $qty * $discount, 100); - 需要高精度则用
bcmath扩展(bcadd/bcmul,指定 scale=2),composer require brick/money是更工程化的选择。
另外一条铁律:总价必须由服务端计算。前端提交的只有 sku_id + qty,价格、优惠券、运费全部服务端重算。
五、库存校验与下单
购物车页面只做“提示”,真正扣减放在下单事务里:
$pdo->beginTransaction();
$rows = $pdo->prepare("UPDATE sku SET stock = stock - ? WHERE id = ? AND stock >= ? AND status = 1");
foreach ($items as $it) {
$rows->execute([$it['qty'], $it['sku_id'], $it['qty']]);
if ($rows->rowCount() === 0) { $pdo->rollBack(); throw new RuntimeException("SKU {$it['sku_id']} 库存不足"); }
}
// 写 order / order_item ...
$pdo->commit();
逐条判断 rowCount() 是防超卖的核心。若购物车含多个 SKU,任一失败要整体回滚,绝不允许“部分扣减”。
六、体验与安全细节
- 失效商品处理:查询时
LEFT JOIN sku并过滤status=1,把下架/无货的单独分组显示为“失效商品”,不要静默消失。 - 数量上限:单 SKU 限购(如 999 或活动限购 2 件)必须在服务端校验,前端输入框限制只是体验。
- CSRF:加购、改数量、删除都是写操作,POST + 表单 token 校验。GET 方式加购会被爬虫预取,凭空生成购物车数据。
- Session 并发阻塞:PHP 默认 session 文件锁会让同一用户的并发请求串行。列表页里有 AJAX 时,用完及时
session_write_close()。 - 价格快照:订单表要存成交时的单价,购物车则实时取价,两者不能混用同一份数据。
七、常见问题 FAQ
问:Session 购物车在集群下怎么办?
答:Session 共享用 Redis(session.save_handler=redis)或粘性会话;更好的方案是尽早引导登录、把购物车落到数据库。
问:怎么实现“加入购物车后 30 分钟提醒”?
答:added_at 字段 + 定时任务扫描,或延迟队列(Redis zset 按时间戳排序,每分钟取到期项)。不要依赖客户端轮询。
问:多规格商品如何防重?
答:以 sku_id(规格维度)而非 goods_id 作为唯一键,这正是上面表结构设计的原因。
一句话收尾:购物车的技术难点不在“存”,而在“算价的权威性与并发下的库存一致性”。价格整数化、库存原子扣减、写操作 POST+CSRF,这三条守住就不会出大事故。
生产内容,同时打通搜索引擎(SEO)与AI搜索引擎(GEO)流量入口的软件! 点击进入365建站器 »
