系列写到第六篇,讲一个很小但贯穿全局的东西,主键。
大多数系统的主键不是自增就是 UUID,各有利弊。我给 Linker 设计了一个 17 位纯数字主键,前 4 位是实体类型编码,后 13 位是毫秒时间戳。好处是拿到一个 ID,不用 join、不用额外传参,就能知道该查哪张表。
为什么自增和 UUID 都不合适
自增主键的问题,在这个系统里被放大了。元数据驱动的 CRM,表是运行时建的,几十上百个实体,各自有各自的表。如果用各自表内的自增,那么客户表的第 1 条记录和订单表的第 1 条记录,主键都是 1。引用字段里存个 1,你根本不知道它引用的是客户还是订单。
UUID 解决了全局唯一,但它是随机串,InnoDB 聚簇索引上插入时页分裂严重,而且 36 位字符串既不可读也不可自解释。雪花算法不错,但要拆位、要机器号、要序列号,对这套系统来说偏重了。
我需要的是:一个值,全局唯一,还能直接告诉我它是哪个实体的。
一个 long 拆成两段
最终的设计是一个公式:
主键 = entityCode × 10^13 + 当前毫秒时间戳
entityCode 是实体类型编码,每个实体建号时分配一个。前 4 位是它,后 13 位是时间戳。生成代码简化后长这样:
java
public static ID generateId(int code) {
long current = System.currentTimeMillis();
synchronized (LAST_TIME_MILLIS) {
while (LAST_TIME_MILLIS.containsKey(code)
&& LAST_TIME_MILLIS.get(code) >= current) {
current = current + 1;
}
LAST_TIME_MILLIS.put(code, current);
}
return id(code * 10000000000000L + current);
}
比如客户实体的 code 是 2000,某次生成的毫秒时间戳是 1755123456789,那这条客户记录的主键就是 20001755123456789。17 位。
为什么是 10^13,以及边界
10^13 这个数是毫秒时间戳的上界。当前毫秒时间戳是 13 位数,约 1.75×10^12,要涨到 10^13 大概要 317 年,短期内不用操心。
真正要卡的是 entityCode 的范围。为了保证整个主键是严格 17 位,代码里有两个常量:
java
private static final long MIN = 10000000000000000L; // 10^16
private static final long MAX = 100000000000000000L; // 10^17
主键必须落在 10^16 和 10^17 之间,也就是 17 位。倒推一下,entityCode 只能在 1000 到 9999 之间,最多 9000 个实体类型。系统实体从 1001 开始排,用户自定义实体从 2000 开始排。对一套 CRM 来说,9000 个实体绰绰有余。
顺带说一句,这里有个反直觉的细节:系统实体的 code 是 1003、1004、1005,而不是 1000。因为 1000×10^13 恰好等于 MIN,而校验要求严格大于,所以 1000 这个值本身不能用。这也是当初踩过的一个边界,写进常量注释里了。
一个除法,就知道该查哪张表
这个设计最值钱的地方在于反解。拿到一个主键,除以 10^13 取整,就得到实体类型:
java
public static int getCode(Long value) {
return (int) (value / 10000000000000L);
}
于是 LinkerDB.find(id) 的实现就很简单:先从 id 里解出 code,用 code 定位到实体,拿到表名和主键列名,再拼 SQL 查。调用方只需要一个 id,不需要告诉它"这是客户还是订单"。
这个能力在系统里到处都在用。权限判断的时候,先解出实体再拼权限码;读引用字段的时候,解出实体再把 ID 补成带名字的对象;软删除、审批、记录锁定,几乎每个跨实体的地方,第一步都是这个除法。
同毫秒冲突怎么办
13 位时间戳是毫秒级的,同一毫秒里对同一个实体插两条记录,理论上会撞。所以有了上面代码里的 LAST_TIME_MILLIS。
它是一个进程内的 ConcurrentHashMap,key 是 entityCode,value 是上一次用的毫秒值。生成时加锁,如果当前毫秒小于等于上一次的值,就加 1 循环,直到超过为止。这样同一 JVM 内,同一个实体生成的主键严格单调递增,扛得住同毫秒并发插入。
代价:多实例不保证唯一
这里我得说实话。这个防冲突机制只在单个 JVM 内有效。LAST_TIME_MILLIS 是进程内静态变量,如果部署成多实例,两个实例在同一毫秒对同一个实体生成主键,可能撞车。
它没有雪花算法那样的机器号,也没有数据库序列兜底。所以这套设计默认你是单实例部署,或者由外层做实例隔离。如果你要做高并发多写实例,这个主键方案就不够看了。
另一个代价是 entityCode 被锁死在 9000 以内。CRM 够用,但如果你拿这套东西去做通用平台,实体数量可能不够,得提前考虑。
引用字段就存这个 long
关联关系是怎么用这个主键的,也顺便说清楚。
单引用字段,物理列就是个 bigint,存被引用记录的那个完整 long 主键。多引用字段,存的是 long 数组的 JSON 文本。没有中间关联表。
主键字段的逻辑名是"实体名加 Id",比如 customerId;物理列名是大写,比如 CUSTOMER_ID。这个映射在第四篇的查询 DSL 里被翻译,业务代码里只看到 customerId。
这个思路别人也用过
把实体类型编码进主键,我不是第一个。Salesforce 的对象 ID 前 3 位就是对象前缀,比如客户是 001,联系人 003,一眼能看出这个 ID 属于哪种记录。我用前 4 位数字实现了同样的效果,顺便用后 13 位时间戳保证了同一实体内的时间有序。
它的核心价值就一句话:主键不再只是一个标识,它自己就携带了路由信息。省掉的是无数次"这个 ID 属于哪张表"的查询和判断。