本文目录导读:

这是一个非常典型且很有价值的电商或会员系统需求,一个健壮的优惠券系统不仅仅是“生成一个码”,它需要处理生成、发放、核销、过期、阈值(满减/折扣)以及防超发等核心逻辑。
下面从数据库设计、核心功能逻辑、防并发处理以及代码示例四个维度,为你提供一个PHP可落地的完整方案。
核心数据库设计
你需要至少两张核心表:优惠券定义表(券模板)和用户持有表(券实例)。
-- 1. 优惠券定义表:定义一张券长什么样
CREATE TABLE `coupon_define` (
`id` int(11) UNSIGNED NOT NULL AUTO_INCREMENT,
`name` varchar(50) NOT NULL DEFAULT '' COMMENT '优惠券名称',
`type` tinyint(1) NOT NULL DEFAULT 1 COMMENT '类型:1=满减券 2=折扣券 3=无门槛/代金券',
`value` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '减免金额(元) 或 折扣率(如0.85表示85折)',
`condition_amount` decimal(10,2) NOT NULL DEFAULT 0.00 COMMENT '满减门槛金额,0表示无门槛',
`total_count` int(10) UNSIGNED NOT NULL DEFAULT 0 COMMENT '发行总量(0表示不限)',
`received_count` int(10) UNSIGNED NOT NULL DEFAULT 0 COMMENT '已领取数(控制超发)',
`start_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '可领取开始时间',
`end_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '可领取截止时间',
`valid_days` smallint(5) UNSIGNED NOT NULL DEFAULT 0 COMMENT '领取后有效期(天数),0表示使用固定截止日',
`status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '状态:0=未发布 1=已发布 2=已失效',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 2. 用户持有的优惠券实例:用户领到的一张具体券
CREATE TABLE `coupon_user` (
`id` int(11) UNSIGNED NOT NULL AUTO_INCREMENT,
`user_id` int(11) UNSIGNED NOT NULL COMMENT '用户ID',
`coupon_define_id` int(11) UNSIGNED NOT NULL COMMENT '关联的券定义ID',
`code` varchar(32) NOT NULL DEFAULT '' COMMENT '优惠券唯一码(可用于展示和验证)',
`status` tinyint(1) NOT NULL DEFAULT 0 COMMENT '状态:0=未使用 1=已使用 2=已过期 3=已冻结',
`used_at` datetime DEFAULT NULL COMMENT '使用时间',
`order_id` int(11) UNSIGNED DEFAULT NULL COMMENT '用在哪笔订单ID',
`start_time` datetime NOT NULL COMMENT '有效期起始',
`end_time` datetime NOT NULL COMMENT '有效期截止',
`created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
KEY `idx_user_status` (`user_id`, `status`),
UNIQUE KEY `uniq_code` (`code`) -- 防重复核销
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
设计要点:
received_count一定要加锁控制。- 在
coupon_user中存储start_time/end_time,是实际生效的时间,可以是固定日期,也可以按领取时间+有效天数动态生成。
核心业务流程
用户领取优惠券
- 校验:领取时间
start_time <= now() < end_time;发行总量received_count < total_count;每人限领(建议用单独的字段或查coupon_user记录判断)。 - 生成:生成唯一码(
uuid/uniqid + rand/ 雪花ID / 防重随机码),目的是防止被猜出或重复。 - 扣减:原子操作
UPDATE coupon_define SET received_count = received_count + 1 WHERE id = ? AND received_count < total_count - 写入:插入
coupon_user记录,并计算失效时间(valid_days > 0则start_time = now(), end_time = DATE_ADD(now(), INTERVAL valid_days DAY);否则使用定义表中的固定时间段)。
订单结算/核销优惠券
- 选择优惠券:前端展示用户可用的券(未使用 + 在有效期 + 订单金额 >=
condition_amount)。 - 适用校验:后端接收
user_id, coupon_user_id, order_amount。- 校验券状态为“未使用”。
- 校验券归属用户。
- 校验
order_amount >= condition_amount。 - 校验有效期。
- 计算优惠金额:根据
type计算(满减直接减value,折扣则order_amount * (1 - value),可设封顶)。 - 锁定/预占:建议将
coupon_user.status = 3(冻结),防止重复提交。 - 下单成功后:更新
status = 1(已使用),used_at,order_id。
过期处理
- 方案A(推荐):查询时实时判断,在查询用户优惠券列表时,用
end_time < NOW()来判断是否过期,不依赖定时任务,更可靠。 - 方案B:定时任务(Cron)每小时更新
status=2,适合数据量超大、需要查询性能极致的场景。
高并发与防超发(关键)
优惠券抢购容易遇到并发问题,PHP + MySQL 下最常见的处理方式:
使用 UPDATE ... WHERE ... 加行锁(最常用且可靠):
<?php
try {
$db->beginTransaction();
// 1. 锁定并扣减库存(行锁)
$sql = "UPDATE coupon_define SET received_count = received_count + 1
WHERE id = ? AND total_count > received_count AND status = 1
AND start_time <= NOW() AND end_time > NOW()";
$stmt = $db->prepare($sql);
$stmt->execute([$couponDefineId]);
$affected = $stmt->rowCount();
if ($affected <= 0) {
$db->rollBack();
throw new \Exception('优惠券已领完或不在领取期间');
}
// 2. 生成唯一券码(建议用足够随机的字符串避免恶意碰撞)
$code = bin2hex(random_bytes(8)); // 生成16位十六进制字符串
// 3. 计算用户这张券的有效期
$now = date('Y-m-d H:i:s');
$expireDate = date('Y-m-d H:i:s', strtotime('+30 days')); // 假设有效期为30天
// 4. 写入用户领券记录
$sql = "INSERT INTO coupon_user (user_id, coupon_define_id, code, status, start_time, end_time, created_at)
VALUES (?, ?, ?, 0, ?, ?, ?)";
$stmt = $db->prepare($sql);
$stmt->execute([$userId, $couponDefineId, $code, $now, $expireDate, $now]);
$db->commit();
return ['coupon_user_id' => $db->lastInsertId(), 'code' => $code];
} catch (\Exception $e) {
$db->rollBack();
throw $e;
}
注意:不要使用
SELECT ... FOR UPDATE+ 应用层判断,除非你确信事务隔离级别并自行处理死锁。UPDATE直接在WHERE中加条件是最简单高效的。
PHP代码示例:核销优惠券
<?php
class CouponService
{
/**
* 核销优惠券
* @param int $userId
* @param int $couponUserId
* @param float $orderAmount
* @param int $orderId
* @return float 优惠金额
* @throws \Exception
*/
public function useCoupon(int $userId, int $couponUserId, float $orderAmount, int $orderId): float
{
$db = \DB::connection();
try {
$db->beginTransaction();
// 1. 锁定用户持有的券记录(行锁)
$sql = "SELECT * FROM coupon_user WHERE id = ? AND status = 0 FOR UPDATE";
$couponUser = $db->selectOne($sql, [$couponUserId]);
if (!$couponUser) {
throw new \Exception('优惠券不存在或已使用');
}
if ($couponUser->user_id !== $userId) {
throw new \Exception('优惠券不属于当前用户');
}
if ($couponUser->end_time < date('Y-m-d H:i:s')) {
throw new \Exception('优惠券已过期');
}
// 2. 查询券定义
$couponDefine = $db->selectOne(
"SELECT * FROM coupon_define WHERE id = ?",
[$couponUser->coupon_define_id]
);
if (!$couponDefine || $couponDefine->status !== 1) {
throw new \Exception('优惠券活动无效');
}
// 3. 计算优惠金额
$discountAmount = 0;
if ($orderAmount < $couponDefine->condition_amount) {
throw new \Exception('订单金额不满足使用条件');
}
switch ($couponDefine->type) {
case 1: // 满减
$discountAmount = min($couponDefine->value, $orderAmount);
break;
case 2: // 折扣
$discountAmount = $orderAmount * (1 - $couponDefine->value);
// 可以设定最高减免金额
break;
case 3: // 无门槛
$discountAmount = min($couponDefine->value, $orderAmount);
break;
default:
throw new \Exception('优惠券类型错误');
}
// 4. 标记为已使用
$db->update(
"UPDATE coupon_user SET status = 1, used_at = NOW(), order_id = ? WHERE id = ? AND status = 0",
[$orderId, $couponUserId]
);
$db->commit();
return round($discountAmount, 2);
} catch (\Exception $e) {
$db->rollBack();
throw $e;
}
}
}
进阶优化点
- 唯一码防撞:对于批量生成或安全要求高,可以使用
hashids或uniqid + crc32组合。 - 用户领券频控:在领取接口加 Redis 计数器,防止脚本刷券:
$redis->incr('coupon:limit:user_' . $userId, 1, 3600)。 - 分表分库:用户券量级上千万时,考虑按
user_id哈希分表。 - 异步核销:高并发下单场景,可以将“用券”操作放入消息队列(RabbitMQ / Redis list),后端异步更新状态。
- 缓存:用户券列表可以缓存到 Redis,但状态变更(过期、使用)需要及时同步清理。
- 表结构:券定义 + 用户券,区分模板与实例。
- 核心逻辑:领取时用
UPDATE ... WHERE received_count < total_count保证不超发;使用事务 +FOR UPDATE保证状态一致。 - 计算优惠:按类型处理,注意折扣不能倒贴。
- 并发处理:数据库行锁是 PHP 项目的首选,简单且稳定。
按照这个方案,可以支撑绝大多数中小型项目的优惠券系统,代码示例可以直接用于 Laravel、ThinkPHP 或原生 PHP。