前言购物车看起来是入门级功能加商品、改数量、删商品、算总价。但它是安全问题最集中的业务模块之一——价格篡改、数量为负、库存超卖、会话并发几乎每一个坑都能直接变成资损。所以这篇文章不会只给一段能跑就行的代码而是把设计上的几个关键决策讲清楚。最常见的错误做法是这样表单里放一个隐藏字段把单价一起提交上来price199后端取出来乘数量。攻击者把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_types1); // PHP 8.0 final class Cart { /** var arrayint,int 商品 ID 数量 */ private array $items []; /** param arrayint,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 arrayint,int */ public function items(): array { return $this-items; } public function count(): int { return array_sum($this-items); } /** * 金额以分为单位返回 * param arrayint,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把价格放在表单隐藏字段里。❌ 前端传price199后端直接用它算钱改一下请求就能一分钱下单。 ✅ 后端只收id和qty价格从数据库或服务端目录查。坑 2数量不校验允许 0 或负数。❌qty-5会让小计变成负数优惠叠加后总额可能为负实测中这类输入经常来自手滑的typenumber。 ✅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)。坑 8session_start()放在输出之后。❌ 前面有一行echo或 BOM就会报headers already sent购物车里的东西全部丢失且没有提示。 ✅session_start()放在入口文件最前面PHP 文件开头不要留 BOM?之后不要留空行。总结关注点做法状态存储会话里只存「商品 ID 数量」其余信息按 ID 查库价格来源只有服务端客户端提交 ID 与数量金额类型整数分展示时除 100数量校验拒绝 0 与负数超过库存按库存封顶库存一致性加购软校验下单在事务中硬校验会话锁写完立刻session_write_close()登录合并会话购物车合并进用户购物车后清空并重置会话 ID一个简单的购物车难点从来不在增删改查而在信任边界与金额计算这两件小事上只信 ID 和数量、价格一律服务端查、金额一律整数运算。把这三条守住再补上库存与并发的处理购物车就不会成为资损的入口。