用 JSON_TABLE 把单据明细 JSON 数组拆行并关联主表统计金额

很多业务系统在设计单据表时,把明细行整体存进一个 JSON 列(比如 order_header.detail_json),主表只留单号、客户、状态等字段。这样做写入简单、结构灵活,但一到对账、报表、按商品汇总金额的环节就卡住:MySQL 不能直接对 JSON 数组里的每个元素做聚合。开发常见的土办法是把数据全捞到应用层用 Java/Python 循环展开,再

很多业务系统在设计单据表时,把明细行整体存进一个 JSON 列(比如 order_header.detail_json),主表只留单号、客户、状态等字段。这样做写入简单、结构灵活,但一到对账、报表、按商品汇总金额的环节就卡住:MySQL 不能直接对 JSON 数组里的每个元素做聚合。开发常见的土办法是把数据全捞到应用层用 Java/Python 循环展开,再拼 SQL,数据量大时内存和网络都扛不住。

MySQL 8.0.4 起提供的内置表函数 JSON_TABLE,可以在 SQL 层把 JSON 数组「横向展开」成多行,随后像普通表一样 JOIN、WHERE、GROUP BY。本文用一个真实的采购单据场景,从建表、造数据开始,一步步拆行、关联主表、按商品和客户统计金额,并给出性能陷阱与常见报错的处理方式。

一、场景与表结构设计

假设有两张表:主表 order_header 存单据头,明细表并不存在,明细以 JSON 数组形式放在主表的 detail_json 列中。每条明细包含商品编码、名称、单价、数量、折扣。

CREATE TABLE order_header (
  id           BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  order_no     VARCHAR(32)     NOT NULL,
  customer_id  INT             NOT NULL,
  status       TINYINT         NOT NULL DEFAULT 1 COMMENT '1待审核 2已审核 3已作废',
  detail_json  JSON            NOT NULL,
  created_at   DATETIME        NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (id),
  UNIQUE KEY uk_order_no (order_no),
  KEY idx_customer (customer_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

CREATE TABLE customer (
  id   INT          NOT NULL AUTO_INCREMENT,
  name VARCHAR(64)  NOT NULL,
  PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

明细 JSON 的典型形态如下,注意 qty 是整数、price 是两位小数、discount 是 0~1 的折扣率:

[
  {"sku":"A1001","name":"螺丝 M6","price":0.50,"qty":200,"discount":0.95},
  {"sku":"A1002","name":"垫片 M6","price":0.10,"qty":500,"discount":1.00},
  {"sku":"B2001","name":"扳手 8寸","price":25.00,"qty":10,"discount":0.90}
]

造一点测试数据,方便后面对比结果:

INSERT INTO customer (id, name) VALUES
  (1, '华东五金'), (2, '西南机电'), (3, '北方工具');

INSERT INTO order_header (order_no, customer_id, status, detail_json) VALUES
('PO20240501001', 1, 2, '[
   {"sku":"A1001","name":"螺丝 M6","price":0.50,"qty":200,"discount":0.95},
   {"sku":"A1002","name":"垫片 M6","price":0.10,"qty":500,"discount":1.00},
   {"sku":"B2001","name":"扳手 8寸","price":25.00,"qty":10,"discount":0.90}]'),
('PO20240501002', 2, 2, '[
   {"sku":"A1001","name":"螺丝 M6","price":0.50,"qty":1000,"discount":0.92},
   {"sku":"C3001","name":"卷尺 5m","price":12.00,"qty":30,"discount":1.00}]'),
('PO20240501003', 3, 1, '[
   {"sku":"B2001","name":"扳手 8寸","price":25.00,"qty":4,"discount":1.00}]'),
('PO20240501004', 1, 3, '[
   {"sku":"A1002","name":"垫片 M6","price":0.10,"qty":2000,"discount":0.98}]');

目标有三个:把每张单据的明细拆成多行;关联 customer 拿到客户名;按商品维度、客户维度分别统计实付金额(price * qty * discount)。

二、JSON_TABLE 的基本语法与列定义

语法骨架是 JSON_TABLE(json_doc, path COLUMNS(...)) [AS] alias。第一个参数是 JSON 值,第二个是行路径(必须返回 JSON 数组),COLUMNS 子句里逐列声明「列名 类型 PATH ‘$.字段’」。

SELECT h.order_no, d.sku, d.name, d.price, d.qty, d.discount
FROM order_header h,
     JSON_TABLE(
       h.detail_json,
       '$[*]' COLUMNS (
         sku      VARCHAR(32)   PATH '$.sku',
         name     VARCHAR(64)   PATH '$.name',
         price    DECIMAL(10,2) PATH '$.price',
         qty      INT           PATH '$.qty',
         discount DECIMAL(4,2)  PATH '$.discount'
       )
     ) AS d
WHERE h.status = 2;

几个必须记住的细节:

  • 行路径必须用 $[*](或 $[0] 这类单元素路径)。如果写成 $,得到的是整个数组作为一行,PATH '$.sku' 全部为 NULL。
  • 列必须显式声明类型。JSON 里的 0.50 如果不声明为 DECIMAL,用 VARCHAR 接会得到字符串 '0.5'(末尾零被丢弃),后续乘法虽然会隐式转换,但精度和索引都用不上。金额列一律 DECIMAL(p,2)。
  • 隐式连接要写 FROM h, JSON_TABLE(h.detail_json, ...) d。JSON_TABLE 是「表函数」,不是子查询,它可以直接引用左侧表的列,等价于 LATERAL 语义。
  • 别名不可省。列引用时写成 d.sku,若别名写成 AS jt 就统一用 jt.sku。

执行上面的语句,PO20240501001 会展开成 3 行,PO20240501002 展开成 2 行,以此类推,共 8 行明细。

2.1 嵌套数组的处理

若明细里还有子数组(比如每个商品带多个批号或库位),用 NESTED PATH 继续展开,嵌套层会与上一层做笛卡尔展开:

SELECT h.order_no, d.sku, b.batch_no, b.loc
FROM order_header h,
     JSON_TABLE(
       h.detail_json,
       '$[*]' COLUMNS (
         sku     VARCHAR(32)   PATH '$.sku',
         qty     INT           PATH '$.qty',
         NESTED PATH '$.batches[*]' COLUMNS (
           batch_no VARCHAR(32) PATH '$.batch_no',
           loc      VARCHAR(32) PATH '$.loc'
         )
       )
     ) AS d
JOIN order_header h2 ON h2.id = h.id
WHERE h.status = 2;

这条语句在上面的数据上会返回 0 行,因为示例数据里没有 batches 字段;一旦某条明细包含 batches 数组,该明细会按批次数量裂成多行,而 sku、qty 会随之复制。做金额统计时,如果批号不影响金额,千万别用 NESTED PATH 去 JOIN,否则金额被重复累加(这是最常见的对账差错来源之一)。

三、关联主表和客户表做金额统计

把上面拆行的结果当成一张临时明细表,JOIN order_header(获取单据头字段)和 customer(获取客户名)即可。注意 order_header h 本身已经在 FROM 里,只要补一个 customer 的连接:

SELECT
  h.order_no,
  c.name                                     AS customer_name,
  d.sku,
  d.name                                     AS item_name,
  d.price,
  d.qty,
  d.discount,
  ROUND(d.price * d.qty * d.discount, 2)     AS line_amount
FROM customer c
JOIN order_header h ON h.customer_id = c.id
JOIN JSON_TABLE(
       h.detail_json,
       '$[*]' COLUMNS (
         sku      VARCHAR(32)   PATH '$.sku',
         name     VARCHAR(64)   PATH '$.name',
         price    DECIMAL(10,2) PATH '$.price',
         qty      INT           PATH '$.qty',
         discount DECIMAL(4,2)  PATH '$.discount'
       )
     ) AS d ON TRUE
WHERE h.status = 2
ORDER BY h.order_no, d.sku;

JOIN ... ON TRUE 是 JSON_TABLE 与前置表连接的惯用写法(等价于逗号连接,但可读性更好,也方便把连接条件写在同一处)。执行结果为:

order_no customer_name sku line_amount
PO20240501001 华东五金 A1001 95.00
PO20240501001 华东五金 A1002 50.00
PO20240501001 华东五金 B2001 225.00
PO20240501002 西南机电 A1001 460.00
PO20240501002 西南机电 C3001 360.00

PO20240501003 是待审核(status=1)、PO20240501004 已作废(status=3),被 WHERE 过滤掉,符合业务口径。

3.1 按商品维度汇总

SELECT
  d.sku,
  MAX(d.name)                              AS item_name,
  SUM(d.qty)                               AS total_qty,
  SUM(ROUND(d.price * d.qty * d.discount, 2)) AS total_amount,
  COUNT(DISTINCT h.id)                     AS order_cnt
FROM order_header h
JOIN JSON_TABLE(
       h.detail_json,
       '$[*]' COLUMNS (
         sku      VARCHAR(32)   PATH '$.sku',
         name     VARCHAR(64)   PATH '$.name',
         price    DECIMAL(10,2) PATH '$.price',
         qty      INT           PATH '$.qty',
         discount DECIMAL(4,2)  PATH '$.discount'
       )
     ) AS d ON TRUE
WHERE h.status = 2
GROUP BY d.sku
ORDER BY total_amount DESC;

结果中 A1001 的 total_qty 为 1200、total_amount 为 555.00,来自两张单据的合并;order_cnt 用 COUNT(DISTINCT h.id) 统计出现次数,避免同一单据多次计入。

这里 MAX(d.name) 是一个小技巧:同一 SKU 名称理论上一致,用聚合函数包一层即可满足 ONLY_FULL_GROUP_BY 严格的 SQL 模式,不必把它写进 GROUP BY。

3.2 按客户维度汇总并做占比

WITH line AS (
  SELECT
    h.customer_id,
    h.id                                   AS order_id,
    ROUND(d.price * d.qty * d.discount, 2) AS line_amount
  FROM order_header h
  JOIN JSON_TABLE(
         h.detail_json,
         '$[*]' COLUMNS (
           price    DECIMAL(10,2) PATH '$.price',
           qty      INT           PATH '$.qty',
           discount DECIMAL(4,2)  PATH '$.discount'
         )
       ) AS d ON TRUE
  WHERE h.status = 2
)
SELECT
  c.name,
  SUM(l.line_amount)                                        AS amount,
  ROUND(SUM(l.line_amount) * 100 / SUM(SUM(l.line_amount)) OVER (), 2) AS pct
FROM line l
JOIN customer c ON c.id = l.customer_id
GROUP BY c.id, c.name
ORDER BY amount DESC;

把拆行逻辑抽进 CTE(MySQL 8.0 支持 WITH)可以避免在多个统计口径里重复写一长串 COLUMNS 定义。窗口函数 SUM(...) OVER () 与聚合函数嵌套时,外层 SUM 是分组求和,内层 OVER () 是全量求和,这是标准写法,MySQL 8.0.22 及以上版本均可正常执行。

四、把 JSON_TABLE 封装成视图

若团队里多个报表都要用明细,建议建视图固化拆行逻辑,业务 SQL 只需 SELECT * FROM v_order_item。但 MySQL 视图有个硬限制:视图定义里不能引用会话变量或某些表函数形式,直接写 JSON_TABLE 在部分小版本上会报 ERROR 1351 (HY000): View's SELECT contains a variable or parameter。稳妥做法是:先用 CTE 包一层再建视图,或在应用层用 CREATE TABLE ... AS SELECT 落地。

CREATE OR REPLACE VIEW v_order_item AS
SELECT
  h.id                                    AS order_id,
  h.order_no,
  h.customer_id,
  h.status,
  h.created_at,
  d.sku,
  d.name                                  AS item_name,
  d.price,
  d.qty,
  d.discount,
  ROUND(d.price * d.qty * d.discount, 2)   AS line_amount
FROM order_header h
JOIN JSON_TABLE(
       h.detail_json,
       '$[*]' COLUMNS (
         sku      VARCHAR(32)   PATH '$.sku',
         name     VARCHAR(64)   PATH '$.name',
         price    DECIMAL(10,2) PATH '$.price',
         qty      INT           PATH '$.qty',
         discount DECIMAL(4,2)  PATH '$.discount'
       )
     ) AS d ON TRUE;

在 MySQL 8.0.21 上执行这条语句会失败,报错信息形如:

ERROR 1351 (HY000): View's SELECT contains a variable or parameter

解决办法有两条:升级到 8.0.30 及以上(该版本对视图中的 JSON_TABLE 支持更完善),或者退而求其次,用 CREATE TABLE v_order_item AS SELECT ... 建物化表,再配一个定时任务(事件或外部调度)增量刷新。生产上做报表底座,物化表反而更可控,因为可以在 sku、customer_id 上建索引,而视图每次查询都要全量展开 JSON。

五、性能与正确性的实战陷阱

5.1 无法走 JSON 内部字段的索引

JSON_TABLE 的拆行发生在执行阶段,WHERE d.sku = 'A1001' 这种条件下推到 JSON 内部是做不到的,MySQL 会把 status 过滤后的每一行 JSON 都解析一遍。表里 10 万行单据、每行 5 条明细,就是 50 万次 JSON 解析。可用的优化手段:

  • 为高频过滤字段建生成列 + 索引。例如 ALTER TABLE order_header ADD COLUMN sku_list VARCHAR(512) GENERATED ALWAYS AS (JSON_UNQUOTE(JSON_EXTRACT(detail_json,'$[*].sku'))) VIRTUAL, ADD INDEX idx_sku_list (sku_list(64));。注意生成列上是 JSON 数组文本,只能做前缀匹配,等值查询仍不理想。
  • 更好的方式是写一张真正的明细表,在应用层双写;JSON 列只作为快照保留。JSON_TABLE 适合「事后分析」和「轻量查询」,不适合高频 OLTP 明细检索。
  • 用 EXPLAIN ANALYZE(8.0.18+)确认 JSON_TABLE 那一步的 rows 估算是否离谱。若估算偏差大,可加 /*+ SET_VAR(optimizer_switch='...') */ 之外的提示有限,通常只能从过滤前置入手。

5.2 NULL 与类型不匹配

当某条明细缺少字段,列值为 NULL,参与 price * qty 运算后整行为 NULL,SUM 会忽略它——金额悄悄变少。用 COALESCE 兜底:

SELECT
  h.order_no,
  d.sku,
  ROUND(COALESCE(d.price,0) * COALESCE(d.qty,0) * COALESCE(d.discount,1), 2) AS line_amount
FROM order_header h
JOIN JSON_TABLE(
       h.detail_json, '$[*]' COLUMNS (
         sku      VARCHAR(32)   PATH '$.sku' DEFAULT '0' ON EMPTY,
         price    DECIMAL(10,2) PATH '$.price' DEFAULT '0' ON EMPTY,
         qty      INT           PATH '$.qty'   DEFAULT '0' ON EMPTY,
         discount DECIMAL(4,2)  PATH '$.discount' DEFAULT '1' ON EMPTY
       )
     ) AS d ON TRUE;

DEFAULT ... ON EMPTY 处理「路径存在但值为 JSON null/空」的情况;DEFAULT ... ON ERROR 处理类型转换失败的情况(比如 price 里存了 "abc")。不加 ON ERROR 时,转换失败会直接抛错:

ERROR 3141 (22032): Invalid JSON text in argument 1 to function json_table: "Invalid value." at position 0.

而类型不匹配(如字符串转 DECIMAL 失败)常见的是 ERROR 3140 (22032): Invalid JSON text in argument 1 to function cast_as_json 或列定义阶段的 ERROR 1366 (HY000): Incorrect decimal value。生产环境强烈建议给每个金额、数量列都写 DEFAULT ... ON ERROR,否则一条脏数据会让整个报表任务失败。

5.3 精度陷阱:先乘后舍入

price*qty*discount 里若 discount 声明为 DECIMAL(4,2),0.95 精确;但若有人图省事声明成 DOUBLE,会出现 0.5*200*0.95 = 94.99999999999999 这类结果,SUM 后与财务系统差几分钱。金额列一律 DECIMAL,且行级先 ROUND 再 SUM,与「先 SUM 再 ROUND」的结果可能不同,必须和业务口径保持一致。对账场景推荐在 CTE 里先把 line_amount 舍入,再在外层 SUM。

5.4 空数组与非法 JSON

如果 detail_json 存的是 [],JSON_TABLE 返回 0 行,内连接时该单据会整体消失。要保留这类单据(比如展示「空单据」列表),把连接改成 LEFT JOIN JSON_TABLE(...) d ON TRUE,此时商品列为 NULL,统计时用 COUNT(d.sku) 区分是否有明细。另外,detail_json 列类型是 JSON 时数据库保证格式合法;若历史遗留是 TEXT,插入前必须 JSON_VALID() 校验,否则 JSON_TABLE 会直接报 ERROR 3144 (22032): Cannot create a JSON value from a string with CHARACTER SET 'binary' 之类的问题。

六、常见问题 FAQ

Q1:报错 ERROR 1064,提示 JSON_TABLE 附近语法错误?

先查版本:SELECT VERSION();。JSON_TABLE 需要 8.0.4 及以上,5.7 完全不支持。5.7 环境只能用 JSON_LENGTH + 数字辅助表 JSON_EXTRACT(detail_json, CONCAT('$[', n.i, ']')) 手工展开,写法繁琐且无法用 COLUMNS 定义类型。

Q2:为什么 COUNT(*) 出来的行数比实际明细多?

检查是否用了 NESTED PATH 展开子数组,或者 JSON_TABLE 之外又 JOIN 了明细可能一对多的表(如物流轨迹、审批记录),导致行放大。统计金额时务必确认 JOIN 的表与明细是一对一关系,或者先用子查询聚合再 JOIN。

Q3:拆行后 SUM(amount) 和单据头存的 total_amount 对不上?

按顺序排查:① 是否漏了 COALESCE,部分明细为 NULL 被 SUM 忽略;② 是否先舍入再汇总的口径不一致;③ discount 是 0.95 还是 95(百分数),列定义宽度 DECIMAL(4,2) 存不下 95.00,会截断成 95.00 之外的值或报错;④ 是否有重复行被 JOIN 放大。建议把明细行单独 SELECT 出来和单据头金额逐单核对。

Q4:能不能在 JSON_TABLE 的 COLUMNS 里做计算,比如直接算金额?

COLUMNS 里只支持 PATH 取值,不支持表达式列。金额计算放在外层 SELECT 里做,或者把它做成 CTE/视图字段(见第三节)。8.0 的 COLUMNS 支持 EXISTS PATH 返回布尔列,用它可以标记某条明细是否带备注、是否有附件这类存在性判断。

Q5:数据量大时查询很慢,有没有替代方案?

JSON_TABLE 适合几千到几十万行的分析场景。到了百万行级别,建议:明细单独建表,应用层在写单据时同步落库;JSON 列只保留快照用于回溯。若短期不能改表结构,可以把拆行结果每日物化到 order_item_daily 表,在 sku、customer_id、order_date 上建联合索引,报表查物化表,响应从秒级降到毫秒级。

Q6:JSON 里的数字在拆行后变成科学计数法或丢精度怎么办?

COLUMNS 的类型声明决定了解析方式。声明 DECIMAL(18,4) 时 MySQL 按定点数解析,不会丢精度;声明 VARCHAR 则按 JSON 文本原样输出。注意大整数(超过 2^53)在 JSON 里本身就可能失真,单据金额超过千万级别时推荐存字符串再转 DECIMAL。

小结:JSON_TABLE 把「JSON 数组拆行」这件事从应用层搬回了 SQL 层,配合 CTE 和窗口函数,几十行 SQL 就能完成单据明细的展开、关联与多维统计。关键点只有四个:行路径写 $[*]、金额列用 DECIMAL、每个列配 DEFAULT ... ON EMPTY/ON ERROR 兜底、行级先舍入再汇总。把这四点做对,JSON 存储的灵活性和关系型统计的严谨性就能同时拿到。