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 不一定属于当前请求插入的记录。并发时可能出现以下情况:
- 请求 A 插入 invoice,ID 为
100。 - 请求 B 插入 invoice,ID 为
101。 - 请求 A 查询最大 ID,得到
101。 - 请求 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 存储引擎,可以按以下顺序处理:
- 开启事务。
- 插入 invoice。
- 立即调用
lastInsertId()获取invoice_id。 - 插入 order,并直接写入
invoice_id。 - 立即获取
order_id。 - 根据明确的
invoice_id更新 invoice 的order_id。 - 提交事务。
<?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”可能因重复点击或网络重试而重复提交,还应增加幂等键或唯一业务编号,防止同一业务请求创建多组订单和发票。
备注:内容仅供参考。
