本文目录导读:

在 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 预扣库存进一步提升性能。