我刚工作那会儿,带我的前辈指着一张两百多行的用户表说:"这表迟早要把你逼疯。"当时我不懂,后来业务越接越多,那张表真的把整个组都逼疯了一次——线上一个简单的用户列表查询,就因为行太宽,把Buffer Pool的命中率拖到谷底,磁盘I/O飙升,数据库CPU打满,故障持续了快两个小时。查来查去,根因不是SQL写得多烂,而是表结构本身出了问题。从那以后,我开始认真研究垂直分表这件事。
垂直分表,简单说就是把一张字段特别多的大宽表,拆成多张字段更少的窄表,让每张表都"瘦身"。它解决的可不是"看起来乱"这种审美问题,而是实打实的存储效率、内存命中率、索引性能和锁竞争问题。这篇文章我想把"互联网公司为什么需要垂直分表"这件事从头到尾说透,包括底层原理、适用场景、完整实操步骤,以及什么情况下千万别拆。
1. 垂直分表拆的是什么——先纠正一个最常见的认知偏差
1.1 垂直分表是"拆列",水平分表是"拆行"
很多刚接触分表的人,第一反应是把"分表"理解成"数据多了放不下,按ID范围拆成多张表"。那是水平分表,拆的是行。比如订单表按user_id取模,拆成order_0、order_1、order_2,每个表里的数据结构一模一样,只是数据各存一份。
垂直分表完全不是这个思路。它拆的是列,也就是把一张宽表纵向拆开。拆完之后,每张表都有主键,但字段集合不同。比如用户表拆成"用户主表"和"用户扩展表",主表放登录账号、手机号、昵称、状态这些高频访问的字段,扩展表放头像URL、个人简介、生日、地区这类低频字段。两张表通过user_id一一对应,本质上是把一条逻辑记录拆成了两条物理记录。
这个区别必须一开始就拎清楚。垂直分表解决的是"行太宽"的问题,水平分表解决的是"行太多"的问题。很多人混着用,结果拆了个四不像,该解决的问题一个都没解决。
1.2 一张表两种拆法:用一个电商场景说清楚
拿一个电商订单表举例。假设有一张orders表,字段包括订单号、用户ID、商品ID、数量、单价、收货地址、支付时间、支付方式、优惠券ID、卖家备注、买家留言、物流公司、物流单号、售后状态、退款原因、发票抬头、发票税号……你数一数,二十多个字段打底。
如果这张表每天新增几十万行,量级上来之后,先触顶的可能是"行数"这个维度,那要做的是水平分表,按订单号或用户ID拆分。但如果你发现行数其实还好,问题是单行特别长:一个订单50个字段,行均大小好几KB,导致查询慢、缓冲池命中率低、更新锁竞争大,这时候就该做垂直分表。
这两件事完全可以叠加。比如先把订单拆成orders主表和orders_extra扩展表(垂直分表),等数据量继续涨,再对orders主表做水平分表(按订单号分片)。很多人以为垂直分表和水平分表是二选一,实际上它们是两个维度的优化,可以组合使用。这也是为什么理解"拆的是什么"这么重要——不搞清楚拆的是行还是列,后面的所有决策都是空中楼阁。
2. 行太宽在InnoDB里到底有多"贵":从数据页到Buffer Pool的连锁反应
2.1 16KB的数据页与那个"装不下的行李箱"
InnoDB的数据是存在数据页(page)里的,默认页大小16KB。你可以把一个数据页想象成一个固定容积的行李箱,行记录就是你要塞进去的衣物。箱子大小是死的,每件衣物占多少空间,决定了这一个箱子能装几件。
假如一行数据平均2KB,一个16KB的页去掉页头页尾、槽位这些开销,大概只能装7行左右。而如果一行只有200字节,同一个页能装七八十行。别小看这个数字差异,它会在数据量大的时候被无限放大。1000万行数据,按每页7行算,需要约143万个数据页;按每页70行算,只需要约14.3万个数据页。差了整整10倍。
这10倍的差距意味着什么?意味着同样一条全表扫描,宽表的磁盘I/O次数可能是窄表的10倍。即使走索引,只要涉及大量行读取,宽表的物理读成本也远高于窄表。所以垂直分表的第一个收益,就是通过压缩行宽,让同样体积的数据用更少的页装下,从根本上减少I/O。
2.2 Buffer Pool的有限容量:宽表如何拖低命中率
数据页从磁盘读出来之后,会缓存到内存里的Buffer Pool。Buffer Pool的大小是有限的,默认值通常是128MB,生产环境可能配到几十GB,但再大也是有限的。热点数据能不能长期驻留内存,直接决定了查询是走内存还是走磁盘。
宽表的问题在于,它把一个页的"有效信息密度"拉低了。你花一个页的内存缓存数据,结果只装了几行记录,而且这几行还未必是你高频访问的那几行。假设你的订单表有5000万行,行均1KB,那全量数据光数据页就需要3TB左右,Buffer Pool就算给30GB,也只能缓存1%的数据。而如果拆掉那些从来不在列表页展示的大字段,把行均降到200字节,同样30GB的Buffer Pool理论上可以缓存5%的数据,热点数据的驻留概率成倍提高。
我自己踩过的坑就是典型的宽表缓存雪崩:某业务系统为了省事,把用户的所有信息都塞在一张表里,连用户的自定义配置JSON都往里放。平时数据量小没事,等用户涨到几百万,缓存命中率从95%掉到70%出头,数据库磁盘读暴涨,再加上慢查询,整个服务响应时间从几十毫秒变成几秒。后来把大字段拆出去,命中率才慢慢恢复。
2.3 行溢出、页分裂和锁开销
除了页密度和缓存命中率,InnoDB还有三个和行宽强相关的隐藏开销。
第一个是行溢出。MySQL 5.7之后默认的行格式是Dynamic,当一个行的长度超过数据页的一半(约8KB)时,InnoDB会把TEXT、BLOB这类大字段挪到独立的溢出页(off-page),在数据页里只保留20字节的指针。听起来很智能对吧?但代价是:你每次访问这个大字段,都要额外读一次溢出页。而且溢出页的数据通常不在Buffer Pool的热点区域,访问它就是一次随机I/O。垂直分表把这类大字段主动拆到单独的扩展表,主表查询就再也不需要触达溢出页。
第二个是页分裂。当数据页接近装满,新插入或更新导致页内空间不足时,InnoDB会触发页分裂,把一部分记录挪到新页。行越宽,单页能容纳的记录越少,页分裂发生的频率就越高。页分裂需要获取额外的闩锁(latch),在高并发写入场景下,这会放大锁等待和性能抖动。窄表单页能塞更多记录,同样数据量下页分裂次数显著减少。
第三个是锁竞争。InnoDB的行锁本质是索引记录锁,宽表带来更低的页内行数密度,多个并发事务更新的行落到同一个页的概率变大,页级闩锁竞争自然变激烈。我以前在压测里对比过一张行均3KB的表和拆出来的窄表,同样500并发更新,窄表的TPS能高出将近40%。这不是个例,而是行宽问题在并发写入场景下的集中体现。
3. 什么样的表才值得拆:我见过的正例与反例
3.1 正例一:订单大宽表拆成主表+明细+扩展
之前在某公司支持过一个订单中心的改造。原始的订单表有六十多个字段,包括核心交易信息、支付信息、物流信息、售后信息,还有各种业务方塞进来的自定义扩展字段。订单量上来之后,列表查询越来越慢,一个简单的分页查询动不动就几百毫秒。
我们做垂直分表的时候,把表拆成了三张:
- 订单主表:订单号、用户ID、商品SKU、数量、单价、订单状态、创建时间等高频字段,行均控制在400字节以内。
- 订单明细表:商品快照、优惠明细、金额分摊等需要展示但不算极高频的字段。
- 订单扩展表:发票信息、买家留言、卖家备注、自定义属性等低频或者根本不在主链路查询的字段。
拆完之后,列表页只查订单主表,分页查询从几百毫秒降到几十毫秒。订单详情页需要什么字段,就按需去明细表和扩展表取。这个改造的核心逻辑就一句话:高频查询只碰最瘦的表,大字段和低频字段绝不拖累主链路。
3.2 正例二:内容发布系统的正文分离
内容类业务是另一个典型场景。某内容平台的稿件表,早期把标题、摘要、正文、封面图、标签全都放在一张表里。正文动不动几十KB,导致行均非常大。更麻烦的是,列表页只需要标题和摘要,但MySQL扫描时依然要读取整行数据,等于每次列表查询都把根本不需要的正文也读了一遍。
这种场景做垂直分表的收益极其明显:一张稿件主表只放标题、摘要、封面图、发布状态、发布时间,正文单独放一张稿件内容表。列表查询的I/O立刻降下来,正文详情页再去查内容表。我们改造后,稿件列表的接口性能提升了接近一个数量级,而且因为主表行宽小,Buffer Pool里能缓存的稿件数量多得多,热点稿件的查询基本都能命中内存。
3.3 反例:一张只有十几列的表被强行拆成了两半
也有反面教材。某团队为了"规范",把一张只有16个字段、行均200多字节的表硬拆成两张表。理由是说"用户资料要拆开",但实际上这张表数据量只有几十万行,也没有TEXT/BLOB大字段,性能瓶颈根本不在这。
拆完之后问题反而来了:原本一条SQL能查完的数据变成两条SQL,应用层代码复杂度增加;更新用户资料要同时更新两张表,稍不注意就出现数据不一致;十几个字段硬拆成两半,还要设计冗余字段来避免跨表查询,设计难度自我加码。最后这个改造上线不到两个月就回滚了。
这个案例给我的教训很深刻:垂直分表是手段,不是目的。它解决的是"行宽带来的性能问题",如果行宽本身不是瓶颈,拆分就是纯折腾。
3.4 我判断要不要拆的五个维度
后来我沉淀了一套自检标准,遇到"要不要做垂直分表"的讨论时,先过一遍这五个维度:
- 单表行宽:行均是否超过1KB,或者存在TEXT/BLOB大字段拖累主表查询。
- 访问模式:高频查询是否只需要部分字段,而不是每次都SELECT *。
- 数据规模:数据量是否大到让行宽问题开始放大,比如千万级别以上。
- 写放大成本:更新宽表时,InnoDB需要处理页分裂和行溢出,成本是否已经影响写入性能。
- 应用层改造成本:团队是否有能力处理跨表查询和事务边界的变化。
如果五项里有至少三项命中,那垂直分表大概率是值得做的。如果只有一两项沾边,我建议先用更轻的手段优化,后面我会专门讲替代方案。
4. 动手拆分一份订单表的完整过程:从字段梳理到灰度切换
4.1 动手前先做字段画像:哪些列值得留在主表
别一上来就写DDL,先搞清楚这一张表到底哪些字段被高频访问。最简单有效的办法,是统计慢查询日志和业务代码里的SELECT列。
如果你们接入了性能监控,可以直接看performance_schema的表I/O统计:
SELECT * FROM performance_schema.table_io_waits_summary_by_table WHERE TABLE_SCHEMA = 'your_db' AND TABLE_NAME = 'orders'\G;这个表会告诉你orders表的读次数、写次数、等待时间占比。如果等待时间主要落在读写上,再看具体是哪些SQL在访问它。把业务代码过一遍,列出每个查询实际用到的列,按访问频率排序。我的经验是,一张大宽表里通常有20%的字段支撑了80%的查询,剩下80%的字段要么在详情页偶尔用,要么根本没人查。
字段画像的输出是一张二维表:左侧是字段名,右侧是"高频查询命中次数"和"是否大字段"。高频且短小的字段留在主表;低频但业务需要的字段进扩展表;大字段单独拆表或者放宽到专门的存储;如果有些字段半年没人查过,直接审计确认后下线。
4.2 拆表DDL与主键关联设计
字段梳理完之后,就可以设计拆表方案了。还是以订单表为例,假设我们拆成订单主表和订单扩展表。主表DDL大概长这样:
CREATE TABLE `order_main` ( `order_id` BIGINT NOT NULL COMMENT '订单号', `user_id` BIGINT NOT NULL COMMENT '用户ID', `sku_id` BIGINT NOT NULL COMMENT '商品SKU', `quantity` INT NOT NULL DEFAULT 1 COMMENT '数量', `pay_amount` DECIMAL(10,2) NOT NULL COMMENT '实付金额', `status` TINYINT NOT NULL COMMENT '订单状态', `created_at` DATETIME NOT NULL COMMENT '创建时间', PRIMARY KEY (`order_id`), KEY `idx_user_id` (`user_id`), KEY `idx_created_at` (`created_at`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单主表';扩展表:
CREATE TABLE `order_extra` ( `order_id` BIGINT NOT NULL COMMENT '订单号,与order_main一一对应', `buyer_message` VARCHAR(500) DEFAULT NULL COMMENT '买家留言', `seller_note` VARCHAR(500) DEFAULT NULL COMMENT '卖家备注', `invoice_title` VARCHAR(200) DEFAULT NULL COMMENT '发票抬头', `invoice_tax_no` VARCHAR(50) DEFAULT NULL COMMENT '发票税号', `ext_attrs` JSON DEFAULT NULL COMMENT '自定义扩展属性', PRIMARY KEY (`order_id`), CONSTRAINT `fk_order_extra` FOREIGN KEY (`order_id`) REFERENCES `order_main` (`order_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='订单扩展表';注意几个细节:两张表的主键都是order_id,一一对应;扩展表建外键约束保证强一致(如果介意外键性能也可以不建,靠应用层保证,但强烈建议至少主键一致不能出现孤儿数据);扩展表里尽量不要再出现高频查询字段,否则拆了等于没拆。
4.3 历史数据迁移:影子表、双写与校验
表结构设计好之后,最怕的是直接停服迁移。我的建议是走影子表和双写,分四步走:
第一步,建好order_main和order_extra,先不切换任何流量。第二步,应用层改造DAO,新写入逻辑同时写主表和扩展表,查询逻辑走老表,这个阶段叫双写。双写期间需要监控两张表的写入成功率,发现问题随时回滚。第三步,写一个迁移脚本,把历史数据从老表分批插入到新表。要注意批量大小,我用的是每次取5000条、按主键范围分批的方式,避免迁移SQL把主库拖垮。迁移前加上一个migrated_at字段做幂等标记,防止中途失败重复跑。第四步,做数据校验。最直接的办法是对比count,再抽一部分订单比对关键字段;数据量大的时候可以用checksum聚合做抽样校验。
这里有个容易忽视的点:迁移期间老表可能还在写入,所以双写逻辑必须保证"一切新数据都同步到新表",迁移脚本只需要处理双写开始之前的历史数据,两边不冲突。双写加迁移跑完之后,老表数据和新表数据的一致性要靠定时对账任务兜底。
4.4 应用层改造的两种路径
数据层拆完,应用层必须跟着变。最简单的改造是把原来的一个实体类拆成两个,DAO层分别屏蔽单表操作。如果你用的是MyBatis这类ORM框架,原来OrderMapper里的一条SQL变成orderMainMapper和orderExtraMapper两个操作;如果用的是JPA,则对应两个Entity和两个Repository。
这里有两种查询路径可以选择。第一种是"主表优先+按需加载扩展":列表页、订单搜索这类高频接口只查order_main;详情页先用order_id查主表,再按需加载order_extra。第二种是"一次组装":在Service层把两个DAO的查询结果组装成一个完整的订单DTO。前者的好处是快,后者的好处是上层代码不用感知表变化。我比较推荐第一种,因为垂直分表的核心目的就是让高频查询只碰最瘦的表,你要是每次都强行把两张表组装起来,性能收益就大打折扣了。
5. 拆完才是麻烦的开始:跨表、事务与冗余设计的现实解法
5.1 跨表查询:从一条SQL到一个短连接
垂直分表之后,最直观的麻烦就是原本一条SQL能完成的事,现在可能要两条甚至更多。很多人看到这会打退堂鼓,但实际上1对1主键关联的跨表查询没那么可怕,因为主键查询走的是聚簇索引,单次查询成本很低,真正贵的是多表JOIN和统计类SQL。
我的原则是:禁止在SQL里对拆出来的表做JOIN。原因很简单,JOIN的本质是嵌套循环,如果你join的主表已经过滤到很小结果集还好,一旦过滤条件不够紧,驱动表很大,JOIN的代价会被放大。更推荐的做法是应用层先按高频条件查主表,拿到一批order_id之后,再用IN查询批量加载扩展表字段,内存里组装。比如列表页需要展示100条订单的发票抬头,就先查order_main拿100个order_id,然后select * from order_extra where order_id in (...),一条IN查询搞定。
还有一条经验:不要试图把拆出来的表再反规范化回去。见过一些团队拆完之后发现报表要查询全字段,又搞了一张宽表视图,导致底层两张表被JOIN得死去活来。这种统计需求就应该走独立的宽表副本或者数仓,而不是让OLTP链路背这个锅。
5.2 主表和扩展表的事务一致性怎么处理
关于事务,有个概念要先澄清:垂直分表通常还是在同一个数据库实例里,并没有跨库,所以不像分库分表那样必然引入分布式事务。只要你在应用层保证"写主表和写扩展表用到的是同一个数据库事务",一致性就能得到很好的保证。
容易出问题的是代码写法。比如某程序员在Service里写了两个方法,先调orderMainMapper.insert,再调orderExtraMapper.insert,但第二个方法走的是另一个事务传播级别,或者一个异常被吞掉了,就会导致主表有数据、扩展表没有。落地时必须在Service方法上统一加@Transactional,并让两个DAO操作共用同一个连接。MyBatis和Spring事务管理器默认就是这样工作的,只要你不在中间捕获异常继续往下走,通常不会出事。
如果团队对一致性要求特别高,比如支付相关的扩展字段,可以在扩展表里加一个write_at时间戳和写入状态,定时任务扫描主表和扩展表的时间差,发现滞后超过一定阈值就告警。这个兜底虽然简单,但比裸奔强得多。
5.3 冗余字段不是偷懒,是刻意的架构选择
拆完之后,很多人会纠结一个问题:主表里到底要不要冗余一些扩展表的字段?我的答案是,看查询路径。
最典型的例子是订单状态。假设订单状态这个字段在业务里高频使用,列表页要展示、统计要按它分组,那它必须留在order_main。至于发票类型这种只有详情页才看的字段,留在扩展表就好,列表页根本不用管它。如果某一天有个新需求说"列表页要按发票类型筛选",那你就要评估:是把这个字段冗余回主表,还是让列表查询走扩展表关联?冗余回主表意味着拆表的意义被削弱,走扩展表关联意味着列表查询变复杂。这是一笔需要权衡的账。
我的经验是,一个字段进主表还是扩展表,不看它"属于"哪块业务,而看它是否参与高频查询和筛选。低频字段进了主表是浪费,高频字段留在扩展表是灾难。每次新需求带来新的高频字段,就做一次表结构调整,这是正常的演进过程,不用害怕。
5.4 拆分后的监控指标与回归验证
拆分上线不是终点,后面至少还要盯一个月的监控。我会重点关注四类指标:
- 主表的平均行大小和Buffer Pool命中率,确认行宽是否真的降下来了。
- 高频接口的P99延迟,看列表查询是否有真实收益。
- 双表写入的失败率和一致性对账告警。
- 从库复制延迟的变化,确认大字段从主表剥离后,主从同步压力是否缓解。
除了监控,还要做一次回归压测。用拆分前的压测报告做基准,同样并发下对比拆分后的TPS和RT。如果没有明显提升甚至下降了,说明拆分方案有问题,比如高频字段被误拆到了扩展表,或者应用层组装逻辑写得太烂。拿数据说话,比什么理论都靠谱。
6. 什么情况下千万别做垂直分表:决策边界与替代优化
6.1 数据量不大且没有大字段:别为了"规范"拆表
垂直分表最容易被滥用的一点,就是把它当成"表结构规范"来做。实际上,一张几十万行的小表,没有TEXT/BLOB大字段,行均三四百字节,跑得好好的,完全没必要拆。拆了之后等于是用应用层的复杂度和事务风险,换一个根本不存在的性能提升。
判断标准很简单:先看监控。如果表读写延迟正常,Buffer Pool命中率正常,慢查询里几乎没有这张表的身影,那恭喜你,这张表很健康。健康的表不需要手术。把精力留到预约问题真正出现的那一天。
6.2 应用层改造成本过高的团队先别拆
拆表最贵的不是SQL,而是应用层。一张表被几十个Service引用,拆成两张之后,所有涉及这张表的查询、更新、统计代码都要过一遍。如果团队比较小,或者业务迭代节奏特别快,改造期间很容易出现漏改导致的线上故障。
而且拆表往往不是一次性的。今天拆成两张,明天业务扩展又加了一张扩展表,应用层的组装逻辑越来越复杂。如果团队没有清晰的DAO层分层和DTO组装规范,拆表会变成一场持续的维护噩梦。所以我的建议是:要么团队已经有良好的分层设计,要么先补上代码结构,再动表结构。顺序反了,坑就会一个接一个。
6.3 比垂直分表更轻的优化手段
在决定拆表之前,至少先试试这几招,成本低、见效快:
第一,改SELECT *成手动选列。很多慢查询的根源是SELECT *把一整行大字段都查出来了,但业务根本用不到。只查必要列,MySQL虽然还是要读页,但返回的数据量和网络传输量会明显下降。第二,对大文本字段建立前缀索引,至少能让排序和查询走索引。第三,做冷热分离,把半年前的订单迁到归档表,主表只留热数据,这是水平维度上的"瘦身"。第四,考虑用JSON字段收纳低频扩展属性,MySQL 8.0对JSON的支持已经比较成熟,多值索引也能建立。但注意:JSON只适合低频且不需要频繁更新的字段,高频筛选字段放JSON就是灾难。
这些手段都试过还不够,再认真考虑垂直分表。我把这个先后顺序视为架构演进的基本素养:先做轻量优化,再做结构性改造。
6.4 一张决策流程,按顺序自检
最后给一个可以直接抄的决策流程,每次有人问我"这表要不要拆",我就让他按这个顺序走一遍:
- 确认性能瓶颈到底在不在行宽上。先看慢查询、看Buffer Pool命中率、看行均大小。
- 如果瓶颈在行宽,先做字段画像,统计哪些字段高频、哪些是大字段。
- 先尝试替代优化:手动选列、前缀索引、冷热归档、JSON收纳。验证一周,看效果。
- 如果替代优化都试过还是不够,再评估垂直分表,重点评估应用层改造工作量。
- 决定拆,就按影子表+双写+校验+灰度的路径走,回滚预案必须提前准备好。
- 上线后至少盯一个月监控,用数据确认收益,没收益就复盘哪里拆错了。
这套流程帮我们避免了好几次不必要的拆表。说实话,垂直分表不是银弹,而是一把需要准确命中痛点的刀。拆对了,性能收益立竿见影;拆错了,它就是用KPI换稳定性的坑。
最后分享一点个人体会
做了这么多年数据库优化,我最大的感受是:垂直分表真正考验的不是SQL能力,而是对业务访问模式的理解。一张表拆不拆、怎么拆,取决于你的高频查询到底碰哪些字段、大字段会不会拖累主链路、团队能不能承担改造风险。技术方案永远服务于业务场景,脱离了真实访问模式的表结构设计,再"规范"都是自娱自乐。希望这篇从原理到实操再到决策边界的完整复盘,能帮你在面对下一张"逼疯人"的大宽表时,有底气做对决定。