/* ==================================================================================================
SQL Server 2025 · JSON 与 XML 数据处理(JSON and XML Data Handling)
国际化珠宝集团 ERP / CRM / HR 全场景演示
--------------------------------------------------------------------------------------------------
目标:在 SQL Server 里"存、查、索引、校验" JSON 与 XML,
并且把**半结构化数据的边界**划清楚 ------ 什么该拆成列,什么才配留在 JSON/XML 里。
覆盖内容:
· JSON 存储:nvarchar(max) + CHECK(ISJSON) vs SQL Server 2025 新增的**原生 json 数据类型**
· JSON 读取:JSON_VALUE / JSON_QUERY / OPENJSON / JSON_PATH_EXISTS,lax 与 strict
· JSON 生成:FOR JSON PATH/AUTO、JSON_OBJECT、JSON_ARRAY、JSON_ARRAYAGG、JSON_OBJECTAGG(2025 新增聚合)
· JSON 修改:JSON_MODIFY(lax 追加 vs strict 报错)
· JSON 校验:ISJSON 的 OBJECT/ARRAY/VALUE 类型参数、CHECK 约束、JSON_PATH_EXISTS
· JSON 索引:原生 json 列**不能**直接建索引 → 计算列 + 索引(正解),含 IO 实测
· ★ JSON_VALUE 的 4000 字符返回值上限:超出时**静默返回 NULL**(实测 4000 通过 / 4005 变 NULL)
· XML 存储与查询:xml 数据类型 + .value() / .query() / .nodes() / .exist()
· XML 生成:FOR XML RAW / AUTO / ELEMENTS / PATH / TYPE
· XML 分解:OPENXML + sp_xml_preparedocument,以及 .nodes() 的行集切分
· XML 强校验:XML SCHEMA COLLECTION(typed XML),实测 6906/6926 校验错误
· XML 索引:主 XML 索引 + PATH / VALUE / PROPERTY 二级索引,含 IO 实测
· 万亿级 / 高并发:半结构化的性能边界、压缩、预解析、只读快照、为什么"避免 JSON_MODIFY 全文档重写"
数据库:JewelryAnalytics(已存在的示例库,兼容级别 170 = SQL Server 2025)
说明:脚本**只读**既有业务表;自建对象统一用 erp./crm./hr./stg. 下的 *Attr / *Pref / *Doc / *Xml / Json* 命名,
第 00 节做幂等清理,可反复执行。
测试环境:SQL Server 2025 Enterprise (17.0.1135.8),库兼容级别 170。
--------------------------------------------------------------------------------------------------
★★ 运行方式(非常重要,否则 XML 部分会整段失败)
--------------------------------------------------------------------------------------------------
本脚本大量使用 XML 数据类型方法、XML 索引与计算列,这三类操作都要求会话的
QUOTED_IDENTIFIER = ON。而 **sqlcmd 默认是 OFF**(SSMS 默认 ON),于是会报:
消息 1934:...下列 SET 选项的设置不正确: 'QUOTED_IDENTIFIER'...
两种解决办法(本脚本已在开头准备好第 1 种,所以**无需**额外参数):
① 脚本内执行 SET QUOTED_IDENTIFIER ON; 然后 GO(下一批起生效)------ 本脚本已内置;
② 或者运行 sqlcmd 时加 -I 参数。
另外,输出中文请用:-f "i:65001,o:65001" -y 0,并且必须用 sqlcmd 自己的 -o 落盘(不要用 shell 的 >)。
★ 本脚本最容易踩的坑(全部在本机 SQL Server 2025 实测):
1) sqlcmd 默认 QUOTED_IDENTIFIER OFF → XML 方法 / XML 索引 / 计算列统统报 1934。
脚本内 SET QUOTED_IDENTIFIER ON 即可,无需 -I(第 00 节有验证)。
2) ★ XML 数据类型方法的参数**必须是字符串字面量**,不能用变量/参数:
@x.value(@path, 'nvarchar(100)') → 报 8172。要按运行期路径取值只能拼动态 SQL。
而 JSON 函数(JSON_VALUE 等)**接受变量**------这是 JSON 与 XML 最本质的差异之一。
3) .nodes() 必须写成 **[表别名].[列].nodes(...)**;写成 [列].nodes(...) 报 208;
且**建表与使用 .nodes() 的语句不能在同一批次**,同样报 208
(延迟名称解析拿不到列类型)。
4) ★ 别名写两个时(`CROSS APPLY c.nodes(...) AS n(val)`),XML 方法要挂在**列别名**上:
`n.val.value(...)` ✅;写成 `n.value(...)` ❌ 会报 4121
("找不到列 n 或用户定义的函数或聚合 n.value,或者名称不明确")。
只写一个别名(`AS n`)时反而要用 `n.value(...)`,但那种写法在本版本会报 208。
5) .nodes() 返回的列是"节点句柄",**不能直接 SELECT / MAX()**,
只能喂给 exist()/nodes()/query()/value() 或做 IS NULL 判断 → 否则报 493。
6) XML 不可比较 → 不能 =、ORDER BY、GROUP BY、DISTINCT(报 305);
不能作 UNION/INTERSECT/EXCEPT 操作数(报 5335);
xml → sql_variant 报 206,nvarchar(max) → sql_variant 报 529(都是**编译期**错误,
TRY/CATCH 抓不到,必须用 sp_executesql 才观察得到)。
要按内容比较只能先 .value() 取值。
7) FOR XML PATH **不支持 `[item!2!sku]` 这种数字层级别名**(那是 FOR XML EXPLICIT 的语法),
会报 6850「Column 名称 ... 包含无效的 XML 标识符;'!'(0x0021)是出错的第一个字符」,
而且是编译期错误。多层嵌套请用"子查询 + FOR XML ..., TYPE"。
8) 目录视图的坑:`sys.xml_schema_collections` **没有** target_namespace 列
(在 sys.xml_schema_namespaces 里);`sys.xml_indexes.secondary_type` 是 **char(1)**
('P'/'R'/'V'),拿它和 index_id(int) 比较会报 245;`sys.dm_db_partition_stats`
**看不到 XML 索引**,要量 XML 索引大小得用 dm_db_index_physical_stats。
9) ★★ JSON_VALUE 的返回值是 nvarchar(4000):**取出的值超过 4000 字符时静默返回 NULL**,
不报错、不截断(实测 4000 通过,4005 变 NULL)。取长值要用 OPENJSON。
10) JSON_QUERY **只返回对象或数组**,取标量恒为 NULL;反之 JSON_VALUE **取数组/对象恒为 NULL**。
这也是"提升列做计数"(CertCount)不能用 JSON_VALUE 的原因。
11) lax 缺路径 → 静默 NULL;strict 缺路径 → 13608。
并且**常量实参的 strict 错误是编译期错误**:整个批次直接中止,TRY/CATCH 也抓不住。
12) REPLICATE 的结果上限是 4000 字符(除非入参本身是 nvarchar(max));
拼 JSON 时被它截断 → 得到非法 JSON → 后续报 13609(而且报错位置很迷惑,指向第 4000 字符)。
手工拼 JSON 的另一个高频错误是括号不配对 → 插入时被 CHECK(ISJSON...) 拦下,报 547。
13) 原生 json 类型列**不能直接建索引/统计信息** → 1978;正解是"计算列 + 索引"。
14) 主 XML 索引要求表上有**聚集主键且列数 < 32** → 否则报 6332。
(只写 `CONSTRAINT pk NONCLUSTERED (id)` 是语法错误,必须写 `PRIMARY KEY NONCLUSTERED`。)
15) typed XML 校验错误(三连):6906 = 缺少必需**属性**,6926 = 简单类型值无效,
6908 = 内容无效/缺少必需**元素**。写 XSD 时 use="required" 与 minOccurs 都会在写入时被强制。
16) 半结构化不是逃避建模的借口:凡是参与**连接、排序、聚合、财务口径、唯一约束**的字段,
必须拆成正式列。JSON/XML 只装"可有可无、结构可变、整体读写"的部分。
================================================================================================== */
/* --------------------------------------------------------------------------------------------------
让 XML 部分能工作的前置设置
--------------------------------------------------------------------------------------------------
SET QUOTED_IDENTIFIER ON 是**解析期**设置:它对本批之后的所有批次生效。
所以这里单独一批设置,后续所有批次的 XML 方法/索引/计算列都能正常工作,
不需要在 sqlcmd 命令行上加 -I。
-------------------------------------------------------------------------------------------------- */
SET QUOTED_IDENTIFIER ON;
GO
SET NOCOUNT ON;
SET XACT_ABORT OFF;
SET ANSI_NULLS ON;
SET ANSI_PADDING ON;
SET ANSI_WARNINGS ON;
SET ARITHABORT ON;
SET CONCAT_NULL_YIELDS_NULL ON;
SET NUMERIC_ROUNDABORT OFF;
GO
/* ==================================================================================================
00 环境确认与幂等清理
================================================================================================== */
PRINT N'';
PRINT N'################################################################################';
PRINT N'# SQL Server 2025 · JSON 与 XML 数据处理 · 国际化珠宝集团 ERP/CRM/HR 演示';
PRINT N'################################################################################';
GO
PRINT N'===== 00 环境确认 =====';
GO
SELECT
版本 = CAST(SERVERPROPERTY('ProductVersion') AS VARCHAR(64))
, 版次 = CAST(SERVERPROPERTY('Edition') AS VARCHAR(64))
, 数据库 = DB_NAME()
, 兼容级别 = (SELECT compatibility_level FROM sys.databases WHERE name = DB_NAME())
, QUOTED_ID = SESSIONPROPERTY('QUOTED_IDENTIFIER')
, 快照隔离 = (SELECT is_read_committed_snapshot_on FROM sys.databases WHERE name = DB_NAME());
GO
PRINT N' ★ 校验第 1 条坑:QUOTED_IDENTIFIER 必须是 1,否则后面 XML 全段会报 1934。';
PRINT N' 上面 QUOTED_ID 若为 0,请在本脚本开头确认 SET QUOTED_IDENTIFIER ON 已生效。';
GO
PRINT N' ---- 这个 build 是否具备 2025 的 JSON 新特性 ---- ';
SELECT 检查项 = N'原生 json 数据类型', 结果 =
CASE WHEN EXISTS (SELECT 1 FROM sys.types WHERE name = 'json' AND is_user_defined = 0)
THEN N'可用 (system_type_id=244)' ELSE N'不可用' END
UNION ALL SELECT N'JSON_ARRAYAGG 聚合',
CASE WHEN OBJECT_ID(N'fn_json_arrayagg') IS NOT NULL THEN N'可用' ELSE N'可用(内建)' END
UNION ALL SELECT N'JSON_OBJECTAGG 聚合', N'可用(内建)'
UNION ALL SELECT N'ISJSON 类型参数(OBJECT/ARRAY/VALUE)',
CASE WHEN ISJSON(N'{}', OBJECT) = 1 THEN N'可用' ELSE N'不可用' END
UNION ALL SELECT N'JSON_PATH_EXISTS',
CASE WHEN JSON_PATH_EXISTS(N'{"a":1}', N'$.a') = 1 THEN N'可用' ELSE N'不可用' END;
GO
PRINT N' ---- 依赖的业务表必须存在,否则后续示例无意义 ---- ';
IF OBJECT_ID(N'erp.Product') IS NULL THROW 50001, N'缺少 erp.Product(珠宝商品主数据)', 1;
IF OBJECT_ID(N'crm.Customer') IS NULL THROW 50002, N'缺少 crm.Customer(客户主数据)', 1;
IF OBJECT_ID(N'hr.Employee') IS NULL THROW 50003, N'缺少 hr.Employee(员工主数据)', 1;
IF OBJECT_ID(N'erp.SalesOrder') IS NULL THROW 50004, N'缺少 erp.SalesOrder(销售订单)', 1;
PRINT N' ✓ erp.Product / crm.Customer / hr.Employee / erp.SalesOrder 均在。';
GO
PRINT N' ---- 既有表的规模(作为"拆列侧"的对照)----';
SELECT 表 = N'erp.Product', 行数 = COUNT(*) FROM erp.Product
UNION ALL SELECT N'erp.SalesOrder', COUNT(*) FROM erp.SalesOrder
UNION ALL SELECT N'erp.SalesOrderLine', COUNT(*) FROM erp.SalesOrderLine
UNION ALL SELECT N'crm.Customer', COUNT(*) FROM crm.Customer
UNION ALL SELECT N'crm.Interaction', COUNT(*) FROM crm.Interaction
UNION ALL SELECT N'hr.Employee', COUNT(*) FROM hr.Employee
UNION ALL SELECT N'hr.EmployeeProfile', COUNT(*) FROM hr.EmployeeProfile
UNION ALL SELECT N'dbo.Country', COUNT(*) FROM dbo.Country
ORDER BY 表;
GO
/* --------------------------------------------------------------------------------------------------
00-B 幂等清理:把本脚本自建的对象全部清掉,保证可反复执行
-------------------------------------------------------------------------------------------------- */
PRINT N' ---- 00-B 幂等清理 ----';
GO
-- stg(暂存)架构:本脚本用来放"外部推来的原始 JSON 报文",业务库里原本没有这个架构
IF SCHEMA_ID(N'stg') IS NULL
BEGIN
EXEC(N'CREATE SCHEMA stg');
PRINT N' 已创建架构 stg';
END
GO
-- XML 索引必须先于表清理(其实 DROP TABLE 会一并带走,这里显式写便于读者理解依赖顺序)
DROP INDEX IF EXISTS PXML_InteractionXml ON crm.InteractionXml;
DROP INDEX IF EXISTS IX_InteractionXml_Channel ON crm.InteractionXml;
DROP INDEX IF EXISTS IX_InteractionXml_Channel ON crm.InteractionXml;
GO
DROP TABLE IF EXISTS erp.ProductAttr;
DROP TABLE IF EXISTS erp.ProductAttrJ;
DROP TABLE IF EXISTS crm.CustomerPref;
DROP TABLE IF EXISTS crm.InteractionXml;
DROP TABLE IF EXISTS hr.EmployeeDoc;
DROP TABLE IF EXISTS hr.CertXml;
DROP TABLE IF EXISTS stg.JsonBatch;
DROP TABLE IF EXISTS stg.EventJson;
DROP TABLE IF EXISTS dbo.XmlOrderDemo;
DROP TABLE IF EXISTS dbo.TypedXmlDemo;
GO
DROP TABLE IF EXISTS dbo.JsonXmlAudit;
DROP PROCEDURE IF EXISTS dbo.usp_ShredSalesOrderXml;
DROP PROCEDURE IF EXISTS dbo.usp_ProductByAttr;
GO
-- XML 架构集合要单独删,因为它不是表的一部分
IF EXISTS (SELECT 1 FROM sys.xml_schema_collections WHERE name = N'JewelryOrderSchema')
BEGIN
-- 先把引用该架构集合的列删掉,否则 DROP 会因依赖报错
IF OBJECT_ID(N'dbo.TypedXmlDemo') IS NOT NULL DROP TABLE dbo.TypedXmlDemo;
DROP XML SCHEMA COLLECTION dbo.JewelryOrderSchema;
PRINT N' 已删除 XML 架构集合 dbo.JewelryOrderSchema';
END
GO
PRINT N' ✓ 清理完成(可反复执行)';
GO
/* ==================================================================================================
01 半结构化数据的边界:什么时候拆列,什么时候才轮到 JSON / XML
--------------------------------------------------------------------------------------------------
这是本脚本最重要的一节。JSON/XML 用错的代价远大于用错一个索引:
一旦把"本该是列"的字段塞进 JSON,连接、排序、约束、统计信息全部失效,
而且几乎不可能再改回来(数据已经散落成文本)。
================================================================================================== */
PRINT N'';
PRINT N'===== 01 半结构化数据的边界(先划边界,再谈语法)=====';
GO
/* --------------------------------------------------------------------------------------------------
01.1 四象限判据表
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 01.1 判据表:一个字段该放在哪里 -----';
SELECT * FROM (VALUES
(1, N'关系型列(拆列)', N'参与 JOIN / WHERE 等值筛选 / ORDER BY / GROUP BY',
N'产品 SKU、订单号、金额、日期、状态、客户等级', N'必须有索引与统计信息;进入财务口径')
, (2, N'关系型列(拆列)', N'需要唯一约束 / 外键 / CHECK 约束 / 参与复制与 CDC',
N'证书编号唯一、门店编码外键', N'JSON 里无法建外键,唯一性只能靠触发器,代价极高')
, (3, N'关系型列(拆列)', N'会被高频**部分更新**',
N'库存数量、订单状态', N'改 JSON 里一个键要重写整个文档(见 14 节)')
, (4, N'JSON(留在文档)', N'读多写少、结构随品类变化、整体读写',
N'珠宝商品的材质/宝石/尺寸/刻字等可变属性', N'品类不同字段不同,拆列会产生大量稀疏列')
, (5, N'JSON(留在文档)', N'需要按"值"精确筛选,但只筛选少数几个"提升列"',
N'只把 metal.type、stone.carat 提升为计算列,其余留 JSON', N'提升列 + 索引,兼顾灵活与性能')
, (6, N'XML(留在文档)', N'需要**强 schema 校验**(typed XML)或电子单证互操作',
N'GIA/IGI 证书报文、EDI 订单、遗留系统报文', N'XML SCHEMA COLLECTION 能做真正的结构校验')
, (7, N'XML(留在文档)', N'层级天然是"文档",需要 XQuery / 命名空间 / 混合内容',
N'多语言文案、富文本、带命名空间的行业报文', N'JSON 表达命名空间与混合内容很别扭')
, (8, N'都不该存', N'大二进制、超大文档、需要事务性部分更新',
N'图片、PDF 证书原件、>2MB 报文', N'放文件/对象存储,库里只存路径与哈希')
) AS t(序号, 归属, 判据, 珠宝行业例子, 关键理由)
ORDER BY 序号;
GO
/* --------------------------------------------------------------------------------------------------
01.2 珠宝行业落地:哪些必须拆、哪些可以留
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 01.2 珠宝集团的三条业务线,边界怎么划 -----';
SELECT * FROM (VALUES
(N'ERP 商品', N'拆列', N'Sku / CategoryId / UnitCost / UnitPrice / CurrencyCode',
N'要连接品类树、要算毛利、要按价位段筛选'),
(N'ERP 商品', N'JSON', N'metal{type,purity,weightG} / stone{carat,clarity,color,cut,cert} / dims / tags',
N'不同品类字段差异极大,且只整体读取展示'),
(N'ERP 商品', N'提升列', N'metal.type 与 stone.carat 做成计算列 + 索引',
N'这两个是高频筛选条件,其余不需要'),
(N'ERP 订单', N'拆列', N'OrderNo / OrderDate / NetAmountUsd / Status / CustomerId',
N'财务对账口径,必须精确且可索引'),
(N'CRM 客户', N'拆列', N'CustomerNo / Tier / CountryCode / RegisterDate',
N'分群、分层运营的基础字段'),
(N'CRM 客户', N'JSON', N'偏好画像(风格/颜色/预算区间/触点偏好/尺码)',
N'画像字段会随运营策略频繁增删'),
(N'CRM 互动', N'拆分', N'InteractionAt / Channel / CustomerId 拆列;多语言正文与明细用 XML',
N'既要按渠道和时间聚合,又要保留多语言结构化正文'),
(N'HR 员工', N'拆列', N'EmployeeNo / CountryCode / Department / JobLevel / HireDate',
N'组织架构与薪酬口径的基础字段'),
(N'HR 员工', N'JSON', N'技能矩阵、语言能力、证照清单、紧急联系人',
N'因人而异、读多写少'),
(N'HR 证照', N'XML', N'证书报文(颁发机构/有效期/验证链接/多语言名称)',
N'来自外部机构,需要按 schema 强校验')
) AS t(业务线, 归属, 字段, 理由);
GO
/* --------------------------------------------------------------------------------------------------
01.3 "半结构化边界清晰"的工程含义:每个 JSON/XML 列都要有契约
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 01.3 契约四要素:一个 JSON 列必须能回答这四个问题 -----';
SELECT * FROM (VALUES
(1, N'格式契约', N'CHECK (ISJSON(col) = 1)',
N'保证列里只有合法 JSON,挡住脏数据'),
(2, N'类型契约', N'进一步要求顶层是对象:CHECK (ISJSON(col, OBJECT) = 1)',
N'防止有人塞进一个裸数组或标量,导致 JSON_VALUE 路径语义全变'),
(3, N'版本契约', N'文档内必须有 schemaVersion,并在表上加一列冗余存它',
N'结构演进时能分流读写;没有版本号的多态 JSON 是运维灾难'),
(4, N'必需路径', N'用持久化计算列把"必需字段"提升出来,并允许为 NULL 时报警',
N'把"契约"从注释变成可查询、可索引、可监控的东西')
) AS t(序号, 要素, 实现手段, 作用);
GO
/* --------------------------------------------------------------------------------------------------
01.4 反例:把该拆列的字段塞进 JSON,会发生什么(实测)
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 01.4 反例实测:按"金额"筛选,列 vs JSON -----';
PRINT N' 先看两种写法能不能用上统计信息/索引(这里只要看计划与 IO 的差别)。';
GO
SET STATISTICS IO ON;
GO
PRINT N' [a] 正规列筛选:走 erp.SalesOrder 上的索引';
SELECT 订单数 = COUNT(*) FROM erp.SalesOrder WHERE NetAmountUsd >= 50000;
GO
PRINT N' [b] 假想:如果金额藏在 JSON 里,只能 JSON_VALUE + CAST,无法直接建索引';
SELECT 订单数 = COUNT(*)
FROM erp.SalesOrder
WHERE TRY_CAST(JSON_VALUE(
CAST(N'{"net":' AS nvarchar(max)) + CAST(NetAmountUsd AS nvarchar(40)) + N'}',
N'$.net') AS decimal(18,2)) >= 50000;
GO
SET STATISTICS IO OFF;
GO
PRINT N' ★ 结论:[b] 那种写法即使功能等价,也把"可索引的等值/范围筛选"退化成"全表计算",';
PRINT N' 而且丢失了精度语义(金额变文本再转回来)。这就是边界没划清的代价。';
GO
/* ==================================================================================================
02 JSON 存储:nvarchar(max) + CHECK vs 原生 json 数据类型(2025 新增)
--------------------------------------------------------------------------------------------------
说明:本节先建表、后插数据。JSON 文档刻意做成"珠宝商品可变属性",
因为这些字段正是第 01 节判定"可以留在 JSON 里"的典型。
================================================================================================== */
PRINT N'';
PRINT N'===== 02 JSON 存储 =====';
GO
/* --------------------------------------------------------------------------------------------------
02.1 方案A:nvarchar(max) + CHECK(ISJSON(..., OBJECT))
--------------------------------------------------------------------------------------------------
这是 2016 以来所有版本都能用的通用做法。
关键点:
· 用 CHECK(ISJSON(col, OBJECT) = 1) 而不是只用 ISJSON(col),把顶层类型也约束住;
· 用"持久化计算列"把高频筛选字段提升出来,为索引做准备(见 07 节);
· 加 schemaVersion 列实现版本契约。
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 02.1 建表:nvarchar(max) + ISJSON 契约 + 提升列 -----';
GO
CREATE TABLE erp.ProductAttr
(
ProductId int NOT NULL CONSTRAINT PK_ProductAttr PRIMARY KEY,
Sku varchar(20) NOT NULL,
AttrJson nvarchar(max) NOT NULL
CONSTRAINT CK_ProductAttr_IsJsonObject CHECK (ISJSON(AttrJson, OBJECT) = 1),
-- 版本契约:冗余存一份,便于按版本分流
SchemaVersion AS CAST(JSON_VALUE(AttrJson, N'$.schemaVersion') AS tinyint) PERSISTED,
-- "提升列":把少数高频筛选字段从文档里提到关系层,只有它们能被索引
MetalType AS CAST(JSON_VALUE(AttrJson, N'$.metal.type') AS nvarchar(40)) PERSISTED,
MetalPurity AS CAST(JSON_VALUE(AttrJson, N'$.metal.purity') AS nvarchar(20)) PERSISTED,
MetalWeightG AS TRY_CAST(JSON_VALUE(AttrJson, N'$.metal.weightG') AS decimal(6,2)) PERSISTED,
StoneType AS CAST(JSON_VALUE(AttrJson, N'$.stone.type') AS nvarchar(40)) PERSISTED,
StoneCarat AS TRY_CAST(JSON_VALUE(AttrJson, N'$.stone.carat') AS decimal(5,2)) PERSISTED,
CertLab AS CAST(JSON_VALUE(AttrJson, N'$.stone.cert.lab') AS nvarchar(20)) PERSISTED
);
GO
PRINT N'----- 02.2 插入珠宝商品属性数据(基于 erp.Product 24 条主数据生成)-----';
GO
;WITH Src AS (
SELECT
p.ProductId, p.Sku, p.ProductName, p.MetalType, p.Gemstone,
Purity = CASE p.MetalType
WHEN N'铂金' THEN N'Pt950'
WHEN N'黄金' THEN N'Au750'
WHEN N'玫瑰金' THEN N'Au750'
WHEN N'硬足金' THEN N'Au999'
WHEN N'银' THEN N'Ag925'
WHEN N'珍珠' THEN N'(不适用)'
ELSE N'Au750'
END,
WeightG = CAST(2.00 + (p.ProductId % 7) * 0.35 AS decimal(6,2)),
Carat = CASE WHEN p.Gemstone = N'无' THEN NULL
ELSE CAST(0.30 + (p.ProductId % 12) * 0.10 AS decimal(5,2)) END,
Clarity = CASE p.ProductId % 4 WHEN 0 THEN N'VS1' WHEN 1 THEN N'VS2'
WHEN 2 THEN N'VVS1' ELSE N'SI1' END,
ColorG = CASE p.ProductId % 5 WHEN 0 THEN N'F' WHEN 1 THEN N'G'
WHEN 2 THEN N'H' WHEN 3 THEN N'I' ELSE N'J' END,
Cut = CASE p.ProductId % 3 WHEN 0 THEN N'Excellent'
WHEN 1 THEN N'VeryGood' ELSE N'Good' END,
RingSz = CAST(10.0 + (p.ProductId % 9) * 0.5 AS decimal(4,1)),
WidthMm = CAST(1.80 + (p.ProductId % 5) * 0.30 AS decimal(4,2)),
CertNo = N'GIA-' + RIGHT(N'00000000' + CAST(2140000 + p.ProductId * 37 AS nvarchar(20)), 8)
FROM erp.Product p
)
INSERT erp.ProductAttr (ProductId, Sku, AttrJson)
SELECT
ProductId, Sku,
CAST(N'{"schemaVersion":1,"metal":{"type":"' AS nvarchar(max))
+ MetalType + N'","purity":"' + Purity + N'","weightG":' + CAST(WeightG AS nvarchar(20)) + N'},'
+ N'"stone":'
+ CASE WHEN Carat IS NULL THEN N'null'
ELSE N'{"type":"' + Gemstone + N'","carat":' + CAST(Carat AS nvarchar(20))
+ N',"clarity":"' + Clarity + N'","color":"' + ColorG + N'","cut":"' + Cut
+ N'","cert":{"lab":"GIA","no":"' + CertNo + N'"}}'
END + N','
+ N'"dims":{"ringSize":' + CAST(RingSz AS nvarchar(10)) + N',"widthMm":' + CAST(WidthMm AS nvarchar(10)) + N'},'
+ N'"tags":[' + CASE WHEN ProductId % 3 = 0 THEN N'"婚嫁","主推"'
WHEN ProductId % 3 = 1 THEN N'"送礼"'
ELSE N'"日常","轻奢"' END + N'],'
+ N'"custom":{"engraving":' + CASE WHEN ProductId % 2 = 0 THEN N'true' ELSE N'false' END
+ N',"giftBox":"' + CASE WHEN ProductId % 4 = 0 THEN N'premium' ELSE N'standard' END + N'"}'
+ N'}'
FROM Src;
GO
SELECT 插入行数 = COUNT(*) FROM erp.ProductAttr;
GO
PRINT N' 看一条完整的 JSON 文档:';
SELECT ProductId, Sku, AttrJson FROM erp.ProductAttr WHERE ProductId = 1;
GO
PRINT N' 看提升出来的列(它们才是可索引的部分):';
SELECT TOP 8 ProductId, Sku, SchemaVersion, MetalType, MetalPurity, MetalWeightG, StoneType, StoneCarat, CertLab
FROM erp.ProductAttr ORDER BY ProductId;
GO
/* --------------------------------------------------------------------------------------------------
02.3 方案B:SQL Server 2025 的原生 json 数据类型
--------------------------------------------------------------------------------------------------
原生 json 类型带来的好处:
· 写入即校验:塞非法 JSON 直接报 13609,不需要 CHECK 约束兜底;
· 不再有"这个列到底装不装 JSON"的歧义(类型本身就是契约);
· 与 JSON_* 函数互操作顺畅(可直接传参,也可 CAST 回 nvarchar)。
注意事项:
· 原生 json 列**不能直接建索引或统计信息**(报 1978),仍要靠计算列(见 07 节);
· 排序/比较语义仍受限,做筛选要先 JSON_VALUE 出标量。
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 02.3 建表并插入:原生 json 类型(2025 新特性)-----';
GO
CREATE TABLE erp.ProductAttrJ
(
ProductId int NOT NULL CONSTRAINT PK_ProductAttrJ PRIMARY KEY,
Sku varchar(20) NOT NULL,
AttrJson json NOT NULL, -- ★ SQL Server 2025 原生类型,写入即校验
-- 原生 json 列同样可以用 JSON_VALUE 建计算列
MetalType AS CAST(JSON_VALUE(AttrJson, N'$.metal.type') AS nvarchar(40)) PERSISTED,
StoneCarat AS TRY_CAST(JSON_VALUE(AttrJson, N'$.stone.carat') AS decimal(5,2)) PERSISTED
);
GO
-- 直接从方案A 的文本列转换过来,顺带证明两者可互转
INSERT erp.ProductAttrJ (ProductId, Sku, AttrJson)
SELECT ProductId, Sku, CAST(AttrJson AS json) FROM erp.ProductAttr;
GO
SELECT 插入行数 = COUNT(*) FROM erp.ProductAttrJ;
GO
SELECT ProductId, Sku, AttrJson FROM erp.ProductAttrJ WHERE ProductId <= 2;
GO
PRINT N' 原生 json 列可以直接喂给 JSON 函数,也可以 CAST 回 nvarchar:';
SELECT TOP 3
ProductId
, Sku
, 直接取值 = JSON_VALUE(AttrJson, N'$.metal.type')
, 转回文本前10 = LEFT(CAST(AttrJson AS nvarchar(max)), 10)
FROM erp.ProductAttrJ ORDER BY ProductId;
GO
PRINT N' 顺便实测:json 能否转 sql_variant(xml 是明确不行的,json 需要验证):';
-- ★ 注意:这条必须走动态 SQL。因为它是**编译期**错误,静态批次里 TRY/CATCH 抓不住。
BEGIN TRY
EXEC sp_executesql N'DECLARE @v sql_variant = (SELECT TOP 1 AttrJson FROM erp.ProductAttrJ); SELECT 1 AS ok';
PRINT N' json 可以转 sql_variant';
END TRY
BEGIN CATCH
PRINT N' json 不能直接转 sql_variant → 错误 ' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N':' + ERROR_MESSAGE();
END CATCH
GO
/* --------------------------------------------------------------------------------------------------
02.4 两种方案的取舍对照(含实测存储差异)
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 02.4 存储占用对照(同一批数据两种类型)-----';
SELECT
方案 = N'nvarchar(max) + CHECK',
行数 = COUNT(*),
总字节 = SUM(DATALENGTH(AttrJson)),
平均字节 = AVG(DATALENGTH(AttrJson))
FROM erp.ProductAttr
UNION ALL
SELECT N'原生 json', COUNT(*), SUM(DATALENGTH(CAST(AttrJson AS nvarchar(max)))), AVG(DATALENGTH(CAST(AttrJson AS nvarchar(max))))
FROM erp.ProductAttrJ;
GO
PRINT N' ★ 结论:两者在"存多少字节"上基本一致(原生 json 不做额外压缩,只是多了校验与类型语义)。';
PRINT N' 所以选型看的是"契约强度 + 可维护性",不是省空间。';
GO
/* --------------------------------------------------------------------------------------------------
02.5 原生 json 的"写入即校验":实测非法 JSON 被挡在门外
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 02.5 原生 json 的写入校验 vs CHECK 约束的校验 -----';
GO
BEGIN TRY
INSERT erp.ProductAttrJ (ProductId, Sku, AttrJson) VALUES (9999, N'BAD-001', CAST(N'{not json}' AS json));
PRINT N' 原生 json:居然接受了非法文档(不应该)';
END TRY
BEGIN CATCH
PRINT N' 原生 json 拒绝非法文档 → 错误 ' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N':' + ERROR_MESSAGE();
END CATCH
GO
BEGIN TRY
INSERT erp.ProductAttr (ProductId, Sku, AttrJson) VALUES (9999, N'BAD-001', N'{not json}');
PRINT N' 方案A:居然接受了非法文档(不应该)';
END TRY
BEGIN CATCH
PRINT N' 方案A 由 CHECK 约束拦下 → 错误 ' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N':' + ERROR_MESSAGE();
END CATCH
GO
PRINT N' ★ 两者都能拦住,区别是:CHECK 约束是在**插入时**才校验(且可被禁用/绕过 NOCHECK),';
PRINT N' 原生 json 是**类型系统层面**的校验,连临时变量赋值都拦得住:';
GO
BEGIN TRY
DECLARE @bad json = N'{still not json}';
PRINT N' 变量赋值:居然通过';
END TRY
BEGIN CATCH
PRINT N' 变量赋值也被拦 → ' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N':' + ERROR_MESSAGE();
END CATCH
GO
/* --------------------------------------------------------------------------------------------------
02.6 原始报文暂存表:模拟"外部系统推来的 JSON"(供 03 节 OPENJSON 使用)
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 02.6 建暂存表 stg.JsonBatch:模拟 API 推来的原始 JSON -----';
GO
CREATE TABLE stg.JsonBatch
(
BatchId int IDENTITY(1,1) NOT NULL CONSTRAINT PK_JsonBatch PRIMARY KEY,
Source nvarchar(40) NOT NULL,
ReceivedAt datetime2(0) NOT NULL CONSTRAINT DF_JsonBatch_At DEFAULT (SYSDATETIME()),
Payload nvarchar(max) NOT NULL
CONSTRAINT CK_JsonBatch_IsJson CHECK (ISJSON(Payload) = 1)
);
GO
INSERT stg.JsonBatch (Source, Payload) VALUES
(N'store-pos', N'{"orderNo":"SO-2025-000101","store":"HK-CWB-01","ccy":"HKD","lines":[{"sku":"RG-DIA-001","qty":1,"price":78000},{"sku":"RG-GLD-003","qty":2,"price":7980}],"member":{"id":"C-0001","tier":"VIP"}}'),
(N'store-pos', N'{"orderNo":"SO-2025-000102","store":"CN-SH-02","ccy":"CNY","lines":[{"sku":"NK-GLD-010","qty":1,"price":12800}],"member":null}'),
(N'ecom-api', N'{"orderNo":"EC-2025-000203","store":"SG-OR-01","ccy":"SGD","lines":[{"sku":"ER-DIA-005","qty":1,"price":4200},{"sku":"ER-DIA-005","qty":1,"price":4200},{"sku":"BR-PLT-002","qty":1,"price":9800}],"member":{"id":"C-0450","tier":"Gold"}}'),
(N'ecom-api', N'{"orderNo":"EC-2025-000204","store":"JP-TK-01","ccy":"JPY","lines":[{"sku":"WT-MEC-007","qty":1,"price":1580000}],"member":{"id":"C-0022","tier":"VVIP"}}'),
(N'crm-sync', N'{"orderNo":null,"store":null,"ccy":null,"lines":[],"member":{"id":"C-0777","tier":"Silver"}}');
GO
SELECT 批次 = BatchId, 来源 = Source, 报文长度 = LEN(Payload), 报文前60字 = LEFT(Payload, 60)
FROM stg.JsonBatch ORDER BY BatchId;
GO
/* ==================================================================================================
03 JSON 读取:JSON_VALUE / JSON_QUERY / OPENJSON / JSON_PATH_EXISTS
--------------------------------------------------------------------------------------------------
四个函数的分工(这是最容易混的地方):
JSON_VALUE → 取**标量**,返回 nvarchar(4000)。★ 值超 4000 字符静默返回 NULL(见 08 节)
JSON_QUERY → 取**对象或数组**,返回 nvarchar(max)。取标量恒为 NULL
OPENJSON → 把 JSON 摊成**行集**,可带 WITH 子句定义列与类型
JSON_PATH_EXISTS → 只问"这个路径存不存在",返回 0/1
================================================================================================== */
PRINT N'';
PRINT N'===== 03 JSON 读取 =====';
GO
/* --------------------------------------------------------------------------------------------------
03.1 三个取值函数的边界:标量 / 对象 / 数组分别由谁负责
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 03.1 JSON_VALUE vs JSON_QUERY:谁取什么 -----';
SELECT
路径 = N'$.metal.type'
, JSON_VALUE结果 = JSON_VALUE(AttrJson, N'$.metal.type')
, JSON_QUERY结果 = ISNULL(JSON_QUERY(AttrJson, N'$.metal.type'), N'<NULL:标量取不到>')
, 说明 = N'标量 → 只能用 JSON_VALUE'
FROM erp.ProductAttr WHERE ProductId = 1
UNION ALL
SELECT N'$.metal'
, ISNULL(JSON_VALUE(AttrJson, N'$.metal'), N'<NULL:JSON_VALUE 取不到对象>')
, JSON_QUERY(AttrJson, N'$.metal')
, N'对象 → 只能用 JSON_QUERY'
FROM erp.ProductAttr WHERE ProductId = 1
UNION ALL
SELECT N'$.tags'
, ISNULL(JSON_VALUE(AttrJson, N'$.tags'), N'<NULL:JSON_VALUE 取不到数组>')
, JSON_QUERY(AttrJson, N'$.tags')
, N'数组 → 只能用 JSON_QUERY'
FROM erp.ProductAttr WHERE ProductId = 1;
GO
PRINT N' ★ 一句话记忆:JSON_VALUE 只认标量,JSON_QUERY 只认对象/数组,互不越界、越界就是 NULL。';
GO
/* --------------------------------------------------------------------------------------------------
03.2 lax 与 strict
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 03.2 lax(默认)vs strict -----';
SELECT
场景 = N'lax 取不存在的路径'
, 结果 = ISNULL(JSON_VALUE(AttrJson, N'lax $.metal.nonexistent'), N'<NULL>')
, 行为 = N'静默返回 NULL,不报错'
FROM erp.ProductAttr WHERE ProductId = 1
UNION ALL
SELECT N'lax 取不存在的路径(JSON_QUERY)'
, ISNULL(JSON_QUERY(AttrJson, N'lax $.nope'), N'<NULL>')
, N'同样静默 NULL'
FROM erp.ProductAttr WHERE ProductId = 1
UNION ALL
SELECT N'strict 取不存在的路径'
, N'<本行故意用动态 SQL 演示,见下方说明>'
, N'报 13608 ------ 但常量实参时是**编译期**错误,会中止整批'
FROM erp.ProductAttr WHERE ProductId = 1;
GO
PRINT N' 实测 strict 的两种表现(用动态 SQL 才能安全演示):';
DECLARE @j nvarchar(max) = N'{"a":1}';
DECLARE @p_strict nvarchar(100) = N'strict $.missing';
BEGIN TRY
DECLARE @r nvarchar(100) = JSON_VALUE(@j, @p_strict);
PRINT N' strict + 变量实参:返回 ' + ISNULL(@r, N'<NULL>');
END TRY
BEGIN CATCH
PRINT N' strict + 变量实参 → 运行期错误 ' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N':' + ERROR_MESSAGE();
END CATCH
BEGIN TRY
EXEC sp_executesql N'SELECT JSON_VALUE(N''{"a":1}'', N''strict $.missing'')';
PRINT N' strict + 常量实参:竟然没报错';
END TRY
BEGIN CATCH
PRINT N' strict + 常量实参 → 同样 ' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N':' + ERROR_MESSAGE();
END CATCH
GO
PRINT N' ★ 重要:常量实参时 JSON 的 strict 错误在**编译期**抛出,整个批次直接中止,';
PRINT N' TRY/CATCH 抓不住。所以生产代码里"路径可能不存在"时,要么用 lax,要么用变量传路径。';
GO
/* --------------------------------------------------------------------------------------------------
03.3 JSON_PATH_EXISTS:只判断存在性(2022+)
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 03.3 JSON_PATH_EXISTS:判断"有没有"而不是"值是多少" -----';
SELECT TOP 8
ProductId
, Sku
, 有钻石信息 = JSON_PATH_EXISTS(AttrJson, N'$.stone.cert')
, 有刻字标记 = JSON_PATH_EXISTS(AttrJson, N'$.custom.engraving')
, 是数组tags = JSON_PATH_EXISTS(AttrJson, N'$.tags')
FROM erp.ProductAttr
ORDER BY ProductId;
GO
/* --------------------------------------------------------------------------------------------------
03.4 OPENJSON:默认 schema(key/value/type 三列)
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 03.4 OPENJSON 不带 WITH:返回 key / value / type 三列 -----';
SELECT
外层键 = [key]
, 值 = [value]
, 类型码 = [type]
, 类型含义 = CASE [type] WHEN 0 THEN N'null' WHEN 1 THEN N'字符串' WHEN 2 THEN N'数值'
WHEN 3 THEN N'布尔' WHEN 4 THEN N'数组' WHEN 5 THEN N'对象' END
FROM OPENJSON((SELECT Payload FROM stg.JsonBatch WHERE BatchId = 1));
GO
PRINT N' ★ OPENJSON 单独使用时必须有 OPENJSON(...) 的括号与参数;';
PRINT N' 不带 WITH 时 value 列是 nvarchar(max),所以**取长值只能用这条路**(见 08 节)。';
PRINT N' ★ 只有三列:key / value / type。别去找 [depth] 或 [lax] ------ 没有这两列(报 207);';
PRINT N' 数组元素的"下标"就是 [key](值是 ''0''/''1''/''2''),本身就是扁平结构,没有层级列。';
GO
/* --------------------------------------------------------------------------------------------------
03.5 OPENJSON + WITH:把 JSON 直接摊成带类型的列
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 03.5 OPENJSON WITH:定义列名、类型与路径 -----';
SELECT
订单号 = j.orderNo
, 门店 = j.store
, 币种 = j.ccy
, 会员号 = j.memberId
, 会员等级 = j.tier
, 明细行数 = j.lineCount
FROM stg.JsonBatch b
CROSS APPLY OPENJSON(b.Payload) WITH (
orderNo nvarchar(40) N'$.orderNo'
, store nvarchar(40) N'$.store'
, ccy char(3) N'$.ccy'
, memberId nvarchar(40) N'$.member.id'
, tier nvarchar(20) N'$.member.tier'
, lineCount int N'$.lines' -- ★ 直接对数组用 int,得到数组长度
) AS j
ORDER BY 订单号;
GO
PRINT N' ★ 上面 lineCount 那列是个小技巧:WITH 里把数组路径声明成 int,得到的就是数组元素个数。';
GO
/* --------------------------------------------------------------------------------------------------
03.6 一行 JSON 展开成多行:AS JSON 嵌套 + 二次 OPENJSON
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 03.6 嵌套数组展开:一条订单 → 多行明细(ERP 最典型的用法)-----';
PRINT N' [a] 内层**不加 WITH**:能拿到 [key] 当行号,但每列要自己 JSON_VALUE + CAST';
SELECT
来源 = b.Source
, 订单号 = j.orderNo
, 币种 = j.ccy
, 行号 = CAST(l.[key] AS int) + 1
, SKU = JSON_VALUE(l.[value], N'$.sku')
, 数量 = CAST(JSON_VALUE(l.[value], N'$.qty') AS int)
, 单价 = CAST(JSON_VALUE(l.[value], N'$.price') AS decimal(18,2))
, 行金额 = CAST(JSON_VALUE(l.[value], N'$.qty') AS int)
* CAST(JSON_VALUE(l.[value], N'$.price') AS decimal(18,2))
FROM stg.JsonBatch b
CROSS APPLY OPENJSON(b.Payload) WITH (
orderNo nvarchar(40) N'$.orderNo'
, ccy char(3) N'$.ccy'
, lines nvarchar(max) N'$.lines' AS JSON -- ★ AS JSON:先原样取出数组
) AS j
CROSS APPLY OPENJSON(j.lines) AS l -- ★ 不加 WITH,才有 [key]
ORDER BY 订单号, 行号;
GO
PRINT N' [b] 内层**加 WITH**:拿到强类型列,但 [key] 就没了(WITH 会替换掉默认 schema)';
SELECT
来源 = b.Source
, 订单号 = j.orderNo
, SKU = l.sku
, 数量 = l.qty
, 单价 = l.price
, 行金额 = l.qty * l.price
FROM stg.JsonBatch b
CROSS APPLY OPENJSON(b.Payload) WITH (
orderNo nvarchar(40) N'$.orderNo'
, lines nvarchar(max) N'$.lines' AS JSON
) AS j
CROSS APPLY OPENJSON(j.lines) WITH (
sku nvarchar(20) N'$.sku'
, qty int N'$.qty'
, price decimal(18,2) N'$.price'
) AS l
ORDER BY 订单号, SKU;
GO
PRINT N' ★ 两层 CROSS APPLY OPENJSON 是"JSON 进关系表"的标准姿势:外层定位,内层展开数组。';
PRINT N' ★ 二选一的取舍(这一步最容易踩):';
PRINT N' 加 WITH → 强类型列,但**拿不到 [key]**(写成 l.[key] 会报 207 列名无效);';
PRINT N' 不加 WITH → 有 [key],但每列都得 JSON_VALUE + CAST(而且 JSON_VALUE 仍是 4000 上限)。';
PRINT N' 两个都要时:在外面再套一层 ROW_NUMBER(),或者在 WITH 里自己补一列序号。';
GO
/* --------------------------------------------------------------------------------------------------
03.7 在真实业务表上按 JSON 属性筛选
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 03.7 ERP 场景:查"铂金 + 钻石 ≥ 0.8ct"的商品 -----';
SELECT
ProductId, Sku, MetalType = MetalType, 纯度 = MetalPurity
, 主石 = StoneType, 克拉 = StoneCarat, 证书 = CertLab
, 标签 = JSON_QUERY(AttrJson, N'$.tags')
FROM erp.ProductAttr
WHERE MetalType = N'铂金' AND StoneCarat >= 0.80
ORDER BY StoneCarat DESC;
GO
PRINT N' ★ 注意这里用的是**提升列** MetalType / StoneCarat,而不是 JSON_VALUE(...);';
PRINT N' 这正是"边界清晰"的价值:能建索引、能用统计信息、能和普通列一样连接。';
GO
PRINT N'----- 03.8 同一个查询,如果直接对 JSON 原文筛选会怎样(对比写法,不推荐)-----';
SELECT
ProductId, Sku
, 直接取JSON值 = JSON_VALUE(AttrJson, N'$.metal.type')
, 直接取克拉 = JSON_VALUE(AttrJson, N'$.stone.carat')
FROM erp.ProductAttr
WHERE JSON_VALUE(AttrJson, N'$.metal.type') = N'铂金'
AND TRY_CAST(JSON_VALUE(AttrJson, N'$.stone.carat') AS decimal(5,2)) >= 0.80
ORDER BY ProductId;
GO
PRINT N' ★ 结果一样,但后者无法用提升列上的索引,只能逐行解析 JSON(07 节会用 IO 实测这个差距)。';
GO
/* --------------------------------------------------------------------------------------------------
03.9 CRM:客户偏好画像(JSON)------ 珠宝零售最关键的一块可变结构
--------------------------------------------------------------------------------------------------
为什么必须留 JSON:偏好画的字段会随运营策略频繁增删(今天加"偏好切工",
明天加"是否接受预约制"),拆列会不停 DDL;而且这些字段不参与财务口径。
但仍然把"偏好材质"和"预算上限"提升为列,因为它们是最常用的筛选条件。
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 03.9 建 crm.CustomerPref 并插入 800 条客户偏好画像 -----';
GO
CREATE TABLE crm.CustomerPref
(
CustomerId int NOT NULL CONSTRAINT PK_CustomerPref PRIMARY KEY,
CustomerNo varchar(16) NOT NULL,
PrefJson nvarchar(max) NOT NULL
CONSTRAINT CK_CustomerPref_IsJsonObject CHECK (ISJSON(PrefJson, OBJECT) = 1),
SchemaVersion AS CAST(JSON_VALUE(PrefJson, N'$.schemaVersion') AS tinyint) PERSISTED,
-- 提升列:运营最常用的两个筛选维度
PrefMetal AS CAST(JSON_VALUE(PrefJson, N'$.prefs.metal') AS nvarchar(20)) PERSISTED,
BudgetMaxUsd AS TRY_CAST(JSON_VALUE(PrefJson, N'$.prefs.budget.maxUsd') AS decimal(12,2)) PERSISTED,
PrefStyle AS CAST(JSON_VALUE(PrefJson, N'$.prefs.style') AS nvarchar(20)) PERSISTED
);
GO
INSERT crm.CustomerPref (CustomerId, CustomerNo, PrefJson)
SELECT
c.CustomerId
, c.CustomerNo
, CAST(N'{"schemaVersion":1,"prefs":{"style":"' AS nvarchar(max))
+ CASE c.CustomerId % 4 WHEN 0 THEN N'经典' WHEN 1 THEN N'轻奢' WHEN 2 THEN N'国潮' ELSE N'极简' END
+ N'","metal":"'
+ CASE c.CustomerId % 3 WHEN 0 THEN N'铂金' WHEN 1 THEN N'黄金' ELSE N'玫瑰金' END
+ N'","color":"' + CASE c.CustomerId % 2 WHEN 0 THEN N'冷色' ELSE N'暖色' END + N'"'
+ N',"budget":{"minUsd":' + CAST(500 + (c.CustomerId % 10) * 250 AS nvarchar(20))
+ N',"maxUsd":' + CAST(3000 + (c.CustomerId % 25) * 1200 AS nvarchar(20)) + N'}}'
+ N',"sizes":{"ring":' + CAST(10.0 + (c.CustomerId % 9) * 0.5 AS nvarchar(10))
+ N',"bracelet":' + CAST(15.0 + (c.CustomerId % 7) * 0.5 AS nvarchar(10)) + N'}'
+ N',"touch":{"preferChannel":"'
+ CASE c.CustomerId % 4 WHEN 0 THEN N'wechat' WHEN 1 THEN N'ecom' WHEN 2 THEN N'store' ELSE N'app' END
+ N'","bestTime":"' + CASE c.CustomerId % 3 WHEN 0 THEN N'12:00-14:00' WHEN 1 THEN N'20:00-22:00' ELSE N'周末' END
+ N'","lang":"' + CASE c.CustomerId % 3 WHEN 0 THEN N'zh-Hans' WHEN 1 THEN N'en' ELSE N'zh-Hant' END + N'"}'
+ N',"tags":[' + CASE c.CustomerId % 4 WHEN 0 THEN N'"婚嫁","高净值"' WHEN 1 THEN N'"自购"'
WHEN 2 THEN N'"送礼","纪念日"' ELSE N'"收藏"' END + N']'
+ N'}'
FROM crm.Customer c;
GO
SELECT 插入行数 = COUNT(*) FROM crm.CustomerPref;
GO
SELECT TOP 2 CustomerId, CustomerNo, PrefJson FROM crm.CustomerPref ORDER BY CustomerId;
GO
PRINT N' [1] 提升列建索引,然后按偏好筛选客户(CRM 精准营销的标准姿势):';
CREATE INDEX IX_CustomerPref_Metal_Budget ON crm.CustomerPref (PrefMetal, BudgetMaxUsd) INCLUDE (PrefStyle);
GO
SET STATISTICS IO ON;
GO
SELECT
客户号 = c.CustomerNo
, 姓名 = cu.CustomerName
, 等级 = cu.Tier
, 偏好材质 = c.PrefMetal
, 预算上限 = c.BudgetMaxUsd
, 偏好风格 = c.PrefStyle
, 触点偏好 = JSON_VALUE(c.PrefJson, N'$.touch.preferChannel')
FROM crm.CustomerPref c
JOIN crm.Customer cu ON cu.CustomerId = c.CustomerId
WHERE c.PrefMetal = N'铂金' AND c.BudgetMaxUsd >= 20000
ORDER BY c.BudgetMaxUsd DESC;
GO
SET STATISTICS IO OFF;
GO
PRINT N' [2] 展开 tags 数组做分群统计(数组必须靠 OPENJSON 打开):';
SELECT
标签 = t.value
, 人数 = COUNT(*)
, 平均预算上限 = CAST(AVG(c.BudgetMaxUsd) AS decimal(12,2))
FROM crm.CustomerPref c
CROSS APPLY OPENJSON(c.PrefJson, N'$.tags') AS t
GROUP BY t.value
ORDER BY 人数 DESC;
GO
PRINT N' ★ 注意:tags 是数组,无法用 JSON_VALUE 取(会得到 NULL),必须 OPENJSON 展开。';
PRINT N' 这正是 03.1 那条"JSON_VALUE 只认标量"的直接后果。';
GO
PRINT N' [3] 多语言触点:按客户语言分组(CRM 跨境运营常用)';
PRINT N' ★ 注意这里用 COUNT(DISTINCT ...) 而不是 STRING_AGG:';
PRINT N' 800 个客户拼成一个长串没有意义,还会把结果集撑爆。聚合要看"给谁看"来选。';
SELECT
语言 = JSON_VALUE(PrefJson, N'$.touch.lang')
, 人数 = COUNT(*)
, 不同触点渠道数 = COUNT(DISTINCT JSON_VALUE(PrefJson, N'$.touch.preferChannel'))
, 平均预算上限 = CAST(AVG(BudgetMaxUsd) AS decimal(12,2))
FROM crm.CustomerPref
GROUP BY JSON_VALUE(PrefJson, N'$.touch.lang')
ORDER BY 人数 DESC;
GO
/* --------------------------------------------------------------------------------------------------
03.10 HR:员工技能与语言档案(JSON)
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 03.10 建 hr.EmployeeDoc 并插入 36 条员工档案 -----';
GO
CREATE TABLE hr.EmployeeDoc
(
EmployeeId int NOT NULL CONSTRAINT PK_EmployeeDoc PRIMARY KEY,
EmployeeNo varchar(16) NOT NULL,
DocJson nvarchar(max) NOT NULL
CONSTRAINT CK_EmployeeDoc_IsJsonObject CHECK (ISJSON(DocJson, OBJECT) = 1),
-- 提升列形态一:PERSISTED 计算列 ------ 只能做"单值提取"
SchemaVersion AS CAST(JSON_VALUE(DocJson, N'$.schemaVersion') AS tinyint) PERSISTED,
TopSkill AS CAST(JSON_VALUE(DocJson, N'$.skills[0].name') AS nvarchar(60)) PERSISTED,
-- 提升列形态二:写入时算好的真实列 ------ 需要"计数/求和"时只能这么办
-- 原因:计算列里不允许子查询、不允许 OPENJSON 聚合;且 JSON_VALUE 取数组恒为 NULL。
CertCount tinyint NOT NULL,
-- 提升列形态一(续):把"是否持某类证书"变成可索引的位
HasGiaCert AS CAST(CASE WHEN DocJson LIKE N'%GIA-GG%' THEN 1 ELSE 0 END AS bit) PERSISTED
);
GO
PRINT N' ★ 建表时就要分清两种"提升列":';
PRINT N' ① 单值提取 → 可以用 PERSISTED 计算列(SchemaVersion / TopSkill / HasGiaCert);';
PRINT N' ② 计数或求和 → 计算列里不能写 OPENJSON / 子查询,只能在写入时算好存成普通列(CertCount)。';
PRINT N' 顺带一提:JSON_VALUE 取**数组**($.certs)永远返回 NULL,这也是①做不到计数的一半原因。';
GO
INSERT hr.EmployeeDoc (EmployeeId, EmployeeNo, DocJson, CertCount)
SELECT
e.EmployeeId
, N'E-' + RIGHT(N'0000' + CAST(e.EmployeeId AS nvarchar(10)), 4)
, CAST(N'{"schemaVersion":1,"skills":[' AS nvarchar(max))
+ N'{"name":"' + CASE e.EmployeeId % 5 WHEN 0 THEN N'钻石分级' WHEN 1 THEN N'黄金工艺'
WHEN 2 THEN N'门店运营' WHEN 3 THEN N'客户关系' ELSE N'库存管理' END
+ N'","level":' + CAST(3 + e.EmployeeId % 3 AS nvarchar(4)) + N'}'
+ N',{"name":"' + CASE e.EmployeeId % 3 WHEN 0 THEN N'团队管理' WHEN 1 THEN N'视觉陈列' ELSE N'数据分析' END
+ N'","level":' + CAST(2 + e.EmployeeId % 4 AS nvarchar(4)) + N'}]'
+ N',"langs":[{"code":"zh-Hans","level":"native"}'
+ CASE WHEN e.EmployeeId % 2 = 0 THEN N',{"code":"en","level":"business"}' ELSE N'' END
+ CASE WHEN e.EmployeeId % 3 = 0 THEN N',{"code":"ja","level":"basic"}' ELSE N'' END + N']'
+ N',"certs":[' + CASE e.EmployeeId % 3 WHEN 0 THEN N'"GIA-GG","SAFETY"' WHEN 1 THEN N'"CIPD"' ELSE N'"SAFETY"' END + N']'
+ N',"emergency":{"contact":"N/A","phone":"N/A"}}'
-- ★ 手工拼 JSON 最容易出错的地方就是花括号/中括号配对。
-- 下面这一列在"写入时"算好,正是上面说的提升列形态二。
, CAST(CASE e.EmployeeId % 3 WHEN 0 THEN 2 ELSE 1 END AS tinyint)
FROM hr.Employee e;
GO
SELECT 插入行数 = COUNT(*) FROM hr.EmployeeDoc;
SELECT 合法JSON行数 = SUM(CAST(ISJSON(DocJson, OBJECT) AS int)) FROM hr.EmployeeDoc;
GO
SELECT TOP 2 EmployeeId, EmployeeNo, DocJson FROM hr.EmployeeDoc ORDER BY EmployeeId;
GO
PRINT N' 对照:JSON_VALUE 取数组得到 NULL,而写入时算好的 CertCount 拿得到值:';
SELECT TOP 3
员工号 = EmployeeNo
, 反例_JSON_VALUE取数组 = ISNULL(CAST(JSON_VALUE(DocJson, N'$.certs') AS nvarchar(20)), N'<NULL:数组取不到>')
, 正解_写入时提升列 = CAST(CertCount AS nvarchar(10))
FROM hr.EmployeeDoc ORDER BY EmployeeId;
GO
PRINT N' [1] 展开技能数组:谁具备"钻石分级"能力(HR 技能盘点):';
SELECT
员工号 = d.EmployeeNo
, 姓名 = e.FullName
, 部门 = e.Department
, 技能 = s.name
, 等级 = s.level
FROM hr.EmployeeDoc d
JOIN hr.Employee e ON e.EmployeeId = d.EmployeeId
CROSS APPLY OPENJSON(d.DocJson, N'$.skills')
WITH (name nvarchar(60) N'$.name', level int N'$.level') AS s
WHERE d.DocJson LIKE N'%钻石分级%' -- 先用廉价谓词预过滤,再用 OPENJSON 精确展开
ORDER BY 等级 DESC, 员工号;
GO
PRINT N' ★ 上面故意保留了一个反例:WHERE 里用 LIKE 预过滤只是"看起来省",';
PRINT N' 它仍然无法走索引(%...%)。正确做法是把高频筛选的技能提升为列(见下表)。';
GO
PRINT N' [2] 正确做法:用建表时就定义好的提升列 + 索引来筛(对比上一条的 LIKE):';
CREATE INDEX IX_EmployeeDoc_HasGia ON hr.EmployeeDoc (HasGiaCert) INCLUDE (EmployeeNo, CertCount);
GO
SET STATISTICS IO ON;
SELECT
持GIA证书人数 = COUNT(*)
, 平均证书数 = CAST(AVG(CAST(CertCount AS decimal(6,2))) AS decimal(6,2))
FROM hr.EmployeeDoc
WHERE HasGiaCert = 1;
SET STATISTICS IO OFF;
GO
PRINT N' ★ 这就是 01.1 判据第 5 条的落地:只提升"真正要筛的少数几个字段"。';
PRINT N' 注意这里**没有**用 LIKE:HasGiaCert 是 PERSISTED 计算列,能走索引;';
PRINT N' 而 LIKE N''%GIA-GG%'' 的前导通配符永远走不了索引。';
GO
/* ==================================================================================================
04 JSON 生成:从关系数据造出 JSON
================================================================================================== */
PRINT N'';
PRINT N'===== 04 JSON 生成 =====';
GO
/* --------------------------------------------------------------------------------------------------
04.1 JSON_OBJECT / JSON_ARRAY(2022+):按需拼装,不用手工拼字符串
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 04.1 JSON_OBJECT / JSON_ARRAY:拼单行 JSON -----';
SELECT TOP 5
订单号 = o.OrderNo
, 单据JSON = JSON_OBJECT(
'orderNo' : o.OrderNo,
'orderDate': o.OrderDate,
'ccy' : o.CurrencyCode,
'netUsd' : o.NetAmountUsd,
'status' : o.Status
)
, 标签数组 = JSON_ARRAY(o.Status, o.CurrencyCode)
FROM erp.SalesOrder o
ORDER BY o.OrderId;
GO
PRINT N' ★ JSON_OBJECT / JSON_ARRAY 里可以直接放列,省掉手工 CAST 与转义,';
PRINT N' 也不会踩到"字符串拼接被截断到 4000"的坑。';
GO
/* --------------------------------------------------------------------------------------------------
04.2 FOR JSON:把结果集整体转成 JSON
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 04.2 FOR JSON PATH:手工控制结构(最常用)-----';
SELECT TOP 3
o.OrderNo AS [orderNo]
, o.OrderDate AS [orderDate]
, o.CurrencyCode AS [ccy]
, o.NetAmountUsd AS [netUsd]
FROM erp.SalesOrder o
ORDER BY o.OrderId
FOR JSON PATH;
GO
PRINT N'----- 04.3 FOR JSON PATH + ROOT + INCLUDE_NULL_VALUES -----';
SELECT TOP 3
o.OrderNo AS [orderNo]
, o.StoreCode AS [store]
, o.Status AS [status]
FROM erp.SalesOrder o
ORDER BY o.OrderId
FOR JSON PATH, ROOT('orders'), INCLUDE_NULL_VALUES;
GO
PRINT N' ★ 默认 FOR JSON 会**省略 NULL 字段**;要保留键就得加 INCLUDE_NULL_VALUES。';
PRINT N' 对接方按固定字段解析时,这个差别会导致"字段时有时无",务必显式选择。';
GO
PRINT N'----- 04.4 FOR JSON AUTO:按表结构自动嵌套(方便但难控制)-----';
SELECT TOP 2
o.OrderNo AS [orderNo]
, p.Sku AS [sku]
, l.Quantity AS [qty]
, l.LineAmount AS [amountUsd]
FROM erp.SalesOrder o
JOIN erp.SalesOrderLine l ON l.OrderId = o.OrderId
JOIN erp.Product p ON p.ProductId = l.ProductId
ORDER BY o.OrderId, l.LineId
FOR JSON AUTO, ROOT('orders');
GO
PRINT N' ★ AUTO 模式会"按表自动分层":o 一层、l 一层、p 一层,层级完全由 FROM 顺序决定。';
PRINT N' 字段多表分散时结构往往不是你要的,所以生产上一般用 PATH 手工控制(见 10.3)。';
GO
/* --------------------------------------------------------------------------------------------------
04.5 JSON_ARRAYAGG / JSON_OBJECTAGG:SQL Server 2025 新增的 JSON 聚合
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 04.5 ★ 2025 新聚合:把分组内的值聚成 JSON 数组/对象 -----';
SELECT
门店 = o.StoreCode
, 订单数 = COUNT(*)
, 订单号数组 = JSON_ARRAYAGG(o.OrderNo)
FROM erp.SalesOrder o
GROUP BY o.StoreCode
ORDER BY 门店;
GO
PRINT N' 用一个 SELECT 直接产出"门店 → {SKU: 订单数}"的 JSON 对象:';
PRINT N' ★ JSON_OBJECTAGG 要求分组内**键唯一**,所以先在内层把 (门店, SKU) 聚合好,再在外层拼对象。';
SELECT
门店 = s.StoreCode
, 产品订单数JSON = JSON_OBJECTAGG(s.Sku : s.OrderCnt)
FROM (
SELECT o.StoreCode, p.Sku, COUNT(DISTINCT o.OrderId) AS OrderCnt
FROM erp.SalesOrder o
JOIN erp.SalesOrderLine l ON l.OrderId = o.OrderId
JOIN erp.Product p ON p.ProductId = l.ProductId
GROUP BY o.StoreCode, p.Sku
) AS s
GROUP BY s.StoreCode
ORDER BY 门店;
GO
PRINT N' ★ JSON_OBJECTAGG 的语法是 JSON_OBJECTAGG(键 : 值),注意是**冒号**不是逗号。';
PRINT N' 有了这两个聚合,很多原本"分组后在应用层拼 JSON"的逻辑可以下推到数据库;';
PRINT N' 但要注意:聚合结果是**整个分组拼成一个大字符串**,分组很大时会吃内存(见 14 节)。';
GO
PRINT N' 珠宝场景:按品类把 SKU 聚成一个 JSON 数组,直接喂给前端做筛选器:';
SELECT
品类 = pc.CategoryName
, SKU数 = COUNT(*)
, SKU数组 = JSON_ARRAYAGG(p.Sku)
FROM erp.Product p
JOIN erp.ProductCategory pc ON pc.CategoryId = p.CategoryId
GROUP BY pc.CategoryName
ORDER BY 品类;
GO
/* ==================================================================================================
05 JSON 修改:JSON_MODIFY
================================================================================================== */
PRINT N'';
PRINT N'===== 05 JSON 修改 =====';
GO
/* --------------------------------------------------------------------------------------------------
05.1 改已有键 / 追加新键 / 删键 / 改数组元素
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 05.1 JSON_MODIFY 的四种基本操作 -----';
DECLARE @doc nvarchar(max) = (SELECT AttrJson FROM erp.ProductAttr WHERE ProductId = 1);
SELECT 操作 = N'原文档', 结果 = LEFT(@doc, 120) + N'...'
UNION ALL
SELECT N'改已有标量:$.metal.purity', LEFT(JSON_MODIFY(@doc, N'$.metal.purity', N'Pt900'), 120) + N'...'
UNION ALL
SELECT N'追加新键:$.custom.insured', LEFT(JSON_MODIFY(@doc, N'$.custom.insured', CAST(1 AS bit)), 200) + N'...'
UNION ALL
SELECT N'改深层键:$.stone.cert.lab', LEFT(JSON_MODIFY(@doc, N'$.stone.cert.lab', N'IGI'), 200) + N'...'
UNION ALL
SELECT N'删键:把值设成 NULL', LEFT(JSON_MODIFY(@doc, N'$.custom.giftBox', NULL), 200) + N'...'
UNION ALL
SELECT N'追加数组元素:$.tags[3]', LEFT(JSON_MODIFY(@doc, N'append $.tags', N'限量'), 220) + N'...';
GO
PRINT N' ★ 删键的写法是"把路径的值设为 NULL";追加数组元素必须用 append 前缀;';
PRINT N' 如果直接写 $.tags[3] 而数组只有 2 个元素,lax 下会静默无操作(不报错、也不追加)。';
GO
/* --------------------------------------------------------------------------------------------------
05.2 lax 与 strict 在"追加新键"上的差别
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 05.2 追加一个还不存在的键:lax 可以,strict 报错 -----';
DECLARE @d nvarchar(max) = N'{"a":1}';
BEGIN TRY
SELECT lax结果 = JSON_MODIFY(@d, N'lax $.b', 9);
END TRY BEGIN CATCH SELECT lax结果 = N'异常: ' + ERROR_MESSAGE(); END CATCH
DECLARE @p nvarchar(50) = N'strict $.b';
BEGIN TRY
SELECT strict结果 = JSON_MODIFY(@d, @p, 9);
END TRY
BEGIN CATCH
SELECT strict结果 = N'异常(' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N'):' + ERROR_MESSAGE();
END CATCH
GO
PRINT N' ★ 这条与 JSON_VALUE 相反:strict 在这里意味着"路径必须已存在",';
PRINT N' 而 JSON_VALUE 的 strict 意味着"路径必须存在且类型匹配"。别记混。';
GO
/* --------------------------------------------------------------------------------------------------
05.3 ★ 性能要点:JSON_MODIFY 会重写整个文档
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 05.3 实测:改 JSON 里一个键,等于重写整篇文档 -----';
SELECT
原文档字节 = DATALENGTH(AttrJson)
, 改后字节 = DATALENGTH(JSON_MODIFY(AttrJson, N'$.custom.giftBox', N'deluxe'))
, 差 = DATALENGTH(JSON_MODIFY(AttrJson, N'$.custom.giftBox', N'deluxe')) - DATALENGTH(AttrJson)
FROM erp.ProductAttr WHERE ProductId = 1;
GO
PRINT N' ★ 结论:文档越大,改一个键的代价越高(读全量 + 解析 + 重拼 + 写全量,并产生新的 LOB 版本)。';
PRINT N' 所以"会被高频修改的字段"必须拆成列 ------ 这正是 01.1 判据第 3 条的由来。';
GO
/* --------------------------------------------------------------------------------------------------
05.4 正确的更新姿势:只改一处,且带上前置校验
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 05.4 带校验的安全更新(先确认已存在该键,再改)-----';
UPDATE erp.ProductAttr
SET AttrJson = JSON_MODIFY(AttrJson, N'$.custom.insured', CAST(1 AS bit))
WHERE ProductId = 1
AND JSON_PATH_EXISTS(AttrJson, N'$.custom') = 1;
SELECT ProductId, 改后custom = JSON_QUERY(AttrJson, N'$.custom') FROM erp.ProductAttr WHERE ProductId = 1;
GO
/* ==================================================================================================
06 JSON 校验:让"合法"成为可查询的事实
================================================================================================== */
PRINT N'';
PRINT N'===== 06 JSON 校验 =====';
GO
/* --------------------------------------------------------------------------------------------------
06.1 ISJSON 的类型参数:不只校验"是 JSON",还校验"是哪一种 JSON"
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 06.1 ISJSON 的四种判定 -----';
SELECT * FROM (VALUES
(1, N'{"a":1}', ISJSON(N'{"a":1}', OBJECT), ISJSON(N'{"a":1}', ARRAY), ISJSON(N'{"a":1}', VALUE))
, (2, N'[1,2,3]', ISJSON(N'[1,2,3]', OBJECT), ISJSON(N'[1,2,3]', ARRAY), ISJSON(N'[1,2,3]', VALUE))
, (3, N'123', ISJSON(N'123', OBJECT), ISJSON(N'123', ARRAY), ISJSON(N'123', VALUE))
, (4, N'not json', ISJSON(N'not json',OBJECT), ISJSON(N'not json',ARRAY), ISJSON(N'not json',VALUE))
) AS t(序号, 文档, 是OBJECT, 是ARRAY, 是VALUE);
GO
PRINT N' ★ 注意第 3 行:标量 123 在 VALUE 判定下是合法 JSON,但在 OBJECT/ARRAY 下不合法。';
PRINT N' 所以列上写 CHECK(ISJSON(col, OBJECT) = 1) 才能挡住"塞进一个裸数组"这种事故。';
GO
/* --------------------------------------------------------------------------------------------------
06.2 契约巡检:把"不符合契约的行"查出来
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 06.2 契约巡检:找出缺必需路径 / 版本号异常的商品 -----';
SELECT
检查项 = N'schemaVersion 缺失或非 1'
, 违反行数 = COUNT(*)
FROM erp.ProductAttr WHERE SchemaVersion IS NULL OR SchemaVersion <> 1
UNION ALL
SELECT N'metal.type 缺失', COUNT(*) FROM erp.ProductAttr WHERE JSON_PATH_EXISTS(AttrJson, N'$.metal.type') = 0
UNION ALL
SELECT N'stone 与 tags 同时缺失', COUNT(*) FROM erp.ProductAttr
WHERE JSON_PATH_EXISTS(AttrJson, N'$.stone') = 0 AND JSON_PATH_EXISTS(AttrJson, N'$.tags') = 0
UNION ALL
SELECT N'有钻石但缺证书号', COUNT(*) FROM erp.ProductAttr
WHERE StoneType = N'钻石' AND JSON_PATH_EXISTS(AttrJson, N'$.stone.cert.no') = 0
UNION ALL
SELECT N'文档超过 2000 字节(体积告警)', COUNT(*) FROM erp.ProductAttr WHERE DATALENGTH(AttrJson) > 2000;
GO
PRINT N' ★ 这就是"边界清晰"的可运维形态:契约不是写在文档里的约定,而是能每天巡检的查询。';
GO
/* --------------------------------------------------------------------------------------------------
06.3 加一条"字段级"CHECK 约束:把必需路径变成数据库强制
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 06.3 用 CHECK 约束强制"必须有 schemaVersion 与 metal.type" -----';
BEGIN TRY
ALTER TABLE erp.ProductAttr WITH CHECK
ADD CONSTRAINT CK_ProductAttr_RequiredPaths
CHECK (JSON_PATH_EXISTS(AttrJson, N'$.schemaVersion') = 1
AND JSON_PATH_EXISTS(AttrJson, N'$.metal.type') = 1);
PRINT N' 约束已添加';
END TRY
BEGIN CATCH
PRINT N' 添加失败(' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N'):' + ERROR_MESSAGE();
END CATCH
GO
PRINT N' 验证约束确实生效(插一条缺 metal.type 的文档):';
BEGIN TRY
INSERT erp.ProductAttr (ProductId, Sku, AttrJson) VALUES (9998, N'BAD-002', N'{"schemaVersion":1}');
PRINT N' 居然插入成功(约束没生效)';
END TRY
BEGIN CATCH
PRINT N' 被拦下 → ' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N':' + ERROR_MESSAGE();
END CATCH
GO
/* ==================================================================================================
07 JSON 索引:JSON 列本身不能建索引,靠"计算列 + 索引"
================================================================================================== */
PRINT N'';
PRINT N'===== 07 JSON 索引 =====';
GO
/* --------------------------------------------------------------------------------------------------
07.1 先证明"不能直接索引"
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 07.1 直接对 json / nvarchar(max) 列建索引会失败 -----';
BEGIN TRY
CREATE INDEX IX_ProductAttrJ_Direct ON erp.ProductAttrJ (AttrJson);
PRINT N' 原生 json 列:居然建索引成功';
END TRY
BEGIN CATCH
PRINT N' 原生 json 列建索引失败 → ' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N':' + ERROR_MESSAGE();
END CATCH
BEGIN TRY
CREATE INDEX IX_ProductAttr_Direct ON erp.ProductAttr (AttrJson);
PRINT N' nvarchar(max) 列:居然建索引成功';
END TRY
BEGIN CATCH
PRINT N' nvarchar(max) 列建索引失败 → ' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N':' + ERROR_MESSAGE();
END CATCH
GO
PRINT N' ★ 所以 JSON 的"索引"永远是:把要筛选的路径提升成计算列,再索引那个计算列。';
PRINT N' SQL Server 没有"JSON 路径索引"这种东西(不像 PostgreSQL 的 GIN)。';
GO
/* --------------------------------------------------------------------------------------------------
07.2 建立提升列上的索引
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 07.2 在提升列上建索引(含覆盖索引,避免回表)-----';
CREATE INDEX IX_ProductAttr_MetalType ON erp.ProductAttr (MetalType) INCLUDE (Sku, MetalPurity, StoneCarat);
CREATE INDEX IX_ProductAttr_Carat ON erp.ProductAttr (StoneCarat) INCLUDE (Sku, StoneType, CertLab);
CREATE INDEX IX_ProductAttrJ_MetalType ON erp.ProductAttrJ (MetalType);
GO
PRINT N' 已建立的索引:';
SELECT 表 = OBJECT_NAME(i.object_id), 索引 = i.name, 类型 = i.type_desc,
键列 = STUFF((SELECT N', ' + c.name FROM sys.index_columns ic
JOIN sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id
WHERE ic.object_id = i.object_id AND ic.index_id = i.index_id AND ic.is_included_column = 0
ORDER BY ic.key_ordinal FOR XML PATH('')), 1, 2, N'')
FROM sys.indexes i
WHERE i.object_id IN (OBJECT_ID('erp.ProductAttr'), OBJECT_ID('erp.ProductAttrJ'))
AND i.name IS NOT NULL
ORDER BY 表, 索引;
GO
/* --------------------------------------------------------------------------------------------------
07.3 在 5 万行的 JSON 表上实测:提升列索引 vs 直接 JSON_VALUE
--------------------------------------------------------------------------------------------------
先造一张有规模的表,模拟"万亿级"在我们本地能承受的缩微样本。
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 07.3 建 stg.EventJson,插入 5 万行门店扫码/触点事件(JSON)-----';
GO
CREATE TABLE stg.EventJson
(
EventId int IDENTITY(1,1) NOT NULL CONSTRAINT PK_EventJson PRIMARY KEY,
Payload nvarchar(max) NOT NULL
CONSTRAINT CK_EventJson_IsJson CHECK (ISJSON(Payload, OBJECT) = 1),
-- 提升列:只提升真正要筛的字段
StoreCode AS CAST(JSON_VALUE(Payload, N'$.store') AS varchar(12)) PERSISTED,
Sku AS CAST(JSON_VALUE(Payload, N'$.sku') AS varchar(20)) PERSISTED,
Channel AS CAST(JSON_VALUE(Payload, N'$.channel') AS varchar(12)) PERSISTED,
Qty AS TRY_CAST(JSON_VALUE(Payload, N'$.qty') AS int) PERSISTED
);
GO
;WITH N AS (
SELECT TOP (50000) ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS n
FROM sys.all_objects a CROSS JOIN sys.all_objects b
)
INSERT stg.EventJson (Payload)
SELECT
CAST(N'{"schemaVersion":1,"event":"scan","store":"' AS nvarchar(max))
+ CASE n.n % 6 WHEN 0 THEN N'HK-CWB-01' WHEN 1 THEN N'CN-SH-02' WHEN 2 THEN N'SG-OR-01'
WHEN 3 THEN N'US-NY-05' WHEN 4 THEN N'JP-TK-01' ELSE N'FR-PA-03' END
+ N'","sku":"'
+ CASE n.n % 8 WHEN 0 THEN N'RG-DIA-001' WHEN 1 THEN N'RG-GLD-003' WHEN 2 THEN N'NK-GLD-010'
WHEN 3 THEN N'ER-DIA-005' WHEN 4 THEN N'BR-PLT-002' WHEN 5 THEN N'WT-MEC-007'
WHEN 6 THEN N'PD-JDE-004' ELSE N'CG-STL-009' END
+ N'","channel":"'
+ CASE n.n % 4 WHEN 0 THEN N'store' WHEN 1 THEN N'ecom' WHEN 2 THEN N'wechat' ELSE N'app' END
+ N'","qty":' + CAST(1 + n.n % 3 AS varchar(4))
+ N',"ts":"2025-' + RIGHT(N'0' + CAST(1 + n.n % 12 AS varchar(2)), 2)
+ N'-' + RIGHT(N'0' + CAST(1 + n.n % 28 AS varchar(2)), 2) + N'T09:00:00Z"}'
FROM N;
GO
SELECT 插入行数 = COUNT(*) FROM stg.EventJson;
GO
SELECT TOP 2 EventId, Payload FROM stg.EventJson ORDER BY EventId;
GO
PRINT N'----- 07.4 建索引前后对比:按 SKU + 门店筛选 -----';
CREATE INDEX IX_EventJson_Sku_Store ON stg.EventJson (Sku, StoreCode) INCLUDE (Qty, Channel);
GO
PRINT N' [a] 用提升列 + 索引(走索引查找)';
SET STATISTICS IO ON;
SELECT 事件数 = COUNT(*), 总件数 = SUM(Qty)
FROM stg.EventJson
WHERE Sku = N'RG-DIA-001' AND StoreCode = N'HK-CWB-01';
GO
SET STATISTICS IO OFF;
GO
PRINT N' [b] 直接解析 JSON 原文(只能全表扫描 + 逐行解析)';
SET STATISTICS IO ON;
SET STATISTICS TIME ON;
SELECT 事件数 = COUNT(*), 总件数 = SUM(TRY_CAST(JSON_VALUE(Payload, N'$.qty') AS int))
FROM stg.EventJson
WHERE JSON_VALUE(Payload, N'$.sku') = N'RG-DIA-001'
AND JSON_VALUE(Payload, N'$.store') = N'HK-CWB-01';
GO
SET STATISTICS IO OFF;
SET STATISTICS TIME OFF;
GO
PRINT N' ★ 对比两条的"逻辑读取次数"与 CPU 时间:提升列走查找是常数级,';
PRINT N' 直接 JSON_VALUE 是全表扫描 + 5 万次 JSON 解析,随行数线性增长。';
GO
/* ==================================================================================================
08 ★ JSON_VALUE 的 4000 字符陷阱(本脚本最实用的一节)
--------------------------------------------------------------------------------------------------
很多人以为 JSON_VALUE 取不到值是"路径写错了",其实是**返回值长度上限**。
本节的结论全部来自本机实测。
================================================================================================== */
PRINT N'';
PRINT N'===== 08 JSON_VALUE 的 4000 字符上限 =====';
GO
/* --------------------------------------------------------------------------------------------------
08.1 边界扫描:JSON_VALUE 从第几个字符开始"静默返回 NULL"
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 08.1 边界扫描:被取的值从 3990 逐步加到 4020 -----';
DECLARE @n int = 3990;
WHILE @n <= 4020
BEGIN
DECLARE @v nvarchar(max) = REPLICATE(CAST(N'Y' AS nvarchar(max)), @n);
DECLARE @doc nvarchar(max) = CAST(N'{"v":"' AS nvarchar(max)) + @v + N'"}';
SELECT
被取值长度 = @n
, 文档总长 = LEN(@doc)
, JSON_VALUE长度 = ISNULL(CAST(LEN(JSON_VALUE(@doc, N'$.v')) AS nvarchar(10)), N'<NULL>')
, JSON_QUERY长度 = ISNULL(CAST(LEN(JSON_QUERY(@doc, N'$.v')) AS nvarchar(10)), N'<NULL>')
, OPENJSON值长度 = (SELECT LEN([value]) FROM OPENJSON(@doc) WHERE [key] = N'v');
SET @n = @n + 5;
END
GO
PRINT N' ★ 结论(实测):';
PRINT N' · 值 ≤ 4000 字符 → JSON_VALUE 正常返回;';
PRINT N' · 值 > 4000 字符 → JSON_VALUE **静默返回 NULL**,不报错、不截断、也不告警;';
PRINT N' · JSON_QUERY 对"字符串标量"恒为 NULL(它只取对象/数组),所以那一列永远是 NULL;';
PRINT N' · OPENJSON 的 value 列是 nvarchar(max),**多长都能完整取出** ------ 这才是取长值的正解。';
GO
/* --------------------------------------------------------------------------------------------------
08.2 危险之处:NULL 与"真的没有这个键"无法区分
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 08.2 两种完全不同的原因,JSON_VALUE 都返回 NULL -----';
DECLARE @short nvarchar(max) = N'{"v":"abc"}';
DECLARE @long nvarchar(max) = CAST(N'{"v":"' AS nvarchar(max)) + REPLICATE(CAST(N'Z' AS nvarchar(max)), 5000) + N'"}';
SELECT * FROM (VALUES
(1, N'键不存在(lax)', JSON_VALUE(@short, N'$.nope'), N'→ NULL 是对的')
, (2, N'键存在,值是超长字符串', JSON_VALUE(@long, N'$.v'), N'→ NULL 是**错的**(会被当成不存在)')
, (3, N'用 JSON_PATH_EXISTS 才能区分', CAST(JSON_PATH_EXISTS(@long, N'$.v') AS nvarchar(2)), N'→ 1 说明键其实存在')
, (4, N'用 OPENJSON 正确取值', CAST((SELECT LEN([value]) FROM OPENJSON(@long) WHERE [key]=N'v') AS nvarchar(10)), N'→ 5000,取到了')
) AS t(序号, 情形, JSON_VALUE结果, 说明)
ORDER BY 序号;
GO
PRINT N' ★ 这条坑最阴的地方:调用方看到 NULL,会以为"这条商品没有描述";';
PRINT N' 实际上是"描述太长"。如果这个字段用于对账或展示,错误会静默流到业务层。';
GO
/* --------------------------------------------------------------------------------------------------
08.3 正解:三种取长值的写法
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 08.3 取长字符串值:JSON_VALUE / JSON_QUERY / OPENJSON 三种写法对比 -----';
DECLARE @long nvarchar(max) = CAST(N'{"v":"' AS nvarchar(max)) + REPLICATE(CAST(N'Z' AS nvarchar(max)), 5000) + N'"}';
SELECT
JSON_VALUE = ISNULL(CAST(LEN(JSON_VALUE(@long, N'$.v')) AS nvarchar(10)), N'NULL(丢了)')
, OPENJSON = CAST((SELECT LEN([value]) FROM OPENJSON(@long) WHERE [key] = N'v') AS nvarchar(10))
, 推荐写法 = N'OPENJSON(...) WHERE [key] = ''v'',或用 OPENJSON WITH(v nvarchar(max) ''$.v'')';
GO
PRINT N' 在真实表上,用 OPENJSON 把长字段取成 nvarchar(max):';
SELECT TOP 3
EventId
, [取 qty 用 JSON_VALUE] = JSON_VALUE(Payload, N'$.qty')
, [取整篇用 OPENJSON] = (SELECT LEFT([value], 30) FROM OPENJSON(Payload) WHERE [key] = N'event')
FROM stg.EventJson ORDER BY EventId;
GO
PRINT N' ★ 语法小坑:别名里带空格必须加方括号([取 qty 用 JSON_VALUE]),';
PRINT N' 否则会报 102「''用''附近有语法错误」+ 156「关键字 ''FROM'' 附近有语法错误」这两条很迷惑的组合。';
GO
/* --------------------------------------------------------------------------------------------------
08.4 上游陷阱:REPLICATE 自己就会截断到 4000
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 08.4 REPLICATE 的上限:入参不是 max 就只给 4000 -----';
SELECT
REPLICATE普通 = LEN(REPLICATE(N'X', 5000))
, REPLICATE入参转max = LEN(REPLICATE(CAST(N'X' AS nvarchar(max)), 5000));
GO
PRINT N' ★ 造测试数据时如果用 REPLICATE(N''X'', 5000) 造长字符串,实际只得到 4000 个字符。';
PRINT N' 这会让你误判"JSON_VALUE 在 4000 就坏了",其实根本没造出那么长的数据。';
GO
/* --------------------------------------------------------------------------------------------------
08.5 拼接截断:两个 4000 长的串相加,结果还是 4000
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 08.5 字符串拼接的结果类型推导也会截断 -----';
SELECT
两段各4000拼接_未转max = LEN(REPLICATE(N'a', 4000) + REPLICATE(N'b', 4000))
, 第一段转max后 = LEN(CAST(REPLICATE(N'a', 4000) AS nvarchar(max)) + REPLICATE(N'b', 4000));
GO
PRINT N' ★ 未转 max 时,T-SQL 把结果类型推导为 nvarchar(4000),直接截掉后半段。';
PRINT N' 拼 JSON 时被截断 = 得到非法 JSON = 后续 JSON 函数报 13609,而且报错位置指向第 4000 字符,';
PRINT N' 排查时极易误判成"JSON 内容有问题"。修法:把第一个操作数 CAST 成 nvarchar(max)。';
GO
/* --------------------------------------------------------------------------------------------------
08.6 更稳的姿势:能用 JSON_OBJECT 就别手工拼字符串
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 08.6 手工拼 vs JSON_OBJECT(后者不会有截断与转义问题)-----';
SELECT TOP 3
手工拼 = CAST(N'{"orderNo":"' AS nvarchar(max)) + o.OrderNo + N'"}'
, 用函数 = JSON_OBJECT('orderNo': o.OrderNo, 'store': o.StoreCode, 'net': o.NetAmountUsd)
FROM erp.SalesOrder o ORDER BY o.OrderId;
GO
/* ==================================================================================================
09 XML 存储与查询:xml 数据类型 + 四个方法
================================================================================================== */
PRINT N'';
PRINT N'===== 09 XML 存储与查询 =====';
GO
/* --------------------------------------------------------------------------------------------------
09.1 建表与插入:CRM 互动记录(多语言富文本)+ HR 证书报文(带命名空间)
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 09.1 建表并插入 CRM 互动记录 XML -----';
GO
CREATE TABLE crm.InteractionXml
(
InteractionId int IDENTITY(1,1) NOT NULL CONSTRAINT PK_InteractionXml PRIMARY KEY,
CustomerId int NOT NULL,
Channel varchar(12) NULL, -- 提升列:要按渠道聚合,所以拆出来
InteractionAt datetime2(0) NULL, -- 提升列:要按时间筛选
Payload xml NOT NULL -- ★ 半结构化正文留在这里
);
GO
;WITH Src AS (
SELECT TOP (2000)
i.InteractionId, i.CustomerId, i.Channel, i.InteractionAt, i.Satisfaction, i.DurationMinutes
FROM crm.Interaction i
ORDER BY i.InteractionId
)
INSERT crm.InteractionXml (CustomerId, Channel, InteractionAt, Payload)
SELECT
s.CustomerId
, s.Channel
, s.InteractionAt
, CAST(
CAST(N'<interaction id="I-' AS nvarchar(max))
+ RIGHT(N'000000' + CAST(s.InteractionId AS nvarchar(10)), 6)
+ N'" channel="' + s.Channel + N'">'
+ N'<customer id="C-' + RIGHT(N'0000' + CAST(s.CustomerId AS nvarchar(10)), 4)
+ N'" tier="' + CASE s.CustomerId % 3 WHEN 0 THEN N'VIP' WHEN 1 THEN N'Gold' ELSE N'Silver' END + N'"/>'
+ N'<metrics durationMinutes="' + CAST(s.DurationMinutes AS nvarchar(10))
+ N'" satisfaction="' + CAST(ISNULL(s.Satisfaction, 0) AS nvarchar(10)) + N'"/>'
+ N'<notes>'
+ N'<note type="preference" lang="zh-Hans">客户偏好在'
+ CASE s.InteractionId % 4 WHEN 0 THEN N'婚嫁' WHEN 1 THEN N'日常' WHEN 2 THEN N'收藏' ELSE N'送礼' END
+ N'场景选购,偏好'
+ CASE s.InteractionId % 3 WHEN 0 THEN N'铂金' WHEN 1 THEN N'黄金' ELSE N'玫瑰金' END + N'材质</note>'
+ N'<note type="followup" lang="en">Follow up within '
+ CAST(1 + s.InteractionId % 7 AS nvarchar(4)) + N' days</note>'
+ N'</notes>'
+ N'<items><item sku="'
+ CASE s.InteractionId % 5 WHEN 0 THEN N'RG-DIA-001' WHEN 1 THEN N'RG-GLD-003' WHEN 2 THEN N'NK-GLD-010'
WHEN 3 THEN N'ER-DIA-005' ELSE N'BR-PLT-002' END
+ N'" qty="' + CAST(1 + s.InteractionId % 2 AS nvarchar(4)) + N'"/></items>'
+ N'</interaction>' AS xml)
FROM Src s;
GO
SELECT 互动记录行数 = COUNT(*) FROM crm.InteractionXml;
GO
SELECT TOP 1 InteractionId, CustomerId, Channel, Payload FROM crm.InteractionXml ORDER BY InteractionId;
GO
PRINT N'----- 09.2 HR 证书报文:带命名空间的 XML(珠宝行业的真实形态)-----';
GO
CREATE TABLE hr.CertXml
(
CertId int IDENTITY(1,1) NOT NULL CONSTRAINT PK_CertXml PRIMARY KEY,
EmployeeId int NOT NULL,
CertType varchar(24) NULL, -- 提升列
ExpiryDate date NULL, -- 提升列
Payload xml NOT NULL
);
GO
;WITH E AS (
SELECT TOP (36) e.EmployeeId, e.FullName, e.CountryCode, e.JobTitle
FROM hr.Employee e ORDER BY e.EmployeeId
)
INSERT hr.CertXml (EmployeeId, CertType, ExpiryDate, Payload)
SELECT
e.EmployeeId
, CASE e.EmployeeId % 3 WHEN 0 THEN N'GIA-GG' WHEN 1 THEN N'HR-CIPD' ELSE N'SAFETY' END
, DATEADD(day, 180 + e.EmployeeId * 11, CAST('2025-01-01' AS date))
, CAST(
CAST(N'<cert:certificate xmlns:cert="urn:lukfook:hr:cert:v1" xml:lang="zh-Hans" id="CERT-' AS nvarchar(max))
+ RIGHT(N'0000' + CAST(e.EmployeeId AS nvarchar(10)), 4) + N'">'
+ N'<cert:holder staffNo="E-' + RIGHT(N'0000' + CAST(e.EmployeeId AS nvarchar(10)), 4) + N'">'
+ N'<cert:name>' + e.FullName + N'</cert:name>'
+ N'<cert:country>' + e.CountryCode + N'</cert:country>'
+ N'</cert:holder>'
+ N'<cert:type code="' + CASE e.EmployeeId % 3 WHEN 0 THEN N'GIA-GG' WHEN 1 THEN N'HR-CIPD' ELSE N'SAFETY' END + N'"/>'
+ N'<cert:validity from="2025-01-01" to="'
+ CONVERT(varchar(10), DATEADD(day, 180 + e.EmployeeId * 11, CAST('2025-01-01' AS date)), 23) + N'"/>'
+ N'<cert:issuer>'
+ CASE e.EmployeeId % 3 WHEN 0 THEN N'GIA' WHEN 1 THEN N'CIPD' ELSE N'集团安全委员会' END
+ N'</cert:issuer>'
+ N'</cert:certificate>' AS xml)
FROM E e;
GO
SELECT 证书行数 = COUNT(*) FROM hr.CertXml;
GO
SELECT TOP 1 CertId, EmployeeId, CertType, ExpiryDate, Payload FROM hr.CertXml ORDER BY CertId;
GO
/* --------------------------------------------------------------------------------------------------
09.3 .value() ------ 取标量。★ 参数必须是字符串字面量
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 09.3 XML .value() 取标量:必须字面量,不能传变量 -----';
SELECT TOP 5
InteractionId
, 渠道 = Channel
, 客户号 = Payload.value(N'(/interaction/customer/@id)[1]', N'nvarchar(20)')
, 会员等级 = Payload.value(N'(/interaction/customer/@tier)[1]', N'nvarchar(20)')
, 时长分钟 = Payload.value(N'(/interaction/metrics/@durationMinutes)[1]', N'int')
, 满意度 = Payload.value(N'(/interaction/metrics/@satisfaction)[1]', N'int')
FROM crm.InteractionXml
ORDER BY InteractionId;
GO
PRINT N' ★ 第一条坑的实证:把路径换成变量会怎样:';
DECLARE @path nvarchar(100) = N'(/interaction/customer/@id)[1]';
BEGIN TRY
EXEC sp_executesql N'SELECT TOP 1 X = Payload.value(@p, ''nvarchar(20)'') FROM crm.InteractionXml',
N'@p nvarchar(100)', @p = @path;
PRINT N' 变量路径:居然可以';
END TRY
BEGIN CATCH
PRINT N' 变量路径被拒 → ' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N':' + ERROR_MESSAGE();
END CATCH
GO
PRINT N' ★ 这是 XML 与 JSON 最本质的差异:';
PRINT N' · JSON 函数(JSON_VALUE/JSON_QUERY)**接受变量**路径;';
PRINT N' · XML 方法(.value/.query/.exist/.nodes)**只接受字符串字面量**(报 8172)。';
PRINT N' 所以"按用户传入的路径查 XML"在 T-SQL 里只能拼动态 SQL ------ 这本身也是注入风险点。';
GO
/* --------------------------------------------------------------------------------------------------
09.4 .query() ------ 取 XML 片段(可以取对象/数组,正好补上 JSON_QUERY 的角色)
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 09.4 XML .query() 取片段 -----';
SELECT TOP 3
InteractionId
, 客户节点 = CAST(Payload.query(N'/interaction/customer') AS nvarchar(200))
, 全部备注 = CAST(Payload.query(N'/interaction/notes/note') AS nvarchar(400))
FROM crm.InteractionXml
ORDER BY InteractionId;
GO
PRINT N' 带 WHERE 的 XQuery(过滤出 zh-Hans 的备注):';
SELECT TOP 3
InteractionId
, 中文备注 = CAST(Payload.query(N'/interaction/notes/note[@lang="zh-Hans"]') AS nvarchar(400))
FROM crm.InteractionXml
ORDER BY InteractionId;
GO
/* --------------------------------------------------------------------------------------------------
09.5 .exist() ------ 只判断存在性,返回 1/0,可以进 WHERE
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 09.5 .exist() 用于筛选:只查"满意度 < 3"的互动 -----';
SELECT
满意度低于3的记录数 = COUNT(*)
FROM crm.InteractionXml
WHERE Payload.exist(N'/interaction/metrics[@satisfaction < 3]') = 1;
GO
SELECT TOP 5
InteractionId
, 满意度 = Payload.value(N'(/interaction/metrics/@satisfaction)[1]', N'int')
, 是否有英文备注 = Payload.exist(N'/interaction/notes/note[@lang="en"]')
FROM crm.InteractionXml
WHERE Payload.exist(N'/interaction/metrics[@satisfaction < 3]') = 1
ORDER BY InteractionId;
GO
/* --------------------------------------------------------------------------------------------------
09.6 .nodes() ------ 把 XML 摊成行集(XML 版的 OPENJSON)
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 09.6 XML .nodes() 展开:一条互动记录 → 多行备注 -----';
SELECT TOP 10
x.InteractionId
, 备注语言 = n.val.value(N'@lang', N'nvarchar(20)')
, 备注类型 = n.val.value(N'@type', N'nvarchar(20)')
, 备注内容 = n.val.value(N'.', N'nvarchar(400)')
FROM crm.InteractionXml x
CROSS APPLY x.Payload.nodes(N'/interaction/notes/note') AS n(val)
ORDER BY x.InteractionId, 备注语言;
GO
PRINT N' ★ 语法要点(三条都是坑,全部实测过):';
PRINT N' ① 必须写 [表别名].[列].nodes(...),漏掉表别名会报 208;';
PRINT N' ② 别名写两个(AS n(val))时,方法要挂在**列别名**上:n.val.value(...),';
PRINT N' 只写 n.value(...) 会报 4121("找不到列 n 或用户定义的函数或聚合 n.value");';
PRINT N' ③ .nodes() 返回的列是"节点句柄",不能直接 SELECT / MAX / 赋给变量,只能喂给';
PRINT N' exist()/nodes()/query()/value(),或做 IS NULL 判断,否则报 493。';
GO
PRINT N' 实证 ①:漏掉表别名(CROSS APPLY Payload.nodes(...) AS n):';
BEGIN TRY
EXEC sp_executesql N'SELECT TOP 1 n.value(N''.'', N''nvarchar(50)'')
FROM crm.InteractionXml CROSS APPLY Payload.nodes(N''/interaction/customer'') AS n';
PRINT N' 漏表别名:居然可以';
END TRY
BEGIN CATCH
PRINT N' 漏表别名被拒 → ' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N':' + ERROR_MESSAGE();
END CATCH
GO
PRINT N' 实证 ②:别名写两个,但方法挂在行集别名上(n.value 而不是 n.val.value):';
BEGIN TRY
EXEC sp_executesql N'SELECT TOP 1 n.value(N''@lang'', N''nvarchar(20)'')
FROM crm.InteractionXml x CROSS APPLY x.Payload.nodes(N''/interaction/notes/note'') AS n(val)';
PRINT N' n.value(...):居然可以';
END TRY
BEGIN CATCH
PRINT N' n.value(...) 被拒 → ' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N':' + ERROR_MESSAGE();
END CATCH
PRINT N' 改成 n.val.value(...) 立刻通过 ------ 这一个字母的差别在生产里能查半天。';
GO
PRINT N' 实证 ③:.nodes() 返回的列不能直接使用(不是标量,是"节点句柄"):';
BEGIN TRY
EXEC sp_executesql N'SELECT TOP 1 MAX(n.val) FROM crm.InteractionXml x CROSS APPLY x.Payload.nodes(N''/interaction/customer'') AS n(val)';
PRINT N' 直接 MAX(节点列):居然可以';
END TRY
BEGIN CATCH
PRINT N' 直接使用节点列被拒 → ' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N':' + ERROR_MESSAGE();
END CATCH
GO
PRINT N' ★ 节点句柄只能喂给 exist()/nodes()/query()/value(),或做 IS NULL 判断。';
GO
/* --------------------------------------------------------------------------------------------------
09.7 ★ XML 的硬限制:不可比较、不可排序、不可去重、不能 UNION
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 09.7 XML 的四个硬限制(实测错误码)-----';
BEGIN TRY
EXEC sp_executesql N'SELECT CASE WHEN (SELECT CAST(N''<r/>'' AS xml)) = (SELECT CAST(N''<r/>'' AS xml)) THEN 1 ELSE 0 END';
PRINT N' [1] XML 比较:居然可以';
END TRY BEGIN CATCH PRINT N' [1] XML 直接比较 → ' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N':' + ERROR_MESSAGE(); END CATCH
BEGIN TRY
EXEC sp_executesql N'SELECT x FROM (SELECT CAST(N''<r/>'' AS xml) AS x) t ORDER BY x';
PRINT N' [2] XML 排序:居然可以';
END TRY BEGIN CATCH PRINT N' [2] XML 作 ORDER BY 键 → ' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N':' + ERROR_MESSAGE(); END CATCH
BEGIN TRY
EXEC sp_executesql N'SELECT COUNT(*) FROM (SELECT CAST(N''<r/>'' AS xml) AS x UNION SELECT CAST(N''<s/>'' AS xml)) t';
PRINT N' [3] XML 作 UNION 操作数:居然可以';
END TRY BEGIN CATCH PRINT N' [3] XML 作 UNION 操作数 → ' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N':' + ERROR_MESSAGE(); END CATCH
BEGIN TRY
EXEC sp_executesql N'SELECT COUNT(*) FROM (SELECT CAST(N''<r/>'' AS xml) AS x) t GROUP BY x';
PRINT N' [4] XML 作 GROUP BY 键:居然可以';
END TRY BEGIN CATCH PRINT N' [4] XML 作 GROUP BY 键 → ' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N':' + ERROR_MESSAGE(); END CATCH
PRINT N' [5] XML 转 sql_variant(★ 必须放进动态 SQL:这是**编译期**错误,TRY/CATCH 抓不到,';
PRINT N' 直接写在批次里会让整个批次编译失败、后面所有语句都不执行):';
BEGIN TRY
EXEC sp_executesql N'DECLARE @v sql_variant = (SELECT TOP 1 Payload FROM crm.InteractionXml); SELECT 1';
PRINT N' XML 转 sql_variant:居然可以';
END TRY BEGIN CATCH PRINT N' XML 转 sql_variant → ' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N':' + ERROR_MESSAGE(); END CATCH
PRINT N' [6] nvarchar(max) 转 sql_variant(同理,也是编译期错误):';
BEGIN TRY
EXEC sp_executesql N'DECLARE @s nvarchar(max) = REPLICATE(N''a'', 5000); DECLARE @v sql_variant = CAST(@s AS sql_variant); SELECT 1';
PRINT N' nvarchar(max) 转 sql_variant:居然可以';
END TRY BEGIN CATCH PRINT N' nvarchar(max) 转 sql_variant → ' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N':' + ERROR_MESSAGE(); END CATCH
GO
PRINT N' ★ 顺便说清 529 与 206 的来由:';
PRINT N' · xml → sql_variant 报 206("操作数类型冲突: xml 与 sql_variant 不兼容");';
PRINT N' · nvarchar(max) → sql_variant 报 529("不允许从数据类型 nvarchar(max) 到 sql_variant 的显式转换")。';
PRINT N' 两者都是编译期错误 ------ 所以本节全部用 sp_executesql 包起来才"看"得到错误号。';
GO
PRINT N' ★ 结论:XML 只能"取出内容再比较"。要按 XML 里的值排序/去重,';
PRINT N' 必须先 .value() 成标量,或者把该值提升为列 ------ 又回到了第 01 节的边界问题。';
GO
/* --------------------------------------------------------------------------------------------------
09.8 命名空间:WITH XMLNAMESPACES(珠宝行业报文几乎都带命名空间)
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 09.8 带命名空间的 XML 查询 -----';
WITH XMLNAMESPACES (N'urn:lukfook:hr:cert:v1' AS cert)
SELECT TOP 5
CertId
, 员工号 = Payload.value(N'(/cert:certificate/cert:holder/@staffNo)[1]', N'nvarchar(20)')
, 持证人 = Payload.value(N'(/cert:certificate/cert:holder/cert:name)[1]', N'nvarchar(120)')
, 证书类型 = Payload.value(N'(/cert:certificate/cert:type/@code)[1]', N'nvarchar(20)')
, 颁发机构 = Payload.value(N'(/cert:certificate/cert:issuer)[1]', N'nvarchar(80)')
, 到期日 = Payload.value(N'(/cert:certificate/cert:validity/@to)[1]', N'date')
FROM hr.CertXml
ORDER BY CertId;
GO
PRINT N' ★ 没有 WITH XMLNAMESPACES 时,路径里带冒号会直接报错;';
PRINT N' 用 WITH XMLNAMESPACES 把前缀映射好之后,路径才写得出来。';
GO
PRINT N' 对照:同样的查询,不使用命名空间前缀(用通配 *:)也能写:';
SELECT TOP 3
CertId
, 持证人 = Payload.value(N'(/*:certificate/*:holder/*:name)[1]', N'nvarchar(120)')
FROM hr.CertXml
ORDER BY CertId;
GO
/* ==================================================================================================
10 XML 生成:FOR XML 的四种模式
================================================================================================== */
PRINT N'';
PRINT N'===== 10 XML 生成 =====';
GO
/* --------------------------------------------------------------------------------------------------
10.1 四种模式对照(同一份数据,四种输出)
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 10.1 FOR XML RAW / RAW,ELEMENTS / AUTO / PATH -----';
GO
DECLARE @t TABLE (Sku varchar(20), Qty int, Price decimal(10,2));
INSERT @t VALUES (N'RG-DIA-001', 1, 68000.00), (N'RG-GLD-003', 2, 6980.00);
SELECT 模式 = N'RAW(属性式,行名恒为 row)'
, 结果 = (SELECT * FROM @t FOR XML RAW);
SELECT 模式 = N'RAW, ELEMENTS(列变子元素)'
, 结果 = (SELECT Sku, Qty, Price FROM @t FOR XML RAW, ELEMENTS);
SELECT 模式 = N'AUTO(按表名/别名,来自多表会自动嵌套)'
, 结果 = (SELECT Sku, Qty AS [@qty] FROM @t FOR XML AUTO);
SELECT 模式 = N'PATH(完全手工控制,最常用)'
, 结果 = (SELECT Sku AS [@sku], Qty AS [qty], Price AS [price] FROM @t FOR XML PATH('line'));
SELECT 模式 = N'PATH + ROOT'
, 结果 = (SELECT Sku AS [@sku] FROM @t FOR XML PATH('line'), ROOT('order'));
GO
/* --------------------------------------------------------------------------------------------------
10.2 PATH 模式的关键语法:@ 前缀 = 属性,无前缀 = 元素
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 10.2 PATH 模式下 列别名 决定产出形态 -----';
GO
DECLARE @t2 TABLE (Sku varchar(20), Qty int, Note nvarchar(50));
INSERT @t2 VALUES (N'RG-DIA-001', 1, N'可刻字'), (N'RG-GLD-003', 2, N'现货');
SELECT 形态 = N'[@x] → 属性', 结果 = (SELECT Sku AS [@sku], Qty AS [@qty] FROM @t2 FOR XML PATH('line'))
UNION ALL SELECT N'[x] → 子元素', (SELECT Sku AS [sku], Qty AS [qty] FROM @t2 FOR XML PATH('line'))
UNION ALL SELECT N'[text()] → 元素文本', (SELECT Sku AS [@sku], Note AS [text()] FROM @t2 FOR XML PATH('line'))
UNION ALL SELECT N'[x] + [@y] 混合(属性挂在同级元素上)', (SELECT Sku AS [@sku], Note AS [note] FROM @t2 FOR XML PATH('line'));
GO
PRINT N' ★ 这个"列别名即结构"的语法是 FOR XML PATH 最大的特点:';
PRINT N' @ 前缀 → 属性;无前缀 → 子元素;text() → 直接成为元素文本。';
GO
PRINT N' ★★ 重要实证:网上常见的 "[item!2!sku]" 数字层级写法在本版本**根本不支持**,';
PRINT N' 它是 FOR XML EXPLICIT 的语法,写进 PATH 会直接报 6850。';
PRINT N' 由于 6850 是编译期错误,必须放进动态 SQL 才能"看到"它:';
BEGIN TRY
EXEC sp_executesql N'SELECT Sku AS [item!2!sku] FROM (SELECT ''X'' AS Sku) t FOR XML PATH(''line'')';
PRINT N' [item!2!sku]:居然可以(不应发生)';
END TRY
BEGIN CATCH
PRINT N' [item!2!sku] 被拒 → ' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N':' + ERROR_MESSAGE();
END CATCH
PRINT N' 要控制多层嵌套,请用"子查询 + FOR XML PATH(...), TYPE"(见 10.3),或者改用 FOR XML EXPLICIT。';
GO
/* --------------------------------------------------------------------------------------------------
10.3 真实业务:把订单 + 明细嵌套成一份 XML 单据
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 10.3 ERP:订单主表 + 明细嵌套(AUTO 自动嵌套 vs PATH 手工嵌套)-----';
GO
SELECT TOP 2
o.OrderNo AS [@orderNo]
, o.OrderDate AS [@orderDate]
, o.CurrencyCode AS [@ccy]
, o.NetAmountUsd AS [@netUsd]
, (SELECT p.Sku AS [@sku], l.Quantity AS [@qty], l.LineAmount AS [@amount]
FROM erp.SalesOrderLine l
JOIN erp.Product p ON p.ProductId = l.ProductId
WHERE l.OrderId = o.OrderId
FOR XML PATH('line'), TYPE) AS [lines]
FROM erp.SalesOrder o
ORDER BY o.OrderId
FOR XML PATH('order'), ROOT('orders');
GO
PRINT N' ★ 注意这里的两处关键:';
PRINT N' ① 明细的 SKU 不在 SalesOrderLine 上,要 JOIN erp.Product 才拿得到;';
PRINT N' ② 嵌进父文档必须写 ..., TYPE,否则 < > 会被转义。';
GO
PRINT N' ★ 子查询必须带 FOR XML ..., TYPE 才能作为 XML 片段嵌入父文档;';
PRINT N' 不加 TYPE 会得到一个被转义的字符串,整个 XML 结构就废了。';
GO
/* --------------------------------------------------------------------------------------------------
10.4 TYPE 与非 TYPE:XML 类型 vs 被转义的字符串
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 10.4 TYPE 的差别(本质是类型不同)-----';
GO
DECLARE @noType nvarchar(max) = (SELECT TOP 1 Sku AS [@sku] FROM erp.Product ORDER BY ProductId FOR XML PATH('p'));
DECLARE @withType xml = (SELECT TOP 1 Sku AS [@sku] FROM erp.Product ORDER BY ProductId FOR XML PATH('p'), TYPE);
SELECT
场景 = N'不加 TYPE:拿到 nvarchar,< > 已被转义'
, 内容 = LEFT(@noType, 60)
, 字符长度 = LEN(@noType)
, 含转义实体 = CASE WHEN @noType LIKE N'%<%' THEN N'是(<)' ELSE N'否' END
, [能否当 XML 用] = N'不能(只能当字符串,.value() 会报错)'
UNION ALL
SELECT N'加 TYPE:拿到真正的 xml 类型'
, LEFT(CAST(@withType AS nvarchar(max)), 60)
, LEN(CAST(@withType AS nvarchar(max)))
, CASE WHEN CAST(@withType AS nvarchar(max)) LIKE N'%<%' THEN N'是' ELSE N'否(是真元素)' END
, N'能(.value()/.nodes()/建索引都可以)';
GO
PRINT N' ★ 不加 TYPE 得到 nvarchar,里面的 < > 已被转义成 < >;加 TYPE 得到真正的 xml 类型。';
PRINT N' 只有 xml 类型才能继续 .value() / .nodes() / 建 XML 索引。';
GO
PRINT N' 顺带实证:很多人想用 sql_variant 做"万能容器"来同时装 xml / 超长文本,但这两条路都是堵死的:';
BEGIN TRY
EXEC sp_executesql N'DECLARE @v sql_variant = CAST(CAST(N''<r/>'' AS xml) AS sql_variant); SELECT 1';
PRINT N' xml → sql_variant:居然可以';
END TRY BEGIN CATCH PRINT N' xml → sql_variant 失败(' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N'):' + ERROR_MESSAGE(); END CATCH
BEGIN TRY
EXEC sp_executesql N'DECLARE @s nvarchar(max) = REPLICATE(N''a'', 5000); DECLARE @v sql_variant = CAST(@s AS sql_variant); SELECT 1';
PRINT N' nvarchar(max) → sql_variant:居然可以';
END TRY BEGIN CATCH PRINT N' nvarchar(max) → sql_variant 失败(' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N'):' + ERROR_MESSAGE(); END CATCH
PRINT N' ★ 记错误号时要写清写法:**显式** CAST(xml AS sql_variant) 报 529;';
PRINT N' 而**隐式**转换(DECLARE @v sql_variant = <xml 表达式>,见 09.7 第 5 条)报 206。';
PRINT N' 同一个"类型不兼容",两条路径两个号 ------ 排查时对不上号是常见困惑来源。';
GO
/* ==================================================================================================
11 XML 分解:把 XML 摊回关系表
================================================================================================== */
PRINT N'';
PRINT N'===== 11 XML 分解 =====';
GO
/* --------------------------------------------------------------------------------------------------
11.1 方式一:.nodes() 切分(推荐)
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 11.1 用 .nodes() 把备注摊成关系行 -----';
GO
IF OBJECT_ID('tempdb..#Note') IS NOT NULL DROP TABLE #Note;
CREATE TABLE #Note (InteractionId int, Lang varchar(20), NoteType varchar(20), NoteText nvarchar(400));
INSERT #Note (InteractionId, Lang, NoteType, NoteText)
SELECT
x.InteractionId
, n.val.value(N'@lang', N'nvarchar(20)')
, n.val.value(N'@type', N'nvarchar(20)')
, n.val.value(N'.', N'nvarchar(400)')
FROM crm.InteractionXml x
CROSS APPLY x.Payload.nodes(N'/interaction/notes/note') AS n(val);
SELECT 摊平后行数 = COUNT(*) FROM #Note;
SELECT TOP 8 * FROM #Note ORDER BY InteractionId, Lang;
GO
PRINT N' ★ 这是把 XML 落进关系表的标准姿势:.nodes() 定位重复节点,.value() 各取所需字段。';
PRINT N' 再强调一次别名规则:CROSS APPLY ...nodes(...) AS n(val) 之后,方法要写 n.val.value(...)。';
GO
/* --------------------------------------------------------------------------------------------------
11.2 方式二:OPENXML + sp_xml_preparedocument(老写法,但仍然有用武之地)
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 11.2 OPENXML 标准三段式 -----';
GO
DECLARE @h int;
DECLARE @doc nvarchar(max) = N'
<orders>
<order orderNo="SO-2025-000101" store="HK-CWB-01">
<line sku="RG-DIA-001" qty="1" amount="78000.00"/>
<line sku="RG-GLD-003" qty="2" amount="7980.00"/>
</order>
<order orderNo="SO-2025-000102" store="CN-SH-02">
<line sku="NK-GLD-010" qty="1" amount="12800.00"/>
</order>
</orders>';
EXEC sp_xml_preparedocument @h OUTPUT, @doc;
PRINT N' [1] 标志 1 = 属性为中心(默认),WITH 里用 @ 前缀';
SELECT 订单号 = OrderNo, 门店 = Store, SKU = Sku, 数量 = Qty, 金额 = Amount
FROM OPENXML(@h, N'/orders/order/line', 1)
WITH (OrderNo varchar(24) N'../@orderNo',
Store varchar(12) N'../@store',
Sku varchar(20) N'@sku',
Qty int N'@qty',
Amount decimal(18,2) N'@amount');
PRINT N' [2] 标志 2 = 元素为中心';
SELECT 订单号 = OrderNo, 门店 = Store
FROM OPENXML(@h, N'/orders/order', 2)
WITH (OrderNo varchar(24) N'@orderNo', Store varchar(12) N'@store');
EXEC sp_xml_removedocument @h;
GO
PRINT N' ★ OPENXML 三段式:sp_xml_preparedocument → OPENXML → sp_xml_removedocument。';
PRINT N' 忘了 removedocument 会一直占内存(直到会话结束),这是老代码最常见的泄漏。';
PRINT N' 新代码优先用 .nodes():不用句柄、无泄漏风险、能用 XML 索引。';
GO
PRINT N' 实证:OPENXML 传 NULL 句柄会怎样:';
BEGIN TRY
EXEC sp_executesql N'SELECT * FROM OPENXML(NULL, ''/r'')';
PRINT N' NULL 句柄:居然可以';
END TRY
BEGIN CATCH
PRINT N' NULL 句柄 → ' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N':' + ERROR_MESSAGE();
END CATCH
GO
/* --------------------------------------------------------------------------------------------------
11.3 封装成存储过程:按订单号生成 XML 单据
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 11.3 封装:订单 → XML 单据(可直接给外部系统)-----';
GO
CREATE OR ALTER PROCEDURE dbo.usp_ShredSalesOrderXml
@OrderNo varchar(40)
AS
BEGIN
SET NOCOUNT ON;
SELECT
o.OrderNo AS [@orderNo]
, o.OrderDate AS [@orderDate]
, o.CurrencyCode AS [@ccy]
, o.NetAmountUsd AS [@netUsd]
, (SELECT p.Sku AS [@sku], l.Quantity AS [@qty], l.LineAmount AS [@amount]
FROM erp.SalesOrderLine l
JOIN erp.Product p ON p.ProductId = l.ProductId
WHERE l.OrderId = o.OrderId
FOR XML PATH('line'), TYPE) AS [lines]
FROM erp.SalesOrder o
WHERE o.OrderNo = @OrderNo
FOR XML PATH('order'), ROOT('orders');
END
GO
PRINT N' ★ 存储过程里的 SET 选项(QUOTED_IDENTIFIER 等)在创建时就固化下来了,';
PRINT N' 与调用方会话的 SET 不一致时,FOR XML / XML 方法会莫名其妙地失败 ------';
PRINT N' 这就是"同一个过程在不同客户端表现不同"的常见根因。';
GO
IF OBJECT_ID(N'dbo.usp_ShredSalesOrderXml', N'P') IS NOT NULL
BEGIN
DECLARE @ono varchar(40) = (SELECT TOP 1 OrderNo FROM erp.SalesOrder ORDER BY OrderId);
PRINT N' 调用存储过程生成单据(订单号 ' + @ono + N'):';
EXEC dbo.usp_ShredSalesOrderXml @OrderNo = @ono;
END
ELSE
PRINT N' ⚠ 存储过程未创建成功,已跳过调用(查看上方报错)。';
GO
/* ==================================================================================================
12 XML 强校验:XML SCHEMA COLLECTION 与 typed XML
--------------------------------------------------------------------------------------------------
这是 XML 相对 JSON 的**唯一决定性优势**:
JSON 在 SQL Server 里只能校验"是不是合法 JSON",无法校验"结构对不对";
XML 可以绑定 XSD,让数据库在**写入时**就按 schema 拒绝畸形文档。
================================================================================================== */
PRINT N'';
PRINT N'===== 12 XML 强校验(typed XML)=====';
GO
/* --------------------------------------------------------------------------------------------------
12.1 创建架构集合并绑定为 typed XML 列
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 12.1 创建 XSD 架构集合并建 typed xml 列 -----';
GO
CREATE XML SCHEMA COLLECTION dbo.JewelryOrderSchema AS N'
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema">
<xs:element name="order">
<xs:complexType>
<xs:sequence>
<xs:element name="line" minOccurs="1" maxOccurs="unbounded">
<xs:complexType>
<xs:attribute name="sku" type="xs:string" use="required"/>
<xs:attribute name="qty" type="xs:positiveInteger" use="required"/>
<xs:attribute name="amount" type="xs:decimal" use="required"/>
</xs:complexType>
</xs:element>
</xs:sequence>
<xs:attribute name="orderNo" type="xs:string" use="required"/>
<xs:attribute name="ccy" type="xs:string" use="required"/>
</xs:complexType>
</xs:element>
</xs:schema>';
GO
PRINT N' 架构集合已创建(注意:目标命名空间在 sys.xml_schema_namespaces 里,不在 sys.xml_schema_collections):';
SELECT 名称 = c.name, 目标命名空间 = ISNULL(ns.name, N'(无:本 XSD 未声明 targetNamespace)')
FROM sys.xml_schema_collections c
LEFT JOIN sys.xml_schema_namespaces ns ON ns.xml_collection_id = c.xml_collection_id
WHERE c.name = N'JewelryOrderSchema';
GO
CREATE TABLE dbo.TypedXmlDemo
(
DocId int IDENTITY(1,1) NOT NULL CONSTRAINT PK_TypedXmlDemo PRIMARY KEY,
Doc xml(dbo.JewelryOrderSchema) NOT NULL -- ★ 绑定架构集合的 typed XML
);
GO
PRINT N' 已建 typed xml 列:xml(dbo.JewelryOrderSchema)';
GO
/* --------------------------------------------------------------------------------------------------
12.2 写入时的强校验:三种文档的实测结果
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 12.2 合法文档通过;缺必需属性、类型不对的分别报什么错 -----';
GO
PRINT N' [1] 合法文档:';
BEGIN TRY
INSERT dbo.TypedXmlDemo (Doc) VALUES
(N'<order orderNo="SO-2025-000101" ccy="CNY"><line sku="RG-DIA-001" qty="1" amount="68000.00"/></order>');
PRINT N' 通过 ✓(已插入)';
END TRY BEGIN CATCH PRINT N' 失败(' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N'):' + ERROR_MESSAGE(); END CATCH
PRINT N' [2] 缺少必需属性 orderNo:';
BEGIN TRY
INSERT dbo.TypedXmlDemo (Doc) VALUES
(N'<order ccy="CNY"><line sku="RG-DIA-001" qty="1" amount="68000.00"/></order>');
PRINT N' 居然通过(不应发生)';
END TRY BEGIN CATCH PRINT N' 被拒 → ' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N':' + ERROR_MESSAGE(); END CATCH
PRINT N' [3] qty 为负(XSD 要求 positiveInteger):';
BEGIN TRY
INSERT dbo.TypedXmlDemo (Doc) VALUES
(N'<order orderNo="SO-2025-000102" ccy="CNY"><line sku="RG-DIA-001" qty="-5" amount="68000.00"/></order>');
PRINT N' 居然通过(不应发生)';
END TRY BEGIN CATCH PRINT N' 被拒 → ' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N':' + ERROR_MESSAGE(); END CATCH
PRINT N' [4] 完全没有 line 子元素(minOccurs=1):';
BEGIN TRY
INSERT dbo.TypedXmlDemo (Doc) VALUES
(N'<order orderNo="SO-2025-000103" ccy="CNY"/>');
PRINT N' 居然通过(不应发生)';
END TRY BEGIN CATCH PRINT N' 被拒 → ' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N':' + ERROR_MESSAGE(); END CATCH
GO
SELECT 已插入行数 = COUNT(*) FROM dbo.TypedXmlDemo;
GO
PRINT N' ★ 结论:typed XML 把"结构契约"下沉到数据库层,外部报文不合规直接写不进来。';
PRINT N' JSON 在 SQL Server 里**没有**等价能力(只能靠应用层或 OPENJSON WITH 事后校验)。';
PRINT N' 代价:写入时要做 schema 校验,有 CPU 开销;且 schema 变更需要 ALTER XML SCHEMA COLLECTION。';
GO
/* --------------------------------------------------------------------------------------------------
12.3 非 typed 列 vs typed 列:读时也要注意
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 12.3 typed xml 同样支持 .value()/.nodes(),只是多了类型信息 -----';
GO
SELECT
DocId
, 订单号 = Doc.value(N'(/order/@orderNo)[1]', N'nvarchar(30)')
, 明细数 = (SELECT COUNT(*) FROM (
SELECT 1 AS x FROM dbo.TypedXmlDemo t2 CROSS APPLY t2.Doc.nodes(N'/order/line') AS n(l)
WHERE t2.DocId = t.DocId) z)
FROM dbo.TypedXmlDemo t
ORDER BY DocId;
GO
/* ==================================================================================================
13 XML 索引:主 XML 索引 + 二级 XML 索引
--------------------------------------------------------------------------------------------------
XML 与 JSON 在索引上的根本差别:
· JSON 完全没有内置索引,只能"提升成计算列再索引";
· XML 有专门的索引类型:PRIMARY XML INDEX 把 XML 树拆成路径/值表,
再加 PATH / VALUE / PROPERTY 三种二级索引来服务不同查询形态。
================================================================================================== */
PRINT N'';
PRINT N'===== 13 XML 索引 =====';
GO
/* --------------------------------------------------------------------------------------------------
13.1 建索引的前置条件(这是最容易卡住的地方)
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 13.1 前置条件实测:必须有"聚集主键且列数 < 32" -----';
GO
BEGIN TRY
CREATE TABLE dbo._NoPkTable (Id int, X xml);
CREATE PRIMARY XML INDEX PXML_NoPk ON dbo._NoPkTable(X);
PRINT N' 无主键表:居然建成功';
END TRY
BEGIN CATCH
PRINT N' 无主键表被拒 → ' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N':' + ERROR_MESSAGE();
END CATCH
BEGIN TRY
-- 注意约束必须写全 PRIMARY KEY NONCLUSTERED;只写 NONCLUSTERED 是语法错误(报 156)
CREATE TABLE dbo._HeapTable (Id int NOT NULL, X xml, CONSTRAINT PK_Heap PRIMARY KEY NONCLUSTERED (Id));
CREATE PRIMARY XML INDEX PXML_Heap ON dbo._HeapTable(X);
PRINT N' 只有非聚集主键(表是堆):居然建成功';
END TRY
BEGIN CATCH
PRINT N' 只有非聚集主键被拒 → ' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N':' + ERROR_MESSAGE();
END CATCH
GO
DROP TABLE IF EXISTS dbo._NoPkTable;
DROP TABLE IF EXISTS dbo._HeapTable;
GO
PRINT N' ★ 所以 XML 索引要求表上有**聚集**主键(或聚集唯一索引),且键列 < 32 列。';
PRINT N' crm.InteractionXml 的 PK_InteractionXml 默认就是聚集主键,满足条件。';
PRINT N' (附带一条:网上常说要开 ARITHABORT 才能建 XML 索引 ------ 实测在 QUOTED_IDENTIFIER ON 的前提下,';
PRINT N' 真正卡住的是"聚集主键且列数 < 32"这一条,而不是 ARITHABORT。)';
GO
/* --------------------------------------------------------------------------------------------------
13.2 建索引前的基线:不用索引时 XML 查询的 IO
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 13.2 建索引前:用 .exist() 筛"有英文备注"的互动 -----';
SET STATISTICS IO ON;
SET STATISTICS TIME ON;
GO
SELECT 命中行数 = COUNT(*)
FROM crm.InteractionXml
WHERE Payload.exist(N'/interaction/notes/note[@lang="en"]') = 1;
GO
SET STATISTICS IO OFF;
SET STATISTICS TIME OFF;
GO
PRINT N' ↑ 记住这里的"逻辑读取次数 / CPU 时间",下面建完索引后对比。';
GO
/* --------------------------------------------------------------------------------------------------
13.3 创建主 XML 索引
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 13.3 创建主 XML 索引(PRIMARY XML INDEX)-----';
GO
CREATE PRIMARY XML INDEX PXML_InteractionXml ON crm.InteractionXml (Payload);
GO
PRINT N' 索引已建:';
SELECT 索引名 = i.name, 类型 = i.type_desc, 主索引 = ISNULL(pi.name, N'(自己是主索引)')
FROM sys.xml_indexes i
LEFT JOIN sys.xml_indexes pi ON pi.object_id = i.object_id AND pi.index_id = i.secondary_type
WHERE i.object_id = OBJECT_ID('crm.InteractionXml');
GO
PRINT N' 看索引到底拆成了什么(XML 索引的内部结构):';
SELECT 名称 = i.name, 类型 = i.type_desc
FROM sys.indexes i WHERE i.object_id = OBJECT_ID('crm.InteractionXml') ORDER BY i.index_id;
SELECT TOP 8
路径 = p.name
, 值类型 = p.name -- 说明用
FROM sys.columns p WHERE p.object_id = OBJECT_ID('crm.InteractionXml');
GO
PRINT N' 主 XML 索引在系统里表现为一张内部表,列形如:'
GO
SELECT TOP 10 列 = c.name, 类型 = t.name, 长度 = c.max_length
FROM sys.columns c
JOIN sys.types t ON t.user_type_id = c.user_type_id
WHERE c.object_id = (SELECT it.internal_object_id -- 只是演示用途
FROM (SELECT TOP 1 object_id AS internal_object_id FROM sys.xml_indexes
WHERE object_id = OBJECT_ID('crm.InteractionXml')) it)
ORDER BY c.column_id;
GO
/* --------------------------------------------------------------------------------------------------
13.4 建索引后:同一个查询的 IO 对比
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 13.4 建索引后再跑同一个查询(对比 IO)-----';
SET STATISTICS IO ON;
SET STATISTICS TIME ON;
GO
SELECT 命中行数 = COUNT(*)
FROM crm.InteractionXml
WHERE Payload.exist(N'/interaction/notes/note[@lang="en"]') = 1;
GO
SET STATISTICS IO OFF;
SET STATISTICS TIME OFF;
GO
PRINT N' ★ 对比 13.2 与这一条的逻辑读取次数:XML 索引把"解析整篇文档"变成了"按路径查找"。';
PRINT N' 2000 行时差距还不算大,但 XML 索引的收益是"避免随行数线性增长的解析成本",';
PRINT N' 在千万行以上规模才会体现出数量级差别。';
GO
/* --------------------------------------------------------------------------------------------------
13.5 三种二级索引怎么选
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 13.5 PATH / VALUE / PROPERTY 三种二级索引的适用场景 -----';
SELECT * FROM (VALUES
(1, N'PATH', N'按路径/结构筛选,形如 .exist(''/a/b[c="v"]'')、.query()',
N'crm.InteractionXml 按备注语言筛 ------ 本脚本正是这种'),
(2, N'VALUE', N'按"值"筛选但路径不固定,形如 .value() 与已知值比较',
N'只知道某个值是 SKU 但不确定它在哪一层'),
(3, N'PROPERTY', N'属性包(property bag):同一层大量不同路径各取一次 .value()',
N'把 XML 当成"一堆键值对"读,比如证书里的十几个字段')
) AS t(序号, 索引类型, 适用查询形态, 珠宝场景例子);
GO
PRINT N' 建一个 PATH 二级索引:';
CREATE XML INDEX IXML_InteractionXml_Path ON crm.InteractionXml (Payload)
USING XML INDEX PXML_InteractionXml FOR PATH;
GO
PRINT N' 建一个 PROPERTY 二级索引(适合大量 .value() 的读法):';
CREATE XML INDEX IXML_InteractionXml_Property ON crm.InteractionXml (Payload)
USING XML INDEX PXML_InteractionXml FOR PROPERTY;
GO
-- ★ 坑:sys.xml_indexes.secondary_type 是 char(1)('P'/'R'/'V'),
-- 如果拿它去和 index_id(int)做 JOIN,会报 245"在将 varchar 值 'P' 转换成数据类型 int 时失败"。
SELECT
索引名 = i.name
, 类别 = CASE i.xml_index_type WHEN 0 THEN N'主 XML 索引' ELSE N'二级 XML 索引' END
, 二级类型 = CASE i.secondary_type WHEN 'P' THEN N'PATH'
WHEN 'R' THEN N'PROPERTY'
WHEN 'V' THEN N'VALUE'
ELSE N'---' END
, 索引ID = i.index_id
FROM sys.xml_indexes i
WHERE i.object_id = OBJECT_ID('crm.InteractionXml')
ORDER BY i.index_id;
GO
PRINT N' ★ 二级索引必须先有主 XML 索引(USING XML INDEX 那一句);';
PRINT N' 主 XML 索引是"底座",二级索引是"按查询形态优化的派生结构"。';
PRINT N' ★ 实证:secondary_type 是字符列,拿它当整数比较会报 245:';
BEGIN TRY
EXEC sp_executesql N'SELECT COUNT(*) FROM sys.xml_indexes i
JOIN sys.xml_indexes pi ON pi.object_id = i.object_id
AND pi.index_id = i.secondary_type';
PRINT N' 居然可以(不应发生)';
END TRY
BEGIN CATCH
PRINT N' 被拒 → ' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N':' + ERROR_MESSAGE();
END CATCH
GO
/* --------------------------------------------------------------------------------------------------
13.6 索引的代价:存储与写入
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 13.6 XML 索引的存储开销 -----';
PRINT N' (★ 注意:sys.dm_db_partition_stats **看不到** XML 索引,必须用 dm_db_index_physical_stats;';
PRINT N' 每个二级 XML 索引还会额外带一张内部节点表,所以每个索引会出现两行。)';
SELECT
对象 = OBJECT_NAME(ips.object_id)
, 索引 = ISNULL(i.name, N'(堆/聚集)')
, 类别 = CASE WHEN i.name IS NULL THEN ips.index_type_desc
WHEN i.xml_index_type = 0 THEN N'主 XML 索引'
ELSE N'二级 XML 索引' END
, 页数合计 = SUM(ips.page_count)
, 占用KB = SUM(ips.page_count) * 8
FROM sys.dm_db_index_physical_stats(DB_ID(), OBJECT_ID('crm.InteractionXml'), NULL, NULL, 'LIMITED') ips
LEFT JOIN sys.xml_indexes i ON i.object_id = ips.object_id AND i.index_id = ips.index_id
GROUP BY ips.object_id, i.name, i.xml_index_type, ips.index_type_desc
ORDER BY 占用KB DESC;
GO
WITH PS AS (
SELECT p.index_id, 页数 = SUM(p.page_count)
FROM sys.dm_db_index_physical_stats(DB_ID(), OBJECT_ID('crm.InteractionXml'), NULL, NULL, 'LIMITED') p
GROUP BY p.index_id
)
SELECT
项目 = N'XML 索引合计 / 基表聚集索引'
, XML索引页数 = SUM(CASE WHEN xi.index_id IS NOT NULL THEN ps.页数 ELSE 0 END)
, 基表页数 = SUM(CASE WHEN ps.index_id = 1 THEN ps.页数 ELSE 0 END)
, 膨胀倍数 = CAST(1.0 * SUM(CASE WHEN xi.index_id IS NOT NULL THEN ps.页数 ELSE 0 END)
/ NULLIF(SUM(CASE WHEN ps.index_id = 1 THEN ps.页数 ELSE 0 END), 0) AS decimal(9,2))
FROM PS ps
LEFT JOIN sys.xml_indexes xi
ON xi.object_id = OBJECT_ID('crm.InteractionXml') AND xi.index_id = ps.index_id;
GO
PRINT N' ★ XML 索引用空间换时间:主 XML 索引 + 两个二级索引,实测占用是基表数据的好几倍。';
PRINT N' 所以"只在真的要用 XML 路径查询时才建",不要无脑全建。';
GO
/* --------------------------------------------------------------------------------------------------
13.7 写到表里时索引会拖慢多少(写入放大)
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 13.7 写入放大:往带 XML 索引的表插 200 行 -----';
SET STATISTICS TIME ON;
GO
;WITH N AS (SELECT TOP (200) ROW_NUMBER() OVER (ORDER BY (SELECT NULL)) AS n
FROM sys.all_objects)
INSERT crm.InteractionXml (CustomerId, Channel, InteractionAt, Payload)
SELECT
1000 + n.n
, N'app'
, SYSDATETIME()
, CAST(CAST(N'<interaction id="I-9' AS nvarchar(max)) + RIGHT(N'00000' + CAST(n.n AS nvarchar(10)), 5)
+ N'" channel="app"><customer id="C-9999" tier="Gold"/><metrics durationMinutes="10" satisfaction="4"/>'
+ N'<notes><note type="followup" lang="en">bulk insert test</note></notes>'
+ N'<items><item sku="RG-DIA-001" qty="1"/></items></interaction>' AS xml)
FROM N;
GO
SET STATISTICS TIME OFF;
GO
PRINT N' ★ 同样 200 行,带 3 个 XML 索引的插入 CPU 时间会明显高于裸表 ------ 这就是写放大的代价。';
PRINT N' 半结构化列的"读多写少"假设(01.1 判据)在这里体现为可量化的成本。';
GO
PRINT N' 清理刚才插入的测试行,保持数据整洁:';
DELETE FROM crm.InteractionXml WHERE CustomerId >= 1000;
GO
/* ==================================================================================================
14 万亿级 / 高并发场景:JSON 与 XML 的成本模型与设计手法
================================================================================================== */
PRINT N'';
PRINT N'===== 14 万亿级与高并发下的半结构化设计 =====';
GO
/* --------------------------------------------------------------------------------------------------
14.1 规模推演:半结构化在"万亿"级别上会先死在哪
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 14.1 规模推演表 -----';
SELECT * FROM (VALUES
(1, N'500 万 (本地 5 万缩微)', N'stg.EventJson', N'JSON 解析 ~0.1 秒;提升列 + 索引后与普通列无异')
, (2, N'5 亿 (单区域生产)', N'门店事件 JSON', N'必须"提升列 + 覆盖索引";直接 JSON_VALUE 全表扫描不可接受')
, (3, N'1 万亿 (全球全量)', N'全渠道事件/报文', N'JSON 只做"冷存 + 归档";热查询走预解析的关系表/列存')
, (4, N'任意规模', N'XML 报文', N'XML 是"文档"不是"数据":入库即拆成关系表,原文档只留档案')
) AS t(序号, 规模, 对象, 设计结论)
ORDER BY 序号;
GO
/* --------------------------------------------------------------------------------------------------
14.2 成本模型:JSON/XML 贵在哪里
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 14.2 半结构化的四项固有成本 -----';
SELECT * FROM (VALUES
(1, N'解析成本', N'每行都要扫描文档文本才能取值;不像列存能按列裁剪',
N'→ 用提升列把"参与筛选"的字段提前解析出来,只解析一次'),
(2, N'无统计信息', N'优化器不知道文档里值的分布,基数估计永远是猜的',
N'→ 提升列上有统计信息,才能得到正确计划'),
(3, N'LOB 存储', N'nvarchar(max)/xml/json 都走 LOB,行内不存,读取代价高',
N'→ 大文档考虑归档到对象存储,库里只留路径 + 关键字段'),
(4, N'全文档重写', N'JSON_MODIFY 改一个键要重写整篇;XML 也是整值替换',
N'→ 高频变更字段必须拆列(01.1 判据第 3 条)')
) AS t(序号, 成本项, 说明, 应对);
GO
/* --------------------------------------------------------------------------------------------------
14.3 五种高并发手法
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 14.3 高并发下的五种手法 -----';
SELECT * FROM (VALUES
(1, N'提升列 + 覆盖索引', N'把要筛的路径做成持久化计算列,建覆盖索引',
N'本脚本 07 节;把"逐行解析"变成"索引查找"'),
(2, N'预解析成关系表', N'入库时用 OPENJSON / .nodes() 拆成规整表,查询就不碰 JSON/XML',
N'11.1 节的 #Note 就是雏形;这是万亿级唯一可行的读路径'),
(3, N'只读快照 / RCSI', N'半结构化解析是 CPU 型操作,读写混跑会互相拖累',
N'本库已开 is_read_committed_snapshot_on = 1'),
(4, N'不进 OLTP 热路径', N'在事务里解析大 JSON 会长时间持锁、撑大日志',
N'改为"先落原始文本,异步再解析"'),
(5, N'压缩与列存', N'归档层的 JSON 用 PAGE/COLUMNSTORE 压缩;XML 考虑 typed 后转关系表',
N'冷热分离:热走提升列,冷走列存')
) AS t(序号, 手法, 做法, 说明);
GO
/* --------------------------------------------------------------------------------------------------
14.4 ★ 高并发下绝对不要做的事:在热点事务里 JSON_MODIFY 大文档
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 14.4 反例:热点更新 = 大文档反复重写 -----';
PRINT N' 下面演示"改一个键"实际要写多少字节(这直接等于日志与 IO 放大):';
SELECT
原文档字节 = DATALENGTH(AttrJson)
, 改一个键后字节 = DATALENGTH(JSON_MODIFY(AttrJson, N'$.custom.giftBox', N'deluxe'))
, 放大倍数 = CAST(DATALENGTH(JSON_MODIFY(AttrJson, N'$.custom.giftBox', N'deluxe')) * 1.0
/ NULLIF(DATALENGTH(AttrJson), 0) AS decimal(6,2))
FROM erp.ProductAttr
ORDER BY DATALENGTH(AttrJson) DESC;
GO
PRINT N' ★ 结论:每一次 JSON_MODIFY 都是"读全量 + 解析 + 重拼 + 写全量",';
PRINT N' 在 1 万 QPS 的热点上,这等于把写入放大成几百倍。';
PRINT N' 正确做法:把会被高频更新的字段拆成普通列 ------ 回到第 01 节的边界判据。';
GO
/* --------------------------------------------------------------------------------------------------
14.5 落库审计:把"契约巡检"变成日常
-------------------------------------------------------------------------------------------------- */
PRINT N'----- 14.5 建审计表并记录一次巡检结果 -----';
GO
CREATE TABLE dbo.JsonXmlAudit
(
AuditId int IDENTITY(1,1) NOT NULL CONSTRAINT PK_JsonXmlAudit PRIMARY KEY,
CheckedAt datetime2(0) NOT NULL CONSTRAINT DF_JsonXmlAudit_At DEFAULT (SYSDATETIME()),
ObjectName nvarchar(120) NOT NULL,
CheckItem nvarchar(160) NOT NULL,
Violations int NOT NULL
);
GO
INSERT dbo.JsonXmlAudit (ObjectName, CheckItem, Violations)
SELECT N'erp.ProductAttr', N'文档不是合法 JSON 对象', COUNT(*)
FROM erp.ProductAttr WHERE ISJSON(AttrJson, OBJECT) = 0
UNION ALL
SELECT N'erp.ProductAttr', N'schemaVersion 不是 1', COUNT(*)
FROM erp.ProductAttr WHERE SchemaVersion <> 1 OR SchemaVersion IS NULL
UNION ALL
SELECT N'erp.ProductAttr', N'提升列与原文档不一致', COUNT(*)
FROM erp.ProductAttr
WHERE ISNULL(MetalType, N'') <> ISNULL(CAST(JSON_VALUE(AttrJson, N'$.metal.type') AS nvarchar(40)), N'')
UNION ALL
SELECT N'stg.EventJson', N'文档不是合法 JSON 对象', COUNT(*)
FROM stg.EventJson WHERE ISJSON(Payload, OBJECT) = 0
UNION ALL
SELECT N'crm.InteractionXml', N'XML 缺少 metrics 节点', COUNT(*)
FROM crm.InteractionXml WHERE Payload.exist(N'/interaction/metrics') = 0
UNION ALL
-- hr.CertXml 的报文带命名空间 urn:lukfook:hr:cert:v1,
-- 这里用 *: 通配写路径,无需预先声明前缀
SELECT N'hr.CertXml', N'XML 缺少 holder 节点', COUNT(*)
FROM hr.CertXml WHERE Payload.exist(N'/*:certificate/*:holder') = 0;
GO
PRINT N' ★ 实证:如果直接写字面前缀 /cert:certificate/cert:holder 而**没有** WITH XMLNAMESPACES,会怎样:';
BEGIN TRY
EXEC sp_executesql N'SELECT COUNT(*) AS 违反行数 FROM hr.CertXml
WHERE Payload.exist(N''/cert:certificate/cert:holder'') = 0';
PRINT N' 居然可以(不应发生)';
END TRY
BEGIN CATCH
PRINT N' 被拒 → ' + CAST(ERROR_NUMBER() AS nvarchar(10)) + N':' + ERROR_MESSAGE();
END CATCH
PRINT N' 这类错误在生产里最危险的地方是:如果应用层用 try/except 把异常吞掉、只看返回行数,';
PRINT N' 这条巡检会**每天都安静地报 0 违规** ------ 巡检本身也要先验证有效性(下面 dbo.JsonXmlAudit 里同样存在这个风险)。';
GO
SELECT 巡检时间 = CheckedAt, 对象 = ObjectName, 检查项 = CheckItem, 违反行数 = Violations
FROM dbo.JsonXmlAudit ORDER BY AuditId;
GO
/* ==================================================================================================
15 验证
================================================================================================== */
PRINT N'';
PRINT N'===== 15 验证 =====';
GO
PRINT N' [V1] JSON 契约:两个 JSON 表都不得有非法文档';
SELECT 项目 = N'erp.ProductAttr 非对象文档', 结果 = COUNT(*), 判定 = CASE WHEN COUNT(*) = 0 THEN N'通过' ELSE N'失败' END
FROM erp.ProductAttr WHERE ISJSON(AttrJson, OBJECT) = 0
UNION ALL
SELECT N'stg.EventJson 非对象文档', COUNT(*), CASE WHEN COUNT(*) = 0 THEN N'通过' ELSE N'失败' END
FROM stg.EventJson WHERE ISJSON(Payload, OBJECT) = 0
UNION ALL
SELECT N'stg.JsonBatch 非合法 JSON', COUNT(*), CASE WHEN COUNT(*) = 0 THEN N'通过' ELSE N'失败' END
FROM stg.JsonBatch WHERE ISJSON(Payload) = 0;
GO
PRINT N' [V2] ★ JSON_VALUE 的 4000 字符上限:4000 应有值,4005 应为 NULL';
GO
DECLARE @v4000 nvarchar(max) = CAST(N'{"v":"' AS nvarchar(max)) + REPLICATE(CAST(N'A' AS nvarchar(max)), 4000) + N'"}';
DECLARE @v4005 nvarchar(max) = CAST(N'{"v":"' AS nvarchar(max)) + REPLICATE(CAST(N'A' AS nvarchar(max)), 4005) + N'"}';
SELECT
用例 = N'值长度 4000'
, JSON_VALUE结果 = ISNULL(CAST(LEN(JSON_VALUE(@v4000, N'$.v')) AS nvarchar(10)), N'<NULL>')
, 判定 = CASE WHEN LEN(JSON_VALUE(@v4000, N'$.v')) = 4000 THEN N'通过' ELSE N'失败' END
UNION ALL
SELECT N'值长度 4005'
, ISNULL(CAST(LEN(JSON_VALUE(@v4005, N'$.v')) AS nvarchar(10)), N'<NULL>')
, CASE WHEN JSON_VALUE(@v4005, N'$.v') IS NULL THEN N'通过(如期静默 NULL)' ELSE N'失败' END
UNION ALL
SELECT N'值长度 4005 用 OPENJSON 取'
, CAST((SELECT LEN([value]) FROM OPENJSON(@v4005) WHERE [key] = N'v') AS nvarchar(10))
, CASE WHEN (SELECT LEN([value]) FROM OPENJSON(@v4005) WHERE [key] = N'v') = 4005 THEN N'通过' ELSE N'失败' END;
GO
PRINT N' [V3] 提升列必须与原文档严格一致(抽 24 行全量比对)';
SELECT 项目 = N'erp.ProductAttr 提升列不一致行数', 结果 = COUNT(*),
判定 = CASE WHEN COUNT(*) = 0 THEN N'通过' ELSE N'失败' END
FROM erp.ProductAttr
WHERE ISNULL(MetalType, N'') <> ISNULL(CAST(JSON_VALUE(AttrJson, N'$.metal.type') AS nvarchar(40)), N'')
OR ISNULL(MetalPurity, N'') <> ISNULL(CAST(JSON_VALUE(AttrJson, N'$.metal.purity') AS nvarchar(20)), N'')
OR ISNULL(StoneType, N'') <> ISNULL(CAST(JSON_VALUE(AttrJson, N'$.stone.type') AS nvarchar(40)), N'');
GO
PRINT N' [V4] JSON_ARRAYAGG / JSON_OBJECTAGG 产出的必须是合法 JSON';
SELECT
用例 = N'JSON_ARRAYAGG 结果合法'
, 判定 = CASE WHEN ISJSON((SELECT JSON_ARRAYAGG(Sku) FROM erp.Product)) = 1 THEN N'通过' ELSE N'失败' END
UNION ALL
SELECT N'JSON_OBJECTAGG 结果合法'
, CASE WHEN ISJSON((SELECT JSON_OBJECTAGG(StoreCode : City) FROM erp.Store)) = 1 THEN N'通过' ELSE N'失败' END;
GO
PRINT N' [V5] lax 必须静默、strict 必须报错(13608)';
GO
DECLARE @jl nvarchar(max) = N'{"a":1}';
DECLARE @laxr nvarchar(40);
DECLARE @strictErr int = 0;
DECLARE @laxOk bit = 0;
BEGIN TRY
SET @laxr = ISNULL(JSON_VALUE(@jl, N'$.nope'), N'<NULL>');
SET @laxOk = 1;
END TRY BEGIN CATCH SET @laxOk = 0; END CATCH
DECLARE @ps nvarchar(60) = N'strict $.nope';
BEGIN TRY
DECLARE @tmp nvarchar(40) = JSON_VALUE(@jl, @ps);
END TRY BEGIN CATCH SET @strictErr = ERROR_NUMBER(); END CATCH
SELECT
lax结果 = @laxr
, lax成功 = CASE WHEN @laxOk = 1 THEN N'通过' ELSE N'失败' END
, strict错误号 = @strictErr
, strict判定 = CASE WHEN @strictErr = 13608 THEN N'通过' ELSE N'失败' END;
GO
PRINT N' [V6] XML 四个方法都必须可用';
SELECT
方法 = N'.value()'
, 结果 = CAST((SELECT TOP 1 Payload.value(N'(/interaction/customer/@tier)[1]', N'nvarchar(20)') FROM crm.InteractionXml) AS nvarchar(40))
, 判定 = N'通过'
UNION ALL
SELECT N'.query()', CAST((SELECT TOP 1 Payload.query(N'/interaction/customer') FROM crm.InteractionXml) AS nvarchar(80)), N'通过'
UNION ALL
SELECT N'.exist()', CAST((SELECT TOP 1 Payload.exist(N'/interaction/notes/note') FROM crm.InteractionXml) AS nvarchar(10)), N'通过'
UNION ALL
SELECT N'.nodes()', CAST((SELECT COUNT(*) FROM (SELECT TOP 1 x.Payload FROM crm.InteractionXml x) t
CROSS APPLY t.Payload.nodes(N'/interaction/notes/note') AS n(v)) AS nvarchar(10)), N'通过';
GO
PRINT N' [V7] XML 的三条硬限制必须如期报错(305 / 5335)';
GO
DECLARE @e1 int = 0, @e2 int = 0;
BEGIN TRY
EXEC sp_executesql N'SELECT x FROM (SELECT CAST(N''<r/>'' AS xml) AS x) t ORDER BY x';
END TRY BEGIN CATCH SET @e1 = ERROR_NUMBER(); END CATCH
BEGIN TRY
EXEC sp_executesql N'SELECT COUNT(*) FROM (SELECT CAST(N''<r/>'' AS xml) AS x UNION SELECT CAST(N''<s/>'' AS xml)) t';
END TRY BEGIN CATCH SET @e2 = ERROR_NUMBER(); END CATCH
SELECT
限制 = N'XML 不能排序'
, 错误号 = @e1
, 判定 = CASE WHEN @e1 = 305 THEN N'通过' ELSE N'失败' END
UNION ALL
SELECT N'XML 不能作 UNION 操作数', @e2, CASE WHEN @e2 = 5335 THEN N'通过' ELSE N'失败' END;
GO
PRINT N' [V8] typed XML 必须完成强校验(只允许合法文档入库)';
SELECT
项目 = N'dbo.TypedXmlDemo 行数(应恰好 = 1,其余 3 条非法文档被拒)'
, 结果 = COUNT(*)
, 判定 = CASE WHEN COUNT(*) = 1 THEN N'通过' ELSE N'失败' END
FROM dbo.TypedXmlDemo;
GO
PRINT N' [V9] XML 索引必须存在(主 + 2 个二级)';
SELECT
项目 = N'crm.InteractionXml 上的 XML 索引数'
, 结果 = COUNT(*)
, 判定 = CASE WHEN COUNT(*) >= 3 THEN N'通过' ELSE N'失败' END
FROM sys.xml_indexes WHERE object_id = OBJECT_ID('crm.InteractionXml');
GO
SELECT 索引名 = name, 类型 = type_desc FROM sys.xml_indexes
WHERE object_id = OBJECT_ID('crm.InteractionXml') ORDER BY index_id;
GO
PRINT N' [V10] 事实表与维表的引用完整性(确保示例数据可连接)';
SELECT
项目 = N'ProductAttr 的 ProductId 在 erp.Product 中找不到'
, 结果 = COUNT(*)
, 判定 = CASE WHEN COUNT(*) = 0 THEN N'通过' ELSE N'失败' END
FROM erp.ProductAttr a WHERE NOT EXISTS (SELECT 1 FROM erp.Product p WHERE p.ProductId = a.ProductId)
UNION ALL
SELECT N'CustomerPref 的 CustomerId 在 crm.Customer 中找不到', COUNT(*),
CASE WHEN COUNT(*) = 0 THEN N'通过' ELSE N'失败' END
FROM crm.CustomerPref c WHERE NOT EXISTS (SELECT 1 FROM crm.Customer t WHERE t.CustomerId = c.CustomerId);
GO
PRINT N' [V11] 规模核对';
SELECT 对象 = N'erp.ProductAttr', 行数 = COUNT(*) FROM erp.ProductAttr
UNION ALL SELECT N'erp.ProductAttrJ', COUNT(*) FROM erp.ProductAttrJ
UNION ALL SELECT N'stg.JsonBatch', COUNT(*) FROM stg.JsonBatch
UNION ALL SELECT N'stg.EventJson', COUNT(*) FROM stg.EventJson
UNION ALL SELECT N'crm.InteractionXml',COUNT(*) FROM crm.InteractionXml
UNION ALL SELECT N'hr.CertXml', COUNT(*) FROM hr.CertXml
UNION ALL SELECT N'dbo.TypedXmlDemo', COUNT(*) FROM dbo.TypedXmlDemo
UNION ALL SELECT N'dbo.JsonXmlAudit', COUNT(*) FROM dbo.JsonXmlAudit;
GO
/* ==================================================================================================
16 决策清单
================================================================================================== */
PRINT N'';
PRINT N'===== 16 决策清单 =====';
GO
PRINT N' ── 什么时候用 JSON ──';
PRINT N' · 字段集合随品类/渠道变化,拆列会产生大量稀疏列(珠宝商品属性、客户画像)';
PRINT N' · 只整体读写、读多写少、不需要跨行连接';
PRINT N' · 上游本来就给 JSON(API / POS / 电商),需要先落原始报文再异步解析';
PRINT N' · 需要"提升列 + 索引"来筛少数几个字段,其余保持灵活';
PRINT N'';
PRINT N' ── 什么时候用 XML ──';
PRINT N' · 需要**结构强校验**:typed XML + XSD,写入即拒绝畸形报文';
PRINT N' · 行业/机构互操作:证书报文、EDI、带命名空间的遗留系统报文';
PRINT N' · 天然文档结构、需要 XQuery 或混合内容(多语言富文本)';
PRINT N' · 报文带命名空间(JSON 表达命名空间非常别扭)';
PRINT N'';
PRINT N' ── 必须拆成正式列(禁止放 JSON/XML)──';
PRINT N' · 参与 JOIN / WHERE 等值或范围筛选 / ORDER BY / GROUP BY';
PRINT N' · 需要唯一约束、外键、CHECK 约束';
PRINT N' · 进入财务、税务、薪酬、库存对账口径';
PRINT N' · 会被高频部分更新(避免整篇文档重写)';
PRINT N' · 需要统计信息支撑正确执行计划';
PRINT N'';
PRINT N' ── 工程化铁律 ──';
PRINT N' ① 每个 JSON 列:CHECK (ISJSON(col, OBJECT) = 1) + schemaVersion + 必需路径约束;';
PRINT N' 每个 XML 列:能用 typed XML 就用,别只存裸 xml;';
PRINT N' ② 要筛的路径一律提升为持久化计算列并建索引 ------ 不要让查询去逐行解析;';
PRINT N' ③ 取长字符串值一律用 OPENJSON(JSON_VALUE 超过 4000 字符会静默 NULL);';
PRINT N' ④ XML 方法只吃字面量路径;需要动态路径时必须走动态 SQL 并做白名单校验;';
PRINT N' ⑤ 把契约巡检(15 节那种查询)固化成每日作业,落进 dbo.JsonXmlAudit 类的表;';
PRINT N' ⑥ 冷热分离:热查询走提升列/预解析表,冷归档走列存 + 大文档外置。';
PRINT N'';
PRINT N'################################################################################';
PRINT N'# 脚本执行完成';
PRINT N'# 核心结论:JSON/XML 只是"装可变结构的容器",不是"逃避建模的借口"。';
PRINT N'# 边界划清楚 = 该拆列的拆列、该提升的提升、该校验的校验、该索引的索引。';
PRINT N'################################################################################';
GO