首页 / 资讯中心 / 文章详情

如何在 PHP 中遍历嵌套 JSON 数据并批量插入 MySQL 数据库

如何在 PHP 中遍历嵌套 JSON 数据并批量插入 MySQL 数据库 ★ FEATURED ARTICLE
前言接口返回的订单 JSON 往往长成这样最外层是一个数组每个订单里又有items数组items里可能还嵌着options。很多人第一次处理它的写法是两层foreach直接拼 SQL跑小样例没问题一上真实数据就出三种症状字段整体错位或大量NULL、脚本跑几分钟后超时、MySQL server has gone away。这些症状的根因其实只有两个。第一JSON 是树MySQL 表是二维表中间缺了一层「展平」flatten的映射逻辑层级没对齐就会错位。第二展平之后如果用「一行一条INSERT」的方式写库每条语句都要走一次网络往返每条语句都在自己的隐式事务里提交磁盘刷盘次数被放大慢得毫不意外。本文用一个完整可运行的程序演示递归遍历任意深度的嵌套 JSON把叶子节点展平成行再用「预编译 多值VALUES 显式事务 分块」的方式批量写入MySQL。示例代码最低要求PHP 7.4推荐PHP 8.1 及以上数据库驱动使用PDOPHP Data Objects的pdo_mysql扩展。一、先把 JSON 的层级和表的行对应起来先把数据摆清楚。假设 JSON 长这样{ orders: [ { id: 1001, user: alice, items: [ {sku: A-1, qty: 2, price: 19.9, options: [{k: color, v: red}]}, {sku: B-2, qty: 1, price: 9.5, options: []} ] }, { id: 1002, user: bob, items: [ {sku: C-3, qty: 5, price: 3.0, options: [{k: size, v: XL}]} ] } ] }orders是一对多items又是一对多。如果把options也塞进同一张表一张order_items表就同时承载了两个层级的语义后面查询会很难受。正确做法是一层一张表orders主表、order_items子表、item_options孙表用外键串起来。所以遍历的目标不是「把 JSON 塞进一个循环」而是「每一层各自产出一批行」。这也决定了遍历函数的形状它不是一次性递归到底而是分层产出。JSON 层级目标表行数关系关联键orders[]orders1 个对象 1 行主键idorders[].items[]order_items1 个元素 1 行外键order_idorders[].items[].options[]item_options1 个元素 1 行外键item_id二、遍历显式分层而不是无限递归如果 JSON 结构是固定的大多数业务接口都是最稳的写法是显式分层循环而不是写一个「自动递归展开」的通用函数。原因很实际通用递归函数一旦遇到某一层出现null而另一层是空数组你很难控制它产出什么。?php declare(strict_types1); /** * 把嵌套的订单 JSON 展平成三层数据。 * * return array{orders: array, items: array, options: array} */ function flattenOrders(array $orders): array { $out [orders [], items [], options []]; foreach ($orders as $order) { $orderId (int) ($order[id] ?? 0); if ($orderId 0) { continue; // 没有主键的订单直接跳过否则子表会插入悬挂外键 } $out[orders][] [ id $orderId, user (string) ($order[user] ?? ), ]; foreach ($order[items] ?? [] as $item) { $sku (string) ($item[sku] ?? ); if ($sku ) { continue; } // 子表用「订单号 SKU」做业务主键避免依赖自增 ID $itemKey $orderId . : . $sku; $out[items][] [ item_key $itemKey, order_id $orderId, sku $sku, qty (int) ($item[qty] ?? 0), price number_format((float) ($item[price] ?? 0), 2, ., ), ]; foreach ($item[options] ?? [] as $k $opt) { $out[options][] [ item_key $itemKey, name (string) ($opt[k] ?? ), value (string) ($opt[v] ?? ), seq (int) $k, ]; } } } return $out; }注意几个刻意的选择用?? []兜底而不是直接foreach接口偶尔会返回null而不是数组直接foreach在 PHP 8 下会抛TypeErrorPHP 7 下是Warning加不执行。子表用业务主键item_key这样同一批数据重跑不会产生重复行配合INSERT ... ON DUPLICATE KEY UPDATE天然幂等。number_format(..., 2, ., )把浮点价格转成字符串再入库。直接传float给DECIMAL字段在某些驱动配置下会出现精度截断。三、批量插入真正让速度差几十倍的是这三件事展平之后写入慢不是 PHP 慢是语句条数太多。批量插入的核心就三点多值VALUESINSERT INTO t (a,b) VALUES (?,?),(?,?),(?,?)把 N 条语句压成 1 条语句。ATTR_EMULATE_PREPARES false让PDO用 MySQL 的原生预编译协议而不是在客户端把参数拼成字符串。开启模拟预处理时中文、二进制、超长文本都更容易踩到转义问题。显式事务把整批插入包在一个BEGIN ... COMMIT里。不开事务时每条语句都是一个独立事务每次都要fsync日志。?php declare(strict_types1); function connect(string $dsn, string $user, string $pass): PDO { return new PDO($dsn, $user, $pass, [ PDO::ATTR_ERRMODE PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE PDO::FETCH_ASSOC, PDO::ATTR_EMULATE_PREPARES false, ]); } /** * 分块批量 upsert。 * * param array $rows 每行必须是键名一致的关联数组 */ function bulkUpsert(PDO $pdo, string $table, array $rows, int $chunk 500): int { if ($rows []) { return 0; } $cols array_keys($rows[0]); $colList . implode(,, $cols) . ; $oneRow ( . implode(,, array_fill(0, count($cols), ?)) . ); $affected 0; $pdo-beginTransaction(); try { foreach (array_chunk($rows, $chunk) as $part) { $sql INSERT INTO $table ($colList) VALUES . implode(,, array_fill(0, count($part), $oneRow)) . AS new ON DUPLICATE KEY UPDATE . implode(,, array_map( static fn(string $c): string $c new.$c, $cols )); // 关键必须手动按顺序展开不能用 array_merge $params []; foreach ($part as $row) { foreach ($row as $value) { $params[] $value; } } $stmt $pdo-prepare($sql); $stmt-execute($params); $affected $stmt-rowCount(); } $pdo-commit(); } catch (Throwable $e) { $pdo-rollBack(); throw $e; } return $affected; }AS new这种行别名写法要求MySQL 8.0.19 及以上。如果你的 MySQL 是 5.7把它换成老式的ON DUPLICATE KEY UPDATE col VALUES(col)只是后者在 8.0.20 之后会给出弃用警告。四、完整可运行示例把下面这段存成import_orders.php准备好建表语句和orders.json直接php import_orders.php即可。CREATE TABLE orders ( id BIGINT UNSIGNED NOT NULL, user VARCHAR(64) NOT NULL DEFAULT , PRIMARY KEY (id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE order_items ( item_key VARCHAR(128) NOT NULL, order_id BIGINT UNSIGNED NOT NULL, sku VARCHAR(64) NOT NULL, qty INT UNSIGNED NOT NULL DEFAULT 0, price DECIMAL(10,2) NOT NULL DEFAULT 0.00, PRIMARY KEY (item_key), KEY idx_order (order_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE item_options ( item_key VARCHAR(128) NOT NULL, seq INT UNSIGNED NOT NULL, name VARCHAR(64) NOT NULL, value VARCHAR(255) NOT NULL, PRIMARY KEY (item_key, seq) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;?php declare(strict_types1); // 最低 PHP 7.4推荐 PHP 8.1 // ---------- 1. 读取并解析 ---------- $file __DIR__ . /orders.json; $raw file_get_contents($file); if ($raw false) { exit(无法读取 $file\n); } try { // JSON_THROW_ON_ERROR 需要 PHP 7.3 $data json_decode($raw, true, 512, JSON_THROW_ON_ERROR); } catch (JsonException $e) { exit(JSON 解析失败 . $e-getMessage() . \n); } $orders $data[orders] ?? []; printf(解析到 %d 个订单\n, count($orders)); // ---------- 2. 展平 ---------- $flat flattenOrders($orders); printf( 展平结果orders%d, items%d, options%d\n, count($flat[orders]), count($flat[items]), count($flat[options]) ); // ---------- 3. 写库 ---------- $dsn mysql:host127.0.0.1;port3306;dbnameshop;charsetutf8mb4; $user app; $pass secret; $pdo connect($dsn, $user, $pass); try { $n1 bulkUpsert($pdo, orders, $flat[orders]); $n2 bulkUpsert($pdo, order_items, $flat[items]); $n3 bulkUpsert($pdo, item_options, $flat[options]); printf(写入完成orders%d, items%d, options%d\n, $n1, $n2, $n3); } catch (Throwable $e) { exit(写库失败 . $e-getMessage() . \n); } // ---------- 4. 抽样校验行数对不对 ---------- $expected count($flat[options]); $actual (int) $pdo-query(SELECT COUNT(*) FROM item_options)-fetchColumn(); printf(item_options 期望 %d 行实际 %d 行%s\n, $expected, $actual, $expected $actual ? OK : 不一致需排查); function flattenOrders(array $orders): array { /* 见上文第二节 */ } function connect(string $dsn, string $user, string $pass): PDO { /* 见上文第三节 */ } function bulkUpsert(PDO $pdo, string $table, array $rows, int $chunk 500): int { /* 见上文第三节 */ }最后这一步「抽样校验」非常重要批量插入最容易出的事故不是报错而是没报错但行数少了几百条。每次导入后都比对一下源数据与目标表的行数问题能早一个环节暴露。常见坑点1.json_decode的第二个参数忘了传true❌$data json_decode($raw);之后写$data[orders]得到Cannot use object of type stdClass as array。 ✅$data json_decode($raw, true, 512, JSON_THROW_ON_ERROR);直接拿关联数组同时把解析失败变成异常而不是null。2. 拼接参数时用了array_merge展开行数据❌$stmt-execute(array_merge(...$part));—— 行的键名是字符串array_merge遇到重复的字符串键会覆盖最后只剩最后一行且参数个数与占位符对不上。 ✅ 手动双层foreach按顺序压平成一维数组如上面的bulkUpsert。3.isset()和array_key_exists()混用❌if (isset($item[qty])) { ... }—— 当字段存在但值为null时判断为假于是业务上「明确设为 0」和「字段缺失」被当成一回事。 ✅ 需要区分时用array_key_exists(qty, $item)只做取值兜底时才用||或??。4. 超大整数被转成浮点❌ 雪花 ID7300000000000001234经json_decode后变成7.3E18写库直接溢出。 ✅ 加JSON_BIGINT_AS_STRING标志json_decode($raw, true, 512, JSON_THROW_ON_ERROR | JSON_BIGINT_AS_STRING)再在绑定前转成字符串交给PDO。5. 一层塞十万条撞上max_allowed_packet❌ 把整批数据拼成一条VALUES报MySQL server has gone away或Packet too large。 ✅ 分块示例里是 500 行并确认max_allowed_packet与服务端一致行宽越大块要越小。6. 事务里捕获了异常却没有回滚❌try { ... commit(); } catch (Exception $e) { log($e); }—— 事务没结束就继续往下跑连接被放回连接池后状态是脏的后续操作行为诡异。 ✅catch (Throwable $e) { $pdo-rollBack(); throw $e; }回滚后再向上抛别吞异常。7. 递归深度超过json_decode的默认上限❌ 深层嵌套超过 512 层时json_decode静默返回null而json_last_error()又容易被人忽略。 ✅ 显式传 depth并配合JSON_THROW_ON_ERROR让失败可见结构真的那么深说明该换数据格式了。8. 每条记录都重新new PDO❌ 在循环体内建立连接 —— 建连接的成本远高于插入本身。 ✅ 连接只建一次循环里只做prepare/execute。总结环节做法解决的问题结构映射一层 JSON 层级对应一张表字段错位、语义混乱遍历显式分层循环 ?? []兜底TypeError、空值穿透主键用业务键如订单号:SKU重跑产生重复行写入多值VALUES 原生预编译 显式事务网络往返与刷盘开销分块每块几百行按行宽调整max_allowed_packet校验导入后比对源与目标行数静默丢数据嵌套 JSON 入库的难点从来不在「怎么遍历」而在于把树形结构诚实地映射成关系模型再让写入的语句条数从「行数级」降到「块数级」。把展平逻辑写成可读的显式循环再套上一个会回滚的事务和一次行数核对这套流程就能稳稳地跑在生产上。
阅读完成 · 觉得有帮助?
咨询建站