小Cの已经记不起来的博客

深入理解 Linux 分库分表 的工作机制

最近在 Linux 服务器上折腾订单表,单表快 8000 万行,查询开始变得有点肉,就顺着“分库分表”去查资料。查完之后发现一个挺尴尬的事实:Linux 本身根本不知道什么是库、什么是表。它只认识进程、文件描述符、TCP 连接、磁盘块、CPU 调度和网络栈。所谓 Linux 分库分表,其实是你在 Linux 上跑 MySQL、跑 Proxy、写应用代码,让 SQL 按规则被拆开、路由到不同数据库或表上。

这句话听着像在抬杠,但真按这个思路去看,很多机制就清楚了。Linux 不是分库分表的主体,它只是承载这些组件的地方。

先把概念拆开:Linux 不懂 SQL,它只看端口、进程和文件

你在 Linux 上执行 netstat -anp | grep 3306,或者更现代的:

ss -tnp | grep -E '3306|13306'

看到的只是一堆 TCP 连接。比如应用连到 127.0.0.1:13306,这个 13306 可能是一个 ShardingSphere-Proxy、MyCat、Vitess、TiDB,或者你自己写的 Python/Go 分片入口。然后这个入口再连到后面多个 MySQL 实例,比如:

127.0.0.1:3306 -> order_db_0
127.0.0.1:3307 -> order_db_1
127.0.0.1:3308 -> order_db_2
127.0.0.1:3309 -> order_db_3

从 Linux 角度看,这些就是多个 MySQL 进程,各自监听自己的端口,各自打开自己的数据目录:

ls /var/lib/mysql
ls /data/mysql/order_db_0
ls /data/mysql/order_db_1

它甚至不关心你把订单按 user_id 哈希分片,还是按日期范围分片。分片规则是在 SQL 层、Proxy 层、应用层完成的。Linux 只负责调度 CPU、管理内存、做网络 IO、刷磁盘。

所以别被“Linux 分库分表”这个词带偏。真正要理解的是:SQL 怎么被解析、怎么被路由、怎么被改写、结果怎么归并,以及多个 MySQL 实例在 Linux 上如何分配资源。

一个能说明原理的最小分片客户端

如果只是学习原理,不用一上来就套中间件。可以自己用 Python 写个极简路由。比如有两个库,四个表,按 user_id % 2 决定库,按 user_id % 4 决定表:

import random
import mysql.connector

configs = [
    {
        "host": "127.0.0.1",
        "port": 3306,
        "user": "root",
        "password": "your_password",
        "database": "order_db_0",
    },
    {
        "host": "127.0.0.1",
        "port": 3307,
        "user": "root",
        "password": "your_password",
        "database": "order_db_1",
    },
]

def route(user_id: int):
    db_idx = user_id % 2
    table_idx = user_id % 4
    return configs[db_idx], f"orders_{table_idx}"

def insert_order(user_id: int, amount: float):
    conf, table_name = route(user_id)
    conn = mysql.connector.connect(**conf)
    cur = conn.cursor()

    order_id = random.getrandbits(63)
    sql = f"INSERT INTO {table_name} (id, user_id, amount) VALUES (%s, %s, %s)"

    try:
        cur.execute(sql, (order_id, user_id, amount))
        conn.commit()
    finally:
        cur.close()
        conn.close()

这段代码能看懂核心动作:先根据分片键 user_id 算出来该去哪个库、哪张表,然后把 SQL 打到对应连接上。

但它不能直接上生产。生产里你会立刻遇到一堆问题:

跨库事务怎么办?
连接池怎么管理?
SQL 解析谁来做?
多表归并结果怎么办?
分页查询怎么合并?
扩容怎么迁数据?
全局唯一 ID 怎么生成?

这些问题一冒出来,你就该理解为什么会有分库分表中间件。

真实项目里,通常让 Proxy 帮你把 SQL 拆了

比较常见的做法是在应用和 MySQL 之间放一个 Proxy。应用只需要连接 Proxy,比如:

mysql -h 127.0.0.1 -P 13306 -u root -p

Proxy 负责接收标准 SQL,然后解析它。比如你执行:

SELECT id, user_id, amount
FROM orders
WHERE user_id = 7;

如果分片规则是:

database = user_id % 2
table    = user_id % 4

那么 Proxy 会算出来:

user_id = 7
7 % 2 = 1 -> order_db_1
7 % 4 = 3 -> orders_3

于是它把原始 SQL 改写成类似:

SELECT id, user_id, amount
FROM order_db_1.orders_3
WHERE user_id = 7;

然后把这条 SQL 发给对应的 MySQL 实例,再把结果返回给应用。

从应用视角看,它只面对一个逻辑库、一张逻辑表 orders。从 MySQL 视角看,物理上数据分散在多个库、多个表。中间这层翻译,就是分库分表机制。

在 Linux 上跑一个分片入口:以 Docker 为例

如果只是测试,用 Docker 起 ShardingSphere-Proxy 会比本地配一堆 Java 环境省事。下面是我常用的最小路径。

先准备配置目录:

mkdir -p ~/sp/conf
cd ~/sp

拉镜像。如果你国内拉 Docker Hub 比较慢,可以先换个加速源;不确定的话,直接官方镜像也行:

docker pull apache/shardingsphere-proxy:5.5.0

如果你之前习惯用加速域名,比如:

docker pull docker.1ms.run/apache/shardingsphere-proxy:5.5.0

那也可以,但我这边不保证这类加速域名长期可用。拉不到就去掉前缀,没准儿是你网络问题,没准儿是域名凉了。

然后用一个配置文件挂载进去:

docker run -d \
  --name sp-proxy \
  --restart unless-stopped \
  -p 13306:3306 \
  -v ~/sp/conf:/opt/shardingsphere-proxy/conf \
  apache/shardingsphere-proxy:5.5.0

配置文件大概是这个意思,不同版本语法有差异,别逐字照抄生产,先理解规则:

databaseName: order_db

dataSources:
  ds0:
    url: jdbc:mysql://127.0.0.1:3306/order_db_0?serverTimezone=UTC&useSSL=false
    username: root
    password: your_password
  ds1:
    url: jdbc:mysql://127.0.0.1:3307/order_db_1?serverTimezone=UTC&useSSL=false
    username: root
    password: your_password

rules:
- !SHARDING
  tables:
    orders:
      actualDataNodes: ds$->{0..1}.orders_$->{0..1}
      databaseStrategy:
        standard:
          shardingColumn: user_id
          shardingAlgorithmName: user_mod
      tableStrategy:
        standard:
          shardingColumn: user_id
          shardingAlgorithmName: orders_mod
  shardingAlgorithms:
    user_mod:
      type: HASH_MOD
      props:
        sharding-count: 2
    orders_mod:
      type: HASH_MOD
      props:
        sharding-count: 2

然后重启或 reload Proxy。进入测试:

mysql -h127.0.0.1 -P13306 -u root -p

看逻辑库:

SHOW DATABASES;
USE order_db;
SHOW TABLES;

如果配置没问题,orders 就是逻辑表。执行分片查询:

EXPLAIN SHARDING SELECT * FROM orders WHERE user_id = 7;

你应该能看到它被路由到了某个数据源和真实表。具体结果取决于你的算法和分片数量。不出问题的话就没有问题了。如果报错,大概率是这几个:

YAML 缩进不对
JDBC URL 不对
MySQL 账号权限没配
actualDataNodes 写错
Proxy 版本和语法不匹配

别慌,都是熟问题。

SQL 被拆开之后,麻烦才开始

分库分表看起来只是把一张大表拆小,真正复杂的是 SQL 语义变化。

单库单表时:

SELECT user_id, SUM(amount) AS total_amount
FROM orders
GROUP BY user_id
ORDER BY total_amount DESC
LIMIT 10;

MySQL 自己就能算。

分库分表后,数据散在多个库多个表。Proxy 需要把这条 SQL 拆成多份,发给不同分片:

ds0.orders_0
ds0.orders_1
ds1.orders_0
ds1.orders_1

每个分片都会返回一组局部结果。然后 Proxy 需要做合并:

GROUP BY 聚合归并
ORDER BY 排序归并
LIMIT 分页归并
DISTINCT 去重归并

这里最坑的是分页。比如你写:

SELECT * FROM orders
ORDER BY created_at DESC
LIMIT 100 OFFSET 10000;

如果数据在多个分片上,Proxy 很可能要从每个分片都查前 10010 条,然后内存里排序、再取目标页。分片越多,深度分页越痛苦。所以生产里经常要求:

尽量用 id 游标分页,不要用 offset 深翻
聚合查询避免跨所有分片扫全量
大报表类查询走离线数仓,不走在线分库分表

读写分离不是分库分表,但经常一起出现

很多 Linux 环境里会同时有分库分表和读写分离。

写请求走主库:

insert / update / delete -> master

读请求走从库:

select -> slave

机制大概是:

应用 -> Proxy
Proxy -> 主 MySQL / 从 MySQL

Linux 上你会看到多个 MySQL 进程,多个端口。比如:

3306 master
3307 slave1
3308 slave2

Proxy 或应用层根据 SQL 类型选择连接。

但这里有个常见误解:加个从库,主从延迟就没了。不会的。主从延迟永远存在,只是大小问题。比如你刚刚插入订单:

应用写主库成功
用户立刻查从库
从库还没同步到
结果:查不到订单

这种“读自己刚写的”场景,要么强制读主,要么等足够久,要么用会话粘滞路由。Linux 层面只是 TCP 连接和复制线程,它不会替你做业务语义保证。

扩容才是真正劝退的部分

一开始按 4 个分片,跑得好好的。过半年数据量翻倍,想扩到 8 个分片。如果分片算法是 user_id % 4 改成 user_id % 8,那会怎样?

以前 user_id % 4 = 0 的数据,有一部分要挪到新的分片。以前 4 个分片,每个分片大概 25% 数据;现在 8 个分片,每个分片大概 12.5%。数据必须迁移。

最粗暴的做法是停写迁移:

mysqldump --single-transaction --routines --triggers order_db_0 orders_0 > orders_0.sql
scp orders_0.sql new-node:/data/backup/
mysql -h new-node order_db_new < orders_0.sql

但线上大表停写,业务方会顺着网线来打人。所以生产上一般是:

双写新旧分片
存量数据迁移
数据校验
灰度读新分片
全量切读
停写旧分片

Linux 上你能看到的是大量磁盘 IO、网络 IO、MySQL redo log、binlog dump 线程、复制延迟。扩容期间要盯这些命令:

iostat -x 1
vmstat 1
top
mysqladmin -uroot -p extended-status | grep -E 'Rpl|Binlog|Qcache|Threads'
SHOW SLAVE STATUS\G

如果磁盘利用率长时间 100%,或者 await 很高,那扩容迁数据会把线上查询带崩。这不是中间件魔法能解决的,底层 IO 资源就那么多。

全局 ID:别拿时间戳硬凑

分库分表后,不能再依赖单表自增 id。因为每个物理表都可能自增,会冲突。常见方案有:

UUID
数据库号段
雪花算法 Snowflake
Redis INCR
中心发号服务

UUID 简单,但不适合做主键索引,太长且随机,写放大明显。

号段法比较稳,比如:

本地缓存一批 id
用完再去全局服务申请下一段

雪花算法最常用,本质是:

时间戳 + 机器ID + 序列号

它的好处是趋势递增,对索引友好。但机器 ID 分配一定要管理好,否则时间回拨、容器重启、IP 变化都可能带来坑。

如果你只是测试,随便生成也没事;如果生产写订单,别图省事。

到底要不要分库分表

说实话,能不分就不分。不是因为它高级,而是因为它复杂。

很多单表性能问题,应该先看:

索引是不是合理
SQL 有没有回表
统计信息有没有过期
连接池是不是太小
磁盘 IO 是不是瓶颈
MySQL 配置是不是明显有问题
有没有必要做分区表
有没有必要做读写分离
有没有必要做垂直拆表

真正到了必须分的时候,通常也要满足几个条件:

单表数据量很大,索引也救不动
单库写入或查询已经接近瓶颈
业务天然可按 user_id / tenant_id / 时间范围拆分
团队能处理跨库查询、事务、扩容、监控、运维

Linux 本身不负责分库分表,但 Linux 上的资源隔离、网络、磁盘、进程管理会直接决定这套方案跑得稳不稳。你把多个 MySQL 实例塞在同一台机器上,CPU 会抢,内存会抢,page cache 会抢,磁盘 IO 也会抢。表面上分库分表了,物理上可能还是单点压力。

所以深入理解这个机制,不要只背“水平拆分”“垂直拆分”“哈希分片”“范围分片”这些词。真正关键的是:一条 SQL 进来以后,谁来解析,谁来改写,结果怎么归并,多个实例之间怎么保证一致性,扩容时数据怎么迁移,Linux 层面网络和磁盘资源怎么分配。

把这些东西想透,分库分表就没那么玄。它不是一句“把表拆开”这么简单,而是一整套分布式数据访问机制。

评论

还没有评论。

发表评论

提交后评论将经过自动审核,审核通过后公开展示。

未在播放