聊到CockroachDB(下面简称CRDB)的多列主键,很多从单机数据库切换过来的朋友会觉得:主键不就是唯一标识吗?一个ID就够了,为什么要多列?在MySQL或PostgreSQL里,用一个自增ID当主键确实最省心,可一旦到了分布式数据库,这个习惯很可能成为性能黑洞。CRDB的最大特点是数据按主键分布在不同节点上,主键就是数据的“物理坐标”。多列主键设计得好不好,直接决定你的写入是不是会往一个节点怼,查询是不是能精准命中范围,甚至决定一次online schema change能不能顺利做完。
这篇文章是给正在使用或者打算使用CRDB的工程师看的,不管你是刚开始建表,还是已经在紧急处理慢查询,都值得花十分钟把这几个设计点过一遍。我会结合一个真实的多租户订单场景,从决策思路到建表DDL,再到用EXPLAIN验证效果,最后把容易踩的坑和排查方法一起整理出来。
1. 为什么多列主键在CRDB里是门手艺活
1.1 从单机主键到分布式主键的思维转变
在单机数据库里,主键主要干两件事:保证唯一性、作为聚簇索引的排序键。你写一个自增ID,它就在B+树右侧追加,顺序写很快,读起来按ID扫范围也顺畅。这个模型在单机内存和磁盘里非常成熟,基本上不需要你做太多思考。
但在CRDB这样的分布式SQL数据库里,事情的性质变了。CRDB底层是一个分布式的KV存储,每一行数据会按照主键编码成一条KV记录,这条记录到底落在哪个节点、哪个Range上,是由主键所在的键空间范围决定的。换句话说,主键不只是唯一标识,它还在决定数据分布的均匀程度和数据访问的局部性。你在建表时写下的每一列顺序,都会被具体编码进KV的key前缀里,直接影响物理存储布局。
所以,当你用PRIMARY KEY (id)这种单主键时,实际上是在把全部写入顺序押在一个单调递增的值上。CRDB会把相邻的键分到同一个Range,而Range又只有一个Leaseholder节点负责写入。如果所有新写入的行都落在同一个Range尾部,其他节点只能干瞪眼,热点就出现了。多列主键的引入,正是在尝试让数据分布更符合业务访问模式,把顺序追加变成分布式追加。
1.2 主键即数据的“物理坐标”
CRDB内部对每行数据的主键会做编码,多列主键会更直接地表现为一个拼接后的字节数组。比如PRIMARY KEY (tenant_id, region, order_id),其实在KV层面的key大概长这样:
code复制/tenant_id/region/order_id
这个key决定了行在Range间如何分布,也决定了同一个tenant、同一个region下的数据会物理相邻。有一个常被忽略的推论:主键这个“物理坐标”一旦被写入,所有后续二级索引都需要用整个主键回表。也就是说,多列主键列越多、值越长,存储开销和写入放大都会增加。设计时不能只图业务语义清晰,还要考虑Key的字节长度和实际查询是否能覆盖。
1.3 多列主键的双重角色:唯一约束 + 物理排序
如果你在传统关系型数据库里用过复合主键,很容易只把它看成“多个列共同保证唯一”。但在CRDB里,这种认知会漏掉另一半:物理排序。CRDB的Range内部是按主键顺序存储的,这意味着主键列的顺序直接决定了一次查询能跳过多少无关数据。
举个例子,一个按用户查看订单的场景,如果用(order_id, user_id)做主键,你查某个user的订单时,需要把order_id段里所有可能的值都过滤一遍,本质上是顺序扫描。而如果把(user_id, order_id)放在前面,同一个user_id的数据在物理上紧挨着,CRDB可以直接定位到这个连续区间,WHERE user_id = 123就能变成一个高效的范围扫描。物理排序决定了SQL的过滤条件下推到KV层的效果,这才是多列主键设计真正的门槛。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 设计多列主键的五个关键决策点
2.1 第一列是谁?先想清楚数据怎么“散”
第一列是主键的“分片锚”,它几乎决定了整个表的写入分布。我们经常说“均匀分散”,但这个均匀分散不是让每个节点写一样多,而是让每个Range的写入速率大体一致。第一列如果是一个高基数的业务标识,比如tenant_id、user_id、device_id,那数据自然会被打散到很多不同Range。第一列如果是一个只有几个固定值的枚举,比如region、status,那么即使后面列基数再大,同一个region的数据也会长时间堆在一个或少数几个Range上,写入热点照旧。
我在实际项目里见过一个最典型的反面案例:把order_status放在第一列,加上order_id做复合主键。当时想的很简单:“我们要按状态过滤订单,所以状态列放在第一列方便查。”结果写入的时候,大量新订单都处在“pending”状态,所有写入都集中在pending对应的那段key范围上,节点负载肉眼可见地不均衡。后来把主键改成(shop_id, order_id),才把热点彻底打散。记住一个原则:第一列不是为了方便查询,而是为了分担写入。查询优化可以靠二级索引,但数据分布只能靠主键。
2.2 列顺序决定查询效率:等值列在前,范围列在后
当确定了分片锚之后,剩下的列顺序就要结合查询模式来排了。对于多维度检索,最佳实践很简单:区分度高的等值过滤列放前面,范围查询列放后面。
比如一个订单表,我们最常用的查询是“查某个用户在某段时间内的订单”,主键是(user_id, created_at)。created_at是范围条件,user_id是等值条件,把等值列放前面,CRDB才能先在user_id上精确定位到对应区间,再在created_at上做范围扫描。如果顺序反了,变成(created_at, user_id),那WHERE user_id = 123 AND created_at BETWEEN ...就只能扫描某一个时间段内的所有用户记录,哪怕在二级索引上已经过滤,回表代价也会高很多。
有一个误区需要提醒:不是所有范围列都必须放最后。如果某列基数特别大、选择性很高,即使它是范围条件,放在某些等值列前面也可能更好。这里没有银弹,最终要以真实查询和EXPLAIN结果为准。我的建议是先把业务查询LIST列出来,统计每一个查询的等值列和范围列,再给主键列排优先级。
2.3 单调递增的坑:为什么自增ID不适合当主键第一列
自增ID在单机数据库里香,是因为顺序写友好。但在CRDB里,顺序写友好恰好是分布式下最不友好的模式。所有新生成的ID都往同一个Range尾部追加,负责这段Range的节点就成了唯一的写入瓶颈。你可能会说:CRDB不是自动会做Range分裂和负载均衡吗?确实会,但分裂和迁移是有延迟的,写入洪峰来的时候,节点承受的压力早就上去了。
我在一个埋点事件表上踩过这个坑。当时设计成(event_id BIGSERIAL PRIMARY KEY),结果压测时22%的写入集中在同一个节点上,其他节点的CPU基本闲置,写入延迟从几毫秒飙到几百毫秒。后来把主键改成(tenant_id, event_time, event_id),event_id依然作为唯一性保证,但第一列变成了tenant_id,数据分布才真正铺开。
如果业务确实需要一个全局唯一的业务流水号,可以考虑用UUID或CRDB的gen_random_uuid()作为主键的一部分,它会随机分布,天然避免热点。代价是UUID索引比bigint大一些,但换来的分布式写入分布在绝大多数场景都是值得的。
2.4 哈希分片索引:治标不治本的应急方案
CRDB从较新版本开始支持给索引加哈希分片,利用桶(bucket)把单调递增的键值在存储层打散。语法大致是:
sql复制CREATE TABLE events (
event_id BIGSERIAL,
tenant_id INT,
data JSONB,
PRIMARY KEY (event_id)
) WITH (hash_sharded = true, bucket_count = 16);
哈希分片能在不改变SQL语义的情况下缓解热点,但我要说一句不好听的:它只能“治标”,不能替代审慎的分片锚设计。因为它把数据分布得更加随机,原本可以利用物理排序做范围查询的能力也会大幅下降,对于点查和范围查混用的场景,很可能得不偿失。如果你发现自己依靠哈希分片才能压住热点,不妨回头审视一下主键第一列是否真的是高基数的业务键,而不是继续加桶数凑合。
2.5 别让主键变成“可失足字段”:不可变原则与更新代价
多列主键设计里还有一个容易被忽略的点:主键列应该是不可变字段。业务上听起来理所当然,但实际我们经常遇到“订单编号当时没有,后来由人工补录”的情况,或者租户ID在跨域迁移时需要被改写。如果主键列包含可变业务字段,一旦该字段更新,CRDB相当于删除旧key,再插入新key。这个过程会牵连所有二级索引,极端情况下可能引起大量冲突和重建。
我在一个CRM系统里见过一个糟糕设计:主键是(company_id, contact_phone),但用户的电话是允许修改的。每次改电话,都要做一次全量索引重写,后来上线期间频繁出现死锁。最终我们加了contact_id UUID作为主键,把phone降级为普通列,加唯一索引做约束,问题才解决。设计时一定要问自己一个问题:这个字段未来的更新频率是多少?超过零就应优先考虑使用代理键。
3. 实操:从表设计到查询性能验证
3.1 场景定义:多租户订单表
为了让你能直接参考,我搭一个尽量接近真实的多租户订单表场景。业务上有三种高频查询:
- 查某个租户下所有订单(按创建时间倒序);
- 查某个租户下某个区域内的订单;
- 查某个订单的详情。
对应的字段包括:租户ID(tenant_id)、区域(region)、订单ID(order_id)、创建时间(created_at)、订单状态(status)、金额(amount)。如果沿用传统单库思维,很可能直接写成order_id做主键,其他列挂普通索引。但在CRDB里,我们需要让主键同时承担分片和查询两个任务。
3.2 建表 DDL:正向设计的主键
根据前面的原则,第一列选tenant_id作为分片锚,第二列放业务范围查询的区域region,第三列放保证唯一性的order_id。order_id使用UUID生成,因为你没有业务需要全局顺序,UUID的随机分布有利于写入铺开。建表语句如下:
sql复制CREATE TABLE orders (
tenant_id INT NOT NULL,
region STRING NOT NULL,
order_id UUID NOT NULL DEFAULT gen_random_uuid(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
status STRING NOT NULL DEFAULT 'pending',
amount DECIMAL(10,2) NOT NULL,
PRIMARY KEY (tenant_id, region, order_id)
);
你可能会问:created_at不是查询里最重要的范围列吗?为什么不放进主键?因为如果主键是(tenant_id, created_at, order_id),就很难再支持“查某个租户下某个区域”的查询了,因为region被放到了中间,无法直接作为范围条件跳过。把region放在第二列,可以让订单记录在同一个租户下按区域物理聚簇。查询“某租户+某区域+最近订单”时,CRDB能通过(tenant_id, region)定位到一批连续key,然后在内存里按created_at排序。对于大多数订单量级,这个排序成本完全可接受。
3.3 用EXPLAIN验证主键是否被有效利用
建表完成不等于设计成功,一定要用EXPLAIN验证。拿“查某租户某区域最近10条订单”来说,语句是:
sql复制SELECT order_id, status, amount
FROM orders
WHERE tenant_id = 101 AND region = 'ap-east-1'
ORDER BY created_at DESC
LIMIT 10;
执行EXPLAIN,你希望看到的是类似这样的Plan:
text复制distribution: local
projections: order_id, status, amount
limit: 10
sort: -created_at
scan orders
estimated row count: 486
filter: (region = 'ap-east-1') AND (tenant_id = 101)
注意这里的scan后面有没有显示“using primary key”这样的说明。在CRDB的EXPLAIN输出里,当主键访问路径被命中时,通常能直接看到扫描对应的索引是主键索引,并且estimated row count应该远小于全表扫描。如果看到类似scan orders@secondary_index并且还需要回表,那就要考虑主键设计是否和查询模式不匹配。
再看“查某租户下区域范围”的查询:
sql复制SELECT order_id, created_at, status
FROM orders
WHERE tenant_id = 101 AND region IN ('ap-east-1', 'eu-west-2')
AND created_at > '2024-01-01';
和上面类似,只要主键前缀(tenant_id, region)在WHERE里被等值或IN条件覆盖,CRDB就能快速定位多个连续key范围。如果发现它走了全量和filter,通常说明主键列顺序没有覆盖到查询前缀。
3.4 如果一开始设计错了,如何低成本调整
CRDB允许在线修改主键,但成本不可小觑。语法很简单:
sql复制ALTER TABLE orders ALTER PRIMARY KEY USING COLUMNS (order_id);
执行这个语句时,CRDB会为旧主键创建一份二级索引,同时为新主键重写整个表,期间需要迁移大量数据。对于小表几十毫秒就能完成,对于亿级大表可能跑几小时,而且会占用不少IO和存储空间。所以我在文章前面反复强调设计先行,绝不是废话。
如果要调整主键且表很大,我的建议是先确认新版CRDB的online schema change机制,给整个流程预留足够的存储空间和低峰期窗口。更稳妥的做法是新建一张表,把数据通过INSERT INTO ... SELECT分批迁移,再用rename切换。CRDB官方文档里也有类似的分阶段变更建议。实操中,我建议任何大表改主键前先在一个克隆分支上跑一遍全流程,记录时间,评估影响。
4. 常见问题与排查技巧实录
4.1 CRDB多列主键常见错误清单
我在团队里带人做CRDB表结构评审时,有一张检查清单,直接用表格列出来,方便你对照自己的表。
| 常见错误 | 问题现象 | 推荐做法 |
|---|---|---|
| 第一列基数过低 | 写入热点集中,节点负载不均 | 把高基数租户/用户/设备ID放第一列 |
| 使用自增ID作主键 | 写扩展性差,延迟抖动 | 改用UUID或业务分片键组合 |
| 范围查询列放在等值列前 | 查询走全量Filter,性能差 | 把等值列放主键前缀 |
| 主键包含可变字段 | 更新主键引发全索引重写 | 增加不可变代理键 |
| 主键列过多且值过长 | 存储放大,二级索引变大 | 用代理键压缩主键长度 |
| 完全依赖哈希分片 | 范围查询失效,写放大 | 重新设计分片锚,哈希只作救济 |
4.2 案例复盘:一个“看似均匀”却频繁热点的问题
有一个读者曾经给我看过他们的订单表,主键设计是将warehouse_id放在第一列,理由是warehouse_id有50多个取值,应该足够分散。但生产环境中发现,他们90%的订单业务都发生在华东仓,warehouse_id的分布极度倾斜。写入和查询全部打在华东仓对应的那几个Range上,其他节点负载很低。这就是“形式均匀”的陷阱:只看基数,不看业务分布。
排查这类问题时,我习惯先做三个步骤。第一步,用SHOW RANGES FROM TABLE orders查看表的Range分布和每段Range包含的key范围。第二步,用SHOW STATISTICS FOR TABLE orders看主键前几列的distinct count和histogram。第三步,看节点级别的CPU/磁盘写入时序图,确认是否存在单节点持续走高。如果三个信号同时出现,基本可以断定分片锚选择不符合真实访问分布。
4.3 大表修主键:先备份还是先演练
大表修改多列主键,我需要强调两点:一是线上执行前的备份不只备份数据,还要备份schema和应用层的访问逻辑;二是先克隆一个环境演练,尤其是评估schema变更后老索引的保留策略。CRDB中的ALTER PRIMARY KEY默认会保留旧主键索引,这样SQL查询不会中断,但也会让你的表多出一份索引data。如果你希望旧索引被回收,需要显式处理,不过要谨慎,因为应用可能还有未改完的依赖。
我处理过一个200GB订单表的案例,当时直接执行ALTER PRIMARY KEY,跑了将近三小时,期间存储使用量上涨了30%多。后来总结出来的经验是:如果业务允许,优先通过应用层灰度切表:新表用新主键,写入双写,读走灰度开关,等数据对齐后再切换。过程更繁琐,但对线上影响最小。
4.4 监控指标:哪些信号告诉你主键设计需要复查
主键设计问题很少在第一天暴露,通常都是写入量或数据量涨到某个量级后才出现。下面几个指标我会长期放在监控面板上:
ranges数量是否长期远低于节点数,比如一个表一直只有几个Range;lease transfers或Range balance事件是否频繁;- 某个节点CPU长期远高于集群平均;
- 写入P99延迟在数据量增长后出现阶梯式上升;
EXPLAIN中关键查询的estimated row count明显是数据量的指数级倍数。
这些指标任何一个异常,我都会先怀疑主键,而不是急着加二级索引。二级索引只能改善读,不能从根本上解决写入分布和物理聚簇问题。遇到慢查询时,我建议你先跑一遍EXPLAIN (VERBOSE),看看最终访问主健的范围是不是合理;如果访问路径本身就是全表,加再多索引也是隔靴搔痒。
最后分享一个我自己的习惯:每张新表的建表评审,我都会拿业务里最热的5条查询出来,手工推演一遍CRDB的KV访问路径。脑子里过一遍“这个WHERE条件会映射到哪一段主键前缀”、“写入时新key会落到哪个Range”,远比事后加索引高效。多列主键设计没有标准答案,但它确实有一套清晰的思考框架,照着这篇文章里的几个决策点走,至少能让你避免80%的分布式性能坑。
