时间轴模型
时间轴模型(Timeline Model)用于按时间维度记录一条业务数据在不同时期的历史切片。它适合那些依赖生效日期的业务场景,例如组织架构、价格、报表口径等。对于同一个业务记录 id,可以存在多条切片记录,每条切片由 sliceId 标识,其有效期由 effectiveStartDate / effectiveEndDate 描述。
1. 时间轴模型元数据
1.1 模型级 Timeline 属性
- 当
timeline = true时,表示该模型为时间轴模型,必须包含保留字段effectiveStartDate和effectiveEndDate。系统会在启动时校验,如缺失则抛出异常。 - 当
timeline = false时,表示普通模型,不允许定义effectiveStartDate与effectiveEndDate保留字段。 - 时间轴模型要求使用应用侧生成的逻辑 id——
idStrategy = DISTRIBUTED_LONG(或DISTRIBUTED_STRING/EXTERNAL_ID)。DB_AUTO_ID会在启动时被拒绝:自增落在物理主键sliceId上,首条切片的共享逻辑id列将无人填充(后续拆分/更正行会携带实体已有的 id 并沿用)。 - 时间轴模型不得声明
activeControl(启动时拒绝):active是实体级开关,而时间轴存储让每个字段都按切片存在——两者组合会悄然改变该特性的语义,并让区间算法的邻居探测失明。期间状态请建为版本化业务字段;终结时间轴用setEndDate(§2.6)。
1.2 主键与关键字段
-
sliceId:时间轴模型的物理主键,用于更新单个切片。 -
effectiveStartDate:切片生效开始日期。 -
effectiveEndDate:切片生效结束日期。 -
id:逻辑(业务)主键,用于与非时间轴模型兼容。所有指向时间轴模型的业务外键都引用该字段。 -
如数据库需要用于变更日志的自增“记录号”(如
record_id),可以自行增加,不属于框架保留字段。 -
推荐的唯一约束:
(id, effectiveStartDate, effectiveEndDate)——同一索引兼顾 as-of 读取覆盖(结束日期可在索引内判定)与并发写入完整性:区间维护是先查后写,同一实体上真正的并发写冲突会以唯一约束冲突暴露,而不是静默产生相同起始日的切片。声明时须给出显式indexName(默认拼接名在表名较长时会超过 60 字符的全局上限):@Index(indexName = "uk_<table>_timeline", fields = {"id", "effectiveStartDate", "effectiveEndDate"}, unique = true)
1.3 元数据关联关系
- 时间轴模型之间可以通过 One2One、Many2One、One2Many、Many2Many 互相关联,存储与引用都基于逻辑主键
id。 - 当时间轴模型关联到非时间轴模型时,中间表同样存储时间轴模型的逻辑主键
id。 - 当非时间轴模型关联到时间轴模型时,Many2One/One2One 字段以及 Many2Many 连接表也统一存储时间轴模型的逻辑主键
id。 - 关联读取时,默认使用
effectiveDate(未指定时为当前日期)过滤时间轴数据,因此在当前日期下可能不存在有效切片。 - 在级联查询链路中(例如 时间轴模型 → 非时间轴模型 → 时间轴模型),需要将
effectiveDate一路传递到链路末端,保证前后一致。
1.4 级联字段
- 级联字段基于 Many2One/One2One 关联。当关联模型是时间轴模型时,会使用
Context.effectiveDate查询关联记录。
1.5 时间轴数据语义
- 每个切片必须具有
effectiveStartDate与effectiveEndDate,同一id下的切片期望在时间上连续、不重叠。 - 为简化查询,最后一个切片通常使用
effectiveEndDate = 9999-12-31。 - 在大多数场景下,业务只需设置
effectiveStartDate,系统会根据前后切片自动计算与填充effectiveEndDate。 - 物理记录:每条切片是一条物理记录(由
sliceId标识),任意生效区间的变化都会新增或更新切片,变更日志与物理记录绑定。 - 逻辑记录:同一
id下的一组物理切片视为一条逻辑业务记录,业务外键始终指向逻辑id,查询时根据请求的有效日期返回对应切片。
示例(同一部门逻辑 ID 的时间轴切片):
| sliceId(物理) | id(逻辑) | 部门编码 | 部门名称 | effectiveStartDate | effectiveEndDate | Manager |
|---|---|---|---|---|---|---|
| 3 | 6 | D001 | 产品研发部 | 2022-09-01 | 9999-12-31 | Joan |
| 2 | 6 | D001 | 研发部 | 2020-05-11 | 2022-08-31 | Tom |
| 1 | 6 | D001 | 研发部 | 2019-08-01 | 2020-05-10 | Mars |
2. 常见使用场景
2.1 生效日期的传递
-
effectiveDate是存放在Context中的LocalDate值,默认是当前日期。 -
查询某个日期下生效的数据:
effectiveStartDate <= effectiveDate && effectiveEndDate >= effectiveDate -
按区间查询生效数据(
startDateValue和endDateValue均非空):effectiveStartDate <= endDateValue && effectiveEndDate >= startDateValue -
查询某个业务记录的所有切片,可以使用
acrossTimelineData()并配合id过滤(或在 Filters 中显式加入effectiveStartDate/effectiveEndDate)。 -
邻接切片查询示例:
上一切片:id = {id} AND effective_end_date = {effectiveStartDate - 1} 下一切片:id = {id} AND effective_start_date = {effectiveEndDate + 1}
2.2 读取 / 搜索 API
getById/getByIds/searchList/searchPage默认只返回在当前effectiveDate下生效的切片。- 如需跨时间维度查询历史记录,可使用
FlexQuery#acrossTimelineData(),或在筛选条件中显式包含effectiveStartDate/effectiveEndDate。 - 级联查询会自动传递
effectiveDate到关联的时间轴模型。 - 通过 REST API 查看某条记录的版本列表:在
/searchPage(或/searchList)的filters中带上该行的id,并设置acrossTimeline: true,即可返回全部切片(每条带各自的sliceId与生效区间)。时间轴模型即使做了窄字段选择,sliceId也会自动随行返回(类似乐观锁下的version):版本行无需二次查询即可直接操作——用update修正、用deleteBySliceId删除。acrossTimeline是双重触发中的显式一侧,暴露在QueryParams/SearchListParams上;不在SearchNameParams上(displayName 选择器需要的是 as-of 选项,而非全部版本)。当acrossTimeline为 true 时,effectiveDate被忽略。示例:
POST /{model}/searchPage
{ "filters": [["id","=",6]], "orders": [["effectiveStartDate","DESC"]], "acrossTimeline": true }2.3 创建接口
- 在
createOne/createList中,如果未提供effectiveStartDate,则默认使用当前effectiveDate;如果未提供effectiveEndDate,则默认使用9999-12-31。 - 当为已有
id再创建新切片时,系统会根据新的effectiveStartDate自动拆分或调整相邻切片。
写入意图,显式区分:
| 意图 | API | 关键点 |
|---|---|---|
| 创建新实体 | create*(不带 id) | 生成新的逻辑 id + 首切片 |
| 给已有实体新增版本 | addVersion(或 create* 带已有 id) | 返回新版本的 sliceId |
| 修正某个已有版本 | update* | 以 sliceId 为键(提交的 id 会被数据库值覆盖) |
| 终结 / 重开时间轴 | setEndDate(§2.6) | 以逻辑 id 为键;写入最后一个切片的结束日 |
addVersion(modelName, row)是显式的新增版本入口(REST:POST /{model}/addVersion,与deleteBySliceId对偶):行数据必须携带已有实体的id,返回新版本的sliceId(当生效开始日与既有切片相同时,就地修正该切片并返回其sliceId)。行里未提供的字段会自动从相邻切片复制前滚,因此推荐提交增量(id+effectiveStartDate+ 变更字段)。addVersionAndFetch额外按sliceId跨时间轴取回完整版本行(新版本的生效日期未必是今天)。- 守卫:
create*携带不存在的id时,对DISTRIBUTED_LONG/STRING模型直接拒绝——避免 id 笔误静默铸造出新实体。豁免:EXTERNAL_ID模型(新实体本就自带 id)与enableInsertId导入模式(预置 id)。
2.4 更新接口
- 当前实现以
sliceId作为更新主键。更新effectiveStartDate时,系统会自动修正前后相邻切片的effectiveEndDate。 effectiveEndDate由系统计算,在所有通用 update 写入中一律被剥离;唯一合法写入口是setEndDate(§2.6)。如果希望新增一个新生效期的切片,应当使用create,传入已有的id和新的effectiveStartDate。- 如果上层需要支持“更正(correct)”类接口(对历史切片就地更正而不新增切片),通常需要通过
sliceId精确定位;目前 ORM 未内置专门的 correct API。
2.5 删除 / 复制接口
deleteById/deleteByIds:删除某个业务id下的所有切片——这是实体删除,也是入站外键删除策略(onDelete的 RESTRICT / CASCADE / SET_NULL,按逻辑id键控)对引用方模型生效的时机。deleteBySliceId:删除单个切片,并自动修正前后切片的时间范围。实体本身仍然存在,因此onDelete刻意不会触发。copyById/copyByIds:把当前(as-of)切片复制为一个新实体——可复制字段集排除所有时间轴结构键(id/sliceId/生效日期),因此副本获得全新的逻辑id和以当天为起点的首切片。它不会复制完整版本历史,也不会给源实体新增切片。(businessKey字段为copyable = false,复制后需为副本设置新的业务编码。)
2.6 终结与空窗(setEndDate)
setEndDate(modelName, id, endDate)(REST:POST /{model}/setEndDate)写入实体最后一个切片的effectiveEndDate——这是对系统计算的结束日的唯一合法写入口。endDate早于9999-12-31即终结时间轴:其后的 as-of 读取返回空。传9999-12-31即重开。尾切片由服务端按逻辑id解析,调用方永远不会与过期的sliceId赛跑。endDate不得早于尾切片自身的effectiveStartDate——要终结到更早的日期,先用deleteBySliceId删除尾部版本;任何数据都不会被隐式丢弃。- 复活(Revive):起始日晚于已终结结束日的后续
addVersion会插入一个全新的开放段,并刻意留下空窗(gap)。路由完全由既有算法自然导出——没有特例:
addVersion 起始日落在… | 行为 |
|---|---|
| 既有覆盖区间内 | 正常拆分 / 同日就地修正;已终结的结束日在拆分中保留 |
| 空窗内、后续段之前 | 向前填充:新切片结束于下一段起始日的前一天 |
| 一切之后(已终结的尾部) | 复活:新段开放至 9999-12-31;空窗保留 |
- 空窗是安全、静默且有意的。 空窗即”无覆盖”:其内的 as-of 读取无行返回——与”尚未生效的实体(未来日期的首切片)“产生的状态完全一致,消费方没有任何新增义务。重叠——真正的损坏类别(同一日期两行)——依然构造性不可能:只有
setEndDate写结束日,且只写尾切片,而尾切片没有右邻。空窗没有一等行,“这段为什么是暗的”只存在于变更日志;需要可查询原因(停用 vs 终结、报表行)的领域应在其上建版本化状态字段——两者可组合。 - 终结不是删除:实体
id保持有效,入站外键照常解析(as-of 关联在结束日之后只是返回空),onDelete策略不会触发。 - 尖锐边界(通用区间规则作用于终结边界的自然结果——在版本列表中可见,一次
setEndDate即可修复):无邻居的复活段不复制任何内容(等同创世——需提供必填字段);把复活段的起始日左移到已终结段上会重新推导该结束日(空窗被桥接);删除携带终结结束日的切片时,该结束日转移给前驱(heal 规则),而不是重开。
2.7 Versioning seam(引擎内部)
ModelServiceImpl中所有时间轴处理都经一条VersioningStrategy接缝(service/versioning/):IdentityStrategy对普通模型是空操作,TimelineStrategy适配TimelineService中的区间维护算法。新增读路径必须把 Filters/FlexQuery 走scopedRead出口——避免在各调用点散落if (isTimelineModel)。- 跨时间轴退出是契约上的双重触发:显式的
FlexQuery.acrossTimelineData()标志(REST 侧由QueryParams/SearchListParams.acrossTimeline设置),或调用方自行提供的effectiveStartDate/effectiveEndDate条件(表示“我自己做时间过滤”)。任一条件都会抑制默认的生效日钳制;二者均为有意、稳定的行为。 - 已接受的限制(曾评估并否决主从表拆分——其主要收益是真实的数据库 FK 目标,但引用完整性由应用层保证且不生成物理 FK,因此该收益无意义):版本无关字段(如
code)会在每个切片上重复;不支持对时间轴模型声明按 code 引用的关系(code在切片间并非物理唯一)。请按逻辑id(as-of)引用时间轴实体,或通过sliceId钉住某一切片;运行时按 “code+ 生效日期” 做 as-of 查询是完全支持的(不重叠区间保证唯一)。 Context.effectiveDate是环境态(默认今天)。跨线程扇出任务的批处理引擎必须把上下文(ScopedValue)传播到工作线程——例如按payDate计价的发薪跑批——否则该分支会静默按“今天”计价。
2.8 时间轴关联查询规则
-
当关联对象是时间轴模型时,Many2One/One2One 的关联查询会在
LEFT JOIN ON中自动附加:effectiveStartDate <= effectiveDate AND effectiveEndDate >= effectiveDate -
One2Many / Many2Many 的级联查询同样会基于
effectiveDate过滤切片。
3. 示例
3.1 模型定义示例
@Data
@EqualsAndHashCode(callSuper = true)
@Model(label = "Product Price", timeline = true, idStrategy = IdStrategy.DISTRIBUTED_LONG)
@Index(indexName = "uk_product_price_timeline",
fields = {"id", "effectiveStartDate", "effectiveEndDate"}, unique = true)
public class ProductPrice extends TimelineModel {
@Serial
private static final long serialVersionUID = 1L;
@Field(label = "ID")
private Long id;
@Field(label = "Product ID")
private Long productId;
@Field(label = "Price") // BigDecimal → DECIMAL(32,8) by default (money)
private BigDecimal price;
}3.2 查询当前生效与历史切片
ContextHolder.getContext().setEffectiveDate(LocalDate.of(2025, 1, 1));
Filters filters = new Filters().eq("productId", 1001L);
List<Map<String, Object>> current = modelService.searchList("ProductPrice", new FlexQuery(filters));
FlexQuery historyQuery = new FlexQuery(new Filters().eq("id", 1L))
.acrossTimelineData()
.orderBy(Orders.ofAsc("effectiveStartDate"));
List<Map<String, Object>> history = modelService.searchList("ProductPrice", historyQuery);3.3 REST:拉取某条记录的完整版本列表
POST /ProductPrice/searchPage
{
"filters": [["id", "=", 1]],
"orders": [["effectiveStartDate", "ASC"]],
"acrossTimeline": true
}4. 性能考虑
- 默认情况下,如果未显式指定
effectiveStartDate/effectiveEndDate条件且未调用acrossTimelineData(),查询不会跨时间维度扫描所有切片,这有助于降低扫描范围。 - 建议为
effectiveStartDate与effectiveEndDate建立索引。
5. 仅需要“生效时间”而非时间轴的场景
有些模型只需要记录带生效时间的历史(例如人事变动、工作履历、教育经历等),但并不需要严格的时间轴语义(允许同一天多条记录、切片不连续等)。
在 Softa 中,effectiveStartDate / effectiveEndDate 被保留给时间轴模型使用。如果只需要“生效时间”而不希望启用时间轴语义,建议:
- 使用单独的历史模型或使用不同字段名称(避免使用保留字段名);
- 将
timeline = false,避免被时间轴相关逻辑误处理。