PHP 钱包流水记录

wen PHP项目 5

PHP钱包流水记录:从零构建高并发、防篡改的交易日志系统

目录导读

  • 第一部分:为什么钱包流水记录如此重要? — 金融级合规与用户信任的基石
  • 第二部分:数据库表设计精髓 — 如何用“单向链表”防篡改
  • 第三部分:PHP核心代码实现 — 流水写入、余额更新与事务原子性
  • 第四部分:高并发与性能优化 — 乐观锁、队列与读写分离实战
  • 第五部分:排查与监控 — 常见坑及流水一致性校验算法
  • 第六部分:常见问题FAQ — 面试官最爱问的5个流水难题

第一部分:为什么钱包流水记录如此重要?

在任何一个涉及资金交易的PHP系统(电商、P2P理财、积分商城、游戏充值)中,钱包流水(Wallet Ledger) 不仅仅是“交易明细列表”,它是整个账户系统的审计日志状态还原依据,如果流水记录不严谨,轻则账目对不上,重则用户资金被盗刷或平台被薅羊毛。

PHP 钱包流水记录

核心痛点: 普通日志(如文本文件或简单Log)无法满足事务性需求,一个完整的钱包流水必须满足三个特性:

  1. 不可篡改性(一旦写入,物理上极难更改)
  2. 幂等性(同一笔业务请求,重复提交不会重复扣款)
  3. 可追溯性(每笔流水都能关联到外部订单号、IP、时间戳,甚至上一条流水的前后关联)

第二部分:数据库表设计精髓(防止余额负数与篡改)

很多新手会直接在一个 wallet 表里存 balance 字段,然后通过加减操作更新,这是灾难的开始——并发情况下会丢失更新。正确的做法是:余额是“衍生值”,流水是“唯一真相”。

1 流水表结构(wallet_transaction_log

CREATE TABLE `wallet_transaction_log` (
  `id` BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
  `user_id` INT UNSIGNED NOT NULL COMMENT '用户ID',
  `wallet_type` TINYINT NOT NULL DEFAULT 1 COMMENT '1-余额 2-冻结',
  `direction` TINYINT NOT NULL COMMENT '1-收入 -1-支出',
  `amount` DECIMAL(12,2) NOT NULL COMMENT '变动金额(正数)',
  `balance_after` DECIMAL(12,2) NOT NULL COMMENT '交易后余额(快照)',
  `ref_no` VARCHAR(64) NOT NULL COMMENT '业务订单号(唯一索引)',
  `prev_id` BIGINT UNSIGNED DEFAULT NULL COMMENT '上一笔流水ID(逻辑链表)',
  `create_time` DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
  `remark` VARCHAR(255) DEFAULT '',
  UNIQUE KEY `uk_ref_no` (`ref_no`),
  KEY `idx_user_time` (`user_id`, `create_time`)
) ENGINE=InnoDB;

关键点:

  • amount 永远存正数,用 direction 区分收入/支出,避免负数陷阱。
  • balance_after缓存快照,用于快速查询,不用于计算。
  • prev_id 字段是实现“防篡改链”的核心——每次插入时,必须带上当前用户最新的 id 作为 prev_id

2 防篡改原理:单向哈希链

在PHP写入时,不仅记录 prev_id,还可生成 hash = sha256(prev_hash + user_id + amount + direction + ref_no),如果黑客修改了某条旧流水,后续所有记录的 prev_hash 全部失效,对账系统一查即破。


第三部分:PHP核心代码实现(事务 + 原子性)

1 错误做法(纯更新余额,无流水)

$pdo->exec("UPDATE wallet SET balance = balance - 100 WHERE user_id=1");
// 并发下问题严重:两个请求同时读到100,都减100,结果变成0,实际应为-100

2 正确做法:流水 + 余额同步更新(悲观锁)

function addWalletLog($userId, $amount, $direction, $refNo, $remark) {
    $pdo->beginTransaction();
    try {
        // 1. 锁住用户钱包行(FOR UPDATE)
        $stmt = $pdo->prepare("SELECT balance, last_log_id FROM wallet WHERE user_id=? FOR UPDATE");
        $stmt->execute([$userId]);
        $wallet = $stmt->fetch();
        if (!$wallet) throw new Exception("钱包不存在");
        // 2. 计算新余额
        $newBalance = $wallet['balance'] + ($direction * $amount);
        if ($newBalance < 0) throw new Exception("余额不足");
        // 3. 插入流水(带上 prev_id 和 balance_after)
        $stmt = $pdo->prepare(
            "INSERT INTO wallet_transaction_log 
            (user_id, wallet_type, direction, amount, balance_after, ref_no, prev_id, remark)
            VALUES (?,1,?,?,?,?,?,?)"
        );
        $stmt->execute([$userId, $direction, $amount, $newBalance, $refNo, $wallet['last_log_id'], $remark]);
        $logId = $pdo->lastInsertId();
        // 4. 更新钱包余额和 last_log_id(原子操作)
        $pdo->prepare("UPDATE wallet SET balance=?, last_log_id=? WHERE user_id=?")
            ->execute([$newBalance, $logId, $userId]);
        $pdo->commit();
        return $logId;
    } catch (Exception $e) {
        $pdo->rollBack();
        throw $e;
    }
}

为什么用 FOR UPDATE 它锁住该行,其他事务必须等待,在秒杀场景下,这是最稳妥的方案,但吞吐量受限(详见第四部分)。

3 幂等性处理:防重复入账

在调用 addWalletLog 前,先检查 ref_no 是否已存在:

$stmt = $pdo->prepare("SELECT id FROM wallet_transaction_log WHERE ref_no=?");
$stmt->execute([$refNo]);
if ($stmt->fetch()) return false; // 说明已处理过,直接返回成功

第四部分:高并发与性能优化(从1000 QPS到5万)

如果只靠 FOR UPDATE,当用户量达到百万级时,数据库行锁会成为瓶颈,解决方案如下:

1 方案A:乐观锁(版本号模式)

wallet 表增加 version 字段,更新时带上旧版本号:

$affected = $pdo->exec(
    "UPDATE wallet SET balance=balance+?, version=version+1 
     WHERE user_id=? AND version=?"
);
// affected=0,说明版本号冲突,重试或报错

这种方式没有Lock,但需要重试机制,适合读多写少的场景。

2 方案B:异步队列(Redis Stream / RabbitMQ)

将流水写入请求推入队列,由单独消费者进程串行处理:

$redis->lpush('wallet_queue', json_encode(['user'=>1, 'amount'=>-50, 'ref'=>'ORDER123']));
// 消费者循环:每次批量取100笔,按 user_id 分组后单线程处理(避免死锁)

消费端使用 SELECT ... WHERE user_id=? FOR UPDATE,保证同一用户的流水严格串行。这种方案能达到每秒几万笔的吞吐量,因为数据库压力被分摊到了多个消费者。

3 方案C:读写分离 + 流水只读副本

wallet_transaction_log 通过 Binlog 同步到 Elasticsearch 或 只读从库,前端查询“流水列表”永远走读库,不占用主库的写性能。


第五部分:排查与监控(对账算法)

1 每日自动对账脚本(PHP实现)

function checkLedger($userId) {
    $total = 0; $prevId = 0;
    $rows = $pdo->query("SELECT * FROM wallet_transaction_log WHERE user_id=$userId ORDER BY id ASC");
    foreach ($rows as $row) {
        $total += $row['direction'] * $row['amount'];
        // 校验链表连续性
        if ($row['prev_id'] != $prevId) { echo "流水断链异常!"; break; }
        $prevId = $row['id'];
    }
    $balance = $pdo->query("SELECT balance FROM wallet WHERE user_id=$userId")->fetchColumn();
    if ($total !== $balance) { echo "余额不一致!"; }
}

2 常见坑:时区与浮点丢失

  • 使用 DECIMAL(12,2),绝不用 FLOAT
  • 时间字段使用 DATETIME(3)(毫秒),避免同一毫秒并发插入时排序不稳定。

第六部分:常见问题FAQ(面试与实战)

Q1:为什么不能用 UPDATE wallet SET balance=balance-100 直接扣款? 答:并发场景下会丢更新(Lost Update),如果两个请求同时扣100,数据库默认行锁会串行执行,但如果你先SELECT再UPDATE,就存在竞态条件,流水表记录了余额变化,是唯一权威,余额表只是缓存。

Q2:如何防止“负余额”? 答:在事务中使用 SELECT ... FOR UPDATE 锁定行,在PHP判断 newBalance < 0 则回滚,不要在数据库层面用 CHECK 约束,因为不支持跨表校验。

Q3:ref_no 的幂等性为什么必须依赖唯一索引? 答:高并发下,即使代码先查后插,也可能因竞态插入重复记录,唯一索引是最后一道防线,数据库会拒绝第二次插入,同时捕获 PDOException 后处理。

Q4:流水数据量过亿,如何分表? 答:按 user_id 做哈希分表(如 wallet_log_0wallet_log_99),但分表后 WITH ROLLUP 等汇总查询困难,通常配合 ES 做搜索,MySQL只负责按用户查询。

Q5:对账发现流水与余额不一致,第一排查步骤是什么? 答:查找是否有 prev_id 为空的“孤儿流水”,或检查是否有返回码失败的逻辑(比如有人手动改了数据库),用上述的 checkLedger 脚本定位第一个断链的流水ID,再对比对应订单日志即可。


PHP钱包流水不是简单的增删改查,而是融合了数据库事务、锁机制、幂等设计、性能优化、对账审计的综合性系统工程,掌握以上设计思路,你便能应对多数金融级业务场景。流水是信仰,余额是影子。

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