批量导入和写入高峰下,为什么要先少建索引

3 阅读13分钟

做过批量导入的人,十有八九都遇过这个场面:前 1 万条写得飞快,后面越来越慢,CPU、磁盘、锁等待一起冒头,SQL 看着也没变,数据库却像突然背了沙袋。

这时很多新手会先怀疑网络、语句、事务大小。它们当然可能有影响,但有个很常见的真凶更容易被忽略:索引太多,写入时每条数据都要顺手把这些索引也更新一遍。说得更直接一点,这篇文章要讲明白一件事:在批量导入、写入峰值这类场景里,适当牺牲一段时间的查询速度,常常能换来更稳的写入速度,而常见手段就是减少索引、延迟索引构建、批量导入后统一建索引。

先讲人话:索引为什么会拖慢写入

索引:先用人话说,它就是数据库提前准备好的目录,目的是让查询别满表乱翻。生活类比是图书馆的检索卡片,书多了以后,没有目录你会找得很崩溃。小案例是客服要按手机号查订单,有索引时几乎直奔目标,没索引时就得一页页翻。

读取性能:先用人话说,就是你查一条数据要等多久、数据库每秒能帮你查多少次。生活类比是去超市找酱油,导购牌清楚时 10 秒就到,不清楚时你会推着车瞎逛。小案例是运营后台查昨天退款单,索引合适时秒开,不合适时页面一直转圈。

写入性能:先用人话说,就是新数据写进去有多快、单位时间能扛多少写请求。生活类比是仓库卸货,货车已经开到门口了,但每来一箱你都要同时改库存表、货架标签、拣货清单,动作一多,卸货自然慢。小案例是大促期间订单不断进来,订单表每插入一条都要更新好几个索引,写入吞吐就会被拖住。

术语上,很多关系型数据库会把普通索引维护在类似 B+ 树的有序结构里。你每插入一行数据,不只是把数据塞进表里,还要把相关键值插到这些索引结构里。索引越多,要同步维护的地方就越多。数据库不是不努力,它只是被你临时安排了太多小活。


有多个索引时

  


新数据到来

-> 写入表数据

-> 更新主键或聚簇结构

-> 更新索引 A

-> 更新索引 B

-> 更新索引 C

-> 提交事务

  


索引较少或延后构建时

  


新数据到来

-> 写入表数据

-> 更新必须保留的结构

-> 提交事务

-> 在低峰期再统一补建索引

看完这张流程图,你下一步就该去数一数:这张表在写入路径上,到底挂了多少个非必要索引。

三种常用手段,到底分别在做什么

1. 减少索引:先砍掉不急着用的目录

先讲人话,这招就是做减法。既然每个索引都会让写入多做一步,那就先把暂时不重要的索引撤掉,让数据库把力气优先花在把数据写进去这件事上。

生活类比是新店开业时,门口先摆最关键的导视牌,别一开始就把每层、每柜、每活动区的牌子都立满。牌子多,顾客找得快;但每调整一次布局,你也得改更多牌子。

小案例是日志平台白天查询维度很多,于是给 user_idstatusdevice_idcitycreated_at 都建了索引。到了夜里回灌历史日志时,团队发现写入明显吃力,于是先保留最核心的主键和少数必要索引,把次要查询索引延后处理,导入速度就稳定了不少。

这招最适合两种情况:第一,某些查询只是方便,不是必须;第二,你有明确的写入窗口,希望先把数据吞进去。代价也很直白:在索引变少的这段时间里,部分在线查询会变慢,甚至从走索引变成扫更多数据。

2. 延迟索引构建:先把货搬进仓库,再统一贴标签

先讲人话,这招不是不要索引,而是晚一点再要。数据先进去,目录稍后补。核心思想是把写入路径上的工作往后挪,让高峰期先活下来。

生活类比是搬家。你不会边搬边把每个抽屉都分门别类贴好标签,通常是先把箱子搬进屋,再统一整理。边搬边整理,动作很精细,但效率往往最差。

小案例是业务系统每天凌晨导入前一天的交易明细。导入时在线查询不多,于是团队选择先导入、后建索引。这样凌晨的主要目标是快速落库,等导入完成再慢慢把查询能力补齐。

这招对批量导入尤其友好,因为批量写入强调的是吞吐,不是单条记录写完立刻可高效查询。代价还是那句话:延迟构建期间,依赖这些索引的查询会变慢;另外,后面补建索引本身也会消耗 CPU、磁盘和 I/O,安排时机要慎重。

3. 批量导入后统一建索引:把零碎活改成一次大活

先讲人话,这招是前两招的升级版:不是每写一条就维护一次目录,而是把大量数据先堆好,再一次性把目录建起来。很多数据库在这种模式下,比边写边维护多个索引更省事。

生活类比是给一整个小区做门牌。你每交付一户就去刻一次、挂一次,效率很碎;等整栋楼名单稳定后统一制作、统一安装,通常更顺。

小案例是数据团队要把过去半年的订单回灌到分析库。如果每插一批就更新多个二级索引,导入会拖很久。改成先导入表,再统一建索引之后,写入过程更顺,索引也能在导入结束后一次补齐。

这一招特别适合离线导数、历史数据回补、初始化装载。它的代价也最容易被低估:统一建索引那一段时间,查询性能可能还没恢复,而且不同数据库对建索引时的锁、并发影响、资源占用不完全一样,不能闭着眼直接上生产。

| 手段 | 本质做法 | 适合场景 | 最大收益 | 主要代价 | 新手提醒 |

|---|---|---|---|---|---|

| 减少索引 | 直接移除非关键索引 | 临时写入压力大、部分查询可让步 | 立刻减轻每次写入的维护成本 | 在线查询变慢 | 不要动主键、唯一性和核心业务必需索引 |

| 延迟索引构建 | 先写数据,后补索引 | 夜间导入、离峰窗口明确 | 把高峰期工作后移 | 补建前查询变慢 | 先确认导入窗口内谁还会查这张表 |

| 批量导入后统一建索引 | 导完一批后一次性建索引 | 历史回灌、初始化装载 | 批量阶段更顺、更稳 | 补建阶段也要吃资源 | 预留时间和空间,别把低峰期排得太满 |

用这张表先做第一层判断:你要的是临时减压,还是明确地把索引工作整体挪到导入之后。

什么时候值得用这套思路

最典型的第一个场景,是批量导入。

比如你要导 3 个月历史订单、迁移老系统数据、补齐前几天丢失的日志。这个时候系统的首要目标通常不是每一条刚写进去就能被复杂查询秒查到,而是先安全、稳定、尽快地把数据装进去。如果你还把所有查询索引都挂在写入路径上,就像搬仓库时要求每一箱刚落地就完成编号、拍照、归档、贴签、上架,动作当然漂亮,但效率会很受伤。

第二个常见场景,是写入峰值。

比如秒杀、报名、物联网采集、日志突发上报。高峰来了,你最怕的不是某个后台筛选条件慢 2 秒,而是写入链路直接堵死,后续消息堆积、超时、重试、雪崩一起发生。这时用读换写的思路,本质上是在保主线:先确保数据能进来,再想办法恢复查询速度。

但有两个场景你要谨慎。第一,系统当前就是强查询型,而且不能接受查询变慢,比如客服实时查单、风控实时拦截、交易前台实时展示。第二,你没有低峰窗口,建索引的动作无处安放。前一种问题是业务不能忍,后一种问题是技术不能落地。

| 状态 | 写入路径上的样子 | 写入体验 | 查询体验 |

|---|---|---|---|

| 全量索引一直在线 | 每写一条都要更新多个目录 | 更重,峰值时更容易抖动 | 更快,更适合实时查 |

| 先少建或晚建索引 | 写入时只做必要动作 | 更轻,更能扛导入和高峰 | 这段时间会变慢 |

| 导入后统一建索引 | 导入阶段最专注写入 | 更顺,适合大批量 | 索引补齐前别指望复杂查询太快 |

这张对比表提醒你的不是索引越少越高级,而是在不同阶段,把数据库的力气花在最该花的地方。

一个能复现的小例子:订单表夜间导入怎么做

假设你有一张 orders 表,白天要支持按用户、状态、下单时间查数据,所以平时建了不少索引。现在你要在夜里补导一批历史订单,目标是尽快写完,白天前恢复正常查询。

第一步,先分清谁必须保留,谁可以让步。主键、唯一性约束、直接关系数据正确性的结构,通常不要乱动。那些只是为了查询更方便的索引,才是优先评估对象。

第二步,导入前移除或延后非关键索引。这里的关键不是勇敢,而是克制。别一拍脑袋全删光,先保住正确性和最核心路径。

第三步,批量写入。能批量就别一条一条塞,能分批就别无限大事务。这个阶段的主目标只有一个:把数据稳定落库。

第四步,导入结束后统一建回索引。建完以后,再拿真实查询去验证是否恢复到可接受水平。


-- 表结构示意

CREATE TABLE orders (

id BIGINT PRIMARY KEY,

user_id BIGINT NOT NULL,

status VARCHAR(20) NOT NULL,

created_at TIMESTAMP NOT NULL,

amount DECIMAL(10,2) NOT NULL

);

  


-- 平时为了查询方便建了多个索引

CREATE INDEX idx_orders_user_id ON orders(user_id);

CREATE INDEX idx_orders_status ON orders(status);

CREATE INDEX idx_orders_created_at ON orders(created_at);

  


-- 夜间导入前,先移除这次不急用的查询索引

DROP INDEX idx_orders_status;

DROP INDEX idx_orders_created_at;

  


-- 批量导入

INSERT INTO orders (id, user_id, status, created_at, amount)

VALUES

(1, 101, 'PAID', CURRENT_TIMESTAMP, 99.00),

(2, 102, 'PAID', CURRENT_TIMESTAMP, 199.00);

  


-- 导入完成后再统一补建

CREATE INDEX idx_orders_status ON orders(status);

CREATE INDEX idx_orders_created_at ON orders(created_at);

上面这段 SQL 是演示思路,具体语法和线上限制要看你使用的数据库。真正执行前,你至少要回答三个问题:导入窗口内谁还会查这张表?哪些索引关乎数据正确性不能动?补建索引时会不会影响早高峰业务?

如果你的线上查询完全不能慢,还有个更稳的进阶做法:先导入中间表,等低峰期建好索引后再切换。它更麻烦,但更适合既要导得快,又不想在线查询打折的场景。

到底怎么选,不要靠感觉,按条件判断

| 你的条件 | 更适合的做法 |

|---|---|

| 明确有夜间或离峰窗口,导入期间查询不重要 | 优先考虑批量导入后统一建索引 |

| 高峰只持续一小段时间,想临时减轻写入压力 | 优先评估减少非关键索引或延迟部分索引 |

| 这张表在高峰期还承担核心在线查询 | 只动次要索引,必要时改用中间表方案 |

| 有主键、唯一性、去重要求 | 保留相关索引或约束,别拿正确性换速度 |

| 团队还没有做过这类操作 | 先在小表、测试库、影子环境演练,再决定上线 |

按这张矩阵做判断,你会发现真正的原则不是删不删索引,而是先保正确,再保写入,最后再恢复查询体验。

新手最容易踩的 5 个坑

坑 1:以为索引越少越好

不是。索引少,写入通常轻一点;但查询会更痛苦。这个策略的关键不是少,而是在某个阶段先少一点,等目标完成后再补回来。

坑 2:把主键、唯一性索引也一起动了

这类结构很多时候不只是为了快,更是为了保证数据不乱。你把它们随便拿掉,可能不是慢一点的问题,而是重复数据、脏数据直接进来了。

坑 3:只盯着导入速度,不验证查询恢复

很多团队在导入结束后松一口气,却忘了验证查询计划、慢 SQL、接口延迟。结果白天业务一上来,用户先替你做了性能测试。这种测试最贵,因为它是真人在线帮你踩坑。

坑 4:没给补建索引预留资源

索引不是凭空长出来的。补建阶段一样会吃 I/O、CPU、内存和时间。如果你把低峰窗口排得太满,导入刚结束,建索引又把机器顶红,那只是把堵车从一个路口挪到了下一个路口。

坑 5:没先做小规模演练

不同数据库、不同表大小、不同索引类型,表现差异会很大。最稳的做法永远是先拿一部分数据演练,记录导入速度、补建时间、查询变化,再决定是否扩大到生产。

最后,把这 5 句话记住就够了

  • 先检查索引数量和用途,再谈写入优化,别一上来就怪数据库慢。

  • 保留关乎正确性的主键、唯一性和核心业务索引,别用数据风险去换吞吐。

  • 在批量导入和写入峰值场景里,优先测试减少索引、延迟索引构建、导入后统一建索引这三种做法。

  • 在执行前后都验证关键查询,确认你接受的是临时变慢,而不是长期失控。

  • 先小规模测试,再安排低峰窗口上线,让数据库先把数据吞进去,再把查询能力补回来。