简介:这是一份面向PHP后端开发者与数据库初学者的轻量级数据迁移工具,解决Excel(.xls)格式数据批量导入MySQL的实际需求,特别适用于后台管理系统的数据初始化、报表导入等场景。资源包共4个文件,含3个核心PHP脚本(负责文件上传、Excel解析与SQL写入)及1个inc封装类(OLE格式读取支持),总大小仅13KB,结构紧凑、无冗余依赖,可快速集成到现有项目中。已有275人学习下载,体现了其在中小规模数据导入任务中的实用价值。读者可直接部署运行,获得完整的xls解析逻辑、UTF-8中文兼容处理方案、数据库表字段动态映射机制,以及对表名、字段名、编码等关键参数的灵活配置能力,避免常见乱码与字段错位问题。
1. 为什么用 PHP 批量导入 XLS 到 MySQL 不是“写个循环读 Excel 再 INSERT”就完事?
你手头有一份销售日报表(.xls 格式),327 行、14 列,含中文表头、空行、合并单元格、日期格式混杂(有的存成2024/3/15,有的是2024-03-15,还有 Excel 序列号45210),老板说“今晚八点前把这周数据灌进生产库的sales_daily表里”。你打开 PhpStorm,敲下mysql_connect()—— 等等,PHP 7.4+ 已弃用mysql_*函数,PDO 是底线;Excel 解析?phpexcel早已停更,phpspreadsheet是当前事实标准;字段映射?sales_daily.id是自增主键,但 XLS 里没这一列,created_at要自动填NOW(),而amount列里混着¥1,234.50和1234.5两种格式……这不是脚本搬运,是数据管道校准:XLS 是非结构化载体,MySQL 是强约束目标,PHP 是中间校验层。它解决的是「业务原始数据如何无损、可追溯、可重跑地进入关系型数据库」——适合需要频繁对接财务/ERP/线下报表的中小系统运维、内部工具开发者,以及被 Excel 拖垮过三次 ETL 流程的后端同学。别信“一行代码搞定”,真实场景里,80% 的时间花在清洗、容错、日志和回滚上。
2. 用 PhpSpreadsheet 在本地跑通 XLS 导入 MySQL 的最小命令链
2.1 安装 PhpSpreadsheet 并验证基础读取能力
PhpSpreadsheet 是目前唯一 actively maintained、支持.xls(Excel 97-2003)和.xlsx双格式的 PHP Excel 库。注意:.xls是二进制 BIFF 格式,不是 XML,旧版PHPExcel对它的兼容性已断裂,必须用phpoffice/phpspreadsheet1.20+ 版本(经实测,1.23.0 对含合并单元格的.xls解析最稳)。
composer require phpoffice/phpspreadsheet:^1.23验证是否能正确加载.xls文件(关键:必须指定XlsReader,否则默认只认.xlsx):
<?php require 'vendor/autoload.php'; use PhpOffice\PhpSpreadsheet\IOFactory; $filename = '/path/to/report.xls'; $reader = IOFactory::createReader('Xls'); // ⚠️ 必须显式指定 'Xls',不能省略 $spreadsheet = $reader->load($filename); // 获取第一个工作表 $worksheet = $spreadsheet->getActiveSheet(); echo "总行数:" . $worksheet->getHighestRow() . "\n"; // 输出实际有数据的行数(跳过空行) echo "总列数:" . $worksheet->getHighestColumn() . "\n"; // 输出如 'M',需转为数字提示:
getHighestRow()返回的是 Excel 中“最后有内容的行号”,不是物理行数。如果第 100 行有数据,第 101~1000 行全空,它返回100。这对后续遍历至关重要——别用for ($i=1; $i<=1000; $i++)硬循环。
2.2 构建 PDO 连接并预设插入语句(带命名占位符)
不要拼接 SQL 字符串!用 PDO 预处理防止注入,且命名占位符(:col_name)比问号占位符(?)更易维护字段映射:
<?php $dsn = 'mysql:host=localhost;dbname=your_db;charset=utf8mb4'; $options = [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES => false, // 强制使用真实预处理 ]; $pdo = new PDO($dsn, 'username', 'password', $options); // 假设目标表结构:id(INT AUTO_INCREMENT), product_name(VARCHAR), sale_date(DATE), amount(DECIMAL) $sql = "INSERT INTO sales_daily (product_name, sale_date, amount, created_at) VALUES (:product_name, :sale_date, :amount, NOW())"; $stmt = $pdo->prepare($sql);参数说明:
PDO::ATTR_EMULATE_PREPARES => false是关键。开启模拟预处理时,PDO 会把占位符替换成值再发给 MySQL,对NULL或特殊字符处理不一致;关闭后由 MySQL 原生解析,NULL插入、0.00保留小数位更可靠。
2.3 逐行读取 + 类型转换 + 执行插入(核心逻辑闭环)
这才是真正干活的部分。重点在于:跳过标题行、处理空单元格、转换日期、清洗金额、捕获单行异常:
<?php // 从第2行开始读(假设第1行是表头) $startRow = 2; $endRow = $worksheet->getHighestRow(); for ($row = $startRow; $row <= $endRow; $row++) { try { // 读取单元格值(自动类型转换) $productName = trim($worksheet->getCell("A{$row}")->getValue()); $rawDate = $worksheet->getCell("B{$row}")->getValue(); $rawAmount = $worksheet->getCell("C{$row}")->getValue(); // 【关键清洗】日期转换:兼容文本、Excel序列号、空值 $saleDate = null; if ($rawDate instanceof \DateTime) { $saleDate = $rawDate->format('Y-m-d'); } elseif (is_numeric($rawDate) && $rawDate > 1) { // Excel 日期序列号(1900-01-01=1) $dateObj = \PhpOffice\PhpSpreadsheet\Shared\Date::excelToDateTimeObject($rawDate); $saleDate = $dateObj->format('Y-m-d'); } elseif (is_string($rawDate) && !empty($rawDate)) { $parsed = date_create_from_format('Y/m/d', $rawDate) ?: date_create_from_format('Y-m-d', $rawDate); $saleDate = $parsed ? $parsed->format('Y-m-d') : null; } // 【关键清洗】金额:移除 ¥、逗号,转为 float $amount = null; if (is_numeric($rawAmount)) { $amount = (float)$rawAmount; } elseif (is_string($rawAmount)) { $cleaned = preg_replace('/[^\d.-]/', '', $rawAmount); // 移除非数字、点、负号 $amount = is_numeric($cleaned) ? (float)$cleaned : null; } // 跳过空行或关键字段缺失的行 if (empty($productName) || $saleDate === null || $amount === null) { error_log("跳过第 {$row} 行:产品名或日期或金额为空"); continue; } // 执行插入 $stmt->execute([ ':product_name' => $productName, ':sale_date' => $saleDate, ':amount' => $amount, ]); } catch (\PhpOffice\PhpSpreadsheet\Reader\Exception $e) { error_log("读取第 {$row} 行时 PhpSpreadsheet 异常:{$e->getMessage()}"); continue; } catch (PDOException $e) { error_log("插入第 {$row} 行失败:{$e->getMessage()}"); continue; // 单行失败不影响整体流程 } } echo "导入完成,共处理 {$endRow - $startRow + 1} 行。\n";逻辑说明:
getCell("A{$row}")->getValue()自动识别 Excel 单元格类型(字符串、数字、日期对象),比手动getFormattedValue()更安全;- 日期处理覆盖三种常见形态:
DateTime对象(新版 PhpSpreadsheet)、Excel 序列号(老 XLS)、文本字符串;- 金额正则
/[^\d.-]/比str_replace(['¥', ','], '', $str)更鲁棒,能处理¥1.234,50这类混合符号;continue而非break,确保单行错误不中断整个文件导入——这是生产环境底线。
3. XLS 到 MySQL 字段映射的 3 个必调参数与动态适配策略
3.1 列映射表:用配置数组替代硬编码列字母
硬写"A{$row}"维护成本极高。当 XLS 表头顺序变动(如product_name从 A 列挪到 D 列),你得改所有getCell("A{$row}")。正确做法是先读表头,构建列名→列字母映射:
<?php // 第1行读取表头 $headerRow = 1; $highestColumn = $worksheet->getHighestColumn(); $columnIndex = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::columnIndexFromString($highestColumn); $headerMap = []; for ($col = 1; $col <= $columnIndex; $col++) { $columnLetter = \PhpOffice\PhpSpreadsheet\Cell\Coordinate::stringFromColumnIndex($col); $header = trim($worksheet->getCell("{$columnLetter}{$headerRow}")->getValue()); if (!empty($header)) { $headerMap[strtolower($header)] = $columnLetter; // 小写键,兼容大小写混用 } } // 使用示例:$productName = $worksheet->getCell($headerMap['product name'] . $row)->getValue(); // $saleDate = $worksheet->getCell($headerMap['date'] . $row)->getValue();参数说明:
Coordinate::stringFromColumnIndex($col)将数字列索引(1=A, 2=B)转为字母(A,B),避免手动chr(64+$col)的边界错误(Z 后是 AA)。
3.2 数据类型强制转换开关:控制 NULL / 0 / 空字符串行为
MySQL 字段允许NULL还是DEFAULT?amount列定义为DECIMAL(10,2) NOT NULL DEFAULT 0.00,但 XLS 里该单元格为空,你该插NULL还是0.00?这必须由配置驱动:
<?php // 映射配置:字段名 => [列名, 类型, 默认值, 是否允许空] $mappingConfig = [ 'product_name' => ['product name', 'string', null, false], 'sale_date' => ['date', 'date', null, false], 'amount' => ['amount', 'decimal', 0.00, true], // 允许空,填默认值 'remark' => ['notes', 'string', '', true], // 允许空,填空字符串 ]; // 在循环中应用: foreach ($mappingConfig as $dbField => $config) { list($xlsHeader, $type, $default, $nullable) = $config; $colLetter = $headerMap[strtolower($xlsHeader)] ?? null; if (!$colLetter) { $value = $default; } else { $raw = $worksheet->getCell("{$colLetter}{$row}")->getValue(); $value = convertByType($raw, $type, $default, $nullable); } $params[":{$dbField}"] = $value; }<?php function convertByType($raw, $type, $default, $nullable) { if ($raw === null || $raw === '') { return $nullable ? $default : $default; // 无论是否 nullable,都给默认值(按业务规则) } switch ($type) { case 'string': return trim((string)$raw); case 'date': return convertToDate($raw); case 'decimal':return (float)preg_replace('/[^\d.-]/', '', (string)$raw) ?: $default; default: return $raw; } }价值点:同一份导入脚本,只需改
$mappingConfig数组,就能适配采购单、库存表、客户信息表——这才是可复用的工程实践。
3.3 批量插入优化:100 行一事务,而非单行事务
每行execute()一次,网络往返开销巨大。将插入聚合成批,用事务包裹:
<?php $batchSize = 100; $batch = []; for ($row = $startRow; $row <= $endRow; $row++) { // ... 清洗逻辑同上,得到 $params 数组 ... $batch[] = $params; // 每满100行执行一次批量插入 if (count($batch) >= $batchSize || $row == $endRow) { try { $pdo->beginTransaction(); // 用 UNION ALL 拼接多值 INSERT(比多次 execute 快3-5倍) $values = []; $allParams = []; foreach ($batch as $i => $params) { $values[] = "( :p{$i}_product_name, :p{$i}_sale_date, :p{$i}_amount, NOW() )"; $allParams += array_map(fn($k, $v) => ":p{$i}_{$k}", array_keys($params), $params); } $sql = "INSERT INTO sales_daily (product_name, sale_date, amount, created_at) VALUES " . implode(', ', $values); $stmt = $pdo->prepare($sql); $stmt->execute($allParams); $pdo->commit(); $batch = []; // 清空批次 } catch (Exception $e) { $pdo->rollback(); error_log("批次插入失败(行 {$row} 起):{$e->getMessage()}"); // 此处可选择:跳过本批次 or 降级为单行重试 } } }参数说明:
$batchSize = 100是经验值。太小(如10)事务开销占比高;太大(如1000)内存占用陡增,且单批次失败损失大。实测 50~200 是安全区间。
4. XLS 导入 MySQL 的 5 个血泪避坑记录(现象 → 原因 → 解决)
4.1 现象:导入后中文全是问号(????)或乱码
原因:PHP 文件编码、MySQL 连接字符集、表字段字符集三者不统一。常见错误是 PHP 文件存为 GBK,却用utf8mb4连接 MySQL。
解决:
- 确保 PHP 源码文件保存为UTF-8 无 BOM(用 VS Code 或 Notepad++ 检查);
- PDO DSN 中明确指定
charset=utf8mb4; - MySQL 表字段用
VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; - 执行
SET NAMES utf8mb4(虽 DSN 已设,但双重保险)。
4.2 现象:XLS 里的日期全部变成0000-00-00
原因:PhpSpreadsheet 读取.xls时,对 Excel 序列号(如45210)的解析依赖PhpOffice\PhpSpreadsheet\Shared\Date类,但若未use该类或版本不匹配,会返回原始数字而非DateTime对象。
解决:
- 显式
use PhpOffice\PhpSpreadsheet\Shared\Date;; - 确保
phpspreadsheet版本 ≥1.20(1.18 有已知日期解析 bug); - 在
convertToDate()函数中增加is_numeric($raw) && $raw > 1判断,主动调用Date::excelToDateTimeObject()。
4.3 现象:金额列导入后小数位丢失(1234.50变成1234.5)
原因:MySQLDECIMAL(10,2)存储时保留精度,但 PHP(float)强制转换会丢失末尾零(1234.50→1234.5),再插入时 MySQL 按1234.5存储。
解决:
- 不用
float,改用number_format($cleaned, 2, '.', '')转为字符串再插入; - 或在 PDO 绑定时用
PDO::PARAM_STR而非PDO::PARAM_INT/PDO::PARAM_STR,让 MySQL 自行 cast。
4.4 现象:合并单元格导致后续行数据错位(如 B2:B5 合并,则第3行 B 列读出来是空)
原因:PhpSpreadsheet 默认只对合并区域的左上角单元格返回值,其余位置返回null。
解决:
- 启用合并单元格读取:
$reader->setReadDataOnly(false);(默认true,跳过样式/合并信息); - 用
$worksheet->mergeCells获取合并范围,对范围内所有单元格手动填充左上角值; - 更简单方案:导入前用 Excel 手动“取消合并单元格,并向下方填充”,这是业务方最容易接受的前置规范。
4.5 现象:大文件(>10MB)导入超时或内存溢出
原因:PhpSpreadsheet 加载整个 XLS 到内存,.xls文件虽小但解析开销大,10MB XLS 可能占用 500MB 内存。
解决:
- 启用
readFilter只读指定行列:$reader->setReadFilter(new class implements \PhpOffice\PhpSpreadsheet\Reader\IReadFilter { public function readCell($column, $row, $worksheetName = '') { return $row <= 1000; } // 只读前1000行 }); - 改用流式读取库(如
box/spout),但它不支持.xls,仅支持.xlsx/.csv; - 最终方案:要求业务方提供
.xlsx或.csv,.xls本质是技术债,应推动淘汰。
5. 生产级导入的 3 层验证机制与失败回滚技巧
5.1 行级验证:在插入前用 MySQLINSERT ... SELECT做原子校验
与其在 PHP 层做一堆if判断,不如把校验逻辑下沉到数据库。创建临时校验表,用INSERT ... SELECT一次性过滤脏数据:
-- 创建临时校验表(结构同目标表,但加校验字段) CREATE TEMPORARY TABLE temp_import AS SELECT TRIM(A) as product_name, CASE WHEN IS_DATE(B) THEN DATE(B) WHEN B REGEXP '^[0-9]{4}-[0-9]{2}-[0-9]{2}$' THEN B ELSE NULL END as sale_date, CAST(REPLACE(REPLACE(C, '¥', ''), ',', '') AS DECIMAL(10,2)) as amount, 'valid' as status FROM your_xls_import_staging; -- 此表需先用 LOAD DATA INFILE 导入原始 XLS(需 MySQL 有文件权限)技巧:
LOAD DATA INFILE比 PHP 读取快 10 倍,但它要求 MySQL 服务端能访问文件路径。若不可行,用 PhpSpreadsheet 读出 CSV 再LOAD DATA,仍是最优解。
5.2 批次级验证:导入后立即执行 COUNT + SUM 对账
导入不是终点,对账才是。每次导入后,必须比对源 XLS 行数与目标表新增行数、金额总和:
<?php // 导入前记下目标表最大 id $beforeCount = $pdo->query("SELECT COUNT(*) FROM sales_daily WHERE created_at >= '2024-03-15'")->fetchColumn(); // 导入完成后 $afterCount = $pdo->query("SELECT COUNT(*) FROM sales_daily WHERE created_at >= '2024-03-15'")->fetchColumn(); $importedRows = $afterCount - $beforeCount; // 计算 XLS 中有效行数(跳过空行/标题) $validXlsRows = 0; for ($row = $startRow; $row <= $endRow; $row++) { if (!empty(trim($worksheet->getCell("A{$row}")->getValue()))) $validXlsRows++; } if ($importedRows !== $validXlsRows) { throw new Exception("行数不一致:XLS {$validXlsRows} 行,DB 插入 {$importedRows} 行"); } // 金额对账(XLS 总和 vs DB 总和) $xlsSum = 0; for ($row = $startRow; $row <= $endRow; $row++) { $raw = $worksheet->getCell("C{$row}")->getValue(); $xlsSum += (float)preg_replace('/[^\d.-]/', '', (string)$raw); } $dbSum = $pdo->query("SELECT SUM(amount) FROM sales_daily WHERE created_at >= '2024-03-15'")->fetchColumn(); if (abs($xlsSum - $dbSum) > 0.01) { // 允许浮点误差 throw new Exception("金额不一致:XLS {$xlsSum}, DB {$dbSum}"); }价值:这步耗时 <1 秒,却能 100% 捕获
INSERT IGNORE误用、UNIQUE KEY冲突静默丢数据、金额计算逻辑错误等致命问题。
5.3 全局回滚:用START TRANSACTION+SAVEPOINT实现部分失败可逆
单次导入可能跨多张表(如sales_daily+inventory_log)。若第二张表插入失败,第一张表不能留脏数据。用SAVEPOINT实现子事务:
<?php try { $pdo->beginTransaction(); // 插入主表 $stmt1->execute($mainParams); // 设置保存点 $pdo->exec("SAVEPOINT after_main_insert"); // 插入关联表 $stmt2->execute($relatedParams); } catch (Exception $e) { // 回滚到保存点,主表数据保留,关联表失败 $pdo->exec("ROLLBACK TO SAVEPOINT after_main_insert"); $pdo->commit(); // 提交主表 error_log("关联表插入失败,已回滚:{$e->getMessage()}"); }教训:我曾在线上环境因没加
SAVEPOINT,一次库存同步失败导致销售单和库存日志全部丢失,花了 2 小时从 binlog 恢复。现在所有跨表导入必加SAVEPOINT,哪怕只有一张表也加上——这是我的后悔药。希望帮到你。
本文还有配套的精品资源,点击获取