PAYATHON 2026

PHP 中如何安全关联订单与发票记录

支付老李

结论

不要用 ORDER BY id DESC LIMIT 1 查找刚插入的记录。每次执行 INSERT 后,应立即通过同一个数据库连接取得该语句生成的自增 ID,再用明确的主键条件更新关联字段。

“创建发票、创建订单、建立关联”应放在同一个事务中。任何一步失败都要回滚,避免只创建其中一条记录或留下不完整的关联。

PHP 的 mysql_* 扩展早已废弃并被移除。新代码应使用 PDO 或 MySQLi。

为什么 ORDER BY id DESC LIMIT 1 不可靠

下面的查询返回整张表中当前最大的 id:

SELECT id FROM tbl_invoice ORDER BY id DESC LIMIT 1

这个 ID 不一定属于当前请求插入的记录。并发时可能出现以下情况:

  1. 请求 A 插入 invoice,ID 为 100。
  2. 请求 B 插入 invoice,ID 为 101。
  3. 请求 A 查询最大 ID,得到 101。
  4. 请求 A 把自己的 order 关联到了请求 B 的 invoice。

同样,下面的更新也可能修改其他请求刚创建的记录:

UPDATE tbl_invoice
SET order_id = ?
ORDER BY id DESC
LIMIT 1

更新时必须使用已经取得的主键:

UPDATE tbl_invoice
SET order_id = ?
WHERE id = ?

使用 PDO 的安全写法

假设两个表的 id 都是 AUTO_INCREMENT,并且使用支持事务的 InnoDB 存储引擎,可以按以下顺序处理:

  1. 开启事务。
  2. 插入 invoice。
  3. 立即调用 lastInsertId() 获取 invoice_id。
  4. 插入 order,并直接写入 invoice_id。
  5. 立即获取 order_id。
  6. 根据明确的 invoice_id 更新 invoice 的 order_id。
  7. 提交事务。
<?php

$pdo = new PDO(
    'mysql:host=localhost;dbname=example;charset=utf8mb4',
    $dbUser,
    $dbPassword,
    [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_EMULATE_PREPARES => false,
    ]
);

try {
    $pdo->beginTransaction();

    $invoiceStmt = $pdo->prepare(
        'INSERT INTO tbl_invoice
            (customer_id, status, invoicedate, payment_method, total, duedate)
         VALUES
            (:customer_id, :status, :invoicedate, :payment_method, :total, :duedate)'
    );

    $invoiceStmt->execute([
        ':customer_id'   => $client,
        ':status'        => $status,
        ':invoicedate'   => $date,
        ':payment_method'=> $method,
        ':total'         => $total,
        ':duedate'       => $duedate,
    ]);

    // 必须在同一连接上,并在下一次可能产生自增 ID 的 INSERT 之前调用。
    $invoiceId = (int) $pdo->lastInsertId();

    $orderStmt = $pdo->prepare(
        'INSERT INTO tbl_order
            (customer_id, status, total, orderdate, invoice_id)
         VALUES
            (:customer_id, :status, :total, :orderdate, :invoice_id)'
    );

    $orderStmt->execute([
        ':customer_id' => $client,
        ':status'      => $status,
        ':total'       => $total,
        ':orderdate'   => $date,
        ':invoice_id'  => $invoiceId,
    ]);

    $orderId = (int) $pdo->lastInsertId();

    $updateInvoiceStmt = $pdo->prepare(
        'UPDATE tbl_invoice
         SET order_id = :order_id
         WHERE id = :invoice_id'
    );

    $updateInvoiceStmt->execute([
        ':order_id'   => $orderId,
        ':invoice_id' => $invoiceId,
    ]);

    if ($updateInvoiceStmt->rowCount() !== 1) {
        throw new RuntimeException('更新发票与订单的关联失败');
    }

    $pdo->commit();
} catch (Throwable $e) {
    if ($pdo->inTransaction()) {
        $pdo->rollBack();
    }

    // 实际项目中应记录异常,并向客户端返回通用错误信息。
    throw $e;
}

lastInsertId() 返回当前数据库连接最近一次插入所产生的自增 ID,而不是整张表中最大的 ID。因此,即使其他连接同时插入数据,也不会改变当前连接取得的 ID。

使用 MySQLi 的写法

如果项目使用 MySQLi,对应的方法是 $mysqli->insert_id:

<?php

$mysqli = new mysqli('localhost', $dbUser, $dbPassword, 'example');
$mysqli->set_charset('utf8mb4');
$mysqli->begin_transaction();

try {
    $invoiceStmt = $mysqli->prepare(
        'INSERT INTO tbl_invoice
            (customer_id, status, invoicedate, payment_method, total, duedate)
         VALUES (?, ?, ?, ?, ?, ?)'
    );
    $invoiceStmt->bind_param(
        'isssds',
        $client,
        $status,
        $date,
        $method,
        $total,
        $duedate
    );
    $invoiceStmt->execute();

    $invoiceId = $mysqli->insert_id;

    $orderStmt = $mysqli->prepare(
        'INSERT INTO tbl_order
            (customer_id, status, total, orderdate, invoice_id)
         VALUES (?, ?, ?, ?, ?)'
    );
    $orderStmt->bind_param(
        'isdsi',
        $client,
        $status,
        $total,
        $date,
        $invoiceId
    );
    $orderStmt->execute();

    $orderId = $mysqli->insert_id;

    $updateStmt = $mysqli->prepare(
        'UPDATE tbl_invoice SET order_id = ? WHERE id = ?'
    );
    $updateStmt->bind_param('ii', $orderId, $invoiceId);
    $updateStmt->execute();

    if ($updateStmt->affected_rows !== 1) {
        throw new RuntimeException('更新发票与订单的关联失败');
    }

    $mysqli->commit();
} catch (Throwable $e) {
    $mysqli->rollback();
    throw $e;
}

bind_param() 中的类型必须与实际表结构一致。上面的示例假设 ID 是整数,total 是数值,其他相关字段是字符串。

表结构可以进一步简化

如果业务规则是一张订单对应一张发票,通常不必在两个表中同时保存对方的 ID。双向字段会造成数据冗余,还可能出现不一致,例如:

tbl_order.invoice_id = 20
tbl_invoice.order_id = 35

此时,编号为 20 的发票未必指向这张订单。

更简单的设计是只保留一侧的外键,例如在 tbl_invoice 中保存 order_id:

ALTER TABLE tbl_invoice
    ADD CONSTRAINT fk_invoice_order
    FOREIGN KEY (order_id) REFERENCES tbl_order(id),
    ADD UNIQUE KEY uq_invoice_order (order_id);

创建记录时,先插入 order,再用取得的 order_id 插入 invoice:

$pdo->beginTransaction();

try {
    $orderStmt->execute([
        ':customer_id' => $client,
        ':status'      => $status,
        ':total'       => $total,
        ':orderdate'   => $date,
    ]);

    $orderId = (int) $pdo->lastInsertId();

    $invoiceStmt->execute([
        ':customer_id'    => $client,
        ':status'         => $status,
        ':invoicedate'    => $date,
        ':payment_method' => $method,
        ':total'          => $total,
        ':duedate'        => $duedate,
        ':order_id'       => $orderId,
    ]);

    $pdo->commit();
} catch (Throwable $e) {
    $pdo->rollBack();
    throw $e;
}

查询订单及其发票时,通过 JOIN 关联即可:

SELECT
    o.*,
    i.id AS invoice_id,
    i.invoicedate,
    i.payment_method,
    i.duedate,
    i.paiddate
FROM tbl_order AS o
LEFT JOIN tbl_invoice AS i
    ON i.order_id = o.id
WHERE o.id = :order_id;

如果一张订单将来可能对应多张发票,更应只在 tbl_invoice 中保留 order_id,并且不要给该字段添加唯一约束。

注意事项

  • lastInsertId() 或 insert_id 必须与对应的 INSERT 使用同一个数据库连接。
  • 应在下一条可能生成自增 ID 的语句执行前读取该 ID。
  • 事务保证多步操作的原子性,获取插入 ID 则保证当前连接拿到自己插入的记录。两者作用不同。
  • 使用事务时,相关表必须采用支持事务的存储引擎,例如 InnoDB。
  • 使用预处理语句传递变量,不要把 $client、$status 等值直接拼接进 SQL,以免发生 SQL 注入。
  • 金额字段应使用 DECIMAL,不要用 FLOAT 或 DOUBLE 保存货币金额。
  • 可以添加外键约束,防止不存在的订单 ID 或发票 ID 被写入关联字段。
  • 不要向客户端直接输出数据库错误或堆栈信息。生产环境应记录日志,并返回不含敏感信息的错误响应。
  • 如果“Proceed to payment”可能因重复点击或网络重试而重复提交,还应增加幂等键或唯一业务编号,防止同一业务请求创建多组订单和发票。

备注:内容仅供参考。