PDO 事务实战:转账扣款不丢数据的完整代码示例
转账不丢数据的核心不是「记得写 beginTransaction」,而是三件事必须同时成立:扣款和入账在同一个事务里、扣款用带余额条件的原子 UPDATE(而不是先查再改)、每笔请求带唯一业务号做幂等。少任何一条,都会分别对应「扣了没到账」「并发双花」「重复扣款」三种线上事故。
先搞清楚钱是怎么丢的
事务只能挡掉第一类问题,另外两类要靠 SQL 写法和表结构解决。
- 扣款成功但脚本挂了:扣款和入账分两条独立 SQL、不包事务,中间抛异常或 PHP 超时,钱就停在半路。
- 并发双花:余额 100,两笔请求各扣 80,都先 `SELECT` 读到 100,都判定「够」,最后余额变成 -60。这是「先查再改」的经典 bug。
- 重复提交:用户连点两次、或者网关重试,同一个业务请求被扣两次。事务完全防不住这个。
结论:事务保证的是「一组写操作要么全成要么全败」,原子性和幂等要靠 SQL 条件与唯一索引单独加。
完整可用的转账函数
下面这段是能直接用的版本,金额统一用「分」的整数存储,避开浮点误差。
function transfer(PDO $pdo, int $fromUid, int $toUid, int $cents, string $currency, string $bizNo): bool
{
if ($cents <= 0) throw new InvalidArgumentException('金额必须大于 0');
if ($fromUid === $toUid) throw new InvalidArgumentException('不能给自己转账');
$pdo->beginTransaction();
try {
// 1) 幂等落点:biz_no 上有唯一索引,重复请求在这里就炸,走 catch 返回 false
$pdo->prepare('INSERT INTO transfer_log(biz_no, from_uid, to_uid, amount, currency, status, created_at)
VALUES(?,?,?,?,?,"pending",NOW())')
->execute([$bizNo, $fromUid, $toUid, $cents, $currency]);
// 2) 按 uid 升序加行锁,两笔互相转账时不会死锁
$ids = [$fromUid, $toUid];
sort($ids);
$in = implode(',', array_fill(0, count($ids), '?'));
$pdo->prepare("SELECT uid FROM wallet WHERE currency=? AND uid IN ($in) ORDER BY uid FOR UPDATE")
->execute(array_merge([$currency], $ids));
// 3) 原子扣款:余额不足时 rowCount 为 0,天然挡并发
$st = $pdo->prepare('UPDATE wallet SET balance = balance - ?, updated_at = NOW()
WHERE uid = ? AND currency = ? AND balance >= ?');
$st->execute([$cents, $fromUid, $currency, $cents]);
if ($st->rowCount() !== 1) {
throw new RuntimeException('余额不足');
}
// 4) 入账
$pdo->prepare('UPDATE wallet SET balance = balance + ?, updated_at = NOW()
WHERE uid = ? AND currency = ?')
->execute([$cents, $toUid, $currency]);
// 5) 双向流水 + 状态置成功,同事务内完成
$pdo->prepare('INSERT INTO wallet_flow(uid, currency, delta, biz_no, created_at) VALUES(?,?,?,?,NOW())')
->execute([$fromUid, $currency, -$cents, $bizNo]);
$pdo->prepare('INSERT INTO wallet_flow(uid, currency, delta, biz_no, created_at) VALUES(?,?,?,?,NOW())')
->execute([$toUid, $currency, $cents, $bizNo]);
$pdo->prepare('UPDATE transfer_log SET status = "success" WHERE biz_no = ?')->execute([$bizNo]);
$pdo->commit();
return true;
} catch (PDOException $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
if ($e->getCode() === '23000') return false; // 唯一索引冲突 = 重复请求
throw $e;
} catch (Throwable $e) {
if ($pdo->inTransaction()) $pdo->rollBack();
throw $e;
}
}
连接 PDO 时务必设两个属性,否则报错会静默、预处理会被模拟,事务行为和你以为的不一样:
$pdo = new PDO($dsn, $user, $pass, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_EMULATE_PREPARES => false,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
]);
三个最容易踩的坑
第一,别在事务里发邮件、调第三方接口、写通知。事务持有行锁期间做网络 IO,一次 3 秒超时就能把整张钱包表堵住。正确做法是先 `commit()`,再发通知;或者写一张 outbox 表,靠定时任务异步消费。
第二,`rowCount()` 的语义要记住。MySQL 下 PDO 默认返回「实际改变的行数」,所以 `balance = balance - 0` 会返回 0,函数开头的 `$cents <= 0` 校验不是多余的。
第三,隔离级别不要想当然。MySQL 默认 REPEATABLE READ 下,普通 `SELECT` 是快照读,读到的余额可能是旧的;想用「先查再改」就必须 `SELECT ... FOR UPDATE`,但更省事、更安全的写法就是上面那种带条件的 UPDATE,一步到位。
在 Clara BBS 这类社区系统里怎么落地
如果你是在 Clara BBS 上做二次开发,同类逻辑系统里已经跑通了:悬赏发帖时从余额托管冻结、采纳后自动发放给被采纳者、未采纳可取消退款,本质就是「冻结 + 转账」两段事务;站内的转账(费率/限额/每日次数均可配)、签到奖励、礼物、附件交易也全部走统一记账。
结论:写插件时涉及货币变更,应调用系统提供的货币记账 API,而不是自己 `UPDATE` 余额表——直连表改数会绕过流水,账目对不上,事后几乎无法追溯。
收尾一句:事务只是底线,真正的「不丢数据」= 单事务包裹 + 带条件的原子 UPDATE + 唯一业务号幂等,三件套缺一不可;再配上一张流水表,出问题时才有据可查。
转载请注明出处,版权归原作者所有。
星耀SVIP
管理员
黑卡会员





