TDSQL分布式数据库核心实践:分片键设计、分布式事务与在线扩容
1. TDSQL 不是"一个"数据库,而是一套分布式数据库产品矩阵
第一次接触 TDSQL 是在一个分库分表的迁移项目里。当时手上的系统是一套主从 MySQL,数据量接近 800G,峰值 QPS 一万出头,慢查询已经开始拖着主流程走。摆在面前的路有两条:自己拿中间件搭一套分库分表,或者直接用腾讯云数据库 TDSQL 这类企业级分布式数据库产品。选型会上争论了整整两天,最后发现争论的焦点其实是一个很基础的问题——大家嘴里的"TDSQL"根本不是同一个东西。
这个坑非常普遍。很多人在需求评审时说的是分布式版,做技术方案时查的是云原生版文档,最后压测出来的数字对不上,才开始回头翻产品说明。所以在聊任何细节之前,先把这套产品矩阵拆清楚,是省时间的一步。
1.1 TDSQL for MySQL、TDSQL-C、TDSQL for PostgreSQL 各自解决什么问题
按照公开的产品资料和实际使用体验,这几个版本的分工大致是这样:
| 版本 | 底层基础 | 主要解决的问题 | 典型场景 |
|---|---|---|---|
| TDSQL for MySQL | MySQL 协议深度定制 | 单机写入与容量天花板,水平拆分 | 高并发交易、订单、账务 |
| TDSQL for PostgreSQL | PostgreSQL 协议 | 复杂查询、地理信息、扩展类型 | 报表分析、GIS、混合类型业务 |
| TDSQL-C | 计算存储分离架构 | 弹性伸缩、存储成本、单实例大容量 | 中小规模但要求弹性的业务 |
| 分析型版本 | 列存 + 分布式 MPP | 大规模聚合与实时分析 | HTAP、实时数仓 |
这里最关键的一条分界线是:分布式版解决的是"写不进去、存不下",云原生版解决的是"弹性不够、存储太贵"。两者不是替代关系。我见过一个团队业务 QPS 只有两三千,却上了分布式版,结果白白承担了分片路由和分布式事务的复杂度,最后又迁回单实例,折腾了小半年。
另一个容易被忽略的点是协议兼容的边界。TDSQL for MySQL 兼容 MySQL 协议,意味着你原来用的驱动、ORM 框架、连接池基本都能直接用,但"协议兼容"不等于"行为完全一致"。分布式场景下,跨分片的 JOIN、外键约束、存储过程、触发器这几类东西要么受限要么不支持。这一点在下面的兼容性清单里会展开。
1.2 与单机 MySQL 主从、应用层分库分表的对比
搞清楚产品家族之后,第二个要回答的问题是:为什么不用单机主从加应用层分库分表?这三种方案的本质差异在于拆分这件事由谁来做。
单机主从是"一写多读",写能力被单主锁死,读可以横向扩。数据量到一定规模,备份、DDL、扩容都会变成大工程。应用层分库分表是把拆分逻辑写在业务代码里,灵活度最高,但代价是所有跨库查询、跨库事务、数据迁移、扩容重分布都得自己实现一遍,而且一旦分片规则变更,业务代码要跟着改。
TDSQL 的做法是把拆分下沉到数据库层,应用基本无感。这个"基本无感"是它最大的价值,也是最大的陷阱——因为它把复杂度从应用层转移到了数据库层,代价变成了:分片键的约束、分布式事务的延迟、跨分片查询的性能上限。换句话说,复杂度没有消失,只是换了个地方。
我在做迁移评估时习惯用一张简单的判断表:
- 单表数据量 500G 以内、峰值 QPS 一万以内、没有强一致的跨表事务需求:单机主从 + 读写分离通常够用,别急着上分布式。
- 单表超过 1T、写入 QPS 超过两万、或者业务天然可以按某个维度切分(比如按商户、按租户):分布式版是合理选择。
- 数据量不大但流量波动剧烈、有明显的峰谷差:云原生版的弹性更划算,秒级扩缩容能省下不少成本。
2. 拆开看 TDSQL 的三层架构:网关、计算 SET、调度与全局时钟
选型确定之后,接下来的问题是:这套东西到底怎么跑的?不理解架构就去调参数、看监控,基本等于盲人摸象。TDSQL 的架构可以粗略切成三层,从上到下分别是接入网关层、计算存储层、调度与元数据层。
2.1 网关层做的六件事
网关(也叫接入层、Proxy 层)是应用连接到的第一个组件。它的职责比大多数人想象的要重得多:
- 协议兼容与连接管理。应用连过来的是网关,不是 MySQL 实例。网关后面维护着一批到计算节点的长连接,做到"前端多连接、后端少连接"的收敛。
- SQL 解析与路由。解析出 SQL 涉及哪些表、哪些分片键条件,然后决定这条 SQL 发给一个分片还是所有分片。
- 结果归并。多个分片返回的结果需要做排序、聚合、去重、分页,这部分内存开销经常被低估。
- 读写分离。根据 SQL 类型和事务状态,把读请求路由到备节点。
- 限流与熔断。防止某条慢 SQL 把整个集群拖垮。
- 分布式事务协调的入口。涉及多分片的写操作,从这里开始进入两阶段提交流程。
我见过最常见的误区是把网关当成一个"透明的代理",以为它没有性能损耗。实际上网关的 CPU 通常先于数据库打满,尤其是结果归并阶段的排序聚合。压测时如果只压数据库不动网关,数字会好看很多,但上线一定出事。
2.2 计算节点 SET 与强同步复制
计算存储层的基本单元叫 SET,一个 SET 通常是一主多备的 MySQL 实例组,数据在这个组内通过强同步复制保持一致。
"强同步"这三个字值得展开讲。普通异步复制的问题是主库提交完就返回,备库还没收到,主库宕机就丢数据,RPO 大于零。强同步的做法是主库在提交前,必须得到至少一个备节点的落盘确认,才能向客户端返回成功。这样做的直接结果是 RPO 等于零,代价是写延迟增加,增加的量大致等于主库到备库的一次网络往返。
这里有个工程上的取舍:如果主备跨可用区部署,一次往返可能是 1 到 3 毫秒;如果跨城市,就是 20 到 40 毫秒。这个数字会直接叠加到每一笔写事务上。所以我在做部署规划时,同城强同步 + 异地异步灾备是最常用的组合,异地机房只保证数据最终有一份,不参与同步确认。
备节点数量也有讲究。一主两备的配置下,即使挂掉一个备节点,仍然有另一个备节点可以确认,集群不会阻塞;如果是一主一备,备节点一挂,主库就只能降级成异步复制或者拒绝写入。这是可用性和成本之间的直接换算。
2.3 调度与全局时钟:分布式事务背后的两个隐形角色
调度层负责的东西在应用视角几乎不可见,但它决定了集群能不能自动恢复。
调度集群维护着整个集群的元数据:有哪些 SET、每个 SET 的主备关系、分片和 SET 的映射关系、每个分片的数据范围。当某个 SET 的主节点故障时,调度集群负责发起选主,把备节点提升为主,然后更新路由元数据。这个过程对应用是透明的,但会有一个短暂的不可用窗口,通常是秒级。
另一个关键组件是全局时钟(GTS)。分布式事务要保证快照读一致性,就必须有一个全局统一的、单调递增的版本号来源。可以这样类比:每个分片各自有一块手表,各自走各自的,跨分片事务读到的数据就可能是"半新半旧"的。全局时钟相当于给所有分片发了一块统一的标准表,事务开始和提交都去取一个全局版本号,这样跨分片的读才能在同一时间点上对齐。
理解了这两个组件,就能明白为什么 TDSQL 的分布式事务延迟会比单机事务高。一次跨分片事务至少要走:取全局版本号 → 各分片执行并准备 → 协调者汇总 → 各分片提交,中间还夹着日志落盘和网络往返。单分片事务则完全走本地路径,没有这个开销。这个差异直接决定了下一节要讲的分片键设计原则。
3. 分片键选错,后面所有优化都是白费
分片键(Shard Key)是 TDSQL 项目里最关键、也最难改的一个决定。表创建之后分片键基本就固定了,后续扩容只改变分片数量,不改变拆分字段。这意味着你在项目第一天做的这个决定,会跟着这套库走很多年。
3.1 分片键的三条硬指标
我总结下来,一个合格的分片键要同时满足三条:
第一,基数足够高。 用性别、状态、类型这种只有个位数取值的字段做分片键,数据会全挤在一两个分片里,等于没拆。一般来说,分片键的基数至少要远大于分片数量,比如 64 个分片,分片键基数最好在百万级以上。
第二,分布足够均匀。 基数和均匀是两回事。用自增 ID 做分片键,哈希之后分布是均匀的;用大客户 ID 做分片键,如果有一两个大客户占了全平台 30% 的流量,那他们的数据就会集中到固定的几个分片上形成热点。这种"数据倾斜"在分布式数据库里是头号性能杀手。
第三,绝大多数查询都会带上它。 这是最容易被忽略的一条。分片键选得好不好,不取决于表本身,而取决于业务怎么查。我曾经参与过一个订单系统的评审,开发同学选了 user_id 做分片键,理由很充分——用户下单、查订单都带用户 ID。结果上线后发现运营后台有一条按 order_no 精确查询的接口,流量不小,每次都变成全分片扫描,64 个分片并发查一遍再把结果归并回来,P99 直接从 20 毫秒飙到 800 毫秒。
后来我们的处理方式是在订单表旁边额外维护一张 order_no → user_id 的映射表,用 order_no 做分片键,运营接口先查映射再走精准路由。多了一次查询,但把全分片扫描彻底消掉了。
3.2 跨分片查询和分布式事务的真实代价
判断一条 SQL 是不是跨分片,看的不是 SQL 复杂不复杂,而是它有没有带上分片键的等值条件。带上的是单分片,不带的就是广播。
常见的跨分片操作和它们的代价:
- 跨分片 JOIN:大多数分布式数据库对跨分片 JOIN 的支持都有限制,通常要求小表作为广播表存在每个分片,或者干脆不支持。广播表适合字典、配置这类更新极少的小表。
- 跨分片 ORDER BY + LIMIT:每个分片各自排序取前 N 条,网关再做一次归并排序取全局前 N 条。分片数越多,归并的数据量越大,内存压力越明显。
- 跨分片聚合(COUNT、SUM):各分片先做局部聚合,网关做二次聚合,这个还好,属于可接受的开销。
- 跨分片写事务:走两阶段提交,延迟明显高于单分片事务,而且持有锁的时间更长,冲突概率更高。
一条实用的经验法则:核心交易链路的所有写操作,尽量设计成单分片事务。怎么做?把强关联的表放在同一个分片规则下,用同一个分片键。比如订单主表和订单明细表都用 user_id 分片,那订单创建就是一个单分片事务,性能跟单机差不多。如果把订单明细改成按 order_id 分片,创建订单立刻变成跨分片事务,QPS 会掉一个数量级。
3.3 自增主键和全局唯一序列的取舍
分布式环境下的自增主键是个老问题。单机 MySQL 的 AUTO_INCREMENT 依赖本地计数器,多分片各自自增必然产生重复值。TDSQL 提供了全局唯一序列的能力,让自增 ID 在全局范围内单调递增,但每一次取号都需要经过序列服务,高并发写入下就是一个集中式的热点。
我的建议分场景:
- 对 ID 连续性没有要求的业务:直接在应用层生成雪花算法 ID。去掉一次远程调用,写入性能最好。
- 需要生成订单号、流水号这类带业务含义的 ID:在应用层拼装(时间戳 + 机器标识 + 序列号),数据库只管存。
- 存量系统迁移、代码不方便改:用数据库层的全局序列,但要评估高并发下的瓶颈,并且注意批量插入时把
innodb_autoinc_lock_mode这类参数调好。
这里有个细节很多人踩过:全局序列在扩容时的表现。分片数量变化后,新老分片的 ID 段需要重新规划,如果业务代码里隐含了"ID 越大越新"这样的假设,扩容后顺序可能会乱。这一点在迁移前一定要跟业务方确认。
4. 企业级能力落到实处的三件事:容灾、在线扩容、混合负载
产品资料里"企业级"三个字出现的频率极高,但真正在项目里体现价值的就三件事:出故障时能不能快速恢复、业务增长时能不能不停机扩容、多类型负载能不能在一套库里共存。
4.1 两地三中心与自动切换的真实边界
典型的部署形态是同城两个可用区各放一个副本,异地放一个灾备副本,也就是常说的"两地三中心"。
同城部分承担的是高可用:主节点所在可用区故障,调度集群在秒级完成选主切换,因为同城网络延迟低,强同步的开销可以接受,切换后 RPO 依然为零。异地部分承担的是灾难恢复,通常走异步复制,允许少量数据延迟,因为跨城强同步会把写入延迟推高到不可接受的水平。
这里必须强调一个操作层面的问题:自动切换一定要做演练,而且要定期做。我见过不止一次这样的案例——配置了自动切换,但两年没演练过,真出事的时候发现 DNS 缓存没刷新、应用连接池还连着旧主库、某些客户端的重连策略没配好,结果自动切换完成了,业务却停了十几分钟。切换能力只有在被验证过的前提下才叫能力。
演练时要重点确认三件事:应用侧的重连与超时配置是否合理(连接超时建议不超过 3 秒,重试次数控制在 2 到 3 次);切换期间那些长事务会不会被强制回滚、业务能不能容忍;以及切换后的读写恢复顺序,避免刚切完就被流量打垮。
4.2 在线扩容的完整链路与性能抖动
水平扩容的流程大致是:新增 SET → 从现有分片把数据搬迁过去 → 追平增量 → 更新路由元数据 → 新老分片开始按新规则提供服务 → 清理冗余数据。
整个过程对应用是透明的,但不等于对性能没有影响。数据搬迁会消耗源分片的 IO 和网络带宽,如果业务本身处在高峰,扩容操作很可能把源分片的延迟推高。我在实际项目里的做法是把扩容窗口放在业务低峰期,同时给迁移任务设置限速,宁可多跑几个小时,也不让在线业务受影响。
还有一个反直觉的点:扩容不一定能提升性能,取决于热点在哪。如果热点是某个大客户,而分片规则是按客户 ID 哈希,那不管扩到多少个分片,这个大客户的数据始终落在一个分片上,扩容一点用都没有。这种场景需要的是业务层面的拆分,比如给大客户单独开一套实例,而不是简单地加机器。
4.3 混合负载:OLTP 之外的读写隔离
业务跑起来一段时间后,几乎必然会出现分析类查询混进来的情况。运营要看当日汇总、财务要跑对账、风控要扫全量数据,这些查询动辄扫几千万行,跟在线交易抢 IO 和 CPU。
TDSQL 在这方面的思路是提供分析型版本,通过列存加分布式 MPP 的方式承接聚合类查询,数据从在线集群同步过去。这个方案确实能解决问题,但有两个前提条件要提前想清楚:一是同步存在延迟,分析侧的数据是最终一致的,不能拿来做实时决策;二是同步链路的稳定性要单独监控,我见过同步中断好几天没人发现,等报表对不上才回头排查的情况。
如果不需要独立部署分析集群,退一步的做法是在线集群上加只读实例,把分析查询路由过去。这在数据量不大时足够用,缺点是大查询依然会消耗存储层资源,只是把 CPU 压力分散了。做压力隔离最彻底的方案还是物理隔离。
5. 上线前必须过的那张检查清单
迁移或新建项目上线前,有三块内容必须逐项确认,缺一项都是给自己埋雷。
5.1 SQL 兼容性自查
分布式版虽然兼容 MySQL 协议,但分布式执行的特性决定了有些语法行为会不一样。重点排查这几类:
- 跨分片 JOIN:确认参与 JOIN 的表是不是广播表,或者 JOIN 条件里带了分片键。没带的,要么改 SQL,要么改成两次查询在应用层拼装。
- 子查询与 UNION:部分场景下会被下推到各分片执行后再归并,结果集顺序可能不保证。依赖顺序的业务代码要注意。
- 外键约束:分布式环境下跨分片外键基本不可用,需要把约束逻辑上移到应用层,用事务保证。
- 存储过程、触发器、事件:支持程度有限,迁移前逐个确认,能改成应用层逻辑的就改。
- DDL 操作:大表加字段、加索引这类操作在分布式环境下是要在所有分片执行的,耗时会成倍增加。上线前用测试环境量一遍实际耗时。
我一般会让开发同学跑一遍全量 SQL 的静态扫描,把不带分片键条件的查询全部列出来,逐条评审。这个动作通常能挖出十几个隐藏的全分片扫描。
5.2 压测要用真实业务 SQL 回放
用 sysbench 跑出来的 QPS 数字很好看,但对容量规划几乎没有参考价值,因为它没有覆盖真实的 SQL 分布、事务组合和热点数据分布。
我在项目里的做法是抓生产环境一段时间的慢查询日志和全量 SQL 采样,按业务类型分类,用真实 SQL 回放的方式压测。压测目标定在预估峰值的 1.5 倍,重点看 P99 而不是平均值。平均值是个非常容易骗人的指标——一万个请求里 9900 个是 5 毫秒,100 个是 2 秒,平均值可能只有 25 毫秒,但用户体验由那 100 个请求决定。
压测时还要刻意构造几种极端情况:分片键热点集中、大事务并发、扩容进行中的混合负载。这些才是真正会打垮集群的场景。
5.3 最该盯的监控指标
上线之后监控面板上的指标很多,但真正需要每天看的就下面这几个:
| 指标 | 关注点 | 异常时的典型动作 |
|---|---|---|
| 慢查询数量与占比 | 突增往往意味着索引失效或数据倾斜 | 抓 SQL 执行计划,检查分片键条件 |
| 跨分片查询比例 | 比例过高说明分片键设计有偏差 | 评审高频 SQL,考虑加映射表 |
| 分布式事务占比 | 占比高会拉高整体写延迟 | 优化表的分片规则,尽量单分片 |
| 主备复制延迟 | 延迟大说明备库压力或网络异常 | 检查备库慢查询与网络质量 |
| 单分片 CPU / IO 不均衡 | 热点分片的直接信号 | 定位大客户,考虑业务层拆分 |
| 连接数与连接等待 | 连接耗尽会导致业务直接不可用 | 检查连接池配置与应用侧泄漏 |
那张"单分片不均衡"的指标特别值得强调。分布式集群的平均 CPU 使用率看起来可能只有 30%,很健康,但其中一个分片已经跑到 90% 了。只看平均值,就会一直以为容量充足,直到那个热点分片把整条链路拖慢。
6. 真实项目里踩过的四个坑
前面讲的都是设计层面的东西,最后分享几个我在实际项目里真正踩过、也真正花时间排查过的坑。
6.1 连接数放大
网关做了连接收敛,但这不等于连接数问题消失了。典型情况是应用侧连接池配得过大,比如 200 个连接的池子,部署了 50 个实例,前端就是 10000 个连接。网关到后端的连接虽然收敛了,但前端连接的维护、SQL 解析的并发度、每个连接占用的内存都是实打实的开销。
我的经验值是:应用侧连接池的 maxActive 控制在 10 到 20 之间,配合合理的排队超时。听上去很小,但配得越大,数据库侧的锁竞争和上下文切换反而越严重,吞吐不升反降。这个结论我做过对比测试,从 50 降到 15,整体吞吐提升了大约 8%。
6.2 大事务与备份窗口打架
有一次线上出现周期性的延迟尖刺,每天固定时间出现,持续一两分钟。排查了很久,最后发现是备份任务和一批批量 delete 撞在了一起。批量删除产生了巨大的 undo 日志,备份任务同时在扫全量数据,两者一起把 IO 打满。
解决方式是把批量删除改成分批执行,每批控制在几千行,中间加短暂间隔;同时把备份窗口挪到业务低峰,并且启用增量备份减少全量扫描的频率。这件事给我的教训是:分布式数据库的容量再大,也扛不住"一次性把几百万行删掉"这种操作,批量任务一定要分批、限速。
6.3 扩容后性能反而下降
前面提过一次,这里展开说说。有个项目扩容后 QPS 不但没涨,反而掉了 15%。排查发现两个原因:一是分片数量从 16 变成 32 后,原本可以单分片完成的查询,因为哈希取模规则变化导致部分数据重新分布,一些原本带分片键的查询在特定值上反而需要查两个分片;二是扩容后网关的路由表变大,路由查找的 CPU 开销上升。
处理方式是把分片数量一次规划到位,避免频繁调整。如果业务增长确实需要更多分片,宁可一次从 16 扩到 64,也不要 16 到 32 再到 64 这样小步走。
6.4 跨分片排序的隐性内存消耗
最后一个是关于分页的。业务上很常见的需求是"查最近 1000 条记录",SQL 里写的是 ORDER BY create_time DESC LIMIT 1000,但没带分片键。这条 SQL 会在每个分片上各取 1000 条,网关拿到 32 × 1000 = 32000 条数据做归并排序,再返回 1000 条。单次请求的内存占用不小,并发一上来,网关的内存就打满了。
优化方向有两个:把查询条件限定到具体分片键上,让它变成单分片查询;如果业务上确实需要全局排序,就把排序改成基于时间范围的分页,用 WHERE create_time > ? 做游标式翻页,让每个分片只需要返回少量数据。这两种改法都不需要改架构,改完效果立竿见影。
我个人在实际操作中的体会是,TDSQL 这类分布式数据库真正的门槛不在安装部署,也不在参数调优,而在分片设计和 SQL 写法这两件"看起来很简单"的事情上。这两件事做对了,剩下的运维工作基本都是标准化流程;做错了,后面所有的扩容、加索引、调参数都是治标不治本。所以每次有新项目要上,我都会要求团队在写第一行建表语句之前,先把核心 SQL 清单和分片键方案拿出来评审一轮——这个前期的两小时,往往能省下后面两个月的排查时间。