一场跨用户的退款事故
四大战场打完(Redis 缓存 16 篇、MySQL 实战 22 篇、消息队列 20 篇、Elasticsearch 20 篇),503 咖啡馆的商城业务也起飞了:单日订单冲破 50 万,单库单表先扛不住。老王照着 MySQL 系列里分库分表的路线图,把订单库按用户维度拆成了 16 个库、每库 16 张表,一共 256 张订单表,顺理成章。
上线第三天,客服炸群:用户 A 申请退款,退的是用户 B 的订单;隔壁用户 B 发工单说订单凭空消失。老王连夜排查,最后盯住两个字段——两张不同表里的订单,ID 一模一样,都是 1001。
根因朴素得扎心:拆表之前,订单 ID 靠单库 auto_increment 发号,天然全局唯一;拆成 256 张表之后,每张表各自从 1 开始发号,「全局唯一」四个字当场作废。A 的 1001 落在表一,B 的 1001 落在表二,按 ID 检索订单的退款请求,指错了人。
auto_increment 在分布式下的三宗罪
第一宗罪:多库必撞车。auto_increment 是表级语义,只保证一张表内唯一。分库分表后,256 个发号器各发各的,撞车是必然不是偶然,除非在它上面再叠一层全局调度。
第二宗罪:扩容是灾难现场。就算拆表时用步长错开了起点(下面马上讲),扩容要重排起点与步长,老号段发完即断档;想避免重排,就得提前预留一大把空库空表,白白占着部署资源。
第三宗罪:ID 出卖业务。连续自增的订单号等于把销量贴在脸上:竞对每天下一单,盯你号涨多快,日单量、增速全被拿捏;订单号还能被遍历刷接口。这也是本系列后面专门留一篇讲 ID 安全的原因。
步长方案:救急不救命
先看只靠数据库能凑合的方案:步长发号。16 张表,起始值分别 1 到 16,步长统一 16:
表 0 发号:1, 17, 33, 49 ...
表 1 发号:2, 18, 34, 50 ...
表 15 发号:16, 32, 48, 64 ...
起点错开、步长拉齐,号段永不交叉这招能立刻止住撞车,老王上线第一周就靠它稳住场面。但硬伤摆在明面上:扩到 32 张表时,步长要从 16 改成 32,所有表的发号起点重排,得停写对齐;多条业务线共用一套规则时步长互相牵连;号段依赖每张表自身的 auto_increment,发号压力还是压在订单库上。这套方案适合「确定不再扩容的小规模」,规模一上来,它是定时炸弹。
业务到底要什么样的 ID
事故复盘会上,老王把订单系统对 ID 的需求写成一页纸,五条:
| 要求 | 为什么 | 反面教材 |
|---|---|---|
| 全局唯一 | 底线要求,撞号直接资损 | 拆表后两库同号,退款退错人 |
| 趋势递增 | 主键顺序写不翻页,索引不折腾 | 随机主键让 B+ 树页分裂频发(第 11 篇细讲) |
| 不暴露量级 | 防竞对侦查与恶意遍历 | 连续订单号泄露日单量 |
| 高可用高并发 | 发号是全站下单的前置依赖 | 发号器一挂,全网不能下单 |
| 长度克制 | 64 位整型省索引省内存 | 超长字符串主键撑大二级索引 |
五条摁下去才发现,没有一款现成方案是五指齐全的——分布式 ID 的本质是权衡:要并发就难绝对连续,要无依赖就难管住时钟,要信息安全就得牺牲一点可读性。
四条候选路线,混个脸熟
| 路线 | 核心原理 | 一句话点评 |
|---|---|---|
| UUID | 本机随机生成 128 位 | 零依赖,但无序又肥,索引吃亏 |
| 号段模式 | DB 批量取一段号,内存里慢慢发 | 趋势递增,DB 压力小,增加一个发号服务 |
| 雪花算法 | 时间戳加机器号加序列拼 64 位 | 本地发号吞吐高,怕时钟回拨 |
| Redis 发号 | INCR 原子自增 | 简单直接,可用性绑在 Redis 上 |
本系列的行进路线:先拆 UUID,看它到底能不能当订单主键;再讲号段模式这个「数据库正解」,以及它的双 buffer 进阶;主战场是雪花算法——结构拆解、时钟回拨这个死穴的原理与三种治理、workerID 分配之争;Redis 发号作为轻量备选;然后是开源方案选型、趋势递增的索引意义、ID 安全、分片基因与高可用,陷阱集和收官地图压轴。
小结
一句话:单库时代 auto_increment 是白给的福利,分库分表后它是事故的源头。分布式 ID 是系统变大后的第一个共识问题:先保证唯一,再谈好用、好看、扛造。下一篇拆 UUID——这把最顺手的瑞士军刀,为什么大厂订单表基本不用它当主键。
评论 (0)