分库分表后,非分表键查询走 ES 异构索引还是覆盖索引?把代价摊开算笔账
非分表键查询没有银弹——ES 异构索引和覆盖索引都是妥协方案,选哪个取决于你的数据量、查询复杂度和运维承受能力。关键决策变量只有三个:数据延迟容忍度、查询灵活性需求、以及你是否养得起一个 ES 集群。
先摊开两种方案的成本结构,再结合真实场景算账。ES 异构索引的代价主要在运维侧,覆盖索引的代价主要在数据库侧,但最终都会传导到你的开发效率和线上稳定性上。
两种方案的本质差异
ES 异构索引是用空间和延迟换灵活查询,覆盖索引是用冗余列和写入开销换分片内查询效率。
覆盖索引方案的核心思路是:非分表键查询时,先在每个分片上通过覆盖索引查出目标数据的分表键值,再用分表键回表或二次路由。举个例子,用户表按 user_id 分 16 个库 256 张表,但经常需要按 phone 查用户。这时候在每个分片上建一个 (phone, user_id) 的联合索引,查询时发 256 条 SQL 到所有分片,每条走 INDEX 不回表,拿到 user_id 后再精确路由。
ES 异构索引则是把 MySQL 里的数据实时或准实时同步到 ES,查询时直接走 ES 的倒排索引,拿到 user_id 后回 MySQL 取完整数据。同步方式通常是 Canal + MQ(比如 Kafka),或者直接用 DataX 做定时全量 + 增量。
这两套方案在表面上都能解决非分表键查询的问题,但代价结构完全不同。
覆盖索引的真实代价:不是索引大小,而是并发查询压力
覆盖索引方案最大的隐性成本不是存储,而是 256 张表的并发查询把你的连接池打爆。
假设单库 16 张表、16 个库,总共 256 张分表。每次按 phone 查询,你至少要发起 256 条 SELECT user_id FROM user_0000 WHERE phone = ?。在实际业务中,这个数字还要乘以连接池的占用时间。
拿我们 2023 年处理过的一个电商订单系统来说,订单表按 buyer_id 分 32 库 256 表,但运营后台需要按 order_no 查订单。DBA 一开始的方案就是覆盖索引:每张表建 (order_no, buyer_id)。上线一个月后发现,运营高峰期(下午 2 点到 4 点)数据库连接池频繁告警,平均等待时间从 5ms 飙升到 230ms。
排查后发现根因:运营后台的列表页每次查询会扫 256 张表,加上关联的退款单、物流单查询,一个运营页面能触发 800+ 条并发 SQL。虽然每条 SQL 都走索引且只查两列,耗时不到 2ms,但连接数撑不住。Druid 连接池默认 maxActive 20,每个库的连接池被瞬间耗尽后,后续请求开始排队。
解决方案不是加连接池大小——那样会把数据库 CPU 打满。最终我们做了两层优化:第一层是在应用层做并发控制,用 CompletableFuture 的线程池限制同时执行的查询数不超过 50;第二层是把 order_no 的映射关系缓存到 Redis,命中率做到 85% 后才勉强稳住。
这个案例说明一个关键点:覆盖索引的代价跟分片数成正比,而且是线性的。 如果你的分表数超过 128,覆盖索引方案就要谨慎评估并发查询的瓶颈。
ES 异构索引的真实代价:不是机器成本,而是数据一致性维护
ES 异构索引最大的坑不是集群费用,而是数据延迟和一致性保障的工程复杂度。
很多人算 ES 方案的成本时只看到机器配置——3 节点 16C32G 的集群,按云服务器价格一年大概 3 万到 5 万。但这个数字在整体成本里占比不到 30%,真正的开销在同步链路的开发和维护。
Canal + Kafka + ES 这条链路看起来成熟,但生产环境会遇到各种问题。我 2022 年在一个社交平台项目里踩过一个典型的坑:用户关注关系表按 follower_id 分片,但需要按 following_id 查粉丝列表。我们用了 ES 异构索引,同步链路是 Canal → Kafka → 自研同步服务 → ES。
上线第二周就出问题:Canal 解析 binlog 时,如果上游有大批量 INSERT(比如数据迁移或定时任务批量写入),Canal 会把多条变更合并成一个 MQ 消息,导致单条消息超过 Kafka 默认的 1MB 限制。解决方式是调整 max.request.size 到 10MB,但这又带来了消息消费的超时问题。
更头疼的是数据不一致的排查。ES 的 near real-time 特性意味着写入后默认 1 秒才可见,如果业务代码写 MySQL 后立即查 ES,有概率查不到。解决这个问题要么在业务层做重试和补偿,要么改 ES 的 refresh_interval 到更小值,但频繁 refresh 会显著增加 CPU 开销。
运维成本同样不能忽视。ES 集群的磁盘使用率超过 85% 后写入性能会断崖式下降,需要定期清理或扩容。堆内存配置超过 32GB 后指针压缩失效,内存利用率反而降低——这些细节都需要专人维护。如果你的团队里没有对 ES 底层机制熟悉的人,出问题时排查周期会很长。
用数据说话:两种方案在三个场景下的量化对比
抛开具体场景谈方案优劣都是耍流氓,我用三个真实量级的场景分别算账。
场景一:数据量 500 万,分 8 库 64 表,QPS 200
这种情况覆盖索引几乎完胜。64 张表的并发查询,用 HikariCP 连接池默认 10 个连接可以轻松应对,每条 SQL 耗时 1-3ms,总耗时在 20ms 以内。索引大小方面,假设每行 (phone, user_id) 占 50 字节,500 万行总索引大小约 250MB,平均到 64 张表每张不到 4MB,完全可以塞进 Buffer Pool。
而 ES 方案在这个量级下显得过度设计:搭建 Canal + ES 集群至少需要 3 台机器,加上同步链路的开发(即使是基于现有模板改造也要 1-2 周),ROI 太低。
结论:数据量小于 1000 万、分片数不超过 128、QPS 低于 500 时,覆盖索引是性价比最高的方案。
场景二:数据量 2 亿,分 32 库 256 表,QPS 2000
这个量级下覆盖索引开始吃力。256 张表并发查询,假设单 SQL 耗时 2ms,连接池需要至少 512 个并发连接才能保证不排队。实际压测中,我们用 MySQL 8.0、InnoDB 引擎、innodb_buffer_pool_size 设为 64GB,256 并发查询时 CPU 使用率从 15% 飙到 70%,平均响应时间从 3ms 退化到 45ms。
ES 方案在这个场景下优势明显。我们用相同的数据量在 3 节点 ES 7.17 集群上测试,按 phone 查询的 P99 延迟稳定在 8ms,集群 CPU 使用率 35%。同步延迟通过调优后稳定在 200ms 以内(Canal 批量大小设 8192,Kafka partition 设 16,ES bulk size 设 1000)。
成本侧:3 台 16C32G 云服务器年费约 4 万,加上一个中级后端工程师 2 周的同步链路开发 + 1 周的压测调优,一次性投入约 1.5 万。而覆盖索引方案需要升级数据库配置(从 16C64G 升到 32C128G),年费增加 3 万,且后续数据增长到 5 亿时还要继续升配。
结论:数据量超过 5000 万、分片数超过 128、QPS 超过 1000 时,ES 异构索引的综合成本开始低于覆盖索引。
场景三:多条件组合查询(范围查询、模糊搜索、聚合统计)
这是覆盖索引的死穴。假设查询条件变成「注册时间在 2023 年 1 月到 6 月、手机号包含 138、订单数大于 10 的用户」,覆盖索引方案需要在每个分片上执行全表扫描或建立大量联合索引,而 ES 的倒排索引天然支持这类组合查询。
我处理过一个 CRM 系统的案例:客户表按 company_id 分片,但销售团队需要按「行业 + 地区 + 最近跟进时间范围 + 标签关键字」组合筛选。覆盖索引方案尝试建了 12 个联合索引,但仍然覆盖不了所有查询组合,最终查询退化成分片全表扫描,3 秒超时。迁移到 ES 后,同样的查询稳定在 200ms 以内,代价是维护了一套包含 18 个字段的索引映射和一条 300ms 延迟的同步链路。
结论:如果你的非分表键查询涉及多条件组合、范围查询或模糊搜索,不用犹豫,直接上 ES。覆盖索引在这种场景下不是代价问题,而是能不能用的问题。
混合方案:别非此即彼
现实中最优策略往往是混合使用,用覆盖索引解决高频简单查询,用 ES 解决低频复杂查询。
一个典型的落地方式是这样的:在分表键之外的查询字段中,选出 1-2 个最高频的字段建覆盖索引(比如 phone、email),其他复杂查询走 ES。同时把覆盖索引查出的映射关系缓存到 Redis,设置 5 分钟过期,进一步降低数据库压力。
但要注意一个边界:如果你的高频查询字段超过 3 个,或者查询模式频繁变化,覆盖索引的维护成本会快速上升。 每增加一个覆盖索引,写入性能下降 5%-15%(取决于索引列大小),存储空间增加 10%-30%。当覆盖索引数量超过 5 个时,写入性能的劣化会传导到主业务流程,这时候就该考虑全部迁移到 ES 了。
还有一个容易被忽略的点:ES 方案不等于放弃 MySQL 的覆盖索引。 即使用了 ES,对于根据分表键的查询仍然走 MySQL 主键索引,ES 只作为非分表键查询的入口。这样可以避免 ES 承载全量查询压力,保持集群规模可控。
常见问题
覆盖索引方案下,如果某个分片挂了,查询会怎样?
这取决于你的分片路由策略。如果用的是 ShardingSphere 这类中间件,可以配置 执行策略 为 ALL_BROADCAST 时跳过故障节点,但会丢失该分片的数据。更稳妥的做法是配合 Sentinel 或 Hystrix 做熔断,当某个分片查询超时率超过阈值时临时降级——比如只查缓存或返回部分结果。根本解决还是要靠数据库本身的高可用(MHA、MGR 或云厂商的 HA 版本),覆盖索引方案对数据库可用性的依赖比 ES 方案更强。
ES 同步延迟 200ms 还是太慢,能不能做到准实时?
可以,但代价很大。把 Canal 的批量大小降到 1、Kafka 的 linger.ms 设为 0、ES 的 refresh_interval 设为 -1(禁用定期 refresh)并改为写入后手动 refresh,可以把延迟压到 50ms 以内。但这样做的副作用是:ES 写入吞吐量下降 60%-80%,CPU 使用率上升 2-3 倍,因为每次 refresh 都会生成一个新的 segment 并触发 segment merge。除非业务有强实时性要求(比如即时通讯),否则不建议这么做。大部分场景 200ms-500ms 的延迟完全可接受。
数据量不大但查询模式多变,选哪个?
选 ES。数据量不大意味着覆盖索引的性能问题不会暴露,但查询模式多变意味着你需要频繁加索引。MySQL 在线加索引(ALGORITHM=INPLACE)虽然不锁表,但在 1000 万行以上时会带来 10%-20% 的性能抖动,而且每加一个索引都要 DBA 审批、在低峰期操作。ES 的动态映射和灵活的查询 DSL 可以让你随时调整查询逻辑而不需要变更存储结构。对于初创项目或需求快速迭代的阶段,这个灵活性比性能优化更重要。