ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

自增主键为何被拉黑?分布式ID方案选型与落地指南

自增主键为何被拉黑?分布式ID方案选型与落地指南

先说一个我印象很深的事。当时我还在某家电商平台的后端团队,某天凌晨快下班的时候,DBA同事在群里炸了一句:“线上合并分表,用户ID撞了。”短短十个字,整个值班群瞬间安静了几秒。群里没人敢接话,因为大家都清楚,撞ID意味着什么——两个不同用户的订单、地址、优惠券会互相串,数据一旦写脏,想恢复简直比登天还难。而罪魁祸首,恰恰是当时被整个团队奉为“最省心默认选项”的自增主键。

这大概就是互联网公司后端团队内部一直流传的一条“潜规则”:凡是上了规模、面向用户的线上核心库表,严禁使用自增主键。很多刚入行的人不理解,觉得自增主键明明又短又有序,性能也够好,为什么公司非要立这种规矩?我的看法是,这条规矩不是拍脑袋定的,是用生产事故和亿级别数据量换来的教训。这篇内容我就把背后的逻辑拆开讲透,从分库分表、数据迁移、安全泄露到并发热点,说清楚自增主键到底“死”在哪,以及不用它之后,主流方案又该怎么选、怎么落地。

1. 自增主键为什么会被拉黑:先看它到底惹了什么事

1.1 从一张订单表说起:自增主键最直接的三宗罪

假设你负责的是一个电商系统的订单表,建表时图省事,主键直接写id BIGINT AUTO_INCREMENT。系统刚上线时确实爽,插入不用管ID,数据库自己往上加,查询走主键索引也快,谁看了不说一句“经典配置”。

但业务跑到一定规模后,问题就来了。

第一宗罪:数据合并直接撞车。如果你做过平台型业务就知道,一个主体下往往有多个子业务线,比如一个公司做商城、做外卖、做本地生活,各自先跑自己的订单表。等到公司要做“会员中台”“订单中台”,把多个业务线的订单拉到一个大池子里统一管理时,自增ID立刻变成地雷。A线订单ID到了 100 万,B线订单ID也到了 80 万,两个库的数据一合并,ID从 1 到 80 万全是冲突。你要么改源数据ID,要么引入映射关系,这个工程量和风险系数,经历过一次的人绝对不想再经历第二次。

第二宗罪:业务信息被白嫖。自增主键天然自带“业务透视镜”属性。你今天的注册用户ID是 10001,明天是 10050,竞争对手只需要隔几天注册一个新账号,就能算出你平台每天的增量用户数。更狠的,抓你订单号,今天下单的ID区间和昨天对比,就能推算出日订单量。这个数据在投融资、市场对标、商家谈判中都值大钱,而自增ID等于你亲手把经营数据做成明牌递给对手。

第三宗罪:ID可预测带来的越权风险。很多系统早期做水平权限校验不严格,接口里传一个订单ID就把详情返回了。用自增ID,攻击者从 1 开始往上遍历,能把你所有历史订单都扒一遍。虽然这是接口鉴权的锅,但自增ID等于给攻击者提供了完美的遍历序列,降低了批量拉数据的成本。安全评审的时候,自增ID往往会被直接标记为高风险项。

1.2 一个真实的生产事故:数据合并时ID撞车后的连锁反应

我之前参与过一个社区产品的用户数据整合项目,两个子产品合并用户体系,两边都用的自增主键。开发初期大家觉得这事很简单,无非就是把B产品的用户搬过来,ID加一个偏移量就行。

结果真做的时候发现,偏移量根本定不下来。B产品线上还在持续产生新用户,你今天定偏移量 100 万,明天它真实用户数涨到 110 万,偏移量就得重算。而且A产品自己也删过一批用户,存在ID空洞,偏移量加上空洞,对账怎么都对不平。

更麻烦的是,用户ID在业务系统里不是只有一张表的事。帖子表里有user_id,评论表里有user_id,私信、关注关系、消息通知、操作日志,全都有用户ID的外键关联。你改了用户主表的主键,就得同步改几十张关联表里的user_id字段。那段时间我们跑数据订正任务跑了两天两夜,跑了三个批次才把关联表的数据修正完,中间还因为漏了一张日志表,导致一部分用户的行为轨迹串到了另一个人身上,后面又花了一周排查和修复。

那次之后,团队里定了一条铁律:凡是核心业务表的新建表,一律禁用自增主键;存量表也列了改造计划,逐步替换为分布式ID。所以如果你所在团队还在大规模用自增主键,我建议你认真评估一下未来是否有合并、拆分、迁移的可能——只要有一条占上,自增主键就是定时炸弹。

2. 分库分表语境下的自增主键:看似能用,实则寸步难行

2.1 分片算法迁移:ID冲突带来的连锁反应

互联网业务增长到单库单表扛不住的时候,分库分表基本是必经之路。常见的分片方式有 range 分片和 hash 分片两种,不管哪种,你都需要一个全局唯一的ID作为业务主键。

如果你一直用自增主键,分库分表那一刻就傻眼了。每个分片的自增ID都从 1 开始,A分片和B分片会生成相同的ID,你拿什么区分这条数据属于哪个分片?有人会说,那我给每个分片设置不同的初始值,比如分片1从 1 开始、分片2从 1 亿开始,步长设为分片数,这不就解决了吗?

理论上听着可行,实际落地全是坑。分片数不是一成不变的,今天 4 个分片,双十一前要扩到 8 个分片,扩的时候步长就得从 4 改成 8。但已经是 4 步长生成出来的ID分布是 1、5、9、13 这种间隔,改成 8 步长后新ID直接撞上已有ID。要平滑扩容,你必须重建整张表的数据分布。更糟的是,自增ID只保证在单实例内递增,扩容时跨实例迁移数据,ID顺序全乱,曾经依赖ID排序展示的业务查询逻辑也会跟着出幺蛾子。

2.2 并发插入热点:自增锁如何拖垮写性能

自增主键在高并发写入场景下有一个常被忽略的瓶颈——自增锁竞争。早期的MySQL InnoDB引擎对自增列使用表级锁,也就是AUTO-INC Lock,插入的时候整个表只能有一个事务在申请自增ID,其他插入全部排队等待。

后来MySQL加入了innodb_autoinc_lock_mode参数,可以调整为交错模式,把表级锁降级成轻量级互斥锁,性能好了一些。但问题在于,如果你的复制模式还是基于语句的复制(statement-based replication),为了保持主从数据一致,批量插入还是需要退回到表级锁。就算你用了行复制,高并发下自增ID的生成依然是一个全局互斥点——所有写入线程都要抢同一把“发号锁”。

对比一下,如果主键是业务生成的分布式ID,比如雪花ID,每个应用节点各自独立生成ID,数据库侧根本不存在锁竞争。这在写入密集型的场景里差别非常大,尤其是秒杀、抢购、GMV对账这种瞬间写入量极大的业务,自增锁的等待时间会直接影响接口的TP99。

2.3 主从复制与ID空洞:运维侧的隐藏坑

自增ID在主从架构里还会暴露另一个问题:ID空洞和主从不一致。

最常见的空洞来源是事务回滚。一个事务插入了一行,拿到了自增ID,但事务最终回滚了,这个ID就废掉了,下次插入不会复用。这是正常现象,但如果业务里对ID有“连续编号”的预期(比如发票号、流水号),就会对不上账。更隐蔽的是,某些数据库运维操作,比如手动插入指定ID、DELETE后重启服务、批量导入数据,也会导致自增计数器被重置或跳跃。

主从不一致就更麻烦。MySQL在binlog_format=statement的情况下,自增ID的生成在主库和从库上可能不一致,因为从库重放的是语句而非实际ID值。一旦从库的数据和主库产生偏差,读从库做报表、做数据分析、做异步搜索索引时,拿到的数据就不对。排查这种问题,往往要翻 binlog 对比ID序列,极其费劲。

所以在分库分表、主从复制、高并发写入三重压力下,自增主键已经不只是“不好用”,而是从根上就不适配互联网核心链路的架构模型。

3. 换个角度想问题:自增主键对业务和安全的杀伤力

3.1 订单号和用户ID泄露:被爬虫白嫖的商业机密

自增ID的泄露问题,不是“可能泄露”,而是“必然泄露”。只要你的业务对外开放,哪怕是登录后才能看到的接口,爬虫用批量注册的账号就能拿到连续ID的访问方式。

举一个当时在电商公司遇到的真实例子。竞品公司用一批自动注册的账号,每隔一小时下一单“测试订单”,然后记录订单ID。连续记录几天,就能画出一条订单ID增长曲线。再结合我们平台公布的GMV增速,他们基本能反推我们各时段的真实订单量。这种信息在外界看来只是“写爬虫的人真厉害”,但对公司战略部门来说,等于是把经营底牌交了出去。

订单ID还会泄露时间信息。自增ID单调递增,ID本身就是时间序列的索引。知道一个订单ID是 50000,另一个是 50005,虽然没有精确到秒,但结合ID生成的速率模型,能猜出大概的时间窗口。如果业务上还用了ID做URL参数,比如https://xxx.com/order/detail/{id},搜索引擎和历史快照可能还会把旧订单页缓存下来,进一步扩大了泄露面。

3.2 数据合并与中台化整改:历史包袱从何而来

我见过很多传统企业转互联网的业务系统,早期全部是自增主键。到公司决心做数据中台、做精细化管理时,第一个要推翻的就是主键设计。

为什么?因为中台化意味着多业务线的数据要进入统一的模型。用户统一、订单统一、商品统一,这是中台的最基本要求。而各业务线独立跑了好几年,各自的自增主键早就形成了自己的生态。A业务的用户ID是 1 到 500 万,B业务的用户ID也是 1 到 300 万,C业务的用户ID还是从 1 开始。把这三个体系合并,没有一个是能直接用的。

处理这种历史包袱,行业里通常的做法是引入一个全局唯一的“新ID”,然后把旧ID作为普通业务字段保留,通过映射表关联。但映射表本身也有数据量大的问题,而且映射关系维护是持续的,每新增一个老系统的用户,就得同步新增一条映射记录。这套链路如果没设计好,就成了后续所有查询的额外 JOIN 开销。可以说,早期图省事选的自增主键,最终都会在系统整合的时候变成加倍偿还的技术债。

3.3 前端性能优化视角:为什么稀疏ID让缓存失效

自增ID还有一个和前端、缓存强相关的隐藏问题——ID稀疏导致的数据分布不均匀。

比如一个内容平台的帖子表,主键自增。用户收藏的帖子ID、浏览历史的帖子ID,在缓存层如果用ID做 key,高维度的稀疏ID会让缓存的内存效率变差。更重要的是,对数据库进行按ID的 range 扫描时,如果中间有大量空洞,InnoDB 会把空洞对应的索引页也扫进来,同样扫描 10000 条ID记录,含空洞的表可能要多读好几倍的索引页。

落实到业务上,最常见的坑是分页。用WHERE id > lastId ORDER BY id LIMIT 20的方式翻页,如果ID空洞过多,每一页实际要跳过的行数会很不稳定,用户体验表现为翻页时出现内容跳跃或重复。改成分布式ID(雪花ID或类雪花ID)后,虽然不能完全消除空洞问题,但你可以通过一些ID含时间信息的方案,让分页条件里加上时间维度,规避空洞对翻页体验的伤害。

4. 不用自增主键,那用什么?主流分布式ID方案全对比

讲完了自增主键的问题,自然要回答“替代方案”。目前行业里用得最成熟的是这么几类:雪花算法、数据库号段发号器、Redis发号器、UUID/类UUID。下面逐一展开。

4.1 雪花算法:高并发场景的主流选择

雪花算法(Snowflake)是分布式ID方案中的“默认答案”,Twitter 提出的思路在行业里被广泛改造和应用。它的ID是一个64位的Long型,通常拆成四个部分:1位符号位 + 41位毫秒时间戳 + 10位工作机器ID + 12位序列号。

41位时间戳可以支撑约69年的时间,在业务内绰绰有余。10位机器ID可以支持1024个节点独立工作。12位序列号表示同一毫秒内最多可以生成4096个ID。三者合在一起,保证了“全局唯一、趋势有序、高吞吐”。

我对雪花算法的评价是:它是互联网核心链路最省心的选择,但有几个细节必须处理。

第一,机器ID的分配要稳定。服务重启、扩容缩容时机器ID不能变,否则可能出现重复ID。第二,时钟回拨问题。如果服务器发生NTP时间回拨,雪花算法可能生成比之前更小的ID甚至重复ID。解决思路是记录上次生成ID的毫秒时间,如果当前时间小于上次时间,就短暂拒绝请求或等待时间追上。

雪花算法生成的ID是“趋势递增”的,不是严格递增。做过订单分库分表的人都知道,这个特性已经足够支撑分页和排序了,别纠结“严格连续”。

4.2 基于数据库号段的发号器:控制权最稳的方案

如果你对“全局唯一”的强一致要求特别高,比如金融交易流水、账务明细,雪花算法那种“靠机器时钟和随机序列”的方案会让你心里不踏实。这时候可以选数据库号段方案,行业内俗称 Leaf、Segment,或者叫“号段发号器”。

这个方案的核心思想很简单:用一个中心数据库表维护发号区间,比如表里存biz_type, max_id, step。应用节点启动时,向数据库申请一个号段,比如[1001, 2000],拿到之后在本地内存里直接自增生成ID,用完再向数据库申请下一个号段。

优点很明显:ID严格递增、全局唯一、不依赖机器时钟,不会出现回拨问题。而且号段方案天然支持批量取号,每次去数据库只申请一次,网络开销固定,应用侧生成ID的性能非常高。

但它的代价是:需要额外维护一套发号服务,并且发号表是单点写入。为了保证高可用,通常要部署双主或者多副本,用分布式锁控制并发取号。这套基础设施的运维成本,小团队不一定扛得住。我的建议是,业务规模到了需要分布式ID但团队还没能力自研雪花算法改造的时候,号段发号器反而是风险最低的方案,因为它的逻辑简单、故障定位容易,生产环境最容易兜底。

4.3 UUID与类UUID方案:什么时候可以选,什么时候千万别用

UUID方案是最常见的“看起来省事”的方案。UUID.randomUUID()一行代码生成一个128位的随机字符串,比自增ID还省心,而且绝对全局唯一、无法猜测。

但如果你在MySQL这样的关系型数据库里用UUID做主键,我劝你先想清楚两个代价。

第一,UUID是128位的十六进制字符串,占用的存储空间比BIGINT大得多,主键索引(聚簇索引)里的每一行都要带这个主键值,二级索引更是会把主键作为“回表指针”嵌入每一条记录。数据量大以后,索引体积膨胀非常恐怖,查询性能会明显下降。第二,UUID的随机性导致插入的数据在索引B+树上的位置完全分散,频繁插入会造成严重的页分裂和随机写,InnoDB的缓冲池命中率也会受影响。

如果你确实要用UUID类方案,行业里有两个改进做法:一是用UUID_TO_BIN函数把字符串转成二进制存储(MySQL 8.0+ 支持),节省空间;二是使用有序UUID(比如UUID v7),它按时间戳排序,能减少页分裂。但老实说,既然已经要在MySQL里存有序ID了,直接用雪花算法不是更省事吗?UUID真正的适用场景是那些“不需要数据库参与生成、完全离线生成”的场景,比如客户端生成的设备ID、消息轨迹ID、日志追踪ID,这些地方主键性能不再关键,UUID方便且唯一,用起来很合适。

4.4 各方案对比表格与选型原则

为了更直观,我做了一张对比表,方便你在方案评审会上直接引用。

方案全局唯一性趋势有序性性能实现复杂度典型场景
自增主键单库内唯一严格递增高(但并发有锁竞争)极低内部工具表、日志流水
雪花算法全局唯一趋势递增高(应用侧生成,并发无锁)中核心业务主键、订单ID、用户ID
数据库号段全局唯一严格递增高(批量取号后本地生成)高金融流水、强一致性账务
UUID(二进制)全局唯一基本无序中(索引膨胀、页分裂)低客户端设备ID、日志追踪ID

选型原则我个人总结为三条:第一,核心业务表的用户可见ID,禁用自增、禁用纯UUID,优先选雪花类方案,因为既要防猜测又要存得住、查得快。第二,强一致且对序要求极高的场景,选号段方案,用运维复杂度换确定性。第三,非核心的内部系统、日志表、临时表,自增主键可以用,没人拦你,别一刀切把简单问题复杂化。

5. 一套可以落地的主键改造方案:从存量系统到增量设计

5.1 存量表改造:如何平滑迁移已有自增主键

如果你团队已经有大量表用了自增主键,也别慌,改造是可行的,但要分步走,千万别一口气在线换主键。

第一步,引入新的全局ID字段。在业务表里增加一个biz_id字段,类型为 BIGINT,索引单独建。这期间线上继续用旧自增ID做主键,新写入的数据同时生成biz_id并落库。

第二步,双写与对账。所有写操作在更新旧主键数据的同时,也保证biz_id被正确赋值。对存量数据,写一个数据订正任务,为每一条旧记录补齐biz_id。这个阶段每天要做对账,确认读写两侧数据一致。

第三步,切读切写。业务代码把所有基于主键的查询和写入条件都改成基于biz_id,数据库的主键可以不变(因为InnoDB主键索引改动风险大),但业务上已经完全不再依赖旧自增ID了。这一步相当于逻辑迁移。

第四步,可选物理重构。如果旧自增主键占用的索引空间影响了性能,或者分库分表计划要求必须换物理主键,可以在低峰期做一次表重建,把biz_id设为主键,旧自增ID退化为普通字段或直接删除。

这套流程里最容易踩的坑是双写阶段的漏写和错写。我的实践经验是,改造期间可以加一个监控项:每次插入或更新记录时,如果检测到biz_id为空,立刻报警并阻塞该操作。宁可牺牲一点写入性能,也不能让脏数据流下去。

5.2 新建表的推荐DDL写法与业务规范

很多新项目从零开始,建表规范可以直接定好,省得未来填坑。我推荐的核心表 DDL 大概是这样的:

CREATE TABLE `order_info` ( `id` BIGINT NOT NULL COMMENT '业务订单ID,由分布式ID生成器产生', `user_id` BIGINT NOT NULL COMMENT '用户ID,同样使用分布式ID生成器', `order_no` VARCHAR(64) NOT NULL COMMENT '业务订单号', `amount` DECIMAL(12,2) NOT NULL, `status` TINYINT NOT NULL DEFAULT 0, `create_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`id`), UNIQUE KEY `uk_order_no` (`order_no`), KEY `idx_user_id` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单主表';

从这个DDL可以看到几个设计要点:主键id不再加AUTO_INCREMENT,由应用层负责生成并传入;字符串型业务流水号走唯一索引,而不是主键;对高频查询条件建独立索引,而不是依赖主键排序。团队内部还可以约定:分布式ID生成器只允许统一封装在基础库的ID生成组件里,业务代码严禁自己 new 雪花算法实现,避免每个团队一套ID规则、排查问题的时候鸡同鸭讲。

5.3 配套的代码规范与监控告警建议

光改数据库不够,代码层和应用基础设施也得跟上。

ID生成器必须做降级演练。如果发号服务挂了,应用是阻塞等待,还是降级为“临时生成UUID并标记”?我的建议是核心链路必须有降级预案,比如设置发号超时时间,超时后使用备用发号通道或者本地内存缓存号段,确保写入链路不中断。

监控方面,需要重点关注三个指标:ID生成延迟、ID重复率、时钟偏差。ID生成延迟直接反映发号服务健康度;ID重复率理论上必须是0,一旦出现非0,立刻进P0故障处理;时钟偏差监控针对雪花算法,服务器每次NTP校时偏差超过阈值要告警。

另外一个容易被忽视的点是测试环境。很多公司测试环境的表也沿用了生产的主键策略,导致联调时经常出现ID冲突。建议测试环境统一使用一个独立的发号服务实例,或者干脆用时间戳+随机数拼接的测试专用ID,别和生产共用同一套ID规则,否则测试数据污染生产数据的问题防不胜防。

6. 常见问题与我的实操体验

6.1 常见争议:自增主键在某些场景下真的完全不能碰吗

每次聊到这个话题,总有人问:那我公司体量小、表数据量只有几十万,用自增主键也不行吗?

我的回答是:技术上能用,管理上要谨慎。“严禁使用自增主键”主要是针对互联网公司面向用户的在线交易、在线互动这类核心链路,因为这些系统的数据天然会有合并、迁移、拆分、防爬取等需求。如果你的表是内部系统的操作日志、后台管理端的字典表、一次性数据导入的暂存表,用自增主键完全没问题,毕竟自增主键的写入效率和索引紧凑度确实是实打实的优势。

但有一个例外请你注意:只要这张表未来存在“对外暴露ID”的可能,或者业务上要和其他系统的数据做关联整合,建议直接上分布式ID,别赌。

6.2 踩坑记录:ID设计不当引发的三个故障

我职业生涯里踩过三个和主键设计直接相关的大坑,分享出来供你参考。

第一个是数据合并的ID撞车,前面讲过了,那次事故让团队加了两周班。第二个是分库分表后使用“步长自增”扩容,扩分片时ID冲突,最后靠临时停机维护重建了数据分布。第三个是测试环境和生产环境用了相同的雪花算法机器ID,导致从测试库导入生产库的部分数据ID重复,引发了用户端数据展示错乱。后面两个问题都是典型的“基础设计缺规范”导致的,如果从一开始就统一发号服务和环境隔离,完全可以避免。

6.3 关于主键设计,我最后的经验心得

做后端这些年,我越来越觉得主键设计不是单纯的数据库性能问题,而是数据生命周期管理问题。一张表的数据未来会怎么增长、怎么流转、怎么被外部访问,决定了你今天该选什么主键策略。单纯因为“用起来顺手”就默认自增主键,等于把未来所有麻烦都提前欠下了。

如果你现在正在设计新系统的库表结构,或者正准备对老系统做主键改造,我觉得最值得先做的事是:把你自己系统未来一年内可能发生的合并、迁移、分片、扩容、对外开放数据接入这些事列一个清单,再对照这篇内容里的方案选型,基本不会走偏。分布式ID的上手成本没有想象中高,从雪花算法的改造做起,配合号段方案兜底,你的核心链路就再也不用半夜被ID撞车的电话叫醒了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进