第3章 审计数据模型设计:结构化与半结构化

说到审计数据模型,我先问一个问题:你手头的审计数据,是规整的表格,还是乱七八糟的日志?我干了这么多年审计接口,见过太多团队在这个问题上踩坑。

结构化数据的优势很明显——查得快、算得准。但审计场景下,会面临大量临时字段、异构系统的异常记录,纯结构化根本兜不住。那怎么办?我的答案是:两者都要,但要分清楚什么时候用谁。

3.1 结构化数据模型:审计系统的骨架

结构化的核心是“定死了”。每一列是什么类型,不能乱填。我最早设计审计表时,犯过一个错:把所有字段都设成 varchar(255)。后来被业务方追着骂,“你这时间字段怎么排序?”“金额字段怎么汇总?”——嗯,那时候我就明白了,类型约束不是摆设。

你想想看,审计数据模型里,结构化部分必须包含哪几样?我给你列个清单:

模型名称 核心字段 用途示例
审计事件模型 event_id, event_type, user_id, action, resource, timestamp 记录“谁在什么时间对什么资源做了什么”
审计日志模型 log_id, session_id, ip_address, user_agent, status, duration 保存每一次请求的原始日志信息
审计结果模型 result_id, rule_id, match_result, risk_level, alert_time 存放审计规则匹配后的判定结果

我个人习惯是:只要字段在90%以上的记录里都会出现,就把它放进结构化表里。剩下的,交给半结构化处理。

举个具体例子。一个标准的审计事件表,DDL大概长这样:

CREATE TABLE audit_event (
    event_id      BIGINT       PRIMARY KEY AUTO_INCREMENT,
    event_type    VARCHAR(64)  NOT NULL COMMENT '事件类型:LOGIN/LOGOUT/DATA_ACCESS/...',
    user_id       VARCHAR(128) NOT NULL,
    action        VARCHAR(32)  NOT NULL COMMENT 'CREATE/READ/UPDATE/DELETE',
    resource      VARCHAR(256) NOT NULL COMMENT '被操作的资源URI或ID',
    timestamp     DATETIME(6)  NOT NULL,
    source_ip     VARCHAR(45)  DEFAULT NULL COMMENT 'IPv4或IPv6',
    status        TINYINT      DEFAULT 0 COMMENT '0-成功 1-失败 2-异常',
    INDEX idx_type_time (event_type, timestamp)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

注意那个 DATETIME(6),为什么是6?审计记录往往在同一秒内并发产生,精确到微秒才能保证不乱序。我在项目中遇到过因为时间精度不够导致日志重排的破事,后来统一改成了微秒级。

3.2 半结构化数据模型:灵活兜底的好兄弟

讲完了骨架,来说血肉。半结构化数据,说白了就是那些“没法完全按表格填”的东西。比如:

  • 用户自定义的审计扩展字段
  • 不同系统传过来的参差不齐的请求头
  • 审计上下文中的业务数据快照
  • 第三方系统的原始报文

这些东西如果强行塞进结构化字段,要么是加一堆NULL列,要么是搞各种“扩展字段1、扩展字段2”这种弱智设计。我曾经接手过一个系统,扩展字段从1排到20,文档全丢,没人知道哪一列存什么——噩梦。

所以我的方案是:在结构化表上加一个 ext_info JSON 字段。像这样:

CREATE TABLE audit_event (
    -- ...结构化字段同上...
    ext_info JSON DEFAULT NULL COMMENT '半结构化扩展信息'
);

JSON 在 MySQL 5.7+ 里已经是原生类型了,不再是你想象的那个又慢又难查的文本。实际上,MySQL 8.0 的 JSON 索引已经能支持路径查询,性能完全够用。

我建议:把所有“可能变”的字段都放到 ext_info 里。比如 A 系统传了 user_agent,B 系统没传,没关系。JSON 天然支持稀疏字段,不会浪费空间。

举个例子,一条审计记录的 JSON 扩展信息可能是这样的:

{
  "request_id": "req-20250321001",
  "user_agent": "Mozilla/5.0 ...",
  "device_fingerprint": "fp_a1b2c3",
  "original_payload": {"action": "export", "count": 500},
  "tags": ["high_risk", "bulk_export"]
}

你看,这里面有些字段是针对特定场景的。如果把这些都做成结构化列,明天的运维日志系统传个新字段,你又得改表结构。结构化稳,但不改;半结构化灵活,但不好查。两者互补才是正道。

3.3 字段规范:别让数据变成野马

模型确定了,字段怎么命名、怎么规定类型,这也是个大坑。我见过太多团队,自己写的审计表用 user_name,对方系统传过来的是 usrNm,代码里再转一次 userName——每次对接都能吵一架。

我的建议是定死三条规则:

  1. 全小写下划线命名。比如 user_ip 而不是 userIP 或 UserIp。为什么?因为不同语言的命名规范不一样,小写下划线是最大公约数。
  2. 字段类型明确且最小化。能用 INT 别用 BIGINT,能用 VARCHAR(64) 别上来就 255。我见过一张审计表,log_id 用 VARCHAR(500),后来查了才知道里面最多存 20 个字符。
  3. 统一时间精度。所有跟时间相关的字段,统一用 DATETIME(6) 或 TIMESTAMP(6),千万别混用。

注意了:不要用 TEXT 类型存储 JSON 字段。JSON 类型的校验、压缩、路径查询能力,TEXT 全都用不了。我就曾经因为偷懒用 TEXT 存 JSON,导致后来做审计查询时被性能问题折磨了一周。

对了,还有一个字段长度的坑。比如 source_ip,IPv4 最长 15 个字符,但 IPv6 是 45 个。我建议直接用 VARCHAR(45),省得到时候改表结构。

3.4 核心逻辑:结构化 + 半结构化双模设计

讲了这么多,核心思想其实就是一张图能说清楚的事。下面这张图展示了审计数据从产生到入库的完整链路,以及结构化与半结构化是如何分工协作的:

审计数据模型:结构化 + 半结构化双模设计 审计数据生成 数据统一化 结构化模型 审计事件表 / 审计日志表 固定字段,强类型约束 半结构化模型 JSON 扩展字段 / 上下文快照 灵活存储,稀疏字段 字段规范:命名 / 类型 / 长度 审计结果统一存储 关键设计:同一份数据,结构化字段负责核心链路的高效过滤与统计,半结构化 JSON 负责弹性扩展。两者通过同一笔 event_id 关联。

我习惯的做法是:先画出这张图,然后对着图一张一张表去落地。别上来就写 DDL,先用图把流程走通。结构化和半结构化的分界线,就是通过这张图来定的。

实际应用的时候,还有一个常被忽略的点:半结构化的字段一样要做校验。我在 ext_info 里存过用户设备指纹,后来发现有的系统传进来的是 null,有的传空字符串,有的传 "null" 字符串——查数据的时候想死的心都有。所以建议在应用层或者存储过程里,对 JSON 里的关键字段做规范性检查,哪怕是半结构化的,也不能放任自流。