在上一篇文章中,我们聊了如何把庞大的数据库大卸八块,也就是分片 (Sharding)。但拆完只是第一步,更棘手的问题接踵而至:如果我想读写数据,怎么知道该找哪个节点?如果我想通过颜色找东西,而数据是按ID分的,又该怎么办?今天,我们就来聊聊分片后的两大难题:请求路由 (Request Routing) 和 二级索引 (Secondary Index)。
请求路由:数据库界的寻址大师
想象一下,你住在一个巨大的、没有门牌号的城市里,送外卖的小哥完全不知道该往哪飞。请求路由 (Request Routing) 的核心问题就是给数据加上坐标,让读写请求能精准送达。
简单说,请求路由要解决的是:客户端如何知道哪个节点上有它要的数据?而这背后离不开一张关键的“地图”——分片映射表 (Shard Map),它记录了哪个分片在哪个节点上。这张表通常由 协调服务 (Coordination Service) 统一管理,比如 ZooKeeper 或 etcd。下面我们来看三种玩法,并重点标注出映射表的位置。
玩法一:随便找个人问问
客户端可以随便连上一个节点,如果这个节点恰好有数据,那就直接处理;如果没有,它就充当二传手,把请求转发给正确的节点,再把结果传回来。
graph TD
Client[客户端] -->|1 请求| NodeA[任意节点]
NodeA -->|2 检查本地缓存/询问协调服务| Map[(协调服务<br>存储分片映射表)]
Map -->|3 返回正确节点地址| NodeA
NodeA -->|4a 如果本节点有数据| Process[直接处理并返回]
NodeA -->|4b 如果本节点无数据| Forward[转发请求到正确节点]
Forward -->|5 返回结果| Client
这就像你去问路,路人虽然不是你要找的人,但他手里有一份全城地图(或者能打电话问总台),知道谁懂,帮你跑个腿。这种方式对客户端最友好,但中间节点可能成为瓶颈,而且转发也增加了延迟。
玩法二:找个前台
引入一个专门的路由层,它不存数据,但知道所有分片的位置。所有请求先到这个前台,由它查表后转发。
graph TD
Client[客户端] -->|1 请求| Router[路由层]
Router -->|2 查询映射表| Map[(协调服务<br>存储分片映射表)]
Map -->|3 返回正确节点地址| Router
Router -->|4 转发请求| CorrectNode[正确节点]
CorrectNode -->|5 返回结果| Router
Router -->|6 返回| Client
这就像公司前台,她手里有一份最新的员工办公位置表(或者能随时问人事部),你只要告诉她找谁,她就告诉你该去哪个办公室。这种方式把复杂性从客户端剥离,集中管理,但路由层本身需要高可用,否则就全剧终。
玩法三:给客户端发张地图
最硬核的方式是让客户端自己知道分片和节点的映射关系。客户端直接连到正确的节点,一步到位。
graph TD
Client[客户端] -->|1 定期获取/订阅映射表| Map[(协调服务<br>存储分片映射表)]
Map -->|2 返回最新分片映射表| Client
Client -->|3 根据映射直接连接| CorrectNode[正确节点]
CorrectNode -->|4 返回结果| Client
这就像你手机里装了最新的导航地图,自己导航过去。性能最好,但客户端需要实时感知集群变化,一旦节点增减,地图得立即更新,否则就找错门。
不管哪种玩法,协调服务都用共识算法确保大家看到的地图是同一个版本,防止出现 脑裂 (Split Brain)——也就是地图显示同一个数据在两个地方。
二级索引:分片世界的混乱制造者
如果说请求路由是找主键,那二级索引就是找“特征”。比如,你记得一个人长什么样,但不记得他身份证号。在单机数据库里,这很简单,但在分片世界,麻烦大了。因为你按主键分片,但你想按非主键的字段来查,这些数据可能散落在所有分片里。
针对这个问题,江湖上有两大流派:局部二级索引 (Local Secondary Index) 和 全局二级索引 (Global Secondary Index)。
局部二级索引:各扫门前雪
每个分片只管自己那一亩三分地,维护自己分片内的二级索引。这就像每个区的图书馆自己建一个只包含本区藏书的目录。
graph TD
subgraph Shard_0[分片0]
Data0[数据: ID0, 颜色红]
Index0[索引: 红 -> ID0]
end
subgraph Shard_1[分片1]
Data1[数据: ID1, 颜色蓝]
Index1[索引: 蓝 -> ID1]
end
subgraph Shard_2[分片2]
Data2[数据: ID2, 颜色红]
Index2[索引: 红 -> ID2]
end
Query[查询: 所有红色物品] --> Broadcast[广播到所有分片]
Broadcast --> Index0
Broadcast --> Index1
Broadcast --> Index2
Index0 -->|返回ID0| Collector[收集结果]
Index1 -->|返回空| Collector
Index2 -->|返回ID2| Collector
Collector --> Final[最终结果: ID0, ID2]
写操作非常友好,因为数据属于哪个分片是确定的,只需更新那个分片的本地索引即可。
但读操作就悲剧了。就像上图所示,你只能向所有分片广播查询:“嘿,你们谁家有红色的?” 然后等所有分片返回结果,自己再汇总。如果某个分片响应慢,整个查询就被拖累,这就是 尾部延迟放大 (Tail Latency Amplification)。更可怕的是,随着集群规模扩大,你要广播的节点越来越多,但每个查询依然要等所有节点,查询吞吐量并不会随着节点增加而提高。局部索引就像让你的查询变成了全员大会,每个人都要发言。
全局二级索引:建立中央情报局
为了解决广播查询的低效,全局索引登场。它建立一个覆盖所有分片的中央索引,但这个索引本身也必须分片,否则就会成为单点瓶颈。
全局索引的分片方式通常和主键不同,它是按索引值来分的,也叫 词条分区 (Term-Partitioned) 索引。比如,按颜色建索引,所有红色物品的ID都集中在一个索引分片里,所有蓝色的在另一个里。这就像按颜色建立全国图书馆联合目录,红色的书都登记在一本册子上,蓝色的在另一本上。
现在,查询“所有红色物品”就简单多了。你只需根据“红色”这个词,找到负责红色那个索引分片的节点,从它那里拿到所有红色物品的ID列表。这一步只需要查询一个索引分片,非常高效。但是,拿到ID后,你依然要根据这些ID去对应的数据分片里取具体记录,这可能需要访问多个数据分片。
全局索引的麻烦在于写操作。当你插入一条数据时,它可能影响多个索引分片。比如,你插入一件红色的短袖,它同时要更新“红色”索引分片和“短袖”类别索引分片。如果这两个索引分片不在同一个节点,你就需要 分布式事务 (Distributed Transaction) 来保证数据的一致性,否则就可能出现“衣服是红色,但红色索引里没它”的尴尬局面。分布式事务是出了名的性能杀手,所以很多系统为了性能,选择让全局索引异步更新,这意味着你查索引时可能读到过时数据。
总结
请求路由解决的是“去哪找数据”的问题,它通过协调服务动态维护分片与节点的映射,让客户端或路由层能精准定位。二级索引解决的是“按特征找数据”的问题,局部索引让写简单读复杂,全局索引让读简单写复杂。
没有完美的方案,只有适合的权衡。如果你的业务主要是通过主键读写,偶尔按条件查询且能容忍延迟,局部索引就够用。如果你需要高性能的按特征检索,并且能接受更复杂的写入逻辑和潜在的分布式事务开销,全局索引是更好的选择。理解这些取舍,你才能在分片的大道上,既找到路,又找到人。