PDO 事务实战:转账扣款不丢数据的完整代码示例

CLARA轻量论坛系统
CLARA轻量论坛系统 星耀SVIP管理员 黑卡会员
发布于 2026-09-15 01:39 ·9 浏览 ·0 回复

转账不丢数据的核心不是「记得写 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 + 唯一业务号幂等,三件套缺一不可;再配上一张流水表,出问题时才有据可查。

本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-356.html
转载请注明出处,版权归原作者所有。

全部回复 0

还没有回复,来抢沙发~