PAYATHON 2026

Laravel Excel 使用 OnEachRow 时如何回滚关联表数据

支付阿杰

结论

把整个导入过程放在同一个 DB::transaction() 中,并确认 Agent 和 Payment 使用相同的数据库连接,两张表也都支持事务。发生异常后不要静默处理,应继续抛出异常,让 Laravel 回滚事务。

$agent->payment()->create() 不会自行脱离事务。如果 agent 已回滚,但 payment 没有回滚,通常要检查以下几项:

  • 两个模型是否使用了不同的数据库连接。
  • payment 表是否使用了 MyISAM 等不支持事务的存储引擎。
  • 导入是否使用了分块或队列,导致数据分属不同事务。
  • 异常是否被捕获后没有重新抛出。

推荐实现

事务应包在调用导入的位置,不要在 onRow() 中为每一行分别开启事务:

use Illuminate\Support\Facades\DB;
use Maatwebsite\Excel\Facades\Excel;

DB::transaction(function () use ($file) {
    Excel::import(new NoteImport(), $file);
});

导入类只负责正常写入:

use Maatwebsite\Excel\Concerns\OnEachRow;
use Maatwebsite\Excel\Row;

class NoteImport implements OnEachRow
{
    public function onRow(Row $row): void
    {
        $data = $row->toArray();

        $agent = Agent::create([
            'agent_code' => '1',
            'name'       => '1',
            'address'    => '1',
            'phone'      => 454,
            'password'   => 'asd',
            'upload'     => '1',
            'status'     => 1,
        ]);

        $agent->payment()->create([
            'firstpay'     => 1,
            'amount'       => 1,
            'payment_type' => 1,
            'info'         => '1',
            'enter_date'   => now()->toDateString(),
            'recieve_date' => now()->toDateString(),
            'status'       => $data['srno'] != 3 ? 1 : '',
        ]);
    }
}

插入第三行 payment 时,如果数据库抛出 QueryException,异常会从 Excel::import() 继续向外传递,随后触发外层事务回滚。只要两次写入使用同一连接,并且两张表都采用支持事务的存储引擎,此前插入的 agent 和 payment 记录都会被撤销。

也可以手动管理事务,但要确保出现任何异常时都执行回滚,并将异常继续抛出:

use Illuminate\Support\Facades\DB;
use Maatwebsite\Excel\Facades\Excel;
use Throwable;

DB::beginTransaction();

try {
    Excel::import(new NoteImport(), $file);

    DB::commit();
} catch (Throwable $e) {
    DB::rollBack();

    throw $e;
}

一般更适合使用 DB::transaction()。代码更短,也能减少遗漏回滚的风险。

检查模型的数据库连接

事务只作用于创建该事务的数据库连接。先确认两个模型没有设置不同的 $connection:

$agent = new Agent();
$payment = new Payment();

dump($agent->getConnectionName());
dump($payment->getConnectionName());

如果两个模型都没有显式设置连接,它们通常会使用同一个默认连接。如果需要手动指定,应将其设为同一个连接:

class Agent extends Model
{
    protected $connection = 'mysql';
}

class Payment extends Model
{
    protected $connection = 'mysql';
}

如果 Agent 和 Payment 位于两个独立的数据库连接中,普通的 DB::transaction() 无法实现跨连接的原子回滚。这种情况需要调整写入流程,或者使用数据库支持的分布式事务方案。

检查表的存储引擎

在 MySQL 中,两张表都应使用 InnoDB 等支持事务的存储引擎:

SHOW TABLE STATUS WHERE Name IN ('agents', 'payments');

如果 payments 使用 MyISAM,即使执行 ROLLBACK,已经插入的记录也不会被撤销。确认业务兼容后,可以将两张表改为 InnoDB:

ALTER TABLE agents ENGINE = InnoDB;
ALTER TABLE payments ENGINE = InnoDB;

也可以在 Laravel migration 中明确指定存储引擎:

Schema::create('payments', function (Blueprint $table) {
    $table->engine = 'InnoDB';

    // columns...
});

不要吞掉异常

下面的代码捕获异常后没有重新抛出。外层代码会把导入视为成功,事务也可能因此提交:

try {
    $agent->payment()->create($options);
} catch (Throwable $e) {
    report($e);
}

如果需要记录错误,应在记录后重新抛出异常:

try {
    $agent->payment()->create($options);
} catch (Throwable $e) {
    report($e);

    throw $e;
}

分块和队列导入的限制

使用 WithChunkReading 或队列导入时,要注意事务的覆盖范围。根据版本和配置,事务可能只覆盖当前 chunk 或当前队列任务。前一个 chunk 一旦成功提交,后续 chunk 抛出的异常无法自动将其回滚。

如果业务要求任意一行失败时回滚整个文件,应优先使用同步导入,并通过一个外层事务覆盖完整的 Excel::import()。对于必须分块处理或加入队列的大文件,可以采用以下流程:

  1. 先把数据导入临时表,并记录批次号。
  2. 全部数据校验通过后,一次性迁移到正式表。
  3. 导入失败时,按批次清理临时数据。

正式导入前还可以先验证 status:

if (!is_numeric($data['status'])) {
    throw new InvalidArgumentException('status 必须是整数');
}

这样产生的业务错误更清楚,也不需要依赖数据库返回 Incorrect integer value 异常。

备注:内容仅供参考。