非分片键查询的异构索引表方案
解读
在国内日均千万级订单、亿级日志的互联网场景里,PHP 业务库往往采用“水平分库分表 + ShardingSphere/TiDB 中间件”架构。分片键(如 order_id、user_id)能直接定位到物理表,但运营后台、财务对账、风控检索等需求常出现“按手机号、按订单状态、按时间段”查询——这些字段不是分片键,直接扫全库会触发“跨分片聚合”,导致连接数暴涨、RT 飙高甚至 OOM。面试官问“异构索引表方案”,本质想看候选人能否用低成本、高可靠、可落地的手段,把非分片键查询的 RT 从秒级降到毫秒级,同时兼顾数据一致性、读写放大、运维复杂度三大痛点。
知识点
- 水平分库分表核心概念:分片键、分片算法、路由规则、广播表、绑定表。
- 异构索引(又称二级索引、反向索引)本质:用另一组键值对重新组织数据,实现“按非分片键快速定位分片”。
- 业界三种主流实现:
- 冗余全量索引表:把索引字段当分片键再建一套表,存储主键或定位信息。
- 增量索引表 + 异步消息:通过 Canal/RocketMQ 监听 binlog,只同步变化行,索引表只存“键→分片”映射。
- 外部高可用索引:ES、TiKV、Redis Search,把索引与数据解耦。
- PHP 侧技术栈:Laravel/Symfony 事件总线、Hyperf 协程消费、Composer 包 eg. longman/canalphp、elasticsearch/elasticsearch。
- 数据一致性模型:最终一致性(常用)、强一致性(分布式事务,成本高)。
- 国内大厂踩坑点:双写失败、Canal 位点回滚、MQ 消息积压、索引表热点、分片扩容后索引重建。
答案
以“订单表按手机号查询”为例,给出可在 3 个月内落地、日增量 2 亿、峰值 3 万 QPS 的异构索引表方案,全部用 PHP 技术栈实现。
-
索引模型选择
采用“增量索引表 + 异步消息”方案,避免冗余全量带来的 3× 存储成本;索引表只存“手机号→user_id→分片位图”三列,单行 < 50 B,百亿行仅 500 GB。 -
表结构设计
索引表同样按手机号哈希分 64 库×128 表,主键 (phone_hash, phone),列:- user_id bigint
- shard_bit map 固定 8 B(64 位,每 bit 代表一个分片,支持 64 分片内任意组合)
索引表与订单表使用相同分片算法,保证“同手机号路由到同一库”,避免跨库二次查询。
-
数据流与一致性
a. 业务 PHP 服务只写订单表,不写索引表,降低耦合。
b. 通过 Canal-PHP 客户端监听 MySQL binlog,过滤table == orders && (type == INSERT || type == UPDATE 手机号变更),将变更投递到 RocketMQ Topic: order_phone_index。
c. 消费端用 Hyperf 协程进程池,批量拉 200 条/次,按手机号哈希路由到对应索引表,ON DUPLICATE KEY UPDATE 更新 bit 位;失败记录重试 3 次后入死信表,人工补偿。
d. 延迟监控:在消息体里带上 binlog 时间戳,消费时计算 lag > 3 s 即报警,保证最终一致性 < 5 s。 -
查询路径
PHP 代码示例(Laravel):$phoneHash = crc32($phone) >> 16 & 0x3F; // 64 库 $index = DB::connection('index_' . $phoneHash) ->table('phone_index_' . ($phoneHash % 128)) ->where('phone', $phone) ->first(); if (!$index) { return []; } $shardBits = $index->shard_bit; $userIds = explode(',', $index->user_id); // 支持多账号 // 根据 bit 位拼出实际分片列表 $shards = []; for ($i = 0; $i < 64; $i++) { if ($shardBits & (1 << $i)) $shards[] = $i; } // 并行查询订单表(Hyperf 协程或 Swoole\Runtime::enableCoroutine) $orders = parallel($shards, function ($shard) use ($userIds) { return DB::connection('order_' . $shard) ->table('orders') ->whereIn('user_id', $userIds) ->get(); }); return $orders;单次查询 RT 均值 15 ms,P99 45 ms,相比全表扫描提升 200 倍。
-
运维与扩容
- 索引表采用 InnoDB + ZSTD 压缩,磁盘节省 60 %。
- 分片扩容到 128 时,只需把 shard_bit 字段扩到 16 B,并写工具脚本离线重建索引,双写切换期间 MQ 停写 3 min,低峰期执行。
- 灰度验证:在 PHP 侧加开关,对比老接口“扫描所有分片”结果,100 % 一致后全量切流。
-
回滚预案
若索引表数据污染,PHP 侧一键关闭“use_phone_index”配置,回退到全分片扫描,保证核心链路可用。
拓展思考
- 如果查询维度继续膨胀(按收货人身份证、按商品 SKU),每新增一个维度都建索引表会导致“索引爆炸”。可演进为“宽列索引”:把多个维度拼成联合键,利用 Redis Cluster 的 HashTag 做分片,单 key 存 JSON 定位信息,PHP 用 Predis 管道批量查,进一步节省 MySQL 连接数。
- 对实时性要求 < 500 ms 的风控场景,可在 PHP-FPM 同步阶段先读 Redis 缓存索引,若缓存未命中再降级到索引表,通过 Guzzle 异步回种 Redis,实现“缓存+索引”混合架构。
- 国内金融合规要求“订单数据不可丢”,Canal 位点需定期备份到 OSS;同时消息体做 PB 序列化,压缩率比 JSON 高 40 %,可显著降低北京-上海专线带宽费用。
- 未来升级到 TiDB 6.x 后,可直接用 TiFlash 列存+全局二级索引,PHP 侧无需维护异构表,但需评估 5 倍存储成本与 HTAP 查询隔离性,做好 PoC 再迁移。