
本教程旨在解决 php yii2 应用中向 mysql 数据库批量导入数据效率低下的问题。文章将深入分析 activerecord `save()` 方法的性能瓶颈,并提出一系列优化策略,包括使用原生 `insert()` 命令、预加载关联数据、采用 `batchinsert()` 批量插入以及利用数据库事务管理,旨在显著提升数据导入速度,为处理百万级数据量提供高效解决方案。
在 php yii2 开发中,当需要从外部数据源(如 jsON 文件)导入大量数据到 mysql 数据库时,开发者常会遇到性能瓶颈。传统的 ActiveRecord save() 方法虽然便捷,但在处理成千上万条记录时,其效率会急剧下降,导致导入时间过长。本教程将探讨导致此问题的原因,并提供一系列行之有效的优化方案。
1. 理解 ActiveRecord save() 的性能瓶颈
最初的代码示例中,循环遍历 json 数据并为每条记录创建一个 Product_dub 实例,然后调用 $item->save()。这种做法在数据量较小时表现尚可,但随着数据量的增加,性能会显著恶化。主要原因如下:
- 对象实例化开销: 每次循环都需要实例化一个新的 ActiveRecord 对象,这涉及到类的加载、属性初始化等,产生不必要的内存和 CPU 开销。
- ActiveRecord 生命周期事件: save() 方法会触发一系列 ActiveRecord 事件(如 beforeValidate, afterValidate, beforeSave, afterSave),并执行数据验证。这些操作在每次循环中重复执行,增加了不必要的计算负担。
- 单条 SQL 语句执行: 最关键的是,save() 方法每次调用都会生成并执行一条独立的 INSERT 或 UPDATE SQL 语句。数据库连接的建立、SQL 解析、事务提交等操作的频繁发生,是导致性能低下的主要原因。
- 关联数据查询: 原始代码中在循环内部使用 Category_dub::findOne() 和 Brands_dub::findOne() 查询关联数据,这导致每次迭代都会产生额外的数据库查询,进一步拖慢了整体速度。
这些因素叠加起来,使得导入速度呈现非线性下降,数据量越大,耗时越长。
2. 优化策略一:使用原生 insert() 命令
Yii2 的 ActiveRecord 提供了便捷的 ORM 操作,但在追求极致性能的批量导入场景下,直接使用数据库命令执行 INSERT 操作是更优的选择。Yii::$app->db->createCommand()->insert() 方法绕过了 ActiveRecord 的大部分生命周期事件和对象实例化开销,直接构建并执行 SQL 语句。
优化前的代码片段(使用 save()):
foreach ($products as $product) { $item = new Product_dub(); // ... 属性赋值 ... if (!$item->save()) { // 处理错误 } }
优化后的代码片段(使用 insert()):
foreach ($products as $product) { Yii::$app->db->createCommand()->insert('product_dub', [ 'id_1c_product' => $product->id, 'category_id' => $categoryMap[$product->category_id] ?? '0', 'title' => $product->title, 'brand_id' => $brandMap[$product->brand_id] ?? 'No brand', // ... 其他属性 ... ])->execute(); }
通过将 save() 替换为 insert()->execute(),导入速度将得到显著提升。原始问题中,1107 条记录的导入时间从数分钟缩短到 40 秒,证明了这种方法立竿见影的效果。
3. 优化策略二:预加载关联数据
在循环内部进行数据库查询(如 findOne())是另一个严重的性能瓶颈。对于 category_id 和 brand_id 这类需要映射的字段,最佳实践是在循环开始前一次性加载所有必要的映射数据到内存中。
优化前的代码片段(循环内查询):
foreach ($products as $product) { $category = Category_dub::findOne(['id_1c_category' => $product->category_id]); $brand = Brands_dub::findOne(['id_1c_brand' => $product->brand->id]); // ... 使用 $category->id 和 $brand->id ... }
优化后的代码片段(预加载映射):
$categoryMap = Category_dub::find()->select(['id', 'id_1c_category'])->indexBy('id_1c_category')->column(); $brandMap = Brands_dub::find()->select(['id', 'id_1c_brand'])->indexBy('id_1c_brand')->column(); foreach ($products as $product) { // 直接从内存映射中获取 ID $categoryId = $categoryMap[$product->category_id] ?? '0'; $brandId = $brandMap[$product->brand_id] ?? 'No brand'; // ... 使用 $categoryId 和 $brandId 进行插入 ... }
通过这种方式,将 N 次数据库查询减少为 2 次(加载分类和品牌映射),大大降低了数据库交互的频率。
4. 优化策略三:使用 batchInsert() 批量插入
即使使用 insert()->execute(),每次循环仍然会执行一条 SQL 语句。对于非常大的数据集(例如十万或百万条记录),更高效的方法是使用 batchInsert()。batchInsert() 允许一次性构建一个包含多条记录的 INSERT SQL 语句,从而显著减少数据库往返次数和 SQL 解析开销。
batchInsert() 示例代码:
public function importProductFileOptimized($file, $return = true) { $products = json_decode($file, true); // 解码为关联数组 $dubTableName = Product::tableName() . "_dub"; $start = microtime(true); // 使用 microtime 更精确 // 假设 createDuplicateTable 成功 // if ($this->db->createDuplicateTable(Product::tableName(), $dubTableName)) { $categoryMap = Category_dub::find()->select(['id', 'id_1c_category'])->indexBy('id_1c_category')->column(); $brandMap = Brands_dub::find()->select(['id', 'id_1c_brand'])->indexBy('id_1c_brand')->column(); $rowsToInsert = []; $batchSize = 1000; // 每批次插入的记录数 foreach ($products as $product) { $rowsToInsert[] = [ 'id_1c_product' => $product['id'], 'category_id' => $categoryMap[$product['category_id']] ?? '0', 'title' => $product['title'], 'brand_id' => $brandMap[$product['brand_id']] ?? 'No brand', 'content1' => $product['content1'], 'content2' => $product['content2'], 'content3' => $product['content3'], 'link_order' => $product['link_order'], 'img' => $product['img'] ?? 'no-image.png', 'in_stock' => $product['in_stock'] ? 1 : 0, 'is_popular' => $product['is_popular'] ? 1 : 0, ]; // 达到批次大小或遍历结束时执行插入 if (count($rowsToInsert) >= $batchSize) { Yii::$app->db->createCommand()->batchInsert('product_dub', array_keys($rowsToInsert[0]), $rowsToInsert)->execute(); $rowsToInsert = []; // 清空数组准备下一批 } } // 插入剩余的记录(如果不足一个批次) if (!empty($rowsToInsert)) { Yii::$app->db->createCommand()->batchInsert('product_dub', array_keys($rowsToInsert[0]), $rowsToInsert)->execute(); } // } $finish = microtime(true); $res = round($finish - $start, 2) . " sec. "; if ($return) { echo $res; // Answer::success(); // 假设 Answer::success() 是一个自定义的成功响应方法 } }
在 batchInsert() 中,array_keys($rowsToInsert[0]) 用于获取字段名列表,$rowsToInsert 是一个包含所有待插入行数据的二维数组。选择合适的 $batchSize 很重要,过小则批次效果不明显,过大则可能导致 SQL 语句过长超出数据库或 PHP 内存限制。通常几百到几千条记录为一个批次是比较合理的。
5. 优化策略四:事务管理
对于任何批量数据操作,使用数据库事务是至关重要的。事务可以确保数据的一致性:要么所有记录都成功插入,要么在发生错误时全部回滚,避免部分数据插入的脏数据问题。
public function importProductFileWithTransaction($file, $return = true) { $products = json_decode($file, true); $dubTableName = Product::tableName() . "_dub"; $start = microtime(true); $transaction = Yii::$app->db->beginTransaction(); // 开启事务 try { // 假设 createDuplicateTable 成功 // if ($this->db->createDuplicateTable(Product::tableName(), $dubTableName)) { $categoryMap = Category_dub::find()->select(['id', 'id_1c_category'])->indexBy('id_1c_category')->column(); $brandMap = Brands_dub::find()->select(['id', 'id_1c_brand'])->indexBy('id_1c_brand')->column(); $rowsToInsert = []; $batchSize = 1000; foreach ($products as $product) { $rowsToInsert[] = [ 'id_1c_product' => $product['id'], 'category_id' => $categoryMap[$product['category_id']] ?? '0', 'title' => $product['title'], 'brand_id' => $brandMap[$product['brand_id']] ?? 'No brand', 'content1' => $product['content1'], 'content2' => $product['content2'], 'content3' => $product['content3'], 'link_order' => $product['link_order'], 'img' => $product['img'] ?? 'no-image.png', 'in_stock' => $product['in_stock'] ? 1 : 0, 'is_popular' => $product['is_popular'] ? 1 : 0, ]; if (count($rowsToInsert) >= $batchSize) { Yii::$app->db->createCommand()->batchInsert('product_dub', array_keys($rowsToInsert[0]), $rowsToInsert)->execute(); $rowsToInsert = []; } } if (!empty($rowsToInsert)) { Yii::$app->db->createCommand()->batchInsert('product_dub', array_keys($rowsToInsert[0]), $rowsToInsert)->execute(); } // } $transaction->commit(); // 提交事务 $finish = microtime(true); $res = round($finish - $start, 2) . " sec. "; if ($return) { echo $res; // Answer::success(); } } catch (Exception $e) { $transaction->rollBack(); // 发生错误时回滚事务 // Answer::failure("导入失败:" . $e->getMessage()); // 自定义错误处理 if ($return) { echo "导入失败:" . $e->getMessage(); } } }
在事务中执行批量操作可以显著提高性能,因为数据库只需要在事务结束时进行一次持久化操作,而不是每次插入都进行。
6. 其他高级优化考量
对于百万级甚至千万级的数据导入,上述 PHP 层的优化可能仍然不够。可以考虑以下更高级的策略:
- 禁用索引和约束: 在导入大量数据之前,暂时禁用目标表的非主键索引、外键约束和唯一约束。导入完成后再重新启用并重建索引。这可以大大加快 INSERT 操作的速度,因为数据库不需要在每次插入时都更新这些结构。
- 使用 LOAD DATA INFILE: 如果数据源是 csv 或其他文本文件,mysql 提供了 LOAD DATA INFILE 命令,这是导入大量数据的最快方式。它直接从文件读取数据并插入到表中,绕过了大部分网络开销和 SQL 解析。PHP 可以通过 Yii::$app->db->createCommand(“LOAD DATA INFILE …”)->execute() 来执行此命令。
- 调整 MySQL 配置: 优化 MySQL 服务器的配置参数,如 innodb_flush_log_at_trx_commit (设置为 0 或 2 可以提高写入性能,但可能牺牲数据安全性)、innodb_buffer_pool_size 等,以适应批量写入负载。
- 分块处理大文件: 如果 JSON 文件非常大,一次性 json_decode() 可能会耗尽内存。可以考虑流式读取文件,或将大文件分割成小块进行处理。
总结与最佳实践
批量数据导入是常见的业务需求,其性能直接影响用户体验和系统效率。通过本教程的分析和优化实践,我们可以得出以下最佳实践:
- 避免在循环中使用 ActiveRecord save(): 对于批量插入,优先使用 Yii::$app->db->createCommand()->insert()。
- 避免在循环中进行数据库查询: 预先加载所有必要的关联数据到内存映射中。
- 使用 batchInsert() 进行批量操作: 这是处理大量数据(数千到数十万)的最高效 PHP/Yii2 方案。
- 利用数据库事务: 确保数据一致性,并能进一步提升批量操作的性能。
- 考虑数据库层面的优化: 对于超大数据量,研究 LOAD DATA INFILE、临时禁用索引和调整数据库配置。
通过综合运用这些策略,即使面对百万级的数据导入任务,也能将其处理时间从数小时缩短到可接受的分钟级别。