Skip to Content
文档特性时间轴模型

时间轴模型

时间轴模型(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(逻辑)部门编码部门名称effectiveStartDateeffectiveEndDateManager
36D001产品研发部2022-09-019999-12-31Joan
26D001研发部2020-05-112022-08-31Tom
16D001研发部2019-08-012020-05-10Mars

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,避免被时间轴相关逻辑误处理。
最后更新于