17 位复合主键:不查表也知道记录属于哪个实体

系列写到第六篇,讲一个很小但贯穿全局的东西,主键。

大多数系统的主键不是自增就是 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 属于哪张表"的查询和判断。

相关推荐
李剑一9 小时前
AI翻译都这么强了,为什么程序员还是躲不开英语?假如你想用上GPT6,我劝你还是早点背单词吧!
面试·产品·创业
阿宸科普3 天前
液冷机组冷却设备,接触器怎么选?三大系列参数与工程校核要点
产品
洞察万事局3 天前
液冷机组冷却设备,接触器怎么选?一个电气设计的一线记录
产品
Mahut3 天前
我在 Mac 上做了个本地剪辑器
javascript·架构·产品
colorknight3 天前
FDE:深入客户,走向产品
产品
Nturmoils4 天前
有了 Parallels Desktop,我终于不用问别人借Windows电脑用了
产品
Jucai_in_AI4 天前
全球化培训平台多语言多时区引擎技术实现:自动语言探测、语种动态管理与本地化渲染方案
架构·产品
掘金015 天前
0 元把联想平板变成 MacBook 副屏:真扩展、支持触控、纯开源
程序员·产品
fxss6 天前
教孩子认字,一个字能讲出一整幅画|宝宝学识屋·学中文
产品