写在前面
建第一张表的时候我就没有用自增主键。当时同事问过一个问题:“单库单表的项目,用自增 ID 不是最省事吗?”
省事是真省事。但 IM 系统有几个特殊需求,让自增 ID 的"省"变成了"险"。这篇记录我的 ID 方案——一个只有三十行的简化版雪花 ID——以及它放弃了什么、换来了什么。
一、自增 ID 在 IM 场景的三个软肋
软肋一:ID 会泄露业务量。 自增 ID 是连续的。拿到两条消息的 ID,一减就知道这个会话发了多少消息;注册一个新账号,看一眼自己的 userId,全公司的用户规模就猜得八九不离十。内部系统也许无所谓,但 ID 可预测还会引来另一种骚扰:拿 id+1 撞接口(虽然还有权限层拦着,但别给攻击者省事)。
软肋二:ID 依赖数据库"发号"。 每条插入都由数据库分配主键,意味着"先 INSERT 才知道 ID"。而我的很多流程需要在插入前就拿到 ID:IM 消息的幂等键、Agent 任务的审计日志(任务还没插进去,日志里就得带 taskId)、异步链路里上游要先把 ID 传给下游。让数据库发号,这些流程全部要改成"插完再查一遍",多一次往返,多一类并发问题。
软肋三:数据合并的深渊。 自增 ID 的隐藏前提是"全局只有一条时间线"。将来要按会话分库分表、要做主从切换、要把测试数据合并进生产——两个库里都有一张 ID=1024 的消息,合并就是一场灾难。单机项目也应该给未来留缝。
二、三十行的简化版雪花 ID
经典雪花 ID 是 64 位:1 位符号 + 41 位毫秒时间戳 + 10 位机器 ID + 12 位序列号。我的版本砍掉了机器 ID 段:
public final class IdGenerator {
private static final AtomicLong LAST_ID = new AtomicLong(System.currentTimeMillis() << 20);
private IdGenerator() {
}
public static long nextId() {
long now = System.currentTimeMillis() << 20;
while (true) {
long current = LAST_ID.get();
long next = Math.max(now, current + 1);
if (LAST_ID.compareAndSet(current, next)) {
return next;
}
}
}
}
结构是时间戳左移 20 位,低 20 位留给自增序列,单机每毫秒可生成约 100 万个 ID。逐行拆一下这段小代码里的三个设计点:
Math.max(now, current + 1) 是全局单调的保证。 时间戳只负责"大体往前走",但遇到时钟回拨(NTP 校时是生产环境常态),now 可能比上一个 ID 还小。这时 Math.max 会放弃时间戳,直接在上一个值上加 1——ID 严格递增的承诺不依赖时钟的正确性。代价是回拨期间 ID 的时间语义失真,但对一个主键来说,可比性比时间语义重要得多。
CAS 循环是无锁的并发安全。 两个线程同时抢 LAST_ID,失败的线程读到新值重算重试。比起给方法加 synchronized,无竞争路径上零开销——ID 生成是全系统的最高频操作之一,这里的热点值得抠。
静态 AtomicLong 意味着单机内全局唯一,跨机不唯一。 这是最明确的取舍:没有机器 ID 段,两个实例同一毫秒、同一序列号会生成相同的 ID。我的系统当前就是单实例部署,这个风险是零;如果将来扩到多实例,方案也现成——低 20 位切出几位做机器 ID,启动时配置注入。我知道这个雷埋在哪、什么时候会响、怎么拆,所以现在不用拆。 为不存在的部署规模提前设计机器 ID 协调( ZooKeeper 那一套),才是真正的过度工程。
三、前端的一段神秘代码
ID 换成 64 位 long 之后,第一个炸的居然是前端。JavaScript 的 Number 是 64 位浮点,整数安全范围是 2^53——雪花 ID 动辄 2^60 起,JSON.parse 一遇到它就静默丢精度,后端发的 7123456789012345678,前端拿到的是 7123456789012345000。
找ID找不到、幂等键对不上,全是这个坑的变种。解法是一段专用的 JSON 解析:
// parseJsonPreservingUnsafeIntegers:识别超出安全范围的整数,按字符串处理
解析时把超范围的长整数保留为字符串,业务字段按需转换。配套的纪律是:后端所有 ID 字段在 JSON 里按 long 传输,前端一律按字符串处理,不做任何算术。这条规矩写进了前后端的接口约定,之后这类 bug 再没出现过。
Java 这边也有个小配合:nextIdString() 把 ID 转成字符串再出去,日志和响应里统一用字符串形态,避免"数字被科学计数法显示"之类的边角问题。
四、ID 是排序的隐形基础设施
选雪花 ID 还有一层意图:让主键自带时间序。同一毫秒生成的 ID 单调递增,不同毫秒按时间递增——这意味着 ORDER BY id 近似等于"按创建时间排序"。
这不是锦上添花,是被上游依赖的特性:游标分页里"时间并列时用 id 决胜负",靠的就是 id 与时间的单调一致——如果 id 是随机的,last_message_at = ? AND id < ? 这个游标谓词会返回乱序的页面。分布式 ID 方案里那些"趋势递增 vs 完全递增"的讨论,落到地上就是这类查询的正确性。
反面的例子是某些 UUID 版本:全局唯一没问题,但完全无序,做主键还会让 InnoDB 的聚簇索引页频繁分裂、写入放大。主键的排序性不是美学,是 InnoDB 的物理结构决定的性能。
五、小结
三点体会:
- ID 方案的本质是"发号权"放在哪。 自增把发号权给了数据库,雪花 ID 把它拿回应用层——拿回来之后,“插入前就有 ID”、幂等键、审计日志这些流程才转得动。
- 每个简化都要写明失效条件。 没有机器 ID 段 = 单实例约束。简化不可怕,可怕的是简化的时候不知道自己放弃了什么、什么时候需要还回来。
- 跨语言的精度边界要在第一天生就设防。 64 位 long 和 JS Number 之间隔着 2^53,这条缝早晚要炸,"ID 按字符串传输"这条约定越早立越好。
三十行的代码撑起了全系统的主键。它不优雅、不通用、不敢上多实例,但每一个"不敢"背后都是一条写下来的约束——工程里的安全感,往往来自知道自己没做什么。
默认评论
Halo系统提供的评论