做决策系统的头一年,我最头疼的不是算法选型,而是数据可能散落在十几个表里,开发要拼SQL、测试要对数、产品想要全链路回放却要等半天。我们后来把整个决策链路的数据结构统一梳理了一遍,内部管这套规范叫“9-Decisions Datastructures”,核心就一句话:把决策过程拆成9类结构,每类结构都有清晰归属,并且和数据库表一一对应。这篇文章想把这套设计方案、落地时踩过的坑以及后来压测复盘改掉的地方完整记录下来,给正在做风控、审批、策略引擎这类决策系统的朋友一个参考。
这套结构解决的核心问题可以用一个场景概括:某业务的订单申请进入决策系统,系统要读取规则、加载物模型、算权重、打分数、出结果、存档日志、生成审计记录,还要保留每次决策的版本快照。如果这些环节的数据结构各写各的,后期联调和对账会非常痛苦。9-Decisions Datastructures的思路,是把决策系统里高频出现的9类数据形态统一成规范,然后直接映射到数据库表,让结构设计和物理存储从一开始就对得上。
1. 9-Decisions的数据结构为什么是9个:按决策链路拆出来的骨架
1.1 从一次失败的统计需求说起
先讲个背景。当时产品提了一个需求:统计某个业务方过去30天里,每个决策规则被命中多少单、平均分是多少、通过率怎么样。听起来很简单,但实际做的时候发现数据分布在多张业务表里,规则配置存一边、执行日志存一边、评分明细又存一边,而且没有统一的决策ID把三者串起来。开发临时拼了一个多月,最后对出来的数字和业务后台还差了几个点。
这件事让我们意识到,决策系统不能等需求来了再去想数据怎么组织,而是要在设计期就把“决策”这个概念拆成稳定结构。于是按决策从发起、计算到落库的完整链路,整理了9类高频结构。
1.2 九个关键节点的职责边界
9类结构分别是:决策请求结构、规则配置结构、候选集合结构、权重配置结构、评分记录结构、决策结果结构、执行日志结构、修订历史结构和统计指标结构。
| 序号 | 结构名称 | 主要职责 | 数据库形态 |
|---|---|---|---|
| 1 | DecisionRequest | 记录一次决策的完整入参和上下文 | 主表 + JSON明细 |
| 2 | RuleConfig | 决策规则的定义、优先级、阈值 | 配置表 |
| 3 | CandidateSet | 待决策的候选对象集合 | 主从关联表 |
| 4 | WeightConfig | 各维度权重、权重生效时段 | 配置表 |
| 5 | ScoreRecord | 明细打分、每个维度的分数与说明 | 明细表 |
| 6 | DecisionResult | 决策结论、命中规则、建议动作 | 结果表 |
| 7 | ExecutionLog | 引擎执行的日志、耗时、异常 | 日志表 |
| 8 | RevisionHistory | 规则与权重的历史版本快照 | 快照表 |
| 9 | MetricStat | 汇总统计口径、指标定义与计算结果 | 汇总表 |
当时划分的依据是三类性质:输入型、过程型、输出型。DecisionRequest和CandidateSet是输入,ScoreRecord和ExecutionLog是过程,DecisionResult和MetricStat是输出,RuleConfig和WeightConfig是配置,RevisionHistory是保证可追溯的补充。把配置和过程数据分开,核心考虑是配置数据修改频度低但查询频度高,过程数据写入量大但查询相对集中,两者混在一起会互相拖累。
如果只有一个决策动作,结构化到5个就够,但实际决策系统往往要支持规则调整、权重调优、版本回滚,所以RevisionHistory必须单独拆出来。没有它,后续复盘规则变化对结果的影响时,就只能靠人去翻上线记录,基本不可靠。这也是9个数字里我认为最容易被忽略但价值最高的一类。
2. 结构到表的映射:建表字段、JSON取舍与索引设计
2.1 一个完整的建表示例
结构定完之后,下一步就是落到数据库。我们当时用的是PostgreSQL,下面的建表语句是经过简化的版本,保留了核心字段。
CREATE TABLE decision_request ( id BIGSERIAL PRIMARY KEY, request_no VARCHAR(64) NOT NULL UNIQUE, scenario_code VARCHAR(32) NOT NULL, owner_id VARCHAR(64) NOT NULL, apply_time TIMESTAMPTZ NOT NULL DEFAULT now(), inputs JSONB NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE candidate_set ( id BIGSERIAL PRIMARY KEY, request_id BIGINT NOT NULL REFERENCES decision_request(id), candidate_id VARCHAR(64) NOT NULL, candidate_type VARCHAR(16) NOT NULL, properties JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE score_record ( id BIGSERIAL PRIMARY KEY, request_id BIGINT NOT NULL REFERENCES decision_request(id), candidate_id VARCHAR(64) NOT NULL, dimension_code VARCHAR(32) NOT NULL, score NUMERIC(10,4) NOT NULL, score_desc TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now() ); CREATE TABLE decision_result ( id BIGSERIAL PRIMARY KEY, request_id BIGINT NOT NULL REFERENCES decision_request(id), candidate_id VARCHAR(64) NOT NULL, result_code VARCHAR(32) NOT NULL, hit_rule_ids JSONB NOT NULL, suggest_action VARCHAR(64), raw_score NUMERIC(10,4) NOT NULL, final_score NUMERIC(10,4) NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now() );几个字段选择上的考虑。request_no用业务单号而不是直接用自增ID做主键,因为业务方来回调的时候用的是request_no,靠它做唯一约束,能避免重复提交。owner_id是发起决策的对象标识,比如订单ID、用户ID,后期按owner查询会很频繁,所以单独一列而不是塞进JSONB里。inputs整体用JSONB,是因为不同场景的入参差异极大,强行拆列会导致大量空字段,JSON省事,也保留了扩展空间。
2.2 独立成表还是塞进JSON:判断标准就一条
凡是要用来筛选、聚合、参加计算的字段,一定要独立成列;凡是为了展示、回放、上下文留痕的字段,才用JSON保存。比如score_record里的dimension_code就独立出来,因为统计时经常要按维度分组;decision_result里的hit_rule_ids虽然是数组形式,但后续要统计“哪些规则被命中最多”,所以存成JSONB只是想省一张关联表,配合GIN索引也能满足查询。
有同学会问,既然JSONB也能过滤,为什么还要拆列?因为数据库对JSON字段的过滤走的是表达式索引,维护成本高,而且对NULL值处理很别扭。有一次排查一个慢查询,发现某张表的owner_id字段被设计成JSONB存了,查询时只能用inputs ->> 'owner_id' = 'xxx',又没法走普通索引,一查几万条全表扫。这就是结构设计没想清楚的下场。
判断标准:结构体里哪些属性是“未来要参与统计和筛选的”,想清楚再决定是否拆列。宁可多拆一个会有冗余的列,也不要事后因为查询性能去补表达式索引。
2.3 索引与主键设计:决策ID怎么串起来
9类结构之间通过request_id建立关联,因此request_id的索引是查询链路的核心。建表时,对所有关联外键列建普通BTree索引,业务上还有几个固定的组合查询模式:
CREATE INDEX idx_candidate_request_id ON candidate_set(request_id); CREATE INDEX idx_score_request_dim ON score_record(request_id, dimension_code); CREATE INDEX idx_result_request_owner ON decision_result(request_id, owner_id); CREATE INDEX idx_request_owner_time ON decision_request(owner_id, apply_time DESC);组合索引的顺序要有讲究。比如idx_score_request_dim,查询条件通常是“先定位某次决策request_id,再查某个维度的评分”,所以request_id在前、dimension_code在后;如果反过来,数据库就无法利用索引最左前缀,等于白建。还有idx_request_owner_time,是为了支持“这个人最近30天的决策记录”类查询,owner_id等值条件下再按apply_time倒序,分页和排序都不需要额外Sort。
主键全部用BIGSERIAL自增,不是UUID。原因很朴素:批量插入时自增主键的页分裂少,性能稳定;而且request_id已经承担了业务唯一标识的角色,不太需要UUID生成器的额外开销。如果以后要分库分表,再改成雪花ID也不迟,表结构和接口已经预留了request_no作为外部标识。
3. 把9类结构串起来的SQL操作:回放查询与统计查询的写法
3.1 最典型的查询:一次决策全链路回放
上线一段时间后,使用最多的SQL是决策回放:产品或者运营在后台点开一条决策记录,要看到从入参、候选对象、评分明细到最终结果的完整过程。关联查询的写法如下。
SELECT r.request_no, r.inputs, c.candidate_id, s.dimension_code, s.score, res.result_code, res.final_score FROM decision_request r JOIN candidate_set c ON c.request_id = r.id JOIN score_record s ON s.request_id = r.id AND s.candidate_id = c.candidate_id JOIN decision_result res ON res.request_id = r.id AND res.candidate_id = c.candidate_id WHERE r.request_no = 'REQ20250101001' ORDER BY s.dimension_code;写这个查询的原因是9类结构在逻辑上天然分表存储,但回放时又需要合并。两个join条件都带上了candidate_id,是为了防止不同候选对象之间的评分串行。如果不加这个条件,数据库会按笛卡尔积把同一次决策里所有候选的评分行全部展开,回放页面会出现评分数据和候选对象对不上的问题。
实际跑下来,单条决策回放在数据量不大的时候毫秒级返回。但要注意,inputs和hit_rule_ids这类JSONB字段如果很大,回放接口一次拉太多字段会导致网络传输变慢。我的做法是查询时只select当前页面需要的字段,把JSONB明细做成懒加载,点击“查看详情”再单独发一次查询。这样列表页和详情页互不拖累。
3.2 统计类查询:不是所有统计都能用直接聚合
统计某个场景下不同维度的平均分时,常规思路是直接对score_record聚合:
SELECT s.dimension_code, COUNT(*) AS score_count, ROUND(AVG(s.score), 4) AS avg_score FROM score_record s JOIN decision_request r ON r.id = s.request_id WHERE r.scenario_code = 'order_risk' AND r.apply_time >= now() - interval '30 days' GROUP BY s.dimension_code ORDER BY s.dimension_code;这个SQL能跑,但要注意语义:如果一次决策里有多个候选对象,这里统计的粒度和业务想要的“单次决策维度均分”往往不一致。更准确的口径是先按request_id和dimension_code分组求平均,再对外层求平均,或者明确业务方要的是记录级统计还是决策级统计。这种口径问题在数据结构层面看不出差异,完全取决于对业务需求的理解,建议写统计SQL之前先和产品确认清楚,避免开发完被一句“不对”打回。
3.3 操作层封装:仓储模式还是裸SQL
在操作层我们用了一个简化的仓储模式,把9类结构各自包装成独立的Repository,每个Repository只负责对应表的读写。比如DecisionRequestRepository负责request表的插入和查询,ScoreRecordRepository负责批量写入评分记录。组合操作放在DecisionService里编排,不直接在业务层散写SQL。
事务边界上,一次决策执行涉及写入request、candidate_set、score_record、result四张表,这四步必须在一个事务里完成,保证决策要么完全落库要么全部回滚。这里容易犯的错误是把日志表ExecutionLog也放进同一个事务。执行日志本来的作用是记录错误现场,如果和业务数据共用事务,一旦日志写入失败反而会连累业务数据回滚,得不偿失。
正确做法是把执行日志单独用一个事务,即使主事务回滚也要保留日志。这个调整后的效果很明显,排障时能看到完整错误现场,而不是事务回滚后日志也一起消失了。
4. 操作层踩过的坑:JSON字段、长事务和索引失效
4.1 JSON字段的便利与陷阱
我们早期把candidate_set的properties设计成JSON,本来只打算存一些展示用属性,后来有业务方希望通过登录城市过滤候选对象。由于city属性只存在properties里,查询只能写成properties ->> 'city' = '上海',走了全表扫描,候选对象一多就卡。
那次排查后,我们硬性规定:凡是已经确认会成为查询条件的属性,必须从JSON中拆出独立字段。当时把city拆成单独一列,加上索引后,过滤查询从几秒降到几十毫秒。这个教训让我在后续所有结构设计会上都反复提一句话:JSON字段是给别人偷懒用的,也是给自己埋坑用的。
4.2 批量写入决策评分时的一次长事务
评分明细的写入有段时间成为瓶颈。原因是评分记录不是一条条写入的,而是一次决策会产生成百上千条评分明细,代码里直接用一个事务循环插入。表面上看每一条插入都很快,但累计事务时间很长,数据库的锁等待随之升高,并发高的时候甚至出现互相阻塞。
修复方式是把评分明细按维度分片,每500条一个批次,批量提交。同时调整了score_record的批量插入SQL,用INSERT INTO ... VALUES (...), (...)一次插多条,而不是循环调单条插入。实测下来,同样的数据量,从原来的十几秒降到了两秒左右。
另外,批量写入时我们遇到过“读己之写”一致性问题:事务还没提交前,回放接口去查数据,查不到最新记录。后来把回放查询加了一个参数,支持指定从库或者强制走主库,解决了提交窗口内的数据可见性问题。
4.3 索引失效的排查过程
有个统计报表查询,按维度统计某场景7天下来的评分分布。SQL里用了一个WHERE apply_time >= now() - interval '7 days',看起来该走索引,实际执行计划却显示全表扫。排查时发现decision_request表的apply_time列是TIMESTAMPTZ,而条件里传入的参数被应用层转成了字符串,数据库做了隐式类型转换,导致索引失效。
排查过程其实是靠执行计划定位的:
EXPLAIN ANALYZE SELECT * FROM decision_request WHERE apply_time >= now() - interval '7 days';执行计划里显示Seq Scan on decision_request,说明没走索引。修复方式是把查询参数明确转成timestamptz类型再用,执行计划就变成Index Scan了。这类问题最容易出现的原因,是从接口层传参到SQL层的类型不一致,建议大家写查询前先确认类型,不要完全依赖数据库自动转换。
还有一次,查询条件用了OR连接两个字段,导致复合索引失效。索引设计只要跟着查询模式走,这类问题基本都能避免:如果业务上确定会同时查request_id和dimension_code,就是组合索引;如果偶尔用OR,考虑改造成UNION ALL或者拆成两个查询再合并。
5. 压测后改掉的三处设计:9-Decisions结构演进复盘
5.1 第一处:请求表按时间分区
第一次压测,决策请求表在5000万行规模时,按owner_id查历史的性能还算可以,但按apply_time做范围查询的统计接口明显变慢。原因是单表数据量太大,BTree索引层级加深,范围查询的扫描代价高。
我们当时把decision_request改成了按月份的分区表,主表保留结构定义,每月自动创建分区。改造后,按时间范围查数据时,数据库直接通过分区裁剪只扫描对应月份的分区,性能提升非常明显。这个操作在PostgreSQL里就是添加分区声明,不需要动应用层代码,结构定义也不变。
5.2 第二处:结果表增加冗余字段
压测过程中发现一个现象:决策结果列表页每次都关联查询decision_result和decision_request两张表,只为显示场景名称和申请时间。虽然join查询性能不算差,但高频访问下仍然会放大数据库压力。
后来直接在decision_result表冗余了两个字段:scenario_code和apply_time。写入结果时从request表带过来,查询列表页就不再需要join。冗余字段的问题是需要维护一致性,但由于决策结果属于不可变数据,写入后不再更新,冗余的风险基本为零。这种“用空间换时间”的做法,在决策系统里很适合。
5.3 第三处:评分表按批次归档
压测还发现score_record表的膨胀速度比预想快。评分明细是9类结构里数据量最大的一类,单决策可能上千行,一年下来几个亿很正常。查询活跃数据时,越来越慢,清理冷数据又怕影响审计。
最终方案是按天归档:运维脚本每天把N天前的score_record冷数据迁移到archive表,原表只保留近期数据,冷热分离。回放接口在查不到实时表时,自动查归档表。虽然代码上多了一个分支,但性能问题从根上解决了。如果当时没有把查询逻辑封装成Repository,这个改造会牵动很多业务方,工作量会大得多。
结语
9-Decisions数据结构从设计到落地,最大的价值不是那9个结构本身,而是它逼着我们在写第一行业务代码前,把决策过程中最核心的数据关系理清楚了。个人体会是,数据结构一旦确定,数据库的表结构、索引策略、查询方式甚至团队分工都会跟着定下来,改动的成本越到后面越高。所以决策系统这类数据密集型项目,优先投入时间做结构设计,非常划算。
最后分享一个小技巧:上线前一定做一次“数据漂移对账”,也就是拿每天的决策总量、请求总量、结果总量交叉验算,确保9类结构之间的数据没有漏写和多写。我们曾经在事务边界没有理清时少写了一批candidate_set,就是靠这类对账发现的。对账脚本不用复杂,但要能长期跑,这会比任何代码review都能更快暴露结构设计上的漏洞。