数据库事务
使用 DatabaseTransactionManagerInterface::transactional() 执行原子性多步骤操作。 如果任何步骤抛出异常,回调中的所有更改都会自动回滚。
关键规则:在回调内部实例化 Repository
警告: 在构造时注入的 Repository 使用不同的 PDO 连接,在事务外部执行。回滚不会撤销它们的更改。
PdoConnectionFactory 每次调用时都会创建一个新连接。transactional() 开启事务时,在新连接上开启。任何在此调用之前创建的 Repository(例如通过构造函数 DI 注入的)持有不同的连接——该连接不属于事务。
正确模式
php
final class OrderService
{
public function __construct(
private readonly DatabaseTransactionManagerInterface $tx,
) {}
public function placeOrder(array $items): int
{
return $this->tx->transactional(function ($executor) use ($items): int {
// ✅ 在回调内部使用事务范围的 executor 实例化 Repository
$inventory = new InventoryRepository($executor);
$orders = new OrderRepository($executor);
foreach ($items as $item) {
// 抛出 InsufficientStockException → 自动触发回滚
$inventory->decrement($item['product_id'], $item['quantity']);
}
return $orders->create($items);
});
}
}错误模式(更改不会被回滚)
php
// ❌ 这些 Repository 持有不同的连接——不属于事务
public function __construct(
private readonly InventoryRepository $inventory,
private readonly OrderRepository $orders,
private readonly DatabaseTransactionManagerInterface $tx,
) {}
public function placeOrder(array $items): int
{
return $this->tx->transactional(function ($executor) use ($items): int {
foreach ($items as $item) {
$this->inventory->decrement(...); // ← 不同连接,在事务外部!
}
return $this->orders->create($items); // ← 同样的问题
// 如果抛出异常,$this->inventory 的更改不会被撤销
});
}这是一个静默失败:代码可以编译,PHPStan 无法检测到它,除非测试专门验证回滚行为,否则测试可能会通过。
回滚行为
transactional() 捕获 Throwable,回滚,然后重新抛出。调用者接收原始异常。
php
try {
$this->orderService->placeOrder($items);
} catch (InsufficientStockException $e) {
// 此调用中的所有库存扣减都已撤销
// 未创建订单
}预验证 + 原子操作模式
上述失败快速模式在第一个缺货商品处停止,客户端不知道其他失败。 当用户体验很重要时,先收集所有错误,然后原子性地执行:
php
public function placeOrder(array $items): int
{
// 第一阶段:在事务外验证所有商品(只读)
$errors = [];
foreach ($items as $item) {
$stock = $this->getStockSnapshot($item['product_id']);
if ($stock < $item['quantity']) {
$errors[] = [
'product_id' => $item['product_id'],
'requested' => $item['quantity'],
'available' => $stock,
];
}
}
if ($errors !== []) {
throw new InsufficientStockException($errors);
}
// 第二阶段:原子扣减——内部仍使用事务范围的 executor
return $this->tx->transactional(function ($executor) use ($items): int {
$inventory = new InventoryRepository($executor);
$orders = new OrderRepository($executor);
foreach ($items as $item) {
// DB CHECK 约束是应对竞争的最终安全网
$inventory->decrement($item['product_id'], $item['quantity']);
}
return $orders->create($items);
});
}权衡:
- 第一阶段的读取不属于事务,因此并发请求可能在第一阶段和第二阶段之间耗尽库存。数据库
CHECK (stock >= 0)约束(或decrement()在负库存时抛出)会捕获这个竞争。 - 对大多数应用这是可以接受的。若需要严格正确性,使用
SELECT ... FOR UPDATE或可序列化隔离级别(SQLite 不支持)。
测试回滚正确性
始终验证失败订单后库存保持不变:
php
$this->inventory->seed(1, 'Widget', 10);
$this->inventory->seed(2, 'Gadget', 1);
// Widget 扣减本会成功,但 Gadget 失败 → 两者都必须回滚
$res = $this->post('/orders', ['items' => [
['product_id' => 1, 'quantity' => 3],
['product_id' => 2, 'quantity' => 5], // 仅有 1 件库存
]]);
assertSame(422, $res->getStatusCode());
assertSame(10, $this->inventory->getStock(1), 'Widget 必须回滚到 10');
assertSame(0, $this->orders->count(), '未创建订单');模拟 Repository 的单元测试无法捕获这类 bug——只有共享同一 SQLite/MySQL 连接的集成测试才能验证回滚正确性。
Laravel vs NENE2
Laravel 的 DB::transaction() 可以与注入的 Model 一起使用,因为它透明地在整个请求中共享一个连接。NENE2 的 PdoConnectionFactory 每次调用都返回一个新连接——这是为了可测试性和显式连接控制而做出的刻意设计选择。其结果是,在 NENE2 中必须使用回调范围的 executor 模式。