这标题看着简单,但只要你碰过CockroachDB(下面我直接叫CRDB),就会明白里面水多深。任何一张表的性能命脉,几乎都攥在主键设计手里。尤其是多列主键,用得好,它能让你的分布式集群数据分布均匀、查询路径极短、写入毫无瓶颈;用不好,那就是写入热点、慢查询、大表重建一条龙服务。很多团队在把业务从单机数据库往CRDB迁移时,第一反应都是“把原来的主键原样搬过来”,结果上线没多久就开始半夜起来看监控。这篇东西,我就想围绕CRDB多列主键设计,把我自己实践下来的一套思路、步骤和踩过的坑完整讲一遍。适合正在做架构选型、准备迁移业务,或者已经在用CRDB但被主键问题折腾得够呛的工程师和DBA看。全文没有多余废话,都是可以直接抄走的方法论。
1. 为什么在CRDB里,主键设计是一门“架构课”
1.1 存储引擎决定了一切:主键就是数据的门牌号
要理解CRDB的主键设计,必须先理解它的存储模型。CRDB的底层本质上是一个分布式KV存储,表里的每一行数据都会被编码成一条或多条KV记录。这条KV的key,就是由主键列的值编码而来;value则是其他非主键列。整个集群的数据,按照key的范围被切分成一个个range,每个range默认有大小上限(在较新版本中大概512MB左右),并被复制到多个节点上。
这个模型带来的直接推论是:主键的字节内容决定了行在集群中的物理位置。数据按key排序,而key按主键列的顺序排序。所以多列主键不只是一个“约束”,它直接决定了哪些数据会被放在同一个range里、查询时会沿着哪一段key区间去扫描、写入时会路由到哪个节点。
这就引出第一个关键认知:在CRDB里设计主键,本质上是在做数据分布和查询路径设计。你设计的主键长什么样,数据的物理布局就长什么样。很多老数据库经验在这里会失效,因为单机数据库的主键主要管唯一性和索引查找,而分布式数据库的主键还要管数据该落在哪、热点怎么避免。这也是为什么我说主键设计在CRDB里是一门“架构课”,而不是建表时顺手填的几个字段。
1.2 多列主键解决什么问题,又带来什么问题
多列主键最常见的价值,是把业务上真正有意义的自然键组合起来。比如订单系统里,同一商户下的订单可以按时间范围查,那 (merchant_id, create_time) 就是一个天然的多列主键候选。这么做的好处很明显:同一商户的所有订单在物理上会连续存储,扫描一段时间的订单只需要访问少数几个range,事务上涉及同一商户的操作也更容易在本地完成,性能非常可观。
但多列主键同时也引入一些麻烦。第一,key变长了。多列主键的每一列都会拼到KV的key里,key越长,存储和内存的开销越大,所有二级索引的value(在CRDB里二级索引会携带主键列)也会跟着膨胀。第二,列的顺序极其敏感。列顺序直接决定哪些查询能利用“左前缀”规则走最优路径,选反了主键索引就跟没建一样,查询照样全表扫。第三,主键不可随便改。改主键列值等于把一行数据从KV空间的一个位置搬到另一个位置,代价高得吓人。
所以多列主键是把双刃剑,用好了是性能利器,用不好就是埋雷。接下来我把几个关键决策点逐一拆开讲,这些是我判断一张表主键设计是否合格的检查项。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 多列主键设计的关键决策点
2.1 列顺序怎么定:先等值,后范围
多列主键的列顺序,是我在做任何schema评审时第一个看的地方。CRDB的主键索引天然支持“左前缀”匹配:如果主键是 (a, b, c),那么查询条件里凡是能用上 a、a+b、a+b+c 组合的情况,都能依靠主键的有序性高效执行。
最有指导意义的一条经验是:把等值查询列放在最前面,把范围查询列放在最后面。因为等值条件可以精确定位到一段key区间的起点,而范围条件只能在区间内部继续做顺序扫描。如果等值列放在后面,范围列放在前面,前面那些等值列就没有办法参与前缀匹配,查询性能会大打折扣。
举个例子。订单查询场景里,最常见的是 WHERE merchant_id = ? AND create_time > ?。如果主键设计成 (merchant_id, create_time),数据库就可以先定位到该商户主键的起始位置,然后沿着key顺序往后扫描,非常顺畅。但如果反过来设计成 (create_time, merchant_id),同样一条查询,数据库只能扫所有时间范围内的数据,再过滤商户,扫描量可能差出几个数量级。
还有一点容易被忽略:列顺序也会影响写入的分布。多列主键中排在前面的列,如果取值种类很少(比如就固定几个值),那么数据就可能都堆积在这几个前缀对应的range上,形成空间和流量上的热点。所以确定列顺序时,不只是看查询,还要结合数据基数和写入模型一起判断。不能只看查询方便,让一个只有几种取值的前缀列顶在前面,结果数据全部压在极少数range上。
2.2 主键列要克制:类型、长度和数量
见过不少表,主键恨不得把所有业务字段都塞进去,列顺序也没规划,最后表结构看得人头皮发麻。这里我统一给一个原则:主键列越短越好,列数越少越好,类型越紧凑越好。这句话背后是实打实的代价。
CRDB里每行数据的主键最终都会编码进KV的key中,key多一个字节,就意味着存储引擎在比较、排序、路由时要多处理一个字节。更关键的是,所有二级索引的value都会带上主键列作为“回表”的依据,主键一长,二级索引也全都跟着膨胀,磁盘和内存的浪费会被放大数倍。所以能用INT/UUID,就不要用VARCHAR;能用原生UUID类型,就不要存成字符串。UUID字符串36个字符,比原生UUID类型多几十个字节,放在主键里完全是浪费。
关于UUID和自增主键的选择,CRDB里默认建议使用UUID配合 gen_random_uuid()。原因不复杂:自增序列(SERIAL)在分布式环境里虽然不像单机数据库那样要全局锁,但生成的ID带有明显的时间趋势,写入很容易集中在最新一段range上,形成热点。而随机UUID分布均匀,天然能把写入散到各个range。这个对比后面第4节还会展开讲,这里先给结论。
至于列数,够用就好。多列主键的本质是基于业务自然键的组合,如果你发现需要塞四五列才能唯一标识一行,不妨考虑缩减:保留最有查询价值的两三列作为前缀,后面再拼一个UUID或短ID做唯一性兜底。这样既保证了唯一约束,又兼顾了查询路径。额外提醒一句,CRDB的主键列会隐式带NOT NULL约束,这和其他数据库一致,设计时不用重复声明,但心里要有数。
2.3 写入热点怎么破:USING HASH的收益与代价
写入热点是分布式数据库绕不开的话题。无论单列主键还是多列主键,只要主键值在持续递增或递减,比如时间戳、自增ID,那么新写入的数据都会集中到同一个range上。这个range所在的节点要承担所有写流量,而集群其他节点闲着,跑不满不说,还会拖垮整个集群的写入能力。
CRDB给了一个官方解法:哈希分片索引。原理是在主键或索引上挂一个 USING HASH 子句,系统会隐式增加一个哈希列,把原本连续的主键值均匀散到固定数量的“桶”里,再把这个哈希列作为排序前缀。这样原本顺序递增的数据会被打散到不同bucket,对应到不同range,写入热点的压力就被摊开了。
它的语法非常简洁,比如:
sql复制CREATE TABLE events (
device_id UUID NOT NULL,
ts TIMESTAMPTZ NOT NULL,
event_id UUID NOT NULL DEFAULT gen_random_uuid(),
payload JSONB,
PRIMARY KEY (device_id, ts, event_id) USING HASH WITH (bucket_count = 32)
);
bucket_count 建议用2的幂次,比如16、32、64。它是一个权衡参数:桶越多,写入分散得越开,但等值查询的定位也越“碎”;桶太少,热点缓解效果又不明显。从我实践经验看,默认16在大多数场景够用,写入并发特别高的表可以上32。
但哈希分片不是免费的午餐,它最大的代价是:范围查询会退化。因为数据经过哈希后已经不再按原主键有序排列,WHERE ts > ? 这种范围查询无法再沿着主键顺序扫描,而是要把所有bucket都扫一遍再合并结果。所以哈希分片索引更适合“等值查询为主、写入压力很大”的表;如果业务核心查询是范围扫描或排序,就要非常谨慎。
我习惯用一个简单的决策表来判断是否加哈希:
| 场景 | 是否适合USING HASH |
|---|---|
| 主键高并发顺序递增,热点明显 | 适合 |
| 查询全部是主键全列等值 | 适合 |
| 查询经常按主键前缀做范围扫描 | 不适合,范围性能会劣化 |
| 数据本身随机分布(如随机UUID) | 不需要,加了反而多余 |
| 需要主键有序输出的场景 | 不适合 |
3. 一套可落地的多列主键设计流程
3.1 第一步:梳理所有核心查询模式
我的习惯是在设计主键之前,先做一次“查询模式盘点”,把系统里所有重要查询全部列出来。这一步看起来简单,但很多人会漏,尤其是那些只在业务代码里偶发出现、却对性能有硬要求的查询。
拿一个真实的订单系统举例。假设我们要设计“订单明细表”,盘点出来的核心查询大概有这些:
| 查询描述 | 等值列 | 范围列 | 频率 |
|---|---|---|---|
| 商户按时间范围查交易记录 | merchant_id | create_time | 高 |
| 用户查自己的历史订单 | user_id | create_time | 中 |
| 订单中心按订单号查详情 | order_id | 无 | 高 |
| 运营按状态批量拉取订单 | status | create_time | 低 |
这张表列出来之后,主键设计的依据就一目了然了。注意,这里不需要列全所有查询,核心原则是覆盖“高频+高成本”的路径。低频的全表扫,可以靠二级索引去兜,主键永远优先服务最重要的查询。
3.2 第二步:确定主键列组成与顺序
查询模式盘清之后,就按第2节的原则来排:先等值,后范围。以订单明细表为例,最高频的查询是“商户按时间范围查交易”,所以 merchant_id 应该放在第一列,create_time 放在第二列。这样的主键天然支持 WHERE merchant_id = ? AND create_time > ? 的高效扫描。
但光有这两列不够保证唯一性。同一商户同一时刻可能有多笔订单,所以需要在末尾追加一个唯一标识列。CRDB里我习惯用 UUID DEFAULT gen_random_uuid(),既不参与前缀匹配,又保证全局唯一。最终建表语句类似:
sql复制CREATE TABLE order_detail (
merchant_id INT NOT NULL,
create_time TIMESTAMPTZ NOT NULL,
order_id UUID NOT NULL DEFAULT gen_random_uuid(),
user_id UUID,
status STRING,
amount DECIMAL(10,2),
PRIMARY KEY (merchant_id, create_time, order_id)
);
这里有个很多人反复纠结的问题:既然最核心查询是按 order_id 查详情,为什么不用 order_id 做主键?答案是可以,但必须做取舍。如果把 order_id 放主键,那“商户按时间查”这条高频查询就走不了主键前缀,只能靠额外二级索引。而二级索引存储的也是主键值的副本,在CRDB里查二级索引最终还要回表/或者做覆盖扫描,整体成本一定高于直接把主键设计成查询前缀。
所以我的建议是:主键优先服务最高频、最关键的那条查询路径,其他查询用二级索引覆盖。如果所有查询都同样关键,那就分析哪条路径的读放大最严重、最容易拖垮集群,优先保它。
3.3 第三步:结合写入模式决定是否哈希分片
列顺序定完后,回头审视写入模式。订单表的 create_time 是第二列,第一列 merchant_id 的取值本身比较分散,所以订单数据不会单一集中在某一个前缀上。这种场景下,除非某个头部商户的写入量大到异常,否则不需要加哈希分片。
但换一个场景就完全不同。假设设计一张“设备事件表”,每个设备持续上报数据,查询大多是“查某个设备的最新几条”。你会自然想到 (device_id, ts) 做主键,因为同一个设备的数据会被连续存储,查历史记录非常高效。可如果你的设备数量少、上报频率极高,所有设备的数据都持续写入同一批range,热点照样会出现。
这种情况下,我的做法是:先看 SHOW RANGES 和节点流量监控,确认热点的确存在,再考虑给主键加 USING HASH。要注意哈希分片对“查某个设备的最新几条”这类按前缀范围扫描的查询是致命的——它会让原本集中的数据被打散,范围查询性能明显劣化。如果无法接受,就需要换思路:主键用随机UUID做分布打散,设备维度查询改走二级索引。这个取舍没有标准答案,完全看业务对“写入峰值”和“范围查询延迟”哪个更敏感。
3.4 第四步:用EXPLAIN和SHOW RANGES验证设计
设计完别急着上线,先用CRDB内置工具验证一下。我最常用的验证命令是 SHOW RANGES FROM TABLE 和 EXPLAIN ANALYZE。
SHOW RANGES FROM TABLE order_detail 可以看到这张表当前被拆成了几个range、每个range落在哪些节点上、大小如何。如果range数量过少,或者数据都集中在一两个range上,说明主键分布有问题。反之,数据散得很开,说明主键设计在分布层面是健康的。
EXPLAIN ANALYZE 则用来验证查询路径。拿核心查询语句实际跑一遍,看计划里是不是走了主键索引,有没有出现 full scan 之类的危险信号。比如执行:
sql复制EXPLAIN ANALYZE
SELECT * FROM order_detail
WHERE merchant_id = 1001 AND create_time > '2024-01-01';
正常计划的输出里应该能看到明确的索引路径,扫描行数也会控制在合理范围。如果提示扫描范围接近全表,那就要回头检查列顺序是否又摆错了。
4. 实操中的坑与排查技巧实录
4.1 从单机数据库迁来的自增主键,为什么是定时炸弹
我在不少迁移项目里见过同一个问题:从MySQL或PostgreSQL迁过来的表,主键还是经典的自增ID。在单机数据库里这是最常规的操作,但搬到CRDB后就成了隐患。CRDB里 SERIAL 本质上是 unique_rowid(),它生成的ID包含时间戳和节点信息,仍然带有明显的顺序性。写入并发一高,所有新ID都会落在当前时间对应的最新range上,写入流量全部砸向那一个range所在的节点。
表现很典型:集群里只有一个节点CPU和网络飙升,其他节点躺平,写入延迟还不稳定。排查时可以执行:
sql复制SHOW RANGES FROM TABLE your_table;
如果看到绝大多数range的size没动静,只有最后一个range持续增长,基本就能断定是写热点。解决这类问题,最直接的办法是把主键换成随机UUID;如果业务严格要求自增语义,那就要认真评估哈希分片了,但需要注意hash之后数据不再有序,可能影响以自增ID排序的查询逻辑。没有两全其美,必须取舍。
4.2 ALTER PRIMARY KEY很贵,改之前先想清楚
不少人刚开始设计主键时比较随意,上线后发现问题,第一反应是“那改个主键不就行了”。这里我必须泼盆冷水:在CRDB里 ALTER PRIMARY KEY 的代价远比想象中高。因为主键改完之后,整张表的KV key全部要重新编码,所有二级索引也需要一并重建,在大表上这个操作可能耗时极长,期间还会给集群带来额外负载。
更重要的是,CRDB中更新主键列的值本身就等于一次“删除+插入”:旧key的value要删,新key的value要写,这个过程中还牵扯事务和一致性维护,代价极高。所以设计阶段就要把主键当作“不可变”的字段来对待。选择主键列时,我会刻意避开那些业务上可能被修改的字段,比如用户手机号、订单状态、存储地域这类可能随业务变化的值。宁可多追加一个稳定的UUID列做唯一性保障,也别把可变字段塞进主键。
4.3 哈希分片救不了所有场景:一个范围查询翻车的例子
有一个实际案例印象很深:某个团队做日志表,主键是 (app_id, event_time),因为写入量太大出现了热点,于是加上 USING HASH 缓解。上线头几天写入平稳了,大家都很满意。结果没过多久,业务方反馈“按时间拉取某个应用最近一小时日志”的接口从几十毫秒变成了好几秒。
原因不复杂:加了哈希分片后,(app_id, event_time) 的数据不再按时间连续存储,范围查询被迫对所有bucket做全量扫描,再在内存里合并排序。写入热点的坑填上了,查询性能却塌了。这个案例给我的教训是:哈希分片这个“药”,一定要先确诊再吃。如果表的核心查询里有大量范围扫,就先不要加;如果一定要加,建议把范围查询拆分出来走单独的二级索引。分布式数据库里没有万能主键,多一层索引设计来分别应对不同查询模式,比指望一个主键通吃所有场景要可靠得多。
4.4 分区表规划要趁早:分区键必须是主键前缀
最后说一个经常被拖到很晚才想起来的问题:分区。CRDB的 PARTITION BY 支持按列表或范围把数据分区管理,用于数据归档、多区域部署等场景。但有个硬性约束——分区键必须是主键的前缀。也就是说,如果未来想按 region 做 PARTITION BY LIST (region),那 region 列就必须出现在主键列中。
很多团队一开始没考虑分区,等到数据量涨到需要归档时才想起来加,结果发现主键里没有分区列,只能重建表或者做一整套痛苦的迁移。所以在设计多列主键时,我建议提前问产品和技术负责人一个问题:这张表未来有没有可能按区域、按时间做分区管理?如果有,就把对应列放进主键的前缀位置。如果涉及多区域部署,CRDB的系统列 crdb_region 也会参与数据分布逻辑,主键设计时要一并考虑。这步做在前面,能省掉后续大量维护成本。
回到整体,CRDB多列主键设计没有银弹,它的核心矛盾永远集中在“查询路径”和“写入分布”之间的博弈。我自己的实践习惯是:先盘清查询模式,确定列顺序,再分析写入是否会产生热点,最后用 SHOW RANGES 和 EXPLAIN ANALYZE 验证整个设计的合理性。这个过程听起来不复杂,但真正执行时每一步都有细节坑。如果你现在正要设计一张新表的主键,我建议别急着敲 CREATE TABLE,先把第2节的检查项逐条过一遍,尤其是“列顺序”和“主键是否可变”这两点。想清楚再建表,比上线后花几倍代价去改要舒服太多。
