简介:面向电商开发者的 PHP 砍价功能实例,完整演示了社交裂变式促销的核心闭环:用户选择商品后发起砍价,生成专属分享链接邀请好友帮忙,每次砍价降低一定金额,并受砍价次数限制;当价格达到商家设定的最低价或活动到期时,系统自动成交。压缩包仅 7KB、共 3 个文件,包含两个 PHP 脚本分别处理砍价活动管理和订单创建、支付回调,另有一个 txt 数据库说明,给出了砍价表、用户表、订单表的字段设计,其中砍价表涵盖商品 ID、初始价格、当前价格、结束时间、用户 ID 等关键字段,覆盖从发起砍价到完成交易的数据链路。已有 420 人学习该示例,适合希望快速在项目中实现砍价功能的初中级 PHP 开发者。通过阅读代码可以掌握用户请求的验证与处理、砍价状态更新、当前价格递减、最低价判断、防止恶意刷砍价等业务细节,并了解 PDO 预处理语句与 MySQL 交互、防范 SQL 注入的实践方法;若再配合前端 AJAX,即可构建完整的社交砍价体验。
1. 砍价功能:一个让 PHP 新手也能hold住的营销玩法
砍价或许是电商营销里最怪的一个功能——买家拉好友来“帮忙砍一刀”,好友点进来看到的是一个几乎不可能拒绝的“低价诱惑”,而商家要做的,不过是在数据库里写好一把“减法”。有人觉得它涉及并发、锁、防刷,属于高难度玩法;实际上砍价的核心逻辑可以用一趟数据库事务 + 一张记录表讲清楚,PHP 本身完全够用。这份“php砍价功能实例.zip”包含的就是这样一套可运行的最小实现:从商品、订单到砍价记录,再到好友助力按钮,覆盖了砍价活动从创建到完成的完整链路。适合正在做电商或营销类小系统的开发者,也适合刚接触 PHP 的从业者用来理解状态机与事务边界。我拿到包后把代码跑了三遍,第一遍能跑通,第二遍开始看边界条件,第三遍才真正理解为什么砍价不能做成简单的 UPDATE 递减。
2. 砍价的数据模型:五张表和一次锁单决定成败
2.1 基础的四个表:商品、订单、砍价记录、助力用户
打开压缩包你会看到 install.sql 文件和一套标准的 PHP 目录结构。数据模型是砍价系统的地基,也是这套实例里最先值得看的东西。最常见的设计是四张基础表:goods_spu 存商品基础信息,goods_sku 存参与活动的规格(比如同一款手机有不同的内存版本),order_main 存砍价订单的主体状态,order_cut_log 存每一次好友点击“帮他砍一刀”产生的记录。实例里还用了一张 cut_config 表来装活动配置,总共五张表。
order_cut_log 是整场砍价的主角。它的核心字段包括:order_id 关联订单,user_id 记录是哪个好友砍的,cut_amount 记录这一刀砍掉了多少钱,create_time 记录砍价时间。为什么需要单独的 user_id?因为砍价规则有一个几乎必备的限制——同一个好友只能帮同一个人砍一次。这个限制在后端进行,不能靠前端隐藏按钮实现,否则换个浏览器就能绕过。
表结构里有几个细节值得我们注意。cut_amount 用的是 DECIMAL(10,2) 而不是 INT,因为砍价金额通常带两位小数——好友砍出的金额需要看起来随机,但累计下来又必须恰好在目标范围内。如果为了省事用 FLOAT,累计到第五刀第六刀就会出现 0.1 + 0.2 = 0.30000000000000004 这种尴尬问题。DECIMAL 是 MySQL 在金融场景下的基本要求,这一点实例处理得很到位。
2.2 状态机:从待砍价到已下单的四个状态
砍价订单在实例里不是只有“未完成”和“完成”两种状态,它被拆成了四段:初始创建(0)、砍价中(1)、砍价完成(2)、已下单(3)。初始创建是指用户点击“我要砍价”但还没发起好友助力;砍价中代表至少有一个好友参与了助力,金额在持续减少;砍价完成表示剩余的金额已经低于砍价最低价,用户可以下单;已下单则是用户实际支付并把订单转化为普通订单。
为什么要把“砍价完成”和“已下单”拆成两个状态?因为砍价完成后用户不一定马上支付,他可能隔天再来。如果完成后直接弹支付页,那么订单的锁定价格就会被写死,一旦后台调整活动规则,老订单会出现价格不一致。实例的做法是:砍价完成时在 order_main 里写一个 cut_done_time 时间戳,用户实际支付时再通过这个时间戳判断订单是否还在有效期内。这样砍价完成只表示“你有资格下单了”,不代表订单必须按当前价格结算。
这个状态机在 updateOrderStatus 函数里实现,入口参数只有两个:order_id 和 target_status。函数内部先 SELECT 订单状态,再用 UPDATE 语句带 where status = old_status 条件更新到新状态。这里用到了乐观锁的思路——UPDATE 影响行数为 0 就说明状态已经被别人改过,需要抛异常回滚。这种写法比直接 UPDATE SET status = 3 安全得多,在多窗口操作订单时能有效避免状态错乱。
2.3 锁单逻辑:为什么砍价到最后一步反而要加锁
砍价系统天然就有并发问题:N 个好友同时点击“帮他砍一刀”,这 N 个请求几乎同时到达服务器,如果每个请求都走“先 SELECT 剩余金额,再 UPDATE 减去 cut_amount”这个流程,那么后到的请求可能会读到过期的剩余金额。实例里用的是行锁方案,核心代码在 lockOrder 方法里,用了一个 SELECT ... FOR UPDATE 语句。
public function lockOrder($orderId) { $pdo = $this->getPdo(); $pdo->beginTransaction(); try { $sql = "SELECT order_id, remain_amount, order_status FROM order_main WHERE order_id = ? FOR UPDATE"; $stmt = $pdo->prepare($sql); $stmt->execute([$orderId]); $order = $stmt->fetch(PDO::FETCH_ASSOC); if (!$order) { throw new Exception('订单不存在'); } if ($order['order_status'] != 1) { throw new Exception('订单不在砍价状态'); } return $order; } catch (Exception $e) { $pdo->rollBack(); throw $e; } }这段代码在事务里锁定订单行,锁会持续到事务提交或回滚。深入说明一下:这里锁定的是 order_main 中该订单的行,不是整张表,所以不同的订单之间不会互相阻塞,只有同一个订单的并发请求会排队。这个粒度对砍价场景来说刚刚好。参数方面,order_status 判断的是当前订单必须是“砍价中”状态,如果订单已经被标记为完成,说明已经有人在处理了,直接抛异常。这里要注意的事务隔离级别是 InnoDB 默认的 REPEATABLE READ,在这个级别下 FOR UPDATE 能读到最新的已提交数据,所以后到的请求会看到前一个请求提交后的剩余金额。
3. 写一个能跑的 PHP 砍价最小实现:订单创建、砍价记录与好友助力
3.1 创建砍价订单:把随机金额和最低价写清楚
实例包里的 createCutOrder 方法实现了砍价发起流程。它接收三个参数:用户 ID、商品 SKU ID、活动 ID。方法内部先检查该用户是否已经为这个 SKU 创建过砍价订单,如果有且状态不是“已下单”,则直接返回已有的订单,防止一个用户反复创建多个砍价订单薅羊毛。
public function createCutOrder($userId, $skuId, $activityId) { $pdo = $this->getPdo(); // 检查是否已有进行中的砍价订单 $sql = "SELECT order_id FROM order_main WHERE user_id = ? AND sku_id = ? AND order_status IN (0,1,2) LIMIT 1"; $stmt = $pdo->prepare($sql); $stmt->execute([$userId, $skuId]); if ($stmt->fetch()) { throw new Exception('你已有一个未完成的砍价订单'); } // 读取活动配置 $config = $this->getCutConfig($activityId); $pdo->beginTransaction(); try { $sql = "INSERT INTO order_main (user_id, sku_id, activity_id, original_price, remain_amount, min_price, order_status, create_time) VALUES (?, ?, ?, ?, ?, ?, 0, NOW())"; $stmt = $pdo->prepare($sql); $stmt->execute([ $userId, $skuId, $activityId, $config['original_price'], $config['original_price'], $config['min_price'] ]); $orderId = $pdo->lastInsertId(); $pdo->commit(); return $orderId; } catch (Exception $e) { $pdo->rollBack(); throw $e; } }这个方法的两个核心设计意图:第一,查询已有订单时把状态 0、1、2 都列为“进行中”,意味着只要订单还没被用户手动取消,就不能创建新订单,这是一个防重复的保护措施;第二,订单初始的 remain_amount 直接等于 original_price,而不是单独算出一个“目标砍价金额”。后续的砍价只是在这个金额上做减法,初始流程省去了额外字段。活动配置表里还有两个重要参数:min_price 是砍价的底线,也就是总价不能再低于这个值;cut_lowest 和 cut_highest 是每刀的下限和上限金额,控制随机数的范围。
3.2 砍价动作核心:随机金额、事务提交和一条兜底规则
砍价动作是本实例里最值得看的一段代码。好友点击“帮他砍一刀”后,服务端需要做四件事:判断这个好友有没有砍过、生成一个随机金额、检查剩余金额减去随机金额后是否会低于最低价、写入砍价记录并更新订单。
public function doCut($orderId, $userId) { $pdo = $this->getPdo(); // 检查该好友是否已经砍过此订单 $sql = "SELECT id FROM order_cut_log WHERE order_id = ? AND user_id = ? LIMIT 1"; $stmt = $pdo->prepare($sql); $stmt->execute([$orderId, $userId]); if ($stmt->fetch()) { throw new Exception('你已经帮TA砍过啦,去喊其他朋友吧'); } $pdo->beginTransaction(); try { // 锁定订单行 $order = $this->lockOrder($orderId); // 生成随机金额 $config = $this->getCutConfig($order['activity_id']); $randAmount = mt_rand( $config['cut_lowest'] * 100, $config['cut_highest'] * 100 ) / 100; $newRemain = $order['remain_amount'] - $randAmount; if ($newRemain < $order['min_price']) { // 兜底规则:不能让剩余金额低于最低价 $randAmount = $order['remain_amount'] - $order['min_price']; $newRemain = $order['min_price']; } // 更新订单 $sql = "UPDATE order_main SET remain_amount = ?, cut_count = cut_count + 1 WHERE order_id = ?"; $stmt = $pdo->prepare($sql); $stmt->execute([$newRemain, $orderId]); // 写入砍价记录 $sql = "INSERT INTO order_cut_log (order_id, user_id, cut_amount, create_time) VALUES (?, ?, ?, NOW())"; $stmt = $pdo->prepare($sql); $stmt->execute([$orderId, $userId, $randAmount]); // 如果砍到最低价,完成订单 if ($newRemain <= $order['min_price']) { $this->updateOrderStatus($orderId, 2); } $pdo->commit(); return $randAmount; } catch (Exception $e) { $pdo->rollBack(); throw $e; } }这里有几个细节必须拆开讲。第一,随机金额用了 mt_rand 而不是 rand,并且把单位换算成分避免浮点运算,这是 PHP 处理金额的标准做法;第二,加了兜底逻辑——当随机金额导致剩余金额低于最低价时,直接把随机金额改为“剩余金额减最低价”,这样保证订单的 remain_amount 最低恰好等于 min_price,不会出现负数或多砍的情况;第三,判断是否完成用的是 newRemain <= min_price 而不是 ==,因为浮点计算场景下等于的判断不可靠。关于 FOR UPDATE 锁,不是每台服务器都能跑同一行代码,但在单库单表的前提下这个方案是可靠的,多服务器部署时需要换成分布式锁或把砍价操作放进消息队列串行化。
3.3 前端调用:一个 ajax 请求背后的完整安全链路
实例的演示页是 show_cut.html,里面用 jQuery 发起了砍价请求。核心代码是:
function doCut(orderId) { $.ajax({ url: 'cut.php', type: 'POST', data: { order_id: orderId, user_token: getToken() // 前端只传标识,真正的鉴权在服务端 }, dataType: 'json', success: function (res) { if (res.code === 0) { $('#cut-amount').text(res.data.cut_amount); $('#remain-amount').text(res.data.remain_amount); if (res.data.remain_amount <= res.data.min_price) { $('#cut-done').show(); } } else { alert(res.msg); } } }); }这段前端代码本身很简单,但它透露出后端的鉴权链路:前端传的 user_token 只是用户的明文标识,后端拿到后要查用户表确认这个 token 有效且对应该用户。真正判定“好友是否已经砍过”必须依赖服务端查询,不能信任前端的隐藏按钮状态。还有一个安全细节容易被忽略:cut.php 入口里需要校验请求来源的 HTTP_REFERER 或者是签名参数,实例中单独封装了一个 checkSign 方法,把 order_id、user_id 和时间戳做 MD5 签名,防止接口被外部批量调用。如果直接部署到公网,这一步不能省,否则刷接口的成本极低,一堆机器人可以把活动砍穿。签名的思路是后端生成一个短期有效的 token 下发到页面,前端提交时带回,后端再校验一致性。
4. 避坑:12 个砍价项目里反复翻车的边界问题
4.1 并发砍价时的金额错乱
现象:两个人同时砍一刀,都返回成功,但后台一看剩余金额只减了其中一刀的钱。
原因:两个请求同时通过了“SELECT remain_amount”检查,拿到的剩余金额是同一个值,然后各自算出 newRemain,后提交的覆盖了先提交的结果。
解决:在事务中先执行 SELECT ... FOR UPDATE 做行锁,锁住订单后第二个请求会等待。实例里 lockOrder 方法已经做好了,关键是开发者要养成习惯——任何改订单金额的操作都先走 lockOrder,不要直接 UPDATE。这个锁的粒度是行不是表,不同订单之间没有性能损失,但要注意事务里不要做远程调用或长时间操作,否则锁的等待时间会拉长。
4.2 砍价完成后还能继续砍价
现象:订单状态已变成完成,但好友仍能点击砍价按钮,并且也写了记录。
原因:doCut 方法里没有先检查订单状态,只是检查了砍价记录。订单如果在砍价完成状态,应该直接拒绝新的砍价请求。说明判断条件漏了状态字段。
解决:在 doCut 开头处加状态校验,判断 order_status 必须等于 1(砍价中)。这个校验要在事务外先做一次,能省掉无谓的锁获取,然后在事务内 lockOrder 后再做一次,防止校验与锁定之间的窗口期。
4.3 同一好友砍两次
现象:好友 A 砍了一次之后,刷新页面重新点击,又砍了一次成功。
原因:检查“是否砍过”的 SQL 没有被索引保护,或者表里没有唯一索引,导致并发状态下两条记录被同时插入。另外前端把“已砍过”的状态放在内存里,刷新后状态丢失。
解决:在 order_cut_log 表上加 UNIQUE KEY (order_id, user_id),这样数据库层面就保证了唯一性,即使应用层有并发穿透,数据库也会拒绝第二条记录。后端返回给前端的响应里要带一个“已砍过”的标记,并把它存在 localStorage 里而不是内存变量中。
4.4 金额出现超出上下限的异常值
现象:某次砍价金额是 0,或者比 cut_highest 还大。
原因:随机数计算时用了浮点乘法或除法,精度丢失导致边界溢出;或者 mt_rand 参数传的是浮点数被自动转型成整数,上下限失真。
解决:全部统一用整数分作为计算单位。随机范围先乘以 100 变成整数,算完再除以 100 转回元。拆成 step 来看是:把金额放大 100 倍 → 用 mt_rand 产生整数值 → 除以 100 得到带两位小数的金额。如果仍担心边界,再加上一层 clamp 判断:cut_amount 小于 cut_lowest 就取 cut_lowest,大于 cut_highest 就取 cut_highest。
4.5 订单创建后长期未砍价占用价格
现象:用户创建砍价订单后一周都不操作,活动倒是结束了,但订单状态仍停留在 0,而商品价格已经上调。
原因:创建订单时没有写入活动的结束时间,也没有定时任务把过期订单标记为失效。查询待支付列表时会把所有状态 0 的订单都当作有效订单展示。
解决:活动配置表里加上 end_time 字段,订单创建时快照活动结束时间;查询时判断 order_main.create_time 是否在活动结束前。如果超出活动期限,直接返回“活动已结束”并把订单状态置为 4(已过期)。这个状态字段实例中已经预留了,只是默认代码里没有启用,需要自己按业务补上。
4.6 砍价记录总数与实际砍价次数不符
现象:订单显示砍价 28 次,但砍价记录表只有 27 条记录。
原因:砍价完成后,最后一次成功把剩余金额砍到最低价的那次操作被记录了但订单的 cut_count 没有增加,或者相反——cut_count 增加了但记录写入失败被回滚。
解决:UPDATE order_main 和 INSERT order_cut_log 必须在同一个事务里,确保要么同时成功要么同时失败。实例的 doCut 已经这样做了,但要注意 catch 块里必须 rollBack,否则事务会缺省提交部分操作。检查手段是订单的 cut_count 应始终等于 order_cut_log 表的 COUNT 数量。
4.7 前端显示剩余金额为负数
现象:页面显示的剩余金额是 -0.01 之类。
原因:显示时直接用 remain_amount 字段,但该字段在数据库层 DECIMAL 精度下可能是 0.00,前端拿到的浮点数精度在转换时被格式化出错,并非数据本身是负的。
解决:不要在前端做加减法,只做展示。后端返回的 remain_amount 直接显示原样,前端只用它做“是否达到最低价”的布尔判断。格式化输出放到 PHP 端用 number_format 去处理。
4.8 取消订单后再创建失败
现象:用户点了取消砍价,然后再次发起砍价,系统提示“已有进行中的订单”。
原因:创建订单时的校验条件是 order_status IN (0,1,2),但取消后状态被置为 4(已取消),没包含在排除列表里,导致系统误判。
解决:状态枚举需要细化并且创建订单时只排除状态 3(已下单)和状态 4(已取消),其他状态都允许创建。这个锅本质上是对状态码理解不一导致的,前后端统一枚举定义是关键。
4.9 优惠价在砍价完成后被二次修改
现象:订单砍价完成后,管理员修改了商品的最低砍价价格,已完成的订单应立即以旧价格结算,结果却以新价格生成。
原因:订单没有在砍价完成时保存价格快照,支付时重新读取了商品表的最新价格。
解决:在 order_main 中增加 cut_done_price 字段,砍价完成时把当时的 min_price 快照到该字段。订单支付时读取 cut_done_price,而不是实时计算。这个字段实例的 SQL 文件里没有预置,需要自己 ALTER TABLE 添加。
4.10 数据库事务未关闭导致锁死
现象:砍价接口第一次正常,第二次开始卡住,请求迟迟不返回,数据库连接数飙升。
原因:事务内抛出了异常但 catch 块里没有 rollBack,或者 commit 之后又执行了其他查询操作没有关闭连接。
解决:检查每个方法的异常处理,务必将 rollBack 放到 catch 块第一行。另外 PDO 连接要设置超时时间,mysqli 的连接设置 connect_timeout,避免异常时连接挂死。实例代码中主要用 PDO,在 new PDO 时配置 ATTR_TIMEOUT 为 3 秒是一个稳妥选择。
4.11 砍价链接被刷子批量调接口
现象:砍价次数在深夜几分钟内暴涨,全是同一个 IP 段。
原因:接口没做频控,也没做 referer 校验。刷子遍历订单号,逐单砍价,干扰正常活动数据。
解决:给 doCut 接口加三个防护:同一 IP 每分钟最多调用 10 次;同一用户 ID 对同一订单只允许一次(已有唯一索引兜底);加上请求签名校验,签名有效期 5 分钟,防止重放。实例中 checkSign 方法已经写好,但注释里明确说了生产环境建议再叠加 IP 频控,这个提醒值得重视。
4.12 随机金额的平均值偏离预期
现象:活动结束后统计,平均每刀金额远低于配置的中心值,导致砍价进度比预期慢。
原因:mt_rand 是均匀分布,但业务上每刀的上下限并不是固定区间——越接近最低价,兜底逻辑越容易把金额截小。例如区间是 1 到 5 元,剩余金额只剩 2 元时,随机数生成 3 会被截成 2,平均值自然往下偏。
解决:如果业务方希望每刀金额在后期仍然保持一定的力度,可以改用区间收窄策略:剩余金额越少,随机区间从 [cut_lowest, cut_highest] 收窄到 [cut_lowest, min(cut_highest, 剩余金额 - min_price)],同时把 cut_highest 作为上限收紧。这个调整在实例代码里是 cut_random_strategy 函数,默认不带收窄逻辑,需要自行微调。
5. 进阶:在高并发和分布式场景下把砍价做到原子化
5.1 把行锁替换为 Redis 分布式锁
实例的 FOR UPDATE 方案在单库场景下没问题,但砍价活动一旦上线,同一时刻可能有几百人同时点击。数据库行锁的等待会让接口响应时间变长,甚至有人反馈“点了没反应”。这时就需要把锁从数据库层升级到 Redis。常见做法是砍价前先尝试获取 key 为 cut_lock:{order_id} 的锁,设置过期时间 10 秒,获取成功才执行业务逻辑,执行完后释放锁。我一般用 setnx 实现:
$lockKey = "cut_lock:" . $orderId; $lockValue = uniqid('', true); $isLock = $redis->set($lockKey, $lockValue, ['nx', 'ex' => 10]); if (!$isLock) { throw new Exception('系统繁忙,请稍后重试'); } try { $cutResult = $this->doCut($orderId, $userId); if ($cutResult === false) { throw new Exception('砍价失败'); } } finally { // 只有持有自己的锁值才释放,防止误删他人锁 if ($redis->get($lockKey) === $lockValue) { $redis->del($lockKey); } }setnx 的效果是:如果 key 不存在则设置成功,返回 true,否则返回 false。第二个参数 uniqid 保证每个请求的锁值不同,释放锁时校验该值,避免因为执行超时锁过期后,下一个请求拿到新锁,旧请求误删新锁。expire 10 秒是经验值——正常的数据库操作在 10 秒内必然完成,如果 10 秒还没完成说明业务有问题,宁可让它超时也不要无限等待。
5.2 用 Redis 计数做砍价前的二次校验
如果把 doCut 里的唯一性校验完全交给 MySQL,高并发下仍然会有一定的数据库压力。更轻量的方案是在 Redis 维护一个集合,key 为 cut_users:{order_id},每次砍价前执行 SISMEMBER 判断好友是否已砍过,若没有则 SADD 加入集合并设置过期时间。这个方案能扛住每秒上千次查询,但有一个风险:Redis 的集合没有持久化保证,如果 Redis 重启,集合数据会丢失,导致同一个好友在重启后可以再砍一次。补救措施是双写双读——Redis 做前置快速校验,MySQL 的唯一索引做最终兜底,只要数据库的唯一索引在,重复数据就写不进去。注意这里 Redis 集合的过期时间要和活动结束时间保持一致,活动结束后自动清理,避免内存垃圾堆积。
5.3 验证系统的三种测试方法
砍价系统写完不能只看功能通不通,还要验证边界条件和并发场景。第一,模拟并发测试:用 ab 工具或者简单的多线程脚本,同时发起 200 个砍价请求,观察最终剩余金额是否等于原价减去所有成功砍价金额的总和。第二,重复请求测试:同一好友连续两次调用 doCut,第二次必须返回“已经砍过”的错误,并且数据库砍价记录数只增加一条。第三,金额一致性测试:跑 1000 次砍价,统计剩余金额的最小值,永远不能低于 min_price。我自己的习惯是写一个脚本自动遍历所有订单,核对每笔订单的 remain_amount 等于 original_price 减去 SUM(cut_amount),数据对不上就说明有逻辑漏洞。
5.4 砍价完成的触发器:短信通知与状态回写
砍价完成不能只靠前端轮询,后端需要在订单状态变为 2 时触发后续动作。实例代码里预留了一个 hook 接口,在 doCut 的最后一行判断状态变化时,调用 sendCutDoneMessage 发送模板消息。这个钩子函数在实际项目里可以改为投递消息队列,把订单数据推给负责发放优惠券的模块,或者给用户推送微信模板消息。注意这个钩子必须在事务提交之后调用,不能在事务内执行——否则消息发送失败会导致整个事务回滚,砍价记录白写了,用户会看到金额退回,体验极差。
这套实例提供的核心价值不是代码本身能直接上线,而是它把砍价业务的关键状态和边界都写清楚了。我拿到后做的最有价值的一件事,是把原来的业务代码全部剥离掉,只保留数据表和 doCut 方法,套进了自己的框架里当作一个独立模块,再用 Redis 重写了并发层。从那以后我每次做营销类活动都会先建一张状态变化表,把每个操作的触发条件写清楚,再用并发脚本把边界打一遍,确认没有状态错乱才敢上线。这套思路放在砍价、拼团、秒杀上都通用。希望帮到你。
本文还有配套的精品资源,点击获取