资讯动态

垂直分表:数据库性能优化中性价比最高的第一步

发布时间:2026/10/22 20:05:12 来源:尧图企业网站定制
干这行时间久了你会发现一个特别有意思的现象很多团队一聊到数据库性能优化满脑子都是“分库分表搞起来”然后下意识就开始想该按用户ID还是订单ID做水平拆分。但你问他有没有先把一张宽表拆成几张窄表他往往愣一下——垂直分表这四个字听过却很少被放在优先级最高的位置。实际上在我接触过的绝大多数互联网系统里垂直分表是比水平分表更应该早做、更划算、性价比更高的一步。我甚至可以说很多所谓的“慢查询”“锁等待”“缓存命中率低”问题根子不在SQL写得差而在表结构设计就成了宽表。今天这篇就专门讲讲互联网公司为什么需要垂直分表以及在什么场景下它才是你该先挥出去的那把刀。垂直分表做的事情一句话就能说清拆列不拆行。它把一张字段很多的“大宽表”按列拆成两张或更多“窄表”表与表之间通过主键对应。听起来简单但背后牵扯到存储引擎的行格式、B树索引的页结构、InnoDB缓冲池的命中率、行锁竞争、以及应用层代码的查询模式环环相扣。这篇内容适合正在做性能调优的后端开发、需要做库表设计的架构师以及那些已经被慢查询搞到头大但还没想清楚优化顺序的团队。1. 先分清垂直分表到底是什么别再和水平分表混在一起1.1 垂直分表的定义拆的是列不是行垂直分表的全称在数据库设计里叫 Vertical Partitioning中文常被直译为“垂直拆分”或“垂直分表”。它的核心逻辑是把一张字段很多的表按照访问特征和字段属性切分成多张字段较少的表。切分之后每张表的行数跟原表一样但列数变少了主键一一对应。举一个最典型的例子。某电商系统的商品表一开始可能长这样CREATE TABLE product ( id BIGINT PRIMARY KEY, title VARCHAR(128), price DECIMAL(10,2), stock INT, status TINYINT, detail TEXT, -- 商品详情富文本 image_urls TEXT, -- 图片URL列表 spec_json JSON, -- 规格参数 supplier_id BIGINT, supply_price DECIMAL(10,2), audit_remark VARCHAR(512), ... );列表页只需要 id、title、price、stock、status 这五六个字段但表里却有四十多个字段。后端同学写了个SELECT *数据库就得把detail、image_urls、spec_json这些几十KB的大字段也一起从磁盘捞出来。垂直分表之后变成两张表CREATE TABLE product_main ( id BIGINT PRIMARY KEY, title VARCHAR(128), price DECIMAL(10,2), stock INT, status TINYINT ); CREATE TABLE product_detail ( id BIGINT PRIMARY KEY, detail TEXT, image_urls TEXT, spec_json JSON );这就是垂直分表的全貌结构不同的两张窄表通过ID关联业务层按需查询。1.2 垂直分表和水平分表到底差在哪很多人把分库分表混为一谈其实核心区别就一句话水平分表拆的是行垂直分表拆的是列。水平分表是把一张表的数据行按照某个sharding key比如用户ID、订单ID散到多张结构完全相同的表里去。表1存ID哈希取模为0的用户表2存取模为1的用户。它解决的是“一张表行数太多索引层数加深扫描代价变大”的问题。垂直分表则是把一张表的多个列拆开。表结构不同行数不变。它解决的是“一行太大信息密度太低IO浪费严重”的问题。两者不是二选一而是可以叠加使用。一个订单系统先按列拆成 order_main 和 order_extend再按订单ID做水平拆分成 order_main_0、order_main_1这就是垂直分表跟水平分表交叉使用的标准姿势。实际大型系统里骨架基本都是先垂直后水平。1.3 “垂直”这个名字是怎么来的这里插一句理解命名有助于建立直观记忆。把一张表想象成一个电子表格横着的行是记录竖着的列是字段。水平分表是在水平方向切一刀把行切到不同表里垂直分表是在垂直方向切一刀把列切到不同表里。有人会问那“纵向分表”是不是也对对但业内惯例叫垂直分表原因是“垂直”对应的英文 vertical 在数据库理论里就是“按列切分”的标准说法大家习惯了就沿用下来了。2. 从InnoDB的存储机制看为什么宽行天生就是性能瓶颈垂直分表不是拍脑袋想出来的设计规范它的必要性直接建立在存储引擎的物理实现之上。这一节把InnoDB的行存储机制捋一遍你就明白宽表到底吃了多少亏。2.1 一页只有16KB行大小直接决定一页能装多少行InnoDB从磁盘读写数据的基本单位是页page默认大小16KB。也就是说不管你怎么写SQL数据库真正执行IO的时候最少得把一个页16KB的数据从磁盘搬到内存。一个页里能放多少行完全取决于行的大小。简单算一笔账。假设一行数据平均占用500字节一个页大概能装下30行左右如果一行平均2KB一页只能装8行左右。现在你有一个范围查询要扫描一万行窄表500字节/行大约需要扫333个页。宽表2KB/行大约需要扫1250个页。这还只是估算实际还要扣除页头、页目录、记录头这些开销。但方向很明确行越宽扫同样的行数就要读更多页磁盘IO次数呈线性上涨。垂直分表把一行从2KB压到500字节扫描成本直接降一个量级这是最根本的收益来源。2.2 宽行让聚簇索引和二级索引都跟着吃亏InnoDB的索引是B树结构主键索引聚簇索引的叶子节点保存的是整行数据不是索引列加个指针。这意味着行越大每个叶子页能装下的行数越少B树要么更高层级加深要么范围扫描需要遍历更多页。对二级索引更明显。二级索引叶子节点保存的是索引列和主键值查询时先通过二级索引找到主键再去聚簇索引里回表读取整行。回表本身已经是一次额外IO如果这一行还是个大宽行这次IO就要搬运大量你根本用不到的字段。比如你只是想查一下商品标题和价格回表却把几十KB的富文本详情也带了出来这个浪费非常冤枉。2.3 行溢出机制大字段是隐藏的IO炸弹MySQL里如果一行数据太大会在页里存不下的情况。InnoDB处理TEXT、BLOB这类大字段时会把它们存到单独的溢出页里原来的数据页只保留一个20字节左右的指针。这看起来挺巧妙但它带来一个副作用任何需要读取这一行的操作都有可能触发溢出页的额外IO。更麻烦的是在默认的DYNAMIC行格式下溢出存储是“值的存储方式”维度的问题不解决“查询时是否加载该列”的问题。你写一个SELECT *MySQL照样得把大字段捞出来溢出页IO照常发生。就算你只查普通列如果优化器选择了覆盖索引以外的路径行记录里的大字段指针也可能带来额外的访问成本。把大字段拆到单独的表里主表里的普通查询就永远碰不到溢出页这是垂直分表一个非常实在的收益。3. 垂直分表解决的真实业务问题从IO放大到缓存命中率再到锁竞争3.1 信息密度与IO放大你不能让大海捞一根针我用个生活化的类比说明什么叫信息密度低下。你去餐厅点一份蛋炒饭厨师却每次都要把整个厨房的所有菜全部端到你面前。你要的只是那盘饭但搬运成本却按整个厨房算。宽表就是这个厨房一行里可能几百个字段你只用到其中几个但InnoDB搬数据的时候是按整行搬的。这个问题的专业说法叫IO放大。数据库每次读操作物理上都把一整个页从磁盘搬到内存页里面装了多少你不需要的字段是白白浪费的带宽和IOPS。特别是在机械硬盘时代随机IO的性能是寸步难行的即使到了NVMe时代IOPS也不是免费的高并发下浪费同样致命。垂直分表的核心作用就是提升信息密度让每一行里存放的字段都是高频访问的值让一次磁盘IO能搬运更多有效数据。主表越窄单次查询的性价比越高。3.2 缓存命中率InnoDB缓冲池对窄表更友好InnoDB有一个内存区域叫缓冲池Buffer Pool用于缓存数据页。查询命中了缓冲池就不需要再走磁盘没命中就得老老实实做一次物理IO。缓冲池大小是有限的装得下多少数据页取决于页的总数量而页的总数量又取决于行大小。还是按之前那个例子计算。一个1GB的缓冲池如果行大小是500字节大约能缓存约6万行不考虑页内其他开销如果行大小是2KB只能缓存约1.5万行。一张宽表的热数据很容易把缓冲池塞满其他重要表的热数据反而被挤出去引发连锁的缓存失效。我做过一次拆分原来缓存命中率在88%左右摇晃拆成窄表后稳定到99%平均查询延迟降了一半以上就是这么立竿见影。3.3 锁竞争与事务开销宽表在高并发写场景下是硬伤InnoDB默认行锁理论上并发更新不同行是互不阻塞的。但宽表在更新场景下有一个隐藏痛点更新某个字段时存储引擎要把整行记录锁住。大字段更新时溢出页数据修改涉及的物理IO更多锁的持有时间被拉长冲突窗口随之变大。举个例子一个商品表里有核心的价格字段也有一个帖子数类的统计字段或者备注字段。运营同学低频地修改备注同时用户高并发地在下单时读取价格和库存。如果两张表不拆运营的修改虽然在另一列上但对同一行的锁竞争和信息拷贝仍然会影响整体性能拆开来以后主表的锁竞争范围显著收窄低频更新的副表哪怕锁久一点也影响不了核心链路。另外事务回滚段和undo log也会随着行变大而增加开销写放大问题在拆表之后也会减轻。3.4 大字段的序列化与网络传输开销比你想象的更贵很多团队忽略了大字段在应用层的连锁消耗。MySQL把数据返回给客户端时要经过MySQL协议封装应用层拿到数据后ORM框架还要做对象映射和JSON反序列化。一个详情字段可能动辄几KB甚至几十KB列表接口每次返回都带着它们网络带宽和CPU周期都在白白燃烧。我记得有次排查一个接口RT大概只有20ms但吞吐一直上不去。最后抓包发现一个列表接口一次返回了300多行记录每行都带着一个细节很长的JSON字段。接口返回体接近10MB。拆表之后列表接口只查主表返回体降到了200KB以内QPS直接翻了好几倍。这个案例说明垂直分表优化的是从磁盘到网络再到应用层的整条链路不只是慢查询日志里的那几行数字。4. 互联网业务里的典型宽表场景哪些表最该拆互联网公司最常见的系统模块翻来覆去就是用户、商品、订单、内容这几大类。这些模块天然存在“高频取核心字段”和“低频取扩展字段”的访问差异是垂直分表的黄金候选。4.1 商品表销售核心和运营扩展电商系统里商品表几乎一定会被拆。列表页、搜索页、购物车只需要ID、标题、价格、库存、上下架状态这几个字段详情页才需要富文本、图片、规格参数、供应商信息这些扩展字段。而且商品表往往还有审核状态、运营备注、促销配置之类的字段不同角色访问不同字段全部揉在一张表里两边都别扭。拆成 product_main 和 product_detail 之后路由规则很清晰列表、搜索、价格计算走主表详情页先查主表拿基础信息再按需查副表拿富文本。这样主表可以放心地做缓存哪怕是应用本地缓存因为它的字段集合足够稳定。4.2 用户表登录认证与用户画像分离用户表是另一种典型的宽表。登录认证链路每次都要查的是uid、账号、密码摘要、昵称、头像、状态这些字段而用户的画像信息标签、偏好设置、设备信息、最后登录IP、个性签名往往只在个人中心、推荐策略或后台运营里用到。把这两类字段拆开认证链路每次查询的返回数据量会变得极窄这对登录这种高频、对延迟极其敏感的接口帮助很明显。用户表拆分的另一好处是安全层面密码摘要、密钥这类敏感字段可以放在权限控制更严格的表里避免列表接口误带出敏感信息。这属于意料之外的安全收益。4.3 订单表交易核心与票据扩展订单表是所有电商和交易系统里行数最多、字段也最多的表之一。核心字段是订单ID、用户ID、金额、订单状态、支付状态、创建时间扩展字段有发票信息、优惠券快照、折扣明细、买家留言、卖家备注、渠道来源、扩展JSON等。对账系统、后台订单列表、C端订单列表、支付回调核心链路访问的字段集合差异非常大。订单表不拆后台列表翻页查询会非常痛苦因为每一行都扛着一堆扩展字段。拆成 order_main 和 order_extend 之后订单主表还能进一步配合水平分表按买家ID或订单ID做拆分扩展表跟着同一套规则一起拆。4.4 内容类表元数据与正文字段分离内容社区、博客平台、视频网站的“内容主表”是另一类重灾区。一篇文章的元数据ID、标题、作者ID、浏览量、点赞数、发布时间很小但正文Markdown原文、渲染后的HTML动辄几十KB。feed流列表接口只需要元数据点进详情页才需要正文。不拆表的话每次刷feed都要把正文从磁盘搬出来再通过API返给客户端。内容表拆分的另一个好处是存储策略更灵活元数据可以放在性能型存储上正文可以放到压缩率更高的存储或者干脆异步迁到对象存储数据库只保留一个访问地址。这种演进在单表时代很难操作。在正式拆表之前我建议你先给所有候选表列一个“字段访问频率表”把字段分成四组高频读低频写、高频读写、低频读低频写、大字段低频访问。分组越清晰拆分方案越稳妥。5. 实操要点从找宽表到迁移落地的完整步骤这部分是纯干货按步骤来基本可以照着操作。5.1 第一步用统计信息和慢查询日志找出真正的宽表不要凭感觉猜哪张表宽用数据说话。MySQL的 information_schema 里可以直接看到表的基本状态SELECT table_name, table_rows, avg_row_length, data_length, index_length FROM information_schema.tables WHERE table_schema 你的库名 ORDER BY avg_row_length DESC LIMIT 20;avg_row_length是平均每行的字节数它等于 data_length 除以 table_rows。一般来说超过4KB就算偏宽了超过8KB基本可以确定有TEXT/BLOB或大量VARCHAR字段属于拆分候选。这个数值只是一个宏观参考VARCHAR是变长的实际行大小要看特定行的数据但用来做初步筛选足够。接着打开慢查询日志找出高频慢SQL的特征。重点关注三个信号SELECT * 出现次数多、单次查询返回行数多但实际只用了其中少数列、以及频繁访问大字段列却只过滤普通列的查询。可以在慢日志里设置一个较低的阈值比如1秒跑一段时间逐条看执行计划和访问的行数。5.2 第二步按访问频率和字段属性设计拆分方案拆分方案的核心原则一句话高频访问的字段留在主表大字段和低频访问的字段放副表。具体操作上我会先画一张字段清单每个字段列出类型、长度、使用它的接口或模块、访问频率通过代码扫描或慢日志统计、更新频率。然后按以下规则分组主表放经常出现在WHERE条件里的列、列表页和详情页都会用到的核心展示列、参与计算或状态流转的列。副表放TEXT/BLOB/JSON这类大字段、仅详情页或后台才访问的扩展列、高频更新但不影响核心链路的列比如浏览次数、运营备注。如果需要两张以上副表优先按“详情内容类”和“运营后台类”分开别把后台管理字段加到C端详情字段里。有一个容易忽略的点主表要尽量做到“所有字段在绝大多数查询里都用得上”。如果拆出来的主表仍然存在大量查询只用到其中一半字段说明拆得还不够彻底需要继续往副表里扔。5.3 第三步新表的主键关系怎么处理拆出来的副表不需要物理外键只保留与主表相同的主键即可。也就是说product_main.id 1001product_detail.id 1001表示同一条商品的两个侧面。这么做的好处是查询简单直接避免外键带来的锁和性能损耗代价是数据库层面不保证引用完整性可能产生主表有、副表没有的孤儿数据。针对孤儿数据常规做法是写一个对账脚本定期检查两边的ID集合差异并报警或自动补偿。这个脚本不用太复杂可以分批比对SELECT count(*) FROM product_detail WHERE id NOT IN (SELECT id FROM product_main WHERE id BETWEEN ? AND ?);注意IN子查询范围不要太大分页或分ID区间处理避免一次性扫描全表。5.4 第四步迁移方案与双写策略拆分上线最稳妥的做法是“双写回填切读”千万不要直接改表结构然后一次性搬迁数据。详细步骤在代码里把对原表的写操作同时写到主表和副表持续一段时间让新的副表积累新数据。写一个回填任务把原表的历史数据分批导入副表。建议按照主键ID分段每批1000行左右用 limit 游标方式推进避免大事务和长锁。导完一批简单校验数据量。回填期间业务流量照常双写已在跑历史数据慢慢补齐即可。全量回填完成后做一致性校验。最直接的方式是比对主键集合和聚合校验值比如按某个字段做CRC32聚合同时抽样几条大字段做完整内容比对。校验通过后开始切读。切读要按流量比例渐进先让新代码里对副表的查询小流量放量比如5%观察接口延迟和错误率再逐步提高到10%、50%、100%。全部切完后保留原表至少一个业务周期比如7天作为回滚预案。确认稳定后原表可以归档或删除。这套流程执行下来风险基本可控。我自己每次做拆分都会在切读前写好回滚脚本一旦出现严重问题直接把读流量切回原表等排查清楚再继续。6. 拆分之后哪些地方会疼怎么应对垂直分表不是一把梭就万事大吉拆完了会有新的问题冒出来。这块提前讲清楚你才不会上线后被吓了一跳。6.1 跨表查询能别join就别join拆表之后原先一条SQL就能查全部字段的动作现在得查两张表。最简单的做法是应用层组装先查主表拿主键集合再按需批量查副表。对大多数互联网业务来说列表页只需要主表详情页才需要副表应用层天然就符合这个调用顺序所以应用层组装是首选。如果确实需要join比如后台报表要同时看主表和副表字段且表数据量在可控范围那直接join问题也不大。数据量大了就得考虑在副表上冗余一些高频查询的维度字段或者用数据同步方案把数据汇聚到分析库里去查。核心思路是垂直分表本来就是为了避免“为了一两个字段把整行抬出来”如果你每个查询都要join回大字段拆分的效果就打了折扣。6.2 跨表一致性能放同一张表就别拆开拆表之前一张表的更新是原子的拆表之后如果一次业务操作要同时更新主表和副表就需要跨表事务。在同一个数据库实例里本地事务仍然可以保证一致性——只要把两个update包在一个事务里即可。但要注意如果主表和副表后续被拆到了不同数据库实例分库本地事务就失效了得引入分布式事务成本陡然上升。所以拆表设计阶段就要想清楚哪些字段在业务上必然一起更新比如订单状态和支付状态几乎是同一时间由同一个支付回调改的它们应该留在同一张表而商品的详情富文本和价格更新频率完全不同拆开没有一致性风险。避免把“强绑定更新”的字段拆到不同表是垂直分表设计中非常重要的一条经验。6.3 冗余字段的取舍快照优先、反范式要克制垂直分表以后为了少做join和跨表查询你可能会想在主表里冗余一些副表字段。这里的原则是冗余字段只放更新低频、用于查询条件的列。比如在订单主表里冗余商品标题和商品快照价格是合理的但在商品主表里冗余供应商的所有联系方式就不合理了。特别要注意快照类字段订单里的商品名称、单价、优惠信息在下单那一刻就应该完整保存进订单扩展表而不是提交时去商品表实时关联。这既是拆分后的技术需要也是业务正确性的要求——商品价格变了历史订单显示的不应该跟着变。6.4 往后的演进垂直分表只是第一步不是终点垂直分表解决的是“行太宽”的问题它解决不了“行太多”的问题。表变窄之后单表能容纳的行数上限提高了但数据量继续增长依然会遇到单表瓶颈。这时候的演进路径很清晰在已经拆窄的主表上做水平分表按业务主键哈希或范围拆分再往后可以做分库、引入数据库中间件。因为主表已经足够窄水平拆分的数据迁移和查询路由都会轻松很多。换句话说垂直分表是把地基打正让后续的水平拆分、分库分表有一个健壮的起点。很多团队跳过垂直分表直接上水平分表结果每张分表仍然是宽表性能优化做了一圈才发现每个分片里的单行还是太大白白浪费了中间件层面的复杂投入。7. 常见问题与排查技巧实录7.1 拆完以后性能反而变差了是怎么回事这是最常见的翻车场景拆完居然更慢了。原因往往不是拆分本身错了而是踩了三个坑第一应用层没有做查询组装优化。每次请求详情都先查主表再等副表返回变成串行的两次网络往返延迟自然增加。解决办法是在应用层并行查询两张表两支协程并发访问或者把副表查询改成批量方式详情页一次性只查一次。第二高频但只访问主表的接口代码里仍然有旧的 SELECT * 没有清理干净或者ORM的映射关系还物理关联着原表。上线前用SHOW PROCESSLIST抓一下正在执行的SQL看看是不是还在查旧表。第三OR映射加载策略问题。有些ORM框架默认懒加载副表字段但如果你在事务里访问了副表属性它会在循环里发N条查询产生典型的N1问题。拆表后排查N1比拆表前更考验代码功底。遇到拆完更慢的情况我的排查顺序是先看执行计划确认走了主键索引再看应用层日志确认没有循环单查最后看缓存命中率确认主表的热数据确实在缓冲池里。绝大多数“拆完变慢”都是这三层里的一层出了问题。7.2 双写期间数据不一致了怎么办双写策略里应用层同时写主表和副表一旦某个写操作只成功了一半就会出现一致性偏离。排查手段主要是对账脚本和补偿任务。对账不能只比对ID数量因为ID数量一致不代表字段内容一致建议对每张副表按ID区间抽样比对重点字段条件允许的话用主键范围分批跑CHECKSUM TABLE或者CRC32聚合做全量校验。补偿任务负责把主表的最新数据定期推送到副表采用幂等更新策略INSERT ON DUPLICATE KEY UPDATE 或 REPLACE。只要最终一致性能被接受就没必要上分布式事务。如果业务上确实强一致那就只能把跨表更新收敛到一个服务接口里用本地事务包住。7.3 怎么判断该做垂直分表还是先做索引优化这是在性能调优时最常纠结的问题。我的判断标准很简单分成两个维度如果你的慢查询可以通过加一个索引、改写一下SQL、或者在应用层加缓存来解决那优先做这些低成本优化但如果表本身行特别宽avg_row_length大于4KB或者存在TEXT/BLOB/JSON大字段并且频繁出现“只需要部分字段却因为表结构不得不读整行”的场景索引优化解决不了根本问题得上垂直分表。还有一种情况是索引优化有效果但收益有限比如你给列表页查询加了覆盖索引查询性能是变快了但底层行的搬运开销并没有消失只是被暂时掩盖住了。随着数据量继续增长问题还会冒出来。我的经验是把垂直分表视为治理手段把索引优化视为临时缓解手段两者结合使用顺序通常是先拆表再针对窄表做精准索引。7.4 一份快速自查清单最后给想动手的团队一份清单照着过一遍基本能判断要不要拆是否存在某一行的 avg_row_length 超过4KB的表是否存在SELECT * 但实际只用少数字段的高频接口是否存在列表类接口因为偶发返回超大结果集导致响应体膨胀是否有多张表共享同一份大字段但只在不同场景访问是否存在高频更新的核心表里混着低频更新的大字段UPDATE热点行时是否经常看到锁等待超时缓冲池命中率是否长期低于95%以上命中2条以上垂直分表值得认真评估。如果只命中1条可以先用索引和缓存顶一顶但要在技术债务台账上记一笔。我个人在实际操作中最大的体会是垂直分表不是那种能让你在朋友圈发“技术前沿”的噱头但它是我做过的所有数据库治理手段里性价比最高、上线风险最低、收益最直观的一种。好几次团队都觉得要上分布式中间件了我坚持先把宽表拆干净结果问题直接消失根本不用上更复杂的架构。如果你正被一张宽得离谱的表折磨别急着搞什么分库分表先拿起垂直分表这把刀。最后再分享一个小技巧拆分之后把主表设计成“所有字段在绝大多数查询里都有用”的状态余下的副表字段慢慢梳理优化你会发现后续所有的数据库运维都顺手了很多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑