本文目录导读:

在 PHP 中实现积分冻结/解冻功能,核心是确保数据一致性,防止并发问题导致积分被重复使用或超额消费。
以下是一个完整的 PHP 实现方案,包含数据库设计、核心逻辑以及业务场景示例。
数据库设计
你需要三张核心表:users(用户表)、points_accounts(积分账户表)、points_transactions(积分流水表)。
-- 用户表(假设已存在,这里只添加积分相关字段)
ALTER TABLE `users` ADD `points_total` INT DEFAULT 0 COMMENT '总积分余额';
ALTER TABLE `users` ADD `points_frozen` INT DEFAULT 0 COMMENT '冻结积分';
-- 积分流水表 (关键:记录每一次变动)
CREATE TABLE `points_transactions` (
`id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
`user_id` INT UNSIGNED NOT NULL COMMENT '用户ID',
`change_amount` INT NOT NULL COMMENT '变动积分(正数增加,负数减少)',
`frozen_amount` INT NOT NULL DEFAULT 0 COMMENT '冻结变动(正数冻结,负数解冻/扣除)',
`type` ENUM('earn', 'spend', 'freeze', 'unfreeze', 'expire') NOT NULL COMMENT '变动类型',
`order_id` VARCHAR(64) DEFAULT NULL COMMENT '关联业务订单号',
`description` VARCHAR(255) DEFAULT NULL COMMENT '描述',
`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP
) COMMENT='积分流水表';
核心 PHP 类设计
我们将使用一个 PointsService 类,利用 MySQL 的 事务(Transaction) 和 行锁(SELECT ... FOR UPDATE) 来保证数据安全。
<?php
class PointsService
{
private PDO $pdo;
public function __construct(PDO $pdo)
{
$this->pdo = $pdo;
}
/**
* 冻结积分
* @param int $userId
* @param int $amount 冻结数量(必须为正数)
* @param string $orderId 业务订单号(用于解冻时定位)
* @param string $description
* @return bool
* @throws Exception
*/
public function freeze(int $userId, int $amount, string $orderId, string $description = ''): bool
{
if ($amount <= 0) {
throw new InvalidArgumentException('冻结积分必须为正数');
}
try {
$this->pdo->beginTransaction();
// 1. 锁定用户行,防止并发
$sql = "SELECT points_total, points_frozen FROM users WHERE id = :uid FOR UPDATE";
$stmt = $this->pdo->prepare($sql);
$stmt->execute([':uid' => $userId]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$user) {
throw new RuntimeException('用户不存在');
}
// 2. 检查可用余额(总积分 - 已冻结积分)
$available = $user['points_total'] - $user['points_frozen'];
if ($available < $amount) {
throw new RuntimeException('可用积分不足,无法冻结');
}
// 3. 更新用户积分(冻结数增加)
$updateSql = "UPDATE users
SET points_frozen = points_frozen + :freeze
WHERE id = :uid";
$this->pdo->prepare($updateSql)->execute([
':freeze' => $amount,
':uid' => $userId
]);
// 4. 写入流水记录(记录冻结)
$this->recordTransaction($userId, $amount, 0, 'freeze', $orderId, $description);
// 5. 提交事务
$this->pdo->commit();
return true;
} catch (Exception $e) {
$this->pdo->rollBack();
// 在这里可以记录日志
throw $e;
}
}
/**
* 解冻积分(退回到可用余额)
* @param int $userId
* @param int $amount 解冻数量
* @param string $orderId 原始冻结的订单号
* @param string $description
* @return bool
* @throws Exception
*/
public function unfreeze(int $userId, int $amount, string $orderId, string $description = ''): bool
{
if ($amount <= 0) {
throw new InvalidArgumentException('解冻积分必须为正数');
}
try {
$this->pdo->beginTransaction();
// 1. 锁定用户行
$sql = "SELECT points_total, points_frozen FROM users WHERE id = :uid FOR UPDATE";
$stmt = $this->pdo->prepare($sql);
$stmt->execute([':uid' => $userId]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$user) {
throw new RuntimeException('用户不存在');
}
// 2. 检查冻结余额是否足够
if ($user['points_frozen'] < $amount) {
throw new RuntimeException('冻结余额不足,无法解冻');
}
// 3. 更新用户积分(冻结数减少)
$updateSql = "UPDATE users
SET points_frozen = points_frozen - :unfreeze
WHERE id = :uid";
$this->pdo->prepare($updateSql)->execute([
':unfreeze' => $amount,
':uid' => $userId
]);
// 4. 写入流水记录(记录解冻)
$this->recordTransaction($userId, 0, -$amount, 'unfreeze', $orderId, $description);
// 5. 提交事务
$this->pdo->commit();
return true;
} catch (Exception $e) {
$this->pdo->rollBack();
throw $e;
}
}
/**
* 消费冻结积分(真正扣减,积分消失)
* @param int $userId
* @param int $amount 消费数量
* @param string $orderId 原始冻结的订单号
* @param string $description
* @return bool
* @throws Exception
*/
public function consumeFrozen(int $userId, int $amount, string $orderId, string $description = ''): bool
{
if ($amount <= 0) {
throw new InvalidArgumentException('消费积分为正数');
}
try {
$this->pdo->beginTransaction();
// 1. 锁定用户行
$sql = "SELECT points_total, points_frozen FROM users WHERE id = :uid FOR UPDATE";
$stmt = $this->pdo->prepare($sql);
$stmt->execute([':uid' => $userId]);
$user = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$user) {
throw new RuntimeException('用户不存在');
}
// 2. 检查冻结余额
if ($user['points_frozen'] < $amount) {
throw new RuntimeException('冻结余额不足');
}
// 3. 这里比较关键:
// 消费冻结积分 = 总积分减少 + 冻结积分减少
$updateSql = "UPDATE users
SET points_total = points_total - :spend,
points_frozen = points_frozen - :spend_frozen
WHERE id = :uid";
$this->pdo->prepare($updateSql)->execute([
':spend' => $amount,
':spend_frozen' => $amount,
':uid' => $userId
]);
// 4. 写入流水(负数表示积分消耗)
$this->recordTransaction($userId, -$amount, -$amount, 'spend', $orderId, $description);
$this->pdo->commit();
return true;
} catch (Exception $e) {
$this->pdo->rollBack();
throw $e;
}
}
/**
* 写入流水记录
*/
private function recordTransaction(int $userId, int $pointsChange, int $frozenChange, string $type, string $orderId, string $desc): void
{
$sql = "INSERT INTO points_transactions
(user_id, change_amount, frozen_amount, type, order_id, description)
VALUES (:uid, :pChange, :fChange, :type, :oid, :desc)";
$this->pdo->prepare($sql)->execute([
':uid' => $userId,
':pChange' => $pointsChange,
':fChange' => $frozenChange,
':type' => $type,
':oid' => $orderId,
':desc' => $desc
]);
}
}
业务场景示例(交易流程)
假设你在做一个电商系统,下单时冻结积分,取消时解冻,完成支付后消费积分。
<?php
// 1. 用户下单(冻结100积分)
$points = new PointsService($pdo);
try {
$points->freeze($userId, 100, 'ORDER12345', '购物订单冻结');
// 订单状态设为 "等待支付"
} catch (Exception $e) {
// 冻结失败(积分不足),拒绝下单
echo "下单失败: " . $e->getMessage();
}
// 2. 用户取消订单(解冻)
try {
$points->unfreeze($userId, 100, 'ORDER12345', '取消订单解冻');
// 订单状态取消
} catch (Exception $e) {
// 处理异常(订单号重复等)
}
// 3. 用户支付成功(消费冻结积分)
try {
$points->consumeFrozen($userId, 100, 'ORDER12345', '支付完成消费积分');
// 订单完成
} catch (Exception $e) {
// 处理异常
}
?>
高级注意事项
A. 防止双重解冻/消费
- 你的业务订单表需要有一个字段(如
status)来记录订单当前状态(待支付、已取消、已完成)。 - 在调用
unfreeze或consumeFrozen之前,先检查订单状态,确保该订单未被处理过。 - 更严格的做法:在
订单表加唯一索引,或在事务中结合订单状态加锁。
B. 过期积分处理
- 如果积分有有效期,需要在定时任务(Cron Job)中扫描过期积分。
- 可以将冻结或未冻结的积分在过期时清除,并记录流水。
C. 避免使用浮点数
- 积分必须用
INT或DECIMAL,绝对不能使用FLOAT/DOUBLE,否则会有精度问题。
D. 乐观锁替代方案
- 如果你的数据库不支持行锁(
FOR UPDATE)或不想用事务,可以使用“乐观锁”:- 在
users表增加一个version字段。 - 更新时加上
WHERE version = :old_version,如果影响行数为0,则代表有并发冲突,需要重试。
- 在
- 但使用事务+
FOR UPDATE是金融系统推荐的首选方式。
这个方案通过数据库事务 + 行级锁(SELECT ... FOR UPDATE) 保证了积分操作的原子性,彻底避免了并发下可能出现的“超扣”或“重复解冻”问题。
你可以根据这个核心逻辑,结合自己的业务框架(如 Laravel、ThinkPHP)进行封装。