PHP钱包流水记录:从零构建高并发、防篡改的交易日志系统
目录导读
- 第一部分:为什么钱包流水记录如此重要? — 金融级合规与用户信任的基石
- 第二部分:数据库表设计精髓 — 如何用“单向链表”防篡改
- 第三部分:PHP核心代码实现 — 流水写入、余额更新与事务原子性
- 第四部分:高并发与性能优化 — 乐观锁、队列与读写分离实战
- 第五部分:排查与监控 — 常见坑及流水一致性校验算法
- 第六部分:常见问题FAQ — 面试官最爱问的5个流水难题
第一部分:为什么钱包流水记录如此重要?
在任何一个涉及资金交易的PHP系统(电商、P2P理财、积分商城、游戏充值)中,钱包流水(Wallet Ledger) 不仅仅是“交易明细列表”,它是整个账户系统的审计日志和状态还原依据,如果流水记录不严谨,轻则账目对不上,重则用户资金被盗刷或平台被薅羊毛。

核心痛点: 普通日志(如文本文件或简单Log)无法满足事务性需求,一个完整的钱包流水必须满足三个特性:
- 不可篡改性(一旦写入,物理上极难更改)
- 幂等性(同一笔业务请求,重复提交不会重复扣款)
- 可追溯性(每笔流水都能关联到外部订单号、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_0 到 wallet_log_99),但分表后 WITH ROLLUP 等汇总查询困难,通常配合 ES 做搜索,MySQL只负责按用户查询。
Q5:对账发现流水与余额不一致,第一排查步骤是什么?
答:查找是否有 prev_id 为空的“孤儿流水”,或检查是否有返回码失败的逻辑(比如有人手动改了数据库),用上述的 checkLedger 脚本定位第一个断链的流水ID,再对比对应订单日志即可。
PHP钱包流水不是简单的增删改查,而是融合了数据库事务、锁机制、幂等设计、性能优化、对账审计的综合性系统工程,掌握以上设计思路,你便能应对多数金融级业务场景。流水是信仰,余额是影子。