前言
购物车看起来是入门级功能:加商品、改数量、删商品、算总价。但它是安全问题最集中的业务模块之一——价格篡改、数量为负、库存超卖、会话并发,几乎每一个坑都能直接变成资损。所以这篇文章不会只给一段"能跑就行"的代码,而是把设计上的几个关键决策讲清楚。
最常见的错误做法是这样:表单里放一个隐藏字段把单价一起提交上来(price=199),后端取出来乘数量。攻击者把199改成0.01提交,你就按一分钱卖出去。凡是能算出价格的信息,都必须从服务端取,客户端只能提交两样东西:商品 ID 和数量。
第二个常见问题是金额用浮点数。0.1 + 0.2 !== 0.3这件事在算总价时会实打实地暴露,优惠叠加后总额对不上账。正确做法是全程用整数"分"存储和运算,只在展示时除 100。
本文用「存储位置选型 → 数据结构 → 服务端计算 → 会话并发 → 登录后合并」的顺序讲,最后给一个既能在命令行跑、又能直接接到 Web 上的购物车实现。
一、购物车状态放哪:三种方案
| 方案 | 存储位置 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 会话购物车 | $_SESSION | 实现最简单,未登录也能用 | 换设备/换浏览器就丢;服务端要存 session | 游客、单机小站 |
| Cookie 购物车 | 客户端 Cookie | 无需服务端存储 | 客户端可篡改(只能存 ID 和数量)、容量小 | 纯游客场景 |
| 数据库购物车 | 数据表 | 跨设备同步、可分析、可持久 | 每次操作都要读写库 | 登录用户、多端业务 |
生产环境常见组合是游客用会话、登录后落库:登录成功时把会话购物车里的条目合并进用户表,然后清空会话。本文的示例覆盖"会话购物车"这条主线,因为它最容易被写错,也最容易迁移到数据库版。
二、数据结构:ID 为键,数量为值
购物车的最小状态只需要两样:商品 ID 和数量。不要存商品名、价格、图片——这些是会变的,存下来就有"用户看到的旧价格"和"结算时的新价格"不一致的问题,结算时永远以数据库为准。
<?php // 会话里的形状:商品 ID => 数量 $_SESSION['cart'] = [ 12 => 2, // 商品 12 买 2 件 35 => 1, ];其余信息(名称、单价、库存)都在需要展示或结算时按这批 ID 去数据库查。这样做的另一个好处是:会话数据很小,不会因为商品描述太长而撑爆 session 存储。
三、价格永远在服务端算
前端提交给后端的只有id和qty,价格从服务端目录(或数据库)里查出来。所有计算集中在购物车对象里,展示层只负责格式化:
<?php // PHP 8.0+ $cart = new Cart(); $cart->add((int) $_POST['id'], (int) $_POST['qty']); // 只信任 id 与 qty $total = $cart->totalCents($catalog); // 单价来自服务端 $catalogadd()内部必须做三件事:把数量规整为合法整数、拒绝非正数、按库存封顶。前端校验只是体验优化,防守线在服务端。
四、会话并发:一个容易被忽略的锁
PHP 默认的文件会话处理器(session.save_handler = files)会给每个会话文件加锁,并且这把锁在整个请求期间一直持有。直接后果是:同一个用户(同一个PHPSESSID)的两个并发请求会被串行执行,后到的那个必须等前一个结束。
对购物车来说这尤其常见——用户在商品列表页快速点了两次"加入购物车",第二个请求被锁住等待,如果第一个请求刚好卡在慢查询上,第二个就会超时或看起来"没反应"。缓解办法有两个:
- 尽早关闭会话:写完购物车就
session_write_close(),把锁释放掉,后续的耗时逻辑(查数据库、渲染页面)不再占着锁。 - 缩短临界区:把会话写入集中在某一小段代码里,不要在处理过程中反复读写。
<?php // PHP 8.0+ session_start(); $_SESSION['cart'][$id] = ($_SESSION['cart'][$id] ?? 0) + $qty; session_write_close(); // 立刻释放会话锁,后面再慢也不影响同一个用户的其它请求如果购物车要处理高并发(比如秒杀),会话购物车本身就不合适,应该改成数据库或 Redis 存储,并把"加购"做成幂等的接口。
代码实战:完整可运行示例
下面的Cart类不依赖$_SESSION,直接在命令行下就能运行;Web 环境里把状态数组换成$_SESSION['cart']即可。示例中金额全部用整数"分"。
<?php declare(strict_types=1); // PHP 8.0+ final class Cart { /** @var array<int,int> 商品 ID => 数量 */ private array $items = []; /** @param array<int,int> $items 从会话里恢复的状态 */ public function __construct(array $items = []) { foreach ($items as $id => $qty) { $id = (int) $id; $qty = (int) $qty; if ($id > 0 && $qty > 0) { $this->items[$id] = $qty; } } } public function add(int $id, int $qty, int $stock = PHP_INT_MAX): void { if ($id <= 0 || $qty <= 0) { return; // 拒绝非正数,负数会把小计拉低 } $current = $this->items[$id] ?? 0; $this->items[$id] = min($current + $qty, $stock); } public function setQty(int $id, int $qty, int $stock = PHP_INT_MAX): void { if ($qty <= 0) { $this->remove($id); // 数量减到 0 等于删除 return; } if (!isset($this->items[$id])) { return; } $this->items[$id] = min($qty, $stock); } public function remove(int $id): void { unset($this->items[$id]); } /** @return array<int,int> */ public function items(): array { return $this->items; } public function count(): int { return array_sum($this->items); } /** * 金额以"分"为单位返回 * @param array<int,array{name:string,price:int,stock:int}> $catalog 服务端商品目录 */ public function subtotalCents(array $catalog): int { $sum = 0; foreach ($this->items as $id => $qty) { if (!isset($catalog[$id])) { continue; // 商品已下架,跳过(结算时应提示) } $sum += $catalog[$id]['price'] * $qty; // 单价只来自服务端 } return $sum; } } /** 分转成展示用字符串 */ function yuan(int $cents): string { return number_format($cents / 100, 2, '.', ''); } // ---------- 模拟服务端商品目录(价格以分计) ---------- $catalog = [ 12 => ['name' => '机械键盘', 'price' => 19900, 'stock' => 3], 35 => ['name' => '鼠标垫', 'price' => 2900, 'stock' => 10], ]; // ---------- 模拟一次请求:客户端只提交 id 和 qty ---------- $request = [['id' => 12, 'qty' => 2], ['id' => 35, 'qty' => 1], ['id' => 12, 'qty' => 99]]; $cart = new Cart(); foreach ($request as $line) { $id = (int) $line['id']; $qty = (int) $line['qty']; $stock = $catalog[$id]['stock'] ?? 0; $cart->add($id, $qty, $stock); // 超过库存会被封顶到 3 } echo "购物车内容:\n"; foreach ($cart->items() as $id => $qty) { printf(" #%d %s x%d = %s 元\n", $id, $catalog[$id]['name'], $qty, yuan($catalog[$id]['price'] * $qty)); } echo '共 ', $cart->count(), " 件商品\n"; echo '合计 ', yuan($cart->subtotalCents($catalog)), " 元\n"; // ---------- 会话持久化(Web 环境下的用法) ---------- // session_start(); // $cart = new Cart($_SESSION['cart'] ?? []); // ... 处理请求 ... // $_SESSION['cart'] = $cart->items(); // session_write_close(); // // 把这段代码放在真实项目里时,$catalog 换成从数据库按 ID 批量查询的结果: // SELECT id, name, price_cents, stock FROM products WHERE id IN (...)预期输出:
购物车内容: #12 机械键盘 x3 = 597.00 元 #35 鼠标垫 x1 = 29.00 元 共 4 件商品 合计 626.00 元注意#12最终是 3 件——请求里第二次提交的 99 件被库存封顶了,这就是服务端校验的作用。
常见坑点
坑 1:把价格放在表单隐藏字段里。❌ 前端传price=199,后端直接用它算钱;改一下请求就能一分钱下单。 ✅ 后端只收id和qty,价格从数据库或服务端目录查。
坑 2:数量不校验,允许 0 或负数。❌qty=-5会让小计变成负数,优惠叠加后总额可能为负;实测中这类输入经常来自"手滑"的type=number。 ✅qty <= 0一律拒绝;减到 0 的语义是删除条目。
坑 3:金额用浮点数。❌199.00 * 3加上折扣运算后出现597.0000000001之类的结果,对账时怎么都差几分钱。 ✅ 全程整数"分",展示时number_format($cents / 100, 2)。
坑 4:库存只在"加入购物车"时校验。❌ 加购时库存够,用户纠结了半小时才结算,期间商品被抢光,结算时直接超卖。 ✅ 加购时做软校验(提示),下单时在事务里再校验一次并对库存行加锁。
坑 5:忘了 session 文件锁导致的并发阻塞。❌ 同一用户并发两个请求被串行化,表现为"点两次只有一次生效""页面莫名慢"。 ✅ 写完会话立刻session_write_close(),把耗时逻辑挪到锁外。
坑 6:登录后不合并游客购物车。❌ 用户在未登录时加了 5 件商品,一登录购物车空了,转化率直接受影响。 ✅ 登录成功后把会话购物车合并进用户购物车(同 ID 数量相加并按库存封顶),再session_regenerate_id(true)防会话固定。
坑 7:把用户输入原样输出。❌ 商品备注、收货人姓名直接echo,存储型 XSS 就来了。 ✅ 所有输出过htmlspecialchars($s, ENT_QUOTES, 'UTF-8')。
坑 8:session_start()放在输出之后。❌ 前面有一行echo或 BOM,就会报headers already sent,购物车里的东西全部丢失且没有提示。 ✅session_start()放在入口文件最前面;PHP 文件开头不要留 BOM,?>之后不要留空行。
总结
| 关注点 | 做法 |
|---|---|
| 状态存储 | 会话里只存「商品 ID => 数量」,其余信息按 ID 查库 |
| 价格来源 | 只有服务端;客户端提交 ID 与数量 |
| 金额类型 | 整数"分",展示时除 100 |
| 数量校验 | 拒绝 0 与负数,超过库存按库存封顶 |
| 库存一致性 | 加购软校验,下单在事务中硬校验 |
| 会话锁 | 写完立刻session_write_close() |
| 登录合并 | 会话购物车合并进用户购物车后清空,并重置会话 ID |
一个"简单"的购物车,难点从来不在增删改查,而在信任边界与金额计算这两件小事上:只信 ID 和数量、价格一律服务端查、金额一律整数运算。把这三条守住,再补上库存与并发的处理,购物车就不会成为资损的入口。