简介:面向PHP初学者与电商开发者的砍价功能实例代码,完整演示从用户发起砍价、邀请好友助力到成交下单的闭环流程。压缩包内共3个文件,包含两个PHP脚本(kanjia.php与order.php)和一个数据库说明文档,整体仅7KB,结构精简,便于快速阅读核心逻辑。已有420人学习下载。实例覆盖砍价活动设计要点,如初始价格与结束时间记录、分享邀请机制、砍价次数限制、达到底价自动成交等;kanjia.php负责发起砍价与处理助力请求,order.php处理砍价成功后的订单创建与支付对接,数据库文档则列出砍价表、用户表、订单表的关键字段及PDO预处理防注入写法。通过这份代码,可掌握PHP+MySQL交互、SQL参数化查询、第三方支付回调等实用技能,也能了解完整的电商促销模块从设计到落地的实现思路,适合用来做二次开发或课程设计参考。
1. 砍价功能不是砍着玩:这份 PHP 实例的关键在算法与防刷
砍价在电商活动里用了这么多年,其实大部分 PHP 开发第一次接的时候都会低估它。需求听起来很简单:用户发起砍价,好友帮忙砍一刀,钱砍完就能下单。可一旦上线就会发现,纯随机金额砍不完、同一个人反复刷、并发下金额被砍成负数,这些问题跟页面 UI 没关系,全在数据结构和算法上。这份「php砍价功能实例.zip」我拆完看了下,表结构、砍价算法和接口防护都给了可跑的代码,适合正在做小程序或 Web 端营销活动、想直接抄一套砍价链路的人。它的价值不在于能跑,而在于把「怎么保证必然砍完」和「怎么防止刷」这两件事讲清楚了。
2. 砍价的数据地基:三张业务表怎么拆才扛得住并发
砍价功能的表结构看起来简单,实际上拆分方式直接决定后面能不能扛住并发和防刷。我见过不少项目把「一次砍价任务」和「每一刀的记录」塞在同一张表里,结果每次展示进度都要用 SUM 聚合,活动压力一大就慢。这份实例用了三张业务表外加一张用户表的做法,先看建表语句。
2.1 活动表:把砍价策略参数存进去,而不是写死在代码里
CREATE TABLE `bargain_activity` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `goods_name` VARCHAR(100) NOT NULL COMMENT '商品名称快照', `goods_price` INT UNSIGNED NOT NULL COMMENT '商品现价,单位分', `floor_price` INT UNSIGNED NOT NULL COMMENT '底价,单位分,砍到这个价即成功', `total_cut` INT UNSIGNED NOT NULL COMMENT '总可砍金额 = goods_price - floor_price', `cut_way` TINYINT NOT NULL DEFAULT 1 COMMENT '1阶梯递减 2平均 3随机', `first_ratio` INT NOT NULL DEFAULT 25 COMMENT '首刀比例,25表示25%', `decay_ratio` INT NOT NULL DEFAULT 80 COMMENT '递减系数,80表示下一刀是上一刀的80%', `start_time` DATETIME NOT NULL, `end_time` DATETIME NOT NULL, `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0未开始 1进行中 2已结束', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='砍价活动表';这段 SQL 的核心设计是把cut_way、first_ratio、decay_ratio这三个策略参数放到活动表里,而不是写在 PHP 代码常量中。这样同一个实例可以同时跑多个不同玩法的活动:A 活动首刀砍 30%,B 活动首刀砍 10%,运营后台改一行记录就行,不用动代码。goods_price和floor_price都存分,避免浮点数精度问题,这也是做金额类功能的基本功。
这里特别注意total_cut这个冗余字段。它可以在查询列表页时直接拿来算「砍价进度百分比」,不用每次去明细表 SUM,算是用空间换时间。但冗余字段有个前提:写入它的地方必须跟goods_price、floor_price的写入处在同一个事务里,否则会出现活动已创建、可砍总额却是旧值的脏状态。
2.2 参与表和明细表:一次砍价任务和每一刀要分开存
CREATE TABLE `bargain_joiner` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `activity_id` INT UNSIGNED NOT NULL COMMENT '活动ID', `user_id` INT UNSIGNED NOT NULL COMMENT '发起人用户ID', `remaining_cut` INT UNSIGNED NOT NULL COMMENT '剩余可砍金额,单位分', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0进行中 1已完成 2已失效', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_activity_user` (`activity_id`, `user_id`), KEY `idx_user` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='砍价参与表,一行代表一次砍价任务'; CREATE TABLE `bargain_record` ( `id` INT UNSIGNED NOT NULL AUTO_INCREMENT, `joiner_id` INT UNSIGNED NOT NULL COMMENT '参与表ID', `activity_id` INT UNSIGNED NOT NULL, `user_id` INT UNSIGNED NOT NULL COMMENT '发起人', `helper_id` INT UNSIGNED NOT NULL COMMENT '帮忙砍价的人', `amount` INT UNSIGNED NOT NULL COMMENT '本次砍掉的金额,单位分', `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_joiner_helper` (`joiner_id`, `helper_id`), KEY `idx_helper` (`helper_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='砍价明细表';bargain_joiner每个用户对同一个活动只能有一条记录,用来维护「剩下还能砍多少钱、是否已完成」;bargain_record存每次被砍的明细。拆成两张表的直接好处是:展示进度时读joiner.remaining_cut一条记录就够了,展示「谁帮我砍了多少钱」时查record列表,两边互不干扰。
bargain_record上的uk_joiner_helper唯一索引是天然去重闸门。同一个好友 ID 对同一次砍价任务只能插入一条记录,哪怕代码里忘了判断,数据库这层也会拦住重复插入。后面讲防刷时还会提到,这是最底层的那道保险。
2.3 初始化一条砍价任务:事务里写两张表
<?php try { $pdo->beginTransaction(); // 查出活动,锁住这一行,防止活动被并发状态下变更 $stmt = $pdo->prepare("SELECT * FROM bargain_activity WHERE id = ? AND status = 1 FOR UPDATE"); $stmt->execute([$activityId]); $activity = $stmt->fetch(PDO::FETCH_ASSOC); if (!$activity) { throw new RuntimeException('活动不存在或未开始'); } $now = time(); if ($now < strtotime($activity['start_time']) || $now > strtotime($activity['end_time'])) { throw new RuntimeException('不在活动时间内'); } $stmt = $pdo->prepare("INSERT INTO bargain_joiner (activity_id, user_id, remaining_cut, status) VALUES (?, ?, ?, 0)"); $stmt->execute([$activityId, $userId, $activity['total_cut']]); $joinerId = $pdo->lastInsertId(); // 发起人自己先砍首刀,后面再邀请好友 $firstAmount = intval($activity['total_cut'] * $activity['first_ratio'] / 100); $firstAmount = max($firstAmount, 1); $stmt = $pdo->prepare("INSERT INTO bargain_record (joiner_id, activity_id, user_id, helper_id, amount) VALUES (?, ?, ?, ?, ?)"); $stmt->execute([$joinerId, $activityId, $userId, $userId, $firstAmount]); // 扣减剩余金额 $stmt = $pdo->prepare("UPDATE bargain_joiner SET remaining_cut = remaining_cut - ? WHERE id = ? AND remaining_cut >= ?"); $stmt->execute([$firstAmount, $joinerId, $firstAmount]); if ($stmt->rowCount() === 0) { throw new RuntimeException('剩余金额不足'); } $pdo->commit(); } catch (Throwable $e) { $pdo->rollBack(); throw $e; }这段代码的逻辑顺序有讲究:先启事务,再查活动并加FOR UPDATE,然后插入参与记录,发起人砍第一刀,最后用UPDATE ... WHERE remaining_cut >= ?的方式扣减。注意这个更新条件是「剩余金额必须够扣」,如果前面多个人同时帮忙砍价,这条语句能保证扣完不会变成负数。
max($firstAmount, 1)是为了兜住首刀比例算出来不足 1 分钱的极端情况,保证每次砍价至少砍掉 1 分。至于为什么首刀要留给发起人自己,这是在模拟真实消费心理:如果第一刀不是发起人自己砍,活动页面的「邀请好友帮我砍」转化率会明显低,用户会觉得这事跟自己没关系。
3. 核心砍价算法:从随机金额到「必砍完」的阶梯递减
砍价功能真正拉开差距的地方不在表结构,在算法。很多初版实现喜欢用mt_rand(1, 剩余金额)直接随机,肉眼看着效果还可以,但一跑完整链路就发现:前面几刀砍太快,后面每一刀只能砍 0.01 元,好友看到几厘钱的金额根本不愿意点。更糟的是,随机算法没有兜底,砍到最后剩几分钱时,怎么砍都结束不了。
3.1 纯随机会翻车在哪
先看一个反面例子。假设商品现价 100 元,底价 50 元,总共要砍掉 50 元。如果每次随机rand(1, 5000)分,前三个好友可能一人一刀就砍掉 80% 的额度,剩下十几个好友只能瓜分 10 元,人均不到 1 元。这种体验下活动的传播性基本为零。
而从后端角度看,纯随机的另一个隐患是「不可收敛」。每次随机的上限是剩余金额,理论上总会砍完,但实际跑的时候,最后剩余 1~2 分钱时用户会放弃,因为再拉一个好友只便宜 0.01 元。要解决这个问题,砍价金额必须是有序的、递减的、且每一步都按预设规律走。
3.2 阶梯递减算法的 PHP 实现
这份实例里给了一种「按预期参与人数预生成权重序列」的做法。先预估这场活动大概会被多少人砍,然后生成一组递减的权重,每次砍价时按权重占比分配金额,最后一刀强制清空剩余。
<?php /** * 生成递减权重序列 * @param int $expectedCuts 预期砍价人数 * @param int $decayRatio 递减系数,单位百分比,80表示下一权重是当前的80% * @return array */ function generateWeights(int $expectedCuts, int $decayRatio = 80): array { $weights = []; $current = 100; for ($i = 0; $i < $expectedCuts; $i++) { $weights[] = $current; $current = intval($current * $decayRatio / 100); if ($current < 1) { $current = 1; } } return $weights; } /** * 计算本次砍价金额 * @param int $remainingCents 剩余可砍金额,单位分 * @param int $totalCents 总可砍金额,单位分 * @param array $weights 权重序列 * @param int $currentIndex 当前是第几刀,从0开始 * @return int 本次应砍金额,单位分 */ function calcCutAmount(int $remainingCents, int $totalCents, array $weights, int $currentIndex): int { $sum = array_sum($weights); if ($sum <= 0) { return $remainingCents; } // 权重占比 × 总可砍金额 $amount = intval($totalCents * $weights[$currentIndex] / $sum); // 最后一刀兜底,清空剩余 if ($currentIndex === count($weights) - 1) { return $remainingCents; } // 不能超过剩余,且不能少于1分 $amount = min($amount, $remainingCents); return max($amount, 1); } $weights = generateWeights(20, 80); // 假设发起人已经砍过一刀,这里从第2刀开始 $cutAmount = calcCutAmount(450000, 500000, $weights, 1);这段代码里有几个参数不是随便定的。expectedCuts是预期参与砍价的总人数,它决定了权重序列的长度;decayRatio=80表示相邻两刀的权重按 20% 递减,这样前期砍得多、后期砍得少,但因为有「最后一刀兜底」的逻辑,不管还剩多少,最后一个好友都能一刀清空。currentIndex对应的是当前是第几刀,需要从参与记录里统计出来,这个后面接口部分会讲。
权重法比直接按人数平均分优雅的地方在于:你不需要提前知道实际参与人数。即使预期 20 人,实际来了 50 人,超出权重序列的部分会一直按最后一个权重算,因为代码里$weights[$currentIndex]在索引越界时会报错,所以实际使用时要做索引取模或用min($currentIndex, count($weights) - 1)兜住。这也是这份实例里容易忽略的一个边界,后面避坑章节会展开说。
3.3 参数怎么调:首刀比例、递减系数和兜底策略
运营后台最常调的就是first_ratio和decay_ratio。首刀比例建议控制在 15%~30% 之间,太高会让用户觉得「好友砍不动」,太低则没有发起动力。递减系数decay_ratio控制在 75~85 比较常见:太接近 100 会导致前期优势不明显,太小则后面每刀几乎没感觉。
最后那刀兜底看似简单,其实有个业务侧确认要做:如果剩余金额巨大(比如发起人一直没邀请人,活动快结束时来了一个好友,一刀砍掉 300 元),用户会觉得反常。所以我在做类似功能时会加一个「单刀上限」。在calcCutAmount返回值之后,再跟一个配置项比对:
$cutAmount = min($cutAmount, intval($config['single_cut_limit']));如果兜底金额仍超上限,就把超出的部分留到下一次砍,而不是一次性清空。这意味着活动可能出现「永远差 1 分钱」的情况,但比起让用户一眼识破算法,业务上更愿意接受这种轻微延迟。
4. 接口与并发防护:一刀砍出负数余额的血泪教训
砍价模块和普通 CRUD 最大的不同在于,剩余金额是一个被高频并发修改的共享资源。以一座 50 万人的商城为例,一个砍价活动跑起来,同一秒钟可能有几十个人在帮忙砍同一个用户的商品。如果不做并发控制,「A 查余额 100、B 查余额 100、A 扣 30、B 扣 30」这种经典交错执行就能把余额扣成负数。
4.1 发起砍价接口:一次事务完成「建任务 + 首刀」
发起接口的逻辑在第 2 章已经给了核心代码,这里补充它在完整请求链路中的位置。接口接收activity_id和user_id,第一步检查用户是否已经参与过(靠uk_activity_user唯一索引),第二步检查活动是否在有效期内,第三步开启事务写入参与表和首刀明细。
之所以把首刀放进发起接口,而不是让用户发起后再单独调一次「自己砍自己」的接口,是为了省掉中间那个不稳定的状态。如果发起后、首刀前进程崩了,会出现一条remaining_cut完整未动的僵尸任务。首刀放进同一个事务里,要么任务建好且首刀已扣,要么全部回滚,不存在中间状态。
4.2 好友助力接口:条件 UPDATE 是防超扣的关键
<?php public function helpCut($activityId, $joinerId, $helperId, $currentIndex) { $pdo = $this->getPdo(); // 防重:同一好友对同一任务只能砍一次,数据库唯一索引兜底 $stmt = $pdo->prepare("INSERT INTO bargain_record (joiner_id, activity_id, user_id, helper_id, amount) VALUES (?, ?, ?, ?, 0)"); try { $stmt->execute([$joinerId, $activityId, $this->userId, $helperId]); } catch (PDOException $e) { if ($e->getCode() === '23000') { throw new RuntimeException('你已经帮TA砍过了'); } throw $e; } // 先插入一条金额为0的记录占位,拿到主键后再更新金额,这个顺序后面再解释 $recordId = $pdo->lastInsertId(); $activity = $this->getActivity($activityId); $joiner = $this->getJoiner($joinerId); $weights = generateWeights(20, $activity['decay_ratio']); $amount = calcCutAmount($joiner['remaining_cut'], $activity['total_cut'], $weights, $currentIndex); // 关键:条件更新,余额足够才扣 $stmt = $pdo->prepare("UPDATE bargain_joiner SET remaining_cut = remaining_cut - ? WHERE id = ? AND remaining_cut >= ?"); $stmt->execute([$amount, $joinerId, $amount]); $affected = $stmt->rowCount(); if ($affected === 0) { throw new RuntimeException('来晚了,已经被砍到底价'); } $pdo->prepare("UPDATE bargain_record SET amount = ? WHERE id = ?")->execute([$amount, $recordId]); $pdo->commit(); return $amount; }这段代码有几个设计选择值得细说。第一,先插一条金额为 0 的明细占位,再用事务更新金额,原因是想用唯一索引挡住重复助力,同时避免并发下重复插入。如果先算出金额再插入,两个请求同时通过代码层判断、同时执行 INSERT,会有一个被唯一索引挡住,另一个成功,逻辑也没问题;但先占位的方式可以把「去重判断」和「真实金额计算」解耦,谁先抢到插入谁才有资格继续往下算金额。
第二,UPDATE ... WHERE remaining_cut >= ?这一句是整个并发控制的核心,比 SELECT 后再算更安全。它把「判断余额够不够」和「扣减余额」合并成一个原子操作。就算两个请求同时闯进来,只有第一个能影响行数大于 0,第二个会拿到 0 行受影响。这个方案可以不加FOR UPDATE锁,也能防超扣,并发性能更好。
4.3 Redis 预扣与 MySQL 兜底:两种方案怎么选
上面这套纯 MySQL 事务方案的特点是绝对正确,但缺点是每个好友砍价都要串行化地对同一行做 UPDATE。一个爆款活动单行并发较高时,InnoDB 的行锁会让请求排队。如果业务规模再往上走,可以引入 Redis 做预扣。
常见做法是:用 Redis 的DECRBY直接扣减剩余金额,扣完再把明细异步写入 MySQL。这个方案吞吐量高,但引入了一致性风险:Redis 扣了、MySQL 写明细失败,两边就对不上。所以实际项目中我会把 Redis 当作第一道关卡,MySQL 事务仍然是最终依据。每次请求先DECRBY拿到扣减后的值,小于 0 直接拒绝;大于等于 0 则继续走 MySQL 事务。如果 MySQL 事务失败,再INCRBY把 Redis 补回去。Redis 偶尔不一致可以接受,补偿机制才是关键。
如果没有 Redis 环境,用第 4.2 节的条件 UPDATE 方案就足够了。这个实例包里两种写法的示例都带了,本地搭环境时可以先跑 MySQL 版,压测过了再决定要不要加 Redis 层。
5. 避坑排查:砍价模块最常见的五个翻车现场
砍价功能实现出来不难,但要稳定跑完整个活动周期,有几个坑几乎每个项目都会踩一轮。我按现象、原因、解决三个维度整理一下。
5.1 金额类问题:浮点精度与砍不完的尾款
现象:活动跑了两天,bargain_joiner.remaining_cut出现 1、2 这样的极小值,好友帮忙砍一刀后余额变成负数,或者剩余 0.5 分一直卡住。
原因:一种是用 FLOAT 或 DOUBLE 存金额,PHP 浮点运算0.1 + 0.2 !== 0.3的问题在累加扣减中会被持续放大;另一种是算法没有做最后一刀兜底,权重序列走完后剩余金额不再变化。
解决:金额字段一律用 INT 存分,所有 PHP 计算用整数运算,展示层再除以 100。算法层必须保证「最后一刀等于剩余金额」。建议在calcCutAmount里加一个断言:当currentIndex === count($weights) - 1时,直接return $remainingCents,不走任何比例计算。
5.2 并发类问题:超扣与插入异常
现象:高峰期同一个任务的remaining_cut在一分钟内从 5000 变成 0,但明细表里的金额加总超过了 5000;或者日志里频繁出现Duplicate entry报错。
原因:超扣是因为代码用了「先 SELECT 再 UPDATE」两步走,两个请求读到的剩余金额一样,各自按自己的计算结果扣减,后一个覆盖了前一个。Duplicate entry则是重复助力请求穿了代码层的判断,同时插入了同一条(joiner_id, helper_id)。
解决:扣减操作全部改成UPDATE ... WHERE remaining_cut >= ?,判断影响行数,不要用 SELECT 的结果做算术后再 UPDATE。数据库唯一索引不要去掉,它是在代码层防重失效时的最后一道闸。如果报错频繁但不想让异常刷日志,可以在 INSERT 前先查一遍,再捕获23000错误码做兜底。
5.3 业务类问题:活动状态与时间判断缺失
现象:活动还没开始,用户能通过直接构造接口请求提前砍价;活动已经结束,好友还能通过分享链接进入并砍成功。
原因:接口只校验了用户 ID 和任务 ID,没有在每次请求里重新读取活动状态和时间段。有些场景下发起接口做了校验,但助力接口只查了bargain_joiner,以为任务存在就放行。
解决:助力接口里每次都要拉取bargain_activity并校验status = 1和当前时间在start_time与end_time之间。注意这里不要用活动中台缓存的状态,因为状态可能被运营后台修改但缓存未失效。
5.4 安全类问题:请求参数可篡改与接口刷量
现象:有用户绕过前端按钮,直接用 HTTP 工具循环请求助力接口,给自己刷了几百刀;或者手动修改joiner_id帮别人砍价。
原因:接口没有做签名校验,参数里的helperId可以被任意指定;服务端只信任了前端传上来的金额或索引,没有基于登录态重新获取。
解决:helper_id不要从请求参数读取,改成从服务端会话或 token 里解出当前登录用户。接口需要加时间戳签名,防止参数被篡改。对单用户短时间内的请求次数做限流,比如 Redis 里存help:{userId}:{hour}的计数器,超过 20 次直接拒绝。
5.5 体验类问题:分享链接失效与进度展示不对
现象:好友点开分享链接,提示任务不存在;或者页面显示「还差 35 元」,点进去一砍却显示「已到底价」。
原因:分享链接里带的joiner_id是临时的,任务被用户删除或重建后 ID 变了;前端进度用的是「活动总金额 × 已完成比例」,但这个比例在并发扣减时是滞后值。
解决:bargain_joiner不做物理删除,只用status标记失效,分享链接始终能查到记录。进度查询直接读remaining_cut,前端公式改为(total_cut - remaining_cut) / total_cut,不要自己拿明细 SUM 去算。每次聚合展示都从bargain_joiner这一行读,避免明细表 SUM 与真实余额不一致。
6. 验证技巧:用并发压测把砍价链路走透
代码写完只能算完成一半。砍价模块最怕的并发超扣,靠人肉点页面根本测不出来。我习惯在本地环境起一个测试活动,然后用 Apache Bench 或类似的压测工具打助力接口验证余额的准确性。
ab -n 200 -c 50 -T 'application/x-www-form-urlencoded' \ -p /tmp/help_cut_post.txt \ 'http://127.0.0.1:8080/api/bargain/help_cut.php?activity_id=1&joiner_id=1'200 个请求、50 个并发同时打同一个助力接口,跑完后看数据库里bargain_joiner.remaining_cut是否等于 0,明细表SUM(amount)是否等于总可砍金额。只要这两个数对得上,说明事务和条件更新没被并发环境打穿。如果余额变成了负数,别急着改代码,先在接口里加日志,每一刀扣减前后都记录old_remaining、cut_amount、new_remaining和affected_rows,压测后拉日志看是哪一步的交错导致超扣。
还有一个我每次必做的验证:检查bargain_joiner.remaining_cut加SUM(bargain_record.amount)是否恒等于bargain_activity.total_cut。这个等式任何时候成立,说明数据链路是自洽的。也可以趁压测时故意把 Redis 停了,确认 MySQL 兜底事务能正常回滚,不会留下明细金额与余额对不上的脏数据。
最后补一句调试时的习惯:砍价这种跟钱相关的模块,上线前我会强制把所有金额字段打出来对比一遍,包括发起接口返回的首刀金额、助力接口返回的本次金额、前端展示的已砍总和。只要这三个数来自同一个数据源且单位一致,页面和接口各算各的就不会被打脸。砍价没有玄学,每个金额都是算出来的,每个请求都要被验证过。希望这些拆解能帮你把这份实例更快跑起来,少走我当年走过的弯路。
本文还有配套的精品资源,点击获取