PHP 用户钱包设计

wen PHP项目 5

PHP用户钱包设计:从零到高并发的架构实践与避坑指南

目录导读

  1. 钱包系统的核心痛点 – 为什么你的余额算不准?
  2. 数据库表设计 – 账户、流水、冻结三表缺一不可
  3. 事务与锁 – 避免超扣与幻读的终极方案
  4. 高并发下的扣款策略 – 乐观锁 vs 悲观锁 vs 队列削峰
  5. 金额精度陷阱 – 浮点数的“0.1+0.2”灾难
  6. 异常补偿与对账机制 – 如何保证最终一致性
  7. 安全防护 – 防刷、幂等、接口重放
  8. PHP代码实战 – 完整扣款/充值示例(含注释)
  9. 常见问答FAQ – 面试与实战高频问题

钱包系统的核心痛点

很多PHP开发者第一次设计钱包时,会直接用一个字段 balance 存余额,这样做在单用户、低并发下没问题,但一旦涉及多笔并发交易,就会出现超扣(余额变成负数)或丢单(充值成功但余额不变)。

PHP 用户钱包设计

根源在于:余额不是“状态”,而是“计算结果”。 正确的做法是:余额 = 所有成功流水的总和,数据库只存储流水,余额通过聚合或缓存得出。

搜索引擎已有知识综合:Stack Overflow、GitHub上主流开源钱包(如Laravel Wallet)均采用“流水账”模式,而非直接更新余额字段。


数据库表设计(三表分离)

1 用户账户表 user_account

CREATE TABLE `user_account` (
  `user_id` INT UNSIGNED NOT NULL,
  `balance` DECIMAL(12,2) NOT NULL DEFAULT 0.00, -- 冗余字段,用于加速查询
  `total_income` DECIMAL(12,2) NOT NULL DEFAULT 0.00,
  `total_expense` DECIMAL(12,2) NOT NULL DEFAULT 0.00,
  `version` INT UNSIGNED NOT NULL DEFAULT 0, -- 乐观锁版本号
  `updated_at` TIMESTAMP,
  PRIMARY KEY (`user_id`)
) ENGINE=InnoDB;

2 钱包流水表 wallet_transaction

CREATE TABLE `wallet_transaction` (
  `id` BIGINT UNSIGNED AUTO_INCREMENT,
  `user_id` INT UNSIGNED NOT NULL,
  `type` TINYINT NOT NULL COMMENT '1-充值 2-消费 3-退款 4-提现',
  `amount` DECIMAL(12,2) NOT NULL,
  `balance_after` DECIMAL(12,2) NOT NULL, -- 交易后余额,方便对账
  `order_no` VARCHAR(64) NOT NULL, -- 业务订单号,唯一索引防重
  `create_time` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (`id`),
  UNIQUE KEY `uk_order_no` (`order_no`),
  KEY `idx_user_time` (`user_id`, `create_time`)
) ENGINE=InnoDB;

3 冻结资金表 wallet_freeze(用于提现/预授权)

CREATE TABLE `wallet_freeze` (
  `id` BIGINT AUTO_INCREMENT,
  `user_id` INT NOT NULL,
  `freeze_amount` DECIMAL(12,2) NOT NULL,
  `status` TINYINT DEFAULT 0 COMMENT '0-冻结中 1-解冻 2-扣减',
  `expire_time` DATETIME,
  `transaction_id` BIGINT,
  PRIMARY KEY (`id`)
);

设计原则

  • 不使用 FLOAT/DOUBLE,用 DECIMAL(12,2) 保证精度。
  • 流水表只增不改删,为审计留痕。
  • 账户表 balance 是冗余,必须通过流水计算后定期校准。

事务与锁:核心扣款流程

1 悲观锁(适合单库,行锁)

// 开启事务
DB::beginTransaction();
try {
    // 锁定用户行 -- 防止并发修改
    $user = DB::selectOne("SELECT * FROM user_account WHERE user_id = ? FOR UPDATE", [$userId]);
    if ($user->balance < $amount) {
        throw new Exception('余额不足');
    }
    // 更新余额
    DB::update("UPDATE user_account SET balance = balance - ? WHERE user_id = ?", [$amount, $userId]);
    // 写流水
    DB::insert("INSERT INTO wallet_transaction ...");
    DB::commit();
} catch (Exception $e) {
    DB::rollBack();
}

缺点:高并发下锁等待严重,吞吐量低。

2 乐观锁(适合读多写少)

// 先读取版本号
$user = DB::selectOne("SELECT balance, version FROM user_account WHERE user_id = ?", [$userId]);
if ($user->balance < $amount) {
    throw new Exception('余额不足');
}
// 更新时检查版本号 -- 如果版本号不对,说明有人改过,重试
$affected = DB::update(
    "UPDATE user_account SET balance = balance - ?, version = version + 1 WHERE user_id = ? AND version = ?",
    [$amount, $userId, $user->version]
);
if ($affected === 0) {
    // 重试逻辑或报错
    throw new Exception('操作冲突,请重试');
}
// 写流水

3 队列削峰(推荐,适合活动/秒杀)

用户请求先入Redis队列,后台PHP进程批量消费,一次处理一批扣款,避免数据库瞬间压力。


金额精度陷阱

不要用 round() 处理货币!PHP的浮点运算会失真:

$amount = 0.1 + 0.2; // 0.30000000000000004

解决方案

  • 数据库:DECIMAL
  • PHP:用整数表示“分”,或使用 bcmath 扩展。
    // 用分做单位
    $amountInCents = 1000; // 表示10元
    // 计算:bcadd, bcsub
    $result = bcsub($userBalanceInCents, $amountInCents, 0);

异常补偿与对账

  • 定时任务:每5分钟拉取支付网关对账单,与本地流水表比对,差异生成异常单。
  • 流水表必须记录 balance_after:便于回溯哪一步出错。
  • order_no 做唯一索引:防止同一次支付回调触发两次充值。

安全防护

攻击方式 防御策略
接口重放 请求头加timestamp + token,Redis缓存5分钟幂等键
并发超扣 乐观锁 / 数据库行锁
篡改金额 服务端重新计算金额,不信任前端传参
SQL注入 使用预处理语句
内部越权 操作前校验用户身份与角色

PHP代码实战(完整扣款示例)

<?php
declare(strict_types=1);
/**
 * 演示:用户充值/扣款,保证并发安全
 * 使用Redis分布式锁 + 数据库事务
 */
class WalletService
{
    private $redis;
    private $pdo;
    public function __construct($pdo, $redis)
    {
        $this->pdo = $pdo;
        $this->redis = $redis;
    }
    /**
     * 扣款
     * @param int $userId
     * @param float $amount
     * @param string $orderNo
     * @return bool
     * @throws Exception
     */
    public function deduct(int $userId, float $amount, string $orderNo): bool
    {
        // 1. 幂等检查
        $exists = $this->pdo->prepare("SELECT id FROM wallet_transaction WHERE order_no = ?");
        $exists->execute([$orderNo]);
        if ($exists->fetch()) {
            return true; // 已处理,直接返回成功
        }
        // 2. 获取分布式锁(Redis),防止多进程竞争
        $lockKey = "wallet:lock:{$userId}";
        $locked = $this->redis->set($lockKey, 1, ['NX', 'EX' => 10]);
        if (!$locked) {
            throw new Exception('系统繁忙,请重试');
        }
        try {
            // 3. 事务处理
            $this->pdo->beginTransaction();
            // 3.1 使用悲观锁读取(或者乐观锁)
            $stmt = $this->pdo->prepare("SELECT balance FROM user_account WHERE user_id = ? FOR UPDATE");
            $stmt->execute([$userId]);
            $current = $stmt->fetchColumn();
            if ($current < $amount) {
                throw new Exception('余额不足');
            }
            // 3.2 扣减余额
            $update = $this->pdo->prepare("UPDATE user_account SET balance = balance - ? WHERE user_id = ?");
            $update->execute([$amount, $userId]);
            // 3.3 记录流水
            $insert = $this->pdo->prepare(
                "INSERT INTO wallet_transaction (user_id, type, amount, balance_after, order_no) VALUES (?, ?, ?, ?, ?)"
            );
            $insert->execute([$userId, 2, $amount, $current - $amount, $orderNo]);
            $this->pdo->commit();
            return true;
        } catch (Exception $e) {
            $this->pdo->rollBack();
            throw $e;
        } finally {
            // 释放锁
            $this->redis->del($lockKey);
        }
    }
}

常见问答FAQ

Q1:为什么不能用FLOAT类型存金额? A:因为浮点数无法精确表示十进制小数(二进制精度限制),会导致0.1+0.2=0.30000000000004,必须用DECIMAL或整数(分)。

Q2:高并发下,乐观锁和悲观锁怎么选? A:悲观锁(FOR UPDATE)适合写多读少、冲突概率高的场景,简单可靠;乐观锁(版本号)适合读多写少,避免长时间锁表,但冲突时需重试,秒杀场景建议用Redis队列削峰。

Q3:余额直接存账户表,总是对不上账怎么办? A:强制规定:任何资金变动必须写流水,账户余额仅作为缓存,每天通过 SELECT SUM(amount) FROM 流水 与账户表对比,不一致时以流水为准重建余额。

Q4:支付回调重复通知,怎么保证只加一次钱? A:使用流水表的order_no唯一索引,插入时捕获Duplicate entry异常,若订单号已存在,直接返回成功,不重复加钱。

Q5:用户提现时,如何防止余额被并发消费? A:采用“冻结”模式,提现申请时,在wallet_freeze表插入冻结记录,并扣减可用余额;提现成功后更新冻结记录状态,查询余额时用 balance - SUM(freeze_amount) 作为可用余额。


(文章基于Laravel、ThinkPHP等主流框架实践,并结合MySQL8.0的锁机制与Redis分布式锁经验总结)

抱歉,评论功能暂时关闭!