如何设计无需人工干预的数据库分区
速览
本文探讨了设计数据库分区的新思路,目标是让分区自动调整而无需人工干预。传统分区管理需要DBA持续监控和手动操作,而新方法通过智能算法或规则实现自动化。这能显著降低运维成本,提升系统稳定性。
AI 深度解读
背景
分区是数据库设计中常见的优化手段,能将大表按某个键拆分成多个物理段,提升查询和运维效率。然而,很多团队在实施分区后,发现查询变慢、代码膨胀、运维负担加重。典型症状是:分区键悄悄渗透到每一行查询代码中,成为必须遵守的“契约”;分区边界若手动硬编码,会随着数据增长而失衡,最终引发性能事故。本文来自 Hacker News,作者提出一套更少人工干预的分区设计思路,核心是:用主键作为分区键,并让后台服务自动管理边界。
核心内容
分区键的「泄漏」问题
原文用一个真实场景引入:订单表按 created_at 做了范围分区。部署一周后,订单仪表盘变慢。团队起初怀疑索引策略,但 EXPLAIN 显示:一个简单的 SELECT * FROM orders WHERE id = 12345 居然要扫描 36 个分区,因为 WHERE 子句没有包含分区键 created_at。修复方法很常见:给仪表盘查询加上 created_at >= '2024-11-01'。它奏效了,但随后审计页面、管理工具、迁移脚本都不得不加上同样的条件。三个月后,团队不得不引入 lint 规则,强制所有对 orders 表的查询必须带上日期过滤,代码评审也增加了“是否加了分区过滤?”这一检查项。分区键从一个存储决策,变成了每个查询必须遵循的契约,而忘记添加并不会报错,只会带来慢查询。
分区键对主键和唯一性的影响
PostgreSQL 和 MySQL 都要求:分区键必须包含在表的主键或任何唯一约束中。这条规则是为了正确性——如果不包含分区键,数据库无法在对所有分区进行扫描之前保证唯一性。后果是:如果你想像上面那样按 created_at 分区,就不能只使用 PRIMARY KEY (id),而必须用 PRIMARY KEY (id, created_at)。日期列现在成了主键的一部分,无论你的应用是否需要。
更微妙的影响是:id 在数据库眼中不再唯一。唯一性是在元组 (id, created_at) 上强制执行的——数据库会允许两条相同 id 但不同时间戳的行存在。应用可能仍然认为 id 唯一,但 schema 中没有任何东西能保证这一点。而且无法通过单独添加 UNIQUE (id) 约束来恢复保证,因为两种数据库都要求分区表上的每个唯一约束必须包含分区键列。唯一性实际上被交易掉了。
这不仅仅是表面问题,它改变了优化器能生成的查询计划:
- 有
PRIMARY KEY (id)时,WHERE id = 1是常数时间查找。MySQL 的EXPLAIN显示为const访问类型;优化器知道恰好匹配一行,执行器找到后立即停止。对id的 join 是eq_ref,最快的 join 类型。 - 有
PRIMARY KEY (id, created_at)时,同样的查询变成ref查找:对最左侧索引列的前缀扫描,从数据库角度看可能返回多行。曾经是eq_ref的 join 变成了ref。基数估计回退到索引统计,而不是保证的“一行”假设,这可能导致优化器在查询树更上层选择更差的计划。
要恢复旧的 const 计划,每个查找必须写出完整的主键(包括 created_at)。这和分区修剪的泄漏如出一辙:分区键强行进入了与日期无关的查询中,先是为了修剪,现在是为了单行访问。
分区修剪的前提:WHERE 子句必须包含分区键
分区修剪是让分区值得做的优化。当查询的 WHERE 子句限定了分区键,数据库可以跳过不可能匹配的分区。例如,查询上周的订单只读取包含上周数据的一两个分区。
但这个优化完全依赖于分区键出现在 WHERE 子句中。按其他字段过滤的查询无法得到修剪,会扫描所有分区。一条按主键 id 查找的查询,在非分区表上是一次索引定位,在分区表上(如果修剪键不在 WHERE 中)则是对每个分区进行一次索引探测——36 次索引查找代替了一次。虽然绝对值仍然快,但比非分区版本差得多,这与引入分区的初衷背道而驰。
团队常见的“修复”是给所有涉及该表的查询加上分区键,这就是泄漏的抽象。存储决策变成了与每个调用方的契约:新代码必须记住分区过滤,旧代码需要审计,ORM 也得围绕它配置。
静态分区边界的困境
另一个常见问题是:在表创建时硬编码分区边界。初始布局反映了团队当时的增长预期。六个月后流量模式改变,某些分区比其他分区大 10 倍,而 p_future 收容分区可能容纳了表中一半的数据。手动拆分和重新平衡分区是没人愿意承担的运维工作:需要安排维护窗口,对可能数百 GB 的表执行 ALTER TABLE ... REORGANIZE PARTITION,与应用团队协调且不能出错。通常直到发生性能故障才会处理,而到那时修复成本已经很高。
更好的方法:用主键作为分区键,并自动化管理
更好的思路是:主键已经在表中存在。对于使用 BIGINT AUTO_INCREMENT 的表,主键是单调递增的——新行有更大的 ID。这正是范围分区需要的特性。所以,让主键成为分区键。
这样一来,几乎所有按 id 过滤的查询(占多数)无需修改任何应用代码就能自动获得分区修剪。按 ID 的范围查询会在少数分区上修剪,等值查询精确到单个分区。主键已经出现在每个重要的 WHERE 子句中。
代价是:分区边界不再直接按时间定义,这看起来破坏了基于时间的保留策略。但实际上这并不是一个严重的权衡:分区的目的往往不是保留数据,而是保持索引规模可管理、使维护操作廉价、限制糟糕查询的爆炸半径。当保留是目标时,边界仍然可以选择与时间对齐——只需在 DDL 时由分区服务选择,而不是硬编码在 schema 中。
自动化范围分区管理
以上讨论假设使用范围分区(按有序值的连续边界定义分区)。运维工作是机械的:监控活动分区填满,在填满前将 MAXVALUE 收容分区拆分为新的有界分区,并删除超过保留阈值的分区。一个按计划运行的小型服务足以保持布局健康。困难不在于逻辑,而在于安全地执行:对一个大表执行 DDL 时不阻塞写操作,处理部分失败,以及在服务中途崩溃时干净地恢复。在空的收容分区上执行 REORGANIZE PARTITION 很快,因为没有任何数据需要移动。
关键要点
- 分区键泄漏是常见陷阱:让分区键(如
created_at)成为必须出现在每个查询中的过滤条件,会导致代码膨胀、维护负担增加,且无报错提示,只有慢查询。 - 分区键必须包含在主键中,这削弱了主键的唯一性:
PRIMARY KEY (id, created_at)使得单独的id不再整体唯一,失去了const和eq_ref的最佳查询计划。 - 分区修剪只在 WHERE 子句中包含分区键时生效:不包含分区键的查询会扫描所有分区,倍增索引查找次数。
- 手工设置静态分区边界不可持续:数据增长模式变化会导致分区大小严重不均,而手动 rebalance 操作风险高、成本大,往往被推迟到性能事故发生。
- 更好的设计:用主键(如自增 ID)作为分区键:绝大多数按 ID 查询的应用代码无需改动即可获得分区修剪,且主键已存在于 WHERE 中。
- 自动管理分区边界:通过一个后台服务,监控活动分区容量,自动拆分 MAXVALUE 分区并删除过期分区,避免人工干预。
- 时间对齐仍可实现:即使分区键不是时间列,也可以通过观察插入速率在 DDL 时选择时间对齐的边界,而不必硬编码在 schema 中。
意义与影响
这篇文章直击了数据库分区实践中最常见的痛点——分区键的泄漏和静态边界的僵化。它提供的解决方案很简单但反直觉:**放弃按时间分区,用
