本文目录导读:

在 PHP 中实现优惠券发放,通常涉及生成、存储、发放和核销几个环节,下面我会从业务逻辑和代码实现两个层面,为你提供一个完整的实施方案,重点在于解决并发问题和防止超发。
数据库表结构设计(MySQL)
需要设计两张核心表:coupon_templates(优惠券模板)和 user_coupons(用户持有的优惠券)。
-- 优惠券模板表 CREATE TABLE `coupon_templates` ( `id` int(11) unsigned NOT NULL AUTO_INCREMENT, `name` varchar(100) NOT NULL COMMENT '优惠券名称', `type` tinyint(1) NOT NULL DEFAULT '1' COMMENT '类型:1-满减券,2-折扣券', `threshold` decimal(10,2) unsigned NOT NULL DEFAULT '0.00' COMMENT '使用门槛(满多少可用)', `discount` decimal(10,2) unsigned NOT NULL DEFAULT '0.00' COMMENT '优惠金额/折扣率', `total_stock` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '总库存', `issued_stock` int(11) unsigned NOT NULL DEFAULT '0' COMMENT '已发放数量', `start_time` datetime NOT NULL COMMENT '生效开始时间', `end_time` datetime NOT NULL COMMENT '生效结束时间', `status` tinyint(1) NOT NULL DEFAULT '1' COMMENT '状态:1-启用,0-禁用', `version` int(11) NOT NULL DEFAULT '0' COMMENT '乐观锁版本号(可选,用于并发控制)', PRIMARY KEY (`id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 用户优惠券表 CREATE TABLE `user_coupons` ( `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT, `user_id` int(11) unsigned NOT NULL COMMENT '用户ID', `template_id` int(11) unsigned NOT NULL COMMENT '模板ID', `code` varchar(32) NOT NULL COMMENT '优惠券码(唯一)', `status` tinyint(1) NOT NULL DEFAULT '0' COMMENT '状态:0-未使用,1-已使用,2-已过期', `received_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '领取时间', `used_at` datetime DEFAULT NULL COMMENT '使用时间', PRIMARY KEY (`id`), UNIQUE KEY `uk_code` (`code`), -- 唯一索引防止重复 KEY `idx_user_id_status` (`user_id`, `status`), KEY `idx_template_id` (`template_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
核心发放逻辑(PHP代码)
关键点:防止并发超发,需要使用数据库事务 + 行锁(SELECT ... FOR UPDATE)或原子更新。
方案A:使用数据库原子更新(推荐,性能较好)
这种方法不依赖锁,利用 SQL 的原子特性,保证库存不会超发。
<?php
class CouponService
{
private PDO $pdo;
public function __construct(PDO $pdo)
{
$this->pdo = $pdo;
}
/**
* 发放优惠券(推荐方案:原子更新 + 防重复)
* @param int $userId 用户ID
* @param int $templateId 优惠券模板ID
* @return bool|string 成功返回优惠券码,失败返回false
* @throws Exception
*/
public function issueCoupon(int $userId, int $templateId)
{
// 1. 查询模板信息(开启事务)
$this->pdo->beginTransaction();
try {
// 检查是否已领取(防重复领取)
$checkSql = "SELECT id FROM user_coupons WHERE user_id = ? AND template_id = ? AND status = 0 LIMIT 1";
$checkStmt = $this->pdo->prepare($checkSql);
$checkStmt->execute([$userId, $templateId]);
if ($checkStmt->fetch()) {
$this->pdo->commit();
return false; // 已领取过
}
// 2. 原子更新库存:增加已发放数量,但不超过总库存
// 关键SQL: UPDATE ... SET issued_stock = issued_stock + 1 WHERE id = ? AND issued_stock < total_stock
$updateSql = "UPDATE coupon_templates
SET issued_stock = issued_stock + 1
WHERE id = ? AND issued_stock < total_stock AND status = 1
AND start_time <= NOW() AND end_time >= NOW()";
$updateStmt = $this->pdo->prepare($updateSql);
$updateStmt->execute([$templateId]);
$affectedRows = $updateStmt->rowCount();
if ($affectedRows === 0) {
// 如果没有更新成功,说明没库存或不在有效期
$this->pdo->rollBack();
return false;
}
// 3. 生成唯一优惠券码
$code = $this->generateUniqueCode(); // 建议使用UUID或加密随机数
// 4. 插入用户优惠券记录
$insertSql = "INSERT INTO user_coupons (user_id, template_id, code, status, received_at) VALUES (?, ?, ?, 0, NOW())";
$insertStmt = $this->pdo->prepare($insertSql);
$insertStmt->execute([$userId, $templateId, $code]);
// 5. 提交事务
$this->pdo->commit();
return $code;
} catch (Exception $e) {
$this->pdo->rollBack();
throw $e;
}
}
/**
* 生成唯一优惠券码
*/
private function generateUniqueCode(): string
{
// 使用 UUID 前缀 + 随机数,确保唯一性
$uuid = md5(uniqid(mt_rand(), true));
// 可以自定义算法,例如生成 16 位大写字母数字组合
return strtoupper(substr($uuid, 0, 16));
}
}
优点:
- 性能高(无行锁阻塞)
- 并发下保证不会超发
方案B:使用 SELECT ... FOR UPDATE(行锁,适合库存量小或对一致性要求极高的场景)
这种方案通过数据库行锁来阻塞并发请求。
public function issueCouponWithLock(int $userId, int $templateId)
{
$this->pdo->beginTransaction();
try {
// 1. 锁住模板记录(悲观锁)
$selectSql = "SELECT * FROM coupon_templates WHERE id = ? AND status = 1 FOR UPDATE";
$selectStmt = $this->pdo->prepare($selectSql);
$selectStmt->execute([$templateId]);
$template = $selectStmt->fetch();
if (!$template) {
$this->pdo->rollBack();
throw new Exception('优惠券模板不存在或已禁用');
}
// 2. 检查有效期
$now = time();
if ($now < strtotime($template['start_time']) || $now > strtotime($template['end_time'])) {
$this->pdo->rollBack();
throw new Exception('不在领取时间范围内');
}
// 3. 检查库存
if ($template['issued_stock'] >= $template['total_stock']) {
$this->pdo->rollBack();
throw new Exception('优惠券已领完');
}
// 4. 检查用户是否已领取(防止重复)
$checkSql = "SELECT id FROM user_coupons WHERE user_id = ? AND template_id = ? LIMIT 1";
$checkStmt = $this->pdo->prepare($checkSql);
$checkStmt->execute([$userId, $templateId]);
if ($checkStmt->fetch()) {
$this->pdo->rollBack();
throw new Exception('您已领取过该优惠券');
}
// 5. 更新库存
$updateSql = "UPDATE coupon_templates SET issued_stock = issued_stock + 1 WHERE id = ?";
$updateStmt = $this->pdo->prepare($updateSql);
$updateStmt->execute([$templateId]);
// 6. 生成并插入用户券
$code = $this->generateUniqueCode();
$insertSql = "INSERT INTO user_coupons (user_id, template_id, code, status) VALUES (?, ?, ?, 0)";
$insertStmt = $this->pdo->prepare($insertSql);
$insertStmt->execute([$userId, $templateId, $code]);
$this->pdo->commit();
return true;
} catch (Exception $e) {
$this->pdo->rollBack();
throw $e;
}
}
异步队列发放(适合大规模营销活动)
如果是类似双十一的抢券场景,直接同步写数据库可能导致数据库压力过大,此时可以采用以下架构:
- 请求先写入 Redis 队列(
lpush coupon_queue_{templateId} userId)。 - 后端 Worker 进程(如使用
RabbitMQ、Redis Stream)消费队列,批量预生成优惠券码存入缓存。 - Worker 异步将发券记录写入数据库。
这种方案能有效削峰填谷,但实现复杂度较高。
关键优化建议
| 优化点 | 说明 |
|---|---|
| 库存预热 | 大促时提前将库存写入 Redis,发放时先扣减 Redis 库存,再异步同步到 MySQL |
| 防刷处理 | 增加接口频率限制(如 RateLimiter 或 Redis 计数),防止脚本刷券 |
| 优惠券码生成 | 使用 snowflake 算法或 UUID 生成唯一码,避免使用简单的自增ID |
| 事务控制 | 确保原子性操作,避免超发 |
| 索引优化 | 在 user_id + template_id 上建唯一索引,从数据库层面防止重复领取 |
完整调用示例(前端接口)
public function couponController(Request $request)
{
$userId = $request->input('user_id');
$templateId = $request->input('template_id');
$service = new CouponService(new PDO(...));
try {
$result = $service->issueCoupon($userId, $templateId);
if ($result) {
return json_encode(['code' => 200, 'msg' => '领取成功', 'coupon_code' => $result]);
} else {
return json_encode(['code' => 400, 'msg' => '领取失败(可能已领完或已领取)']);
}
} catch (Exception $e) {
return json_encode(['code' => 500, 'msg' => $e->getMessage()]);
}
}
最推荐使用 方案A(原子更新),它在高并发下性能最优,且能有效防止超卖,如果业务允许,还可以结合 Redis 预扣库存进一步提升性能。
版权声明:NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
NBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
CBA直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
足球直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播
英超直播