--- tags: [MySQL, 分库分表, Sharding, Vitess, ShardingSphere, Snowflake] create time: 2026-05-16 00:00 --- # 分库分表 ## 概述 想象你开了一家超市,所有商品都放在一个仓库里。当商品只有几千种时,一个仓库绰绰有余;但当商品增长到几十万种,一个仓库就变得拥挤不堪——找货慢、入库堵、仓库还会塌。解决方案很简单:**建多个仓库,按规则分配商品**。这就是分库分表(Sharding)的核心思想。 当单库单表的数据量超出 MySQL 的舒适区时,分库分表成为必选方案。但分片不仅是技术决策,更是业务架构的改变——它引入了跨分片查询、分布式 ID、数据重平衡等一系列新问题,每一个都是对团队工程能力的考验。 > [!QUESTION] 你真的需要分库分表吗? > > InnoDB 在单表千万级数据下依然表现优秀。一条合适的联合索引 + 读写分离就能解决 80% 的性能瓶颈。分片引入的复杂度远超收益——跨分片 JOIN、分布式事务、数据倾斜、在线迁移……每一个都是生产环境的深夜警报。**建议仅在以下情况才考虑分片:** > > 1. 单表已突破 **5000 万行**且增长趋势明显 > 2. QPS 已接近单机 MySQL 的物理极限(约 1~2 万写入/秒) > 3. SSD 存储成本显著上升,无法继续横向扩展 ## 分片策略总览 ```mermaid flowchart TD subgraph "水平分片 Horizontal" HS["按用户哈希
user_id % N"] HR["按范围 Range
created_at 月份"] HL["按列表 List
country IN ('CN','US')"] end subgraph "垂直分片 Vertical" V1["按业务域拆分
订单库 / 用户库 / 商品库"] V2["冷热数据分离
热表 SSD / 冷表 HDD"] end style HS fill:#00D866,color:#fff style V1 fill:#00B6BC,color:#fff ``` | 维度 | 类比 | 适用场景 | 优点 | 缺点 | |------|------|---------|------|------| | **Hash 分片** | 按姓名首字母分班 | 均匀写入、分散压力 | 数据分布均匀 | 范围查询需遍历所有分片 | | **Range 分片** | 按年份归档文件 | 时间序列、归档场景 | 天然支持范围查询 | 热点数据倾斜(如双 11) | | **List 分片** | 按部门分配办公区 | 租户隔离、地域隔离 | 边界清晰、易于运维 | 新增分区需提前规划 | | **垂直拆分** | 超市按品类分区 | 多业务混合负载 | 业务解耦、故障隔离 | 跨库 JOIN 变复杂 | > [!TIP] 最佳实践:先垂直再水平 > 如果不同业务的表耦合在同一库里,先做**垂直拆分**(按业务域拆成多个库),再对单个大表做**水平拆分**。两步都做能最大化收益。 ## 水平分表实战 ### Hash 分片 ```sql -- 假设拆分 orders 表为 4 张子表:orders_0 ~ orders_3 -- 路由规则:order_id % 4 = 表后缀 -- order_id=1001 → orders_1 -- order_id=1004 → orders_0 ``` ```go // 通过 order_id 计算目标分片 func GetShardTableName(orderID int64, shardCount int) string { return fmt.Sprintf("orders_%d", orderID%int64(shardCount)) } ``` > ⚠️ Hash 分片的坑:`order_id` 本身由数据库自增生成——如果你还没上分布式 ID,`order_id` 不能用作分片键(因为它是单库内递增的)。此时应改用 `user_id`(雪花算法生成的分布式 ID)。 ### Range 分片 Range 分片的核心思想是**按连续区间划分**,最典型的场景就是按时间分表——每个月一张表,就像把文件按年份放进不同的文件夹。 ```sql -- 按月分表:审计日志、交易流水等时间序列数据 -- 表名:logs_202601, logs_202602, ..., logs_202612 -- 查询某月数据 → 只需访问一张表 SELECT * FROM logs_202603 WHERE level = 'ERROR' ORDER BY created_at DESC LIMIT 100; -- 跨月范围查询 → 路由层自动合并多张表 SELECT * FROM logs_202603 UNION ALL SELECT * FROM logs_202604 WHERE created_at BETWEEN '2026-03-01' AND '2026-04-30'; ``` ```go // 路由层:根据时间范围计算需要查询哪些表 func GetLogTableNames(start, end time.Time) []string { var tables []string for t := start; !t.After(end); t = t.AddDate(0, 1, 0) { tables = append(tables, fmt.Sprintf("logs_%s", t.Format("200601"))) } return tables } ``` > [!TIP] Range 分片天然适合做数据归档 > 历史数据可以直接迁移到低成本存储,操作极其简单: > ```sql > -- 将上月数据归档到 archive 库,腾出 SSD 空间 > ALTER TABLE logs_202601 RENAME TO archive.logs_202601_old; > -- 为下月预创建空表 > CREATE TABLE logs_202602 LIKE logs_202601; > ``` > [!QUESTION] Range 分片最大的风险是什么? > 答:**数据倾斜**。如果你按月分表,双 11 当天的写入量可能是平时的 100 倍——所有的写入压力集中在一张表上。解决方案:对热点时段做二次 Hash 分片(见下方"联合分片")。 ### List 分片 List 分片是**按枚举值分组**,最典型的场景是 SaaS 多租户——每个租户的数据天然隔离,互不干扰。就像办公楼按公司分配楼层,A 公司在 3 楼,B 公司在 5 楼。 ```sql -- SaaS 多租户场景:每个租户的数据在独立的库中 -- tenant_A: db_tenant_0.orders, db_tenant_0.users -- tenant_B: db_tenant_1.orders, db_tenant_1.users -- 查询时自动路由到对应库 -- 应用层根据 tenant_id 选择数据库连接 SELECT * FROM orders WHERE status = 'paid' ORDER BY created_at DESC; -- → tenant_A 走 db_tenant_0,tenant_B 走 db_tenant_1 ``` ```go // 路由层:根据租户 ID 选择目标数据库 func GetTenantDB(tenantID string) *sql.DB { // 租户 → 库的映射关系(可存配置中心或 Redis) dbMap := map[string]*sql.DB{ "tenant_A": dbTenant0, "tenant_B": dbTenant1, } return dbMap[tenantID] } ``` > [!NOTE] List 分片的额外好处:合规审计 > 某些行业(金融、医疗)要求租户数据物理隔离。List 分片天然满足这一需求——不同租户的数据在不同物理库中,审计时只需检查对应库即可。 ### 联合分片 单一维度的分片总有局限:Hash 分片不支持范围查询,Range 分片会数据倾斜。**联合分片是两种策略的组合**——用两个(或更多)维度共同决定数据归属。 ```go // 联合分片公式:shard_id = (user_id % 16) * 4 + (month % 4) // 第一层 user_id % 16 → 分散写入压力,同一用户的数据集中 // 第二层 month % 4 → 按时间归档,热点月份自动分散到 4 张表 // 共 16 × 4 = 64 张子表 func GetShardTableName(userID int64, t time.Time) string { userShard := userID % 16 // 用户维度:16 个桶 timeShard := int(t.Month()-1) % 4 // 时间维度:4 个桶 shardID := userShard*4 + int64(timeShard) return fmt.Sprintf("orders_%d", shardID) } // 举例: // user_id=1001, 2026年3月 → orders_38 (1001%16=9, month=2, 9*4+2=38) // user_id=1001, 2026年5月 → orders_36 (1001%16=9, month=4, 9*4+0=36) // user_id=2002, 2026年3月 → orders_10 (2002%16=2, month=2, 2*4+2=10) // 同一用户不同月份的数据落在相邻分片,方便按用户维度聚合 ``` ## 分片键选型指南 分片键(Sharding Key)是分库分表中最关键的决策——**一旦选定,后期几乎无法更换**。选错了分片键,就像把图书馆的书按"书名首字"分区,找"数据库"相关的书要跑遍 20 个书架。 分片键的核心要求只有一个:**让最常用的查询能精确定位到一个分片**,而不是扫描所有分片。 ```mermaid flowchart TD Start["选定分片键"] --> Q1{"查询是否
总是包含此字段?"} Q1 -->|是| Q2{"数据量是否
分布均匀?"} Q1 -->|否| FAIL1["❌ 范围查询需扫描全部分片"] Q2 -->|是| RECOMMEND["✅ 推荐作为分片键"] Q2 -->|否| REBALANCE["⚠️ 考虑 List 或 Range 分片"] RECOMMEND --> Q3{"是否有业务
级联删除?"} Q3 -->|无| FINAL["🏆 user_id 是最常见的分片键"] Q3 -->|有| WARNING["⚠️ 级联操作会触发多分片写"] FAIL1 --> Q4{"是否按时间查询为主?"} Q4 -->|是| TIME["✅ Range 按时间分片"] Q4 -->|否| TRYDIFF["🔄 换一个能覆盖主查询的字段"] ``` > [!EXAMPLE] 经典案例:电商订单的分片键选择 > - **错误选择**:`order_id` —— 自增 ID,数据集中在最后一个分片 > - **正确选择**:`user_id` —— 分布式 ID,天然分散;且 90% 的订单查询都带 `WHERE user_id = ?` > - **折中方案**:`(user_id, order_id)` 联合主键,`user_id` 作分片键,`order_id` 作二级索引 ### 验证分片键的三个问题 在决定分片键之前,用这三个问题自检: | # | 问题 | 如果答案是否定的 | |---|------|-----------------| | 1 | **核心查询路径是否都能带上这个字段?** | 会出现大量跨分片扫描 | | 2 | **数据分布是否足够随机化?** | 需要加盐(salt)打散 | | 3 | **未来 1~2 年的业务增长是否会改变查询模式?** | 预留可调整空间 | > [!NOTE] 数据倾斜检测 > ```sql > -- 监控每个分片的数据量差异 > SELECT table_name, table_rows > FROM information_schema.tables > WHERE table_schema = 'mydb' AND table_name LIKE 'orders_%' > ORDER BY table_rows DESC; > -- 如果最大分片行数 > 最小分片的 3 倍 → 存在倾斜 > ``` ## 分布式 ID 与分片的关系 分库分表后,**全局唯一 ID** 是第一道门槛。想象有两个班级各自编号:A 班有 1 号、2 号、3 号……B 班也有 1 号、2 号、3 号……当两个班级合并参加运动会时,"1 号"到底是谁?这就是分库后自增 ID 冲突的问题。 > [!IMPORTANT] 主键策略必须适配分片 > > 如果你的主键是 AUTO_INCREMENT,每个分库各自从 1 开始计数——一旦需要合并数据或做跨库查询,ID 冲突不可避免。这就是为什么**要做分片就必须先上分布式 ID**。 > > 详细的 ID 生成方案请参考:[[hhs/MySQL/05-表设计/22-主键策略对比]](Snowflake、ULID、UUID_TO_BIN 等方案的深度对比)。 快速选型参考: | 场景 | 推荐方案 | 理由 | |------|---------|------| | 已有 Snowflake Worker | 直接用现有 WorkerID | 保持一致性,无需额外组件 | | 已有独立 ID 服务 | 调用 gRPC/HTTP ID 接口 | 集中管控,可回拨告警 | | 轻量级项目 | MySQL sequence 表 | 简单可靠,但高并发下有瓶颈 | ## 跨分片查询 这是分库分表最大的痛点。分片前,一个 `JOIN` 就能搞定的查询,分片后可能需要遍历所有分片再手动合并。 ### 不可行方案 ```sql -- ❌ 跨分片 JOIN:MySQL 的 JOIN 只在同一实例内有效 -- orders 表在 sharded_db,users 表在 user_db,两个不同的实例 -- 这条 SQL 会直接报错:Table 'user_db.users' doesn't exist SELECT o.*, u.name FROM orders o JOIN users u ON o.user_id = u.id; -- ❌ 暴力 UNION ALL:除非你能精确计算分片号,否则必须扫描全部分片 -- 4 个分片还好,如果有 64 个分片,每次都扫全量就太浪费了 SELECT * FROM orders_0 WHERE user_id = 100 UNION ALL SELECT * FROM orders_1 WHERE user_id = 100 UNION ALL SELECT * FROM orders_2 WHERE user_id = 100 UNION ALL SELECT * FROM orders_3 WHERE user_id = 100; ``` ### 可行方案:应用层 JOIN ```go // Step 1: 获取用户信息 userInfo, _ := userService.GetUser(userID) // Step 2: 计算订单所在分片 shards := []string{fmt.Sprintf("orders_%d", userID%4)} // Step 3: 并发查询各分片 type OrderResult struct { Orders []Order Err error } ch := make(chan OrderResult, len(shards)) for _, shard := range shards { go func(s string) { orders := queryOrders(s, userID) ch <- OrderResult{Orders: orders} }(shard) } var allOrders []Order for range shards { result := <-ch allOrders = append(allOrders, result.Orders...) } sort.Slice(allOrders, func(i, j int) bool { return allOrders[i].CreatedAt.After(allOrders[j].CreatedAt) }) ``` ```mermaid flowchart LR Client["客户端请求"] --> GW["应用网关
计算目标分片"] GW -->|"concurrent"| S1["Query orders_0"] GW -->|"concurrent"| S2["Query orders_1"] GW -->|"concurrent"| S3["Query orders_2"] GW -->|"concurrent"| S4["Query orders_3"] S1 & S2 & S3 & S4 --> Merge["合并排序
应用层聚合"] Merge --> Resp["返回最终结果"] style Merge fill:#FF9F43,color:#000 ``` ### 跨分片分页(难点中的难点) ```sql -- ❌ LIMIT 深分页在各分片各自生效,合并后分页结果不对 -- 分片 0: SELECT * FROM orders_0 ORDER BY id LIMIT 0, 20 → 拿到前 20 -- 分片 1: SELECT * FROM orders_1 ORDER BY id LIMIT 0, 20 → 拿到前 20 -- 合并取前 20?→ 错了!实际全局可能有 80 条比这些更早的记录 -- ✅ 正确做法:游标分页 + 应用层归并 -- 每个分片取更大窗口:ORDER BY created_at LIMIT 0, (N+depth) -- 应用层全量合并后再截取 ``` ```go // 核心思路:游标分页 + 多路归并 // 第一步:每个分片多取一些数据(扩大缓冲区) // 第二步:应用层合并所有结果,排序后截取需要的条数 type PageRequest struct { UserID int64 Limit int // 期望返回条数,比如 20 AfterTs int64 // 游标:上一页最后一条的时间戳,首次请求传 0 } func QueryShardedOrders(req PageRequest) ([]Order, error) { // 1. 计算目标分片(这里只查 1 个分片,因为 user_id % 4 路由精确) shard := fmt.Sprintf("orders_%d", req.UserID%4) // 2. 扩大窗口:请求 20 条,但从每个分片取 40 条作为缓冲 // 为什么要多取?因为游标边界附近的数据可能分布在不同分片 bufferSize := req.Limit * 2 query := fmt.Sprintf( "SELECT * FROM %s WHERE user_id = %d AND created_at < %d ORDER BY created_at DESC LIMIT %d", shard, req.UserID, req.AfterTs, bufferSize, ) rows, _ := db.Query(getConn(shard), query) orders := scanOrders(rows) // 3. 截取需要的数量 if len(orders) > req.Limit { orders = orders[:req.Limit] } return orders, nil } // 当分片键不是查询条件时(比如按时间全局排序),需要扫描所有分片: func QueryAllShards(req PageRequest) ([]Order, error) { var allOrders []Order for i := 0; i < 4; i++ { shard := fmt.Sprintf("orders_%d", i) query := fmt.Sprintf( "SELECT * FROM %s WHERE created_at < %d ORDER BY created_at DESC LIMIT %d", shard, req.AfterTs, req.Limit*2, ) rows, _ := db.Query(getConn(shard), query) allOrders = append(allOrders, scanOrders(rows)...) } // 全局排序 + 截取 sort.Slice(allOrders, func(i, j int) bool { return allOrders[i].CreatedAt.After(allOrders[j].CreatedAt) }) if len(allOrders) > req.Limit { allOrders = allOrders[:req.Limit] } return allOrders, nil } ``` > [!QUESTION] 为什么不在数据库层做多分片聚合? > 因为每个分片上的 `LIMIT` 只在自己分片范围内有效。举个具体例子:你有 4 个分片,想查全局第 100~120 条数据。如果每个分片执行 `LIMIT 100, 20`,得到的是"每个分片自己排第 100~120 条"——合起来 80 条数据,但它们和全局第 100~120 条完全不同。 > > **正确做法**:从每个分片多取一些数据(扩大窗口),在应用层做全局排序后截取。代价是内存和延迟增加,但这是分布式系统的固有 Trade-off。 ### 跨分片聚合(COUNT / SUM / AVG) 简单聚合(COUNT、SUM、AVG)的思路很直接:**每个分片各自算一遍,应用层再合并**。比如总订单数 = 分片 0 的 COUNT + 分片 1 的 COUNT + …… 但 `GROUP BY + 聚合` 就麻烦了——你需要合并各分片的分组结果,逻辑复杂且性能差。**更好的方案是预计算**: ```sql -- 方案:定时汇总表(cron 每小时执行一次) -- 每个分片各自聚合,结果写入同一张汇总表 INSERT INTO summary_daily (date, total_orders, total_amount) SELECT DATE(created_at), COUNT(*), SUM(amount) FROM orders_0 GROUP BY DATE(created_at) UNION ALL SELECT DATE(created_at), COUNT(*), SUM(amount) FROM orders_1 GROUP BY DATE(created_at) UNION ALL SELECT DATE(created_at), COUNT(*), SUM(amount) FROM orders_2 GROUP BY DATE(created_at) UNION ALL SELECT DATE(created_at), COUNT(*), SUM(amount) FROM orders_3 GROUP BY DATE(created_at); -- 查询时直接读汇总表,响应速度从"秒级"降到"毫秒级" SELECT date, SUM(total_orders), SUM(total_amount) FROM summary_daily WHERE date BETWEEN '2026-05-01' AND '2026-05-31' GROUP BY date; ``` ## 中间件方案 前面的代码示例都是手动计算分片路由——在生产环境中,我们更常用成熟的中间件来处理分片逻辑。中间件就像一个"智能路由器":应用发一条普通 SQL,中间件自动帮你拆分、路由、合并结果。 | 中间件 | 开发者 | 部署模式 | 核心特点 | |--------|-------|---------|---------| | **Vitess** | YouTube / Google | Proxy | 透明分片、在线 Re-sharding、K8s 原生 | | **ShardingSphere** | Apache | Proxy / JDBC | 功能最全、生态广、Java 生态为主 | | **MyCat** | 开源社区 | Proxy | 轻量简单、上手快 | | **Cobar** | Alibaba | Proxy | 已停止维护,被 ShardingSphere 取代 | ### Vitess 架构 ```mermaid flowchart TB subgraph App["应用层"] GO["Go / Java App"] end subgraph VT["Vitess Layer"] VTGate["vtgate
SQL 路由 + 连接池"] VSchemas["vschema
分片策略定义"] end subgraph Shards["MySQL Instances"] S1["Shard-0
mysqld-3000 ~ 3002"] S2["Shard-1
mysqld-3003 ~ 3005"] S3["Shard-2
mysqld-3006 ~ 3008"] end GO --> VTGate VTGate --> VSchemas VSchemas -->|"keyspace/table routing"| S1 VSchemas -->|"keyspace/table routing"| S2 VSchemas -->|"keyspace/table routing"| S3 style VTGate fill:#00B6BC,color:#fff style VSchemas fill:#C44569,color:#fff ``` > [!NOTE] Vitess 的核心优势 > 1. **透明路由**:应用感知不到分片,SQL 无需改动 > 2. **在线 Re-sharding**:数据迁移过程不影响线上服务 > 3. **Global Search**:通过 secondary index 支持全分片搜索 > 4. **Kubernetes Native**:原生支持 K8s 部署与弹性伸缩 ## 数据迁移策略 **从零停机迁移角度,这是生产环境最关键的一环。** ### 双写迁移法(推荐) 双写迁移的思路很直观:**新旧两套库同时写入,逐步切换读流量**。就像搬新家时,先把旧家具搬到新房子,同时新旧两个地址都收快递——等新家收拾好了,再把快递地址改成新家。 ```mermaid flowchart TD A["旧库 single_db"] --> B["新集群 sharded_db_0..N"] subgraph "Phase 1: 存量数据搬迁" P1["离线导出旧表
mysqldump"] --> P2["分批导入新集群
每批 ~100 万行"] end subgraph "Phase 2: 双写过渡" APP["应用代码"] -->|"读旧+写旧"| P1 APP -->|"写新(异步)"| B NOTE1["新旧数据一致
允许短暂不一致"] end subgraph "Phase 3: 切读" P3["数据校验工具
row count / checksum"] --> P4["切换读路由到新集群"] end subgraph "Phase 4: 清理" P5["停止旧库写入"] --> P6["观察 1~2 周无异常
下线旧库"] end P1 -.->|阶段顺序| P2 -.->|阶段顺序| P3 -.->|阶段顺序| P4 -.->|阶段顺序| P5 -.->|阶段顺序| P6 ``` ```go // 双写伪代码:写入新集群时走异步通道 func CreateOrder(order *Order) error { // 同步写旧库(保证兼容) if err := writeOldDB(order); err != nil { return err } // 异步写新分片(不阻塞主流程) go func() { shard := GetShardTableName(order.ID, 4) writeNewDB(shard, order) }() return nil } ``` ### 渐进式迁移(适合超大表) 渐进式迁移的核心思想是**分批切换**——先迁移一部分流量到新集群,验证无误后再迁移下一批,最终全部切完。就像搬办公室:先把一个部门搬过去,安顿好了再搬下一个,而不是一次性把整层楼的人都搬走。 ```mermaid flowchart LR subgraph Init["初始状态"] AllOld["全部流量"] --> OldDB["老库"] end subgraph P1["Phase 1: 50% 流量"] P1Even["hash(user_id) % 2 == 0"] --> NewS01["新分片 0,1"] P1Odd["hash(user_id) % 2 == 1"] --> OldDB2["老库"] end subgraph P2["Phase 2: 100% 流量"] P2A["hash(user_id) % 4 == 0,1"] --> NewS01B["新分片 0,1"] P2B["hash(user_id) % 4 == 2,3"] --> NewS23["新分片 2,3"] end Init -->|"Phase 1: 先切一半"| P1 P1 -->|"Phase 2: 再切剩余"| P2 style P1 fill:#FFEAA7,color:#000 style P2 fill:#00D866,color:#fff ``` > [!TIP] 渐进式迁移的核心优势 > - **风险可控**:每次只影响部分用户,出问题时只需回滚那部分流量 > - **可灰度验证**:先选一小批内部用户试跑,确认无误后再扩大范围 > - **无需停机**:整个过程对用户透明,业务零感知 > > 劣势:需要在应用层维护两套路由规则,增加了代码复杂度。 > [!WARNING] 迁移 checklist > > - [ ] DDL 变更兼容性:旧版代码能否读取新表结构?(永远先加字段、后删字段) > - [ ] 数据校验:用 `pt-table-checksum` 比对新旧数据一致性 > - [ ] 回滚预案:切到新集群后发现问题,能在 5 分钟内切回 > - [ ] 索引重建:新分片是否需要不同的索引策略? > - [ ] 监控接入:分片后的慢查询监控面板是否就绪? ## 分片的挑战与反模式 分库分表不是银弹,它解决了一个问题,却引入了更多问题。下表列出了最常见的"坑"和应对策略: | 问题 | 具体表现 | 应对策略 | |------|---------|---------| | **数据倾斜** | 某些分片数据量远大于其他(比如大卖家的订单集中在某个分片) | 检查 hash 分布,必要时换分片键或加 salt 打散 | | **跨分片事务** | 一笔业务涉及多个分片,InnoDB 不支持跨库事务 | 用 Saga / TCC / 消息队列补偿(见 [[hhs/MySQL/08-工程实践/36-分布式事务]]) | | **Rebalance** | 新增节点后,老数据如何迁移到新分片? | 渐进式迁移,Vitess 内置 Scatter-Gather | | **复杂聚合** | COUNT / SUM 跨分片很贵,GROUP BY 更是噩梦 | 预计算 + 定时汇总到汇总表(见上方详解) | | **分页困难** | LIMIT 深分页在各分片各自生效,合并后结果不对 | 游标分页 + 应用层合并(见上方详解) | | **全局唯一约束** | UNIQUE KEY 在多分片下失效(两个分片可能各插入同一条) | 改为业务层面去重,或用 Redis SET | > [!WARNING] 别过早分片 > > 再次强调:**90% 的项目永远不需要分库分表**。InnoDB 单表千万级性能依然优秀。只有在满足前述三个条件时才考虑分片。如果团队规模小、迭代快,花两周优化 SQL 和索引的收益远高于花一个月做分片架构。 ## 关联笔记 - [[hhs/MySQL/05-表设计/22-主键策略对比]] — 分布式 ID 生成方案(Snowflake / ULID / UUID) - [[hhs/GORM/13-多数据库支持]] — GORM 对多库连接的支持 - [[hhs/MySQL/08-工程实践/35-Go 连接池配置]] — 多分片场景下的连接池隔离 - [[hhs/MySQL/08-工程实践/38-监控指标]] — 分片集群的监控指标体系