土法炼钢兴趣小组的算法知识备份

【系统架构设计百科】数据库扩展:分库分表的工程实践与替代方案

文章导航

分类入口
architecture
标签入口
#database-scaling#sharding#Vitess#TiDB#CockroachDB#NewSQL

目录

你负责的电商订单系统上线两年了。订单表(orders)从最初的几十万行涨到了 5000 万行。半年前,按订单 ID 查询还能在 10ms 内返回;现在同样的查询经常要 800ms,按用户 ID 分页查询订单列表偶尔超过 2s。DBA 加了索引、调了 buffer pool、升级了机器配置,每次能撑两个月,然后又回到老样子。

上周的技术评审会上,有人提出”该分库分表了”。CTO 问了三个问题:按什么字段分?分多少片?分完之后跨用户的运营查询怎么办?会议室沉默了三十秒。

这不是假设场景。几乎每一个业务增长到一定规模的团队都会撞上这堵墙。分库分表不是一个”做不做”的问题,而是一个”什么时候做、怎么做、做完之后怎么活”的问题。更关键的是,2024 年的技术选项已经不止”手动分库分表”这一条路——TiDB、CockroachDB 等 NewSQL 数据库号称能让你”透明水平扩展”,但它们真的能替代分库分表吗?

本文从工程实践的角度,把这些问题一个一个拆开。

上一篇中我们讨论了缓存架构的设计与陷阱。缓存能缓解读压力,但当数据量本身超出单节点承载能力时,缓存无法解决根本问题——你需要的是数据库层面的水平扩展。


一、什么时候该分库分表

分库分表是一个高成本操作:代码改造、数据迁移、运维复杂度上升、跨分片查询的代价。在不需要的时候做分库分表,是典型的过早优化(Premature Optimization)。所以第一个问题是:怎么判断”该做了”?

1.1 信号一:单表行数超出引擎舒适区

MySQL InnoDB 引擎在 B+ 树索引结构下,单表在 1000 万到 5000 万行之间通常能维持良好的查询性能。超过这个范围后,索引树的高度增加,磁盘 I/O 次数上升,查询延迟开始不可控。

需要强调的是,这不是一个硬阈值。表结构、索引设计、行宽度、查询模式都会影响这个临界点。一个只有 5 列且索引设计合理的窄表,8000 万行可能依然表现良好;一个有 50 列、多个 TEXT 字段的宽表,500 万行就可能出问题。

-- 检查表的行数和数据大小
SELECT
    table_name,
    table_rows,
    ROUND(data_length / 1024 / 1024, 2) AS data_size_mb,
    ROUND(index_length / 1024 / 1024, 2) AS index_size_mb,
    ROUND((data_length + index_length) / 1024 / 1024, 2) AS total_size_mb
FROM information_schema.tables
WHERE table_schema = 'order_db'
    AND table_name = 'orders'
ORDER BY table_rows DESC;

1.2 信号二:查询延迟劣化且索引优化已到极限

慢查询日志显示,核心查询的 P99 延迟从 50ms 涨到了 500ms 甚至更高。你已经做了以下优化但效果有限:

如果这些都做了,延迟依然在恶化,那大概率是数据量本身的问题。

1.3 信号三:写入吞吐触及单节点瓶颈

单个 MySQL 实例的写入吞吐量受限于多个因素:redo log 刷盘频率、binlog 同步方式、锁竞争强度。在标准 SSD 硬件上,单实例的写入 TPS(Transactions Per Second)通常在 5000-20000 之间,取决于事务复杂度和隔离级别。

当写入请求排队、锁等待时间增长、主从复制延迟持续上升时,说明单节点的写入能力已经到了极限。

1.4 信号四:存储容量触及上限

单实例的数据文件超过 2TB 后,备份恢复时间可能需要数小时,在线 DDL 风险急剧上升。

1.5 信号五:连接池耗尽

MySQL 单实例活跃连接数受限于 CPU 核数和内存。当多个应用实例共享数据库、连接池频繁打满时,需要通过分库来分散连接压力。

1.6 不该分的时候

以下情况说明你还不需要分库分表:

经验法则:先做垂直拆分(按业务域拆库),再做读写分离,最后才考虑水平分片(分表)。每一步都应该在上一步的优化空间用尽之后再推进。


二、三种分片策略

水平分片(Horizontal Sharding)的核心问题是:给定一条数据记录,它应该被放到哪个分片上?三种经典策略各有适用场景。

2.1 范围分片(Range-based Sharding)

按分片键的值域范围,把数据划分到不同的分片。最常见的是按 ID 范围或时间范围分片。

# 范围分片:按订单 ID 范围路由
class RangeShardRouter:
    def __init__(self):
        self.ranges = [
            (0, 10_000_000, "shard_0"),
            (10_000_001, 20_000_000, "shard_1"),
            (20_000_001, 30_000_000, "shard_2"),
            (30_000_001, 40_000_000, "shard_3"),
        ]

    def get_shard(self, order_id: int) -> str:
        for lower, upper, shard in self.ranges:
            if lower <= order_id <= upper:
                return shard
        # 超出已有范围,需要动态扩容
        raise ValueError(f"No shard found for order_id={order_id}")

    def get_shards_for_range(self, start_id: int, end_id: int) -> list[str]:
        """范围查询可以精确定位到涉及的分片"""
        result = []
        for lower, upper, shard in self.ranges:
            if start_id <= upper and end_id >= lower:
                result.append(shard)
        return result

优点

缺点

2.2 哈希分片(Hash-based Sharding)

对分片键做哈希运算,然后取模确定目标分片。

import hashlib

class HashShardRouter:
    def __init__(self, shard_count: int):
        self.shard_count = shard_count

    def _hash(self, key: str) -> int:
        """使用一致性哈希,避免简单取模在扩容时的大规模数据迁移"""
        return int(hashlib.md5(key.encode()).hexdigest(), 16)

    def get_shard(self, shard_key: str) -> str:
        shard_index = self._hash(shard_key) % self.shard_count
        return f"shard_{shard_index}"

    def get_all_shards(self) -> list[str]:
        """范围查询需要扫描全部分片"""
        return [f"shard_{i}" for i in range(self.shard_count)]


# 使用示例
router = HashShardRouter(shard_count=8)
print(router.get_shard("user_10086"))   # 确定性路由
print(router.get_shard("user_10087"))   # 不同用户大概率落在不同分片

优点

缺点

2.3 目录分片(Directory-based Sharding)

用一张独立的映射表记录每个分片键到分片的对应关系。

import redis

class DirectoryShardRouter:
    def __init__(self, redis_client: redis.Redis):
        self.redis = redis_client
        self.directory_key = "shard_directory"

    def register(self, shard_key: str, shard_name: str):
        """注册一个键到分片的映射"""
        self.redis.hset(self.directory_key, shard_key, shard_name)

    def get_shard(self, shard_key: str) -> str:
        shard = self.redis.hget(self.directory_key, shard_key)
        if shard is None:
            # 新键,按策略分配分片(如最少数据量的分片)
            shard = self._assign_shard(shard_key)
        return shard.decode() if isinstance(shard, bytes) else shard

    def _assign_shard(self, shard_key: str) -> str:
        """根据各分片当前数据量,分配到最空闲的分片"""
        shard_loads = {}
        for shard_name in ["shard_0", "shard_1", "shard_2", "shard_3"]:
            count = self.redis.get(f"load:{shard_name}")
            shard_loads[shard_name] = int(count or 0)
        target = min(shard_loads, key=shard_loads.get)
        self.register(shard_key, target)
        self.redis.incr(f"load:{target}")
        return target

    def migrate(self, shard_key: str, new_shard: str):
        """数据迁移时只需更新目录"""
        self.redis.hset(self.directory_key, shard_key, new_shard)

优点

缺点

2.4 分片架构总览

下图展示了三种分片策略在系统中的位置和数据流向:

graph TB
    APP[应用层] --> PROXY[分片代理 / ShardRouter]

    PROXY --> |"范围分片<br/>order_id: 1~10M"| S1[(Shard 0<br/>MySQL)]
    PROXY --> |"范围分片<br/>order_id: 10M~20M"| S2[(Shard 1<br/>MySQL)]
    PROXY --> |"哈希分片<br/>hash(user_id) % N"| S3[(Shard 2<br/>MySQL)]
    PROXY --> |"哈希分片<br/>hash(user_id) % N"| S4[(Shard 3<br/>MySQL)]

    PROXY --> DIR[(目录服务<br/>Redis / ZooKeeper)]
    DIR --> |"lookup(key) → shard"| S1
    DIR --> |"lookup(key) → shard"| S2

    S1 --> R1[(Replica)]
    S2 --> R2[(Replica)]
    S3 --> R3[(Replica)]
    S4 --> R4[(Replica)]

    style PROXY fill:#f9f,stroke:#333
    style DIR fill:#ff9,stroke:#333

三、分片键的选择方法论

分片键(Shard Key)的选择是整个分库分表方案中最关键、最难回退的决策。选错了分片键,后续的跨分片查询代价、数据倾斜、热点问题会让你痛苦很久。

3.1 基数要求

分片键的基数(Cardinality)必须足够高。如果分片键只有几个可能的值(比如订单状态:待付款/已付款/已发货/已完成),那么数据只能分到有限的几个桶里,无法实现均匀分布。

经验法则:分片键的基数至少应该是分片数量的 10 倍以上。

3.2 查询模式分析

分片键应该出现在绝大多数核心查询的 WHERE 条件中。如果 80% 的查询都是”按用户 ID 查订单”,那么 user_id 就是一个好的分片键候选。

-- 分析核心查询模式
-- 假设以下是最高频的查询

-- 查询 1:按用户查订单列表(占比 60%)
SELECT * FROM orders WHERE user_id = ? ORDER BY created_at DESC LIMIT 20;

-- 查询 2:按订单 ID 查订单详情(占比 25%)
SELECT * FROM orders WHERE order_id = ?;

-- 查询 3:按时间范围查运营报表(占比 10%)
SELECT COUNT(*), SUM(amount) FROM orders
WHERE created_at BETWEEN ? AND ? GROUP BY status;

-- 查询 4:按商户查订单(占比 5%)
SELECT * FROM orders WHERE merchant_id = ? AND created_at > ?;

在这个场景下,user_id 是最佳分片键——它覆盖了 60% 的查询。order_id 查询可以通过在 order_id 中嵌入 user_id 信息来解决。运营报表查询可以通过离线数据同步解决。

3.3 写入分布分析

分片键的值在写入时应该尽量均匀分布。如果某个分片键值对应大量的写入(比如某个大商户的 merchant_id),就会形成写入热点。

# 分析分片键的写入分布
# 检查是否存在数据倾斜
def analyze_shard_distribution(connection, shard_key: str, table: str):
    query = f"""
    SELECT
        {shard_key} % 8 AS shard_id,
        COUNT(*) AS row_count,
        ROUND(COUNT(*) * 100.0 / (SELECT COUNT(*) FROM {table}), 2) AS percentage
    FROM {table}
    GROUP BY shard_id
    ORDER BY shard_id;
    """
    # 理想情况下每个分片的 percentage 应该接近 12.5%(1/8)
    # 如果某个分片超过 20%,说明存在倾斜
    return connection.execute(query).fetchall()

3.4 复合分片键

当单一字段无法同时满足查询覆盖和均匀分布的需求时,可以考虑复合分片键。

# 复合分片键:将 user_id 和时间结合
def generate_order_id(user_id: int, timestamp: int) -> str:
    """
    订单 ID 格式:{时间戳前缀}{user_id后4位}{序列号}
    这样既能通过 order_id 反推出 user_id 对应的分片,
    又保持了时间有序性。
    """
    time_part = str(timestamp)[:10]       # 10 位时间戳
    user_part = str(user_id % 10000).zfill(4)  # 4 位用户标识
    seq_part = str(next_sequence()).zfill(4)    # 4 位序列号
    return f"{time_part}{user_part}{seq_part}"

def route_by_order_id(order_id: str, shard_count: int) -> int:
    """从订单 ID 中提取 user_id 信息,路由到同一个分片"""
    user_part = int(order_id[10:14])
    return user_part % shard_count

3.5 反模式

反模式一:使用自增 ID 作为分片键

自增 ID 在范围分片下会导致写入集中在最后一个分片;在哈希分片下虽然分布均匀,但无法利用业务语义做查询优化。

反模式二:使用低基数字段作为分片键

用 status(状态)、type(类型)这类枚举字段做分片键,数据必然倾斜到某几个分片。

反模式三:选择与核心查询无关的字段

如果分片键不在主要查询条件中,几乎每个查询都要做全分片扫描,分库分表的意义就没了。

3.6 好的分片键 vs 坏的分片键

场景 好的分片键 理由 坏的分片键 问题
电商订单 user_id 覆盖主要查询,分布均匀 order_status 基数太低(4-5 个值)
社交动态 user_id 用户查自己的动态是最高频操作 created_at 写入集中在最新时间分片
IoT 数据 device_id 按设备查询是核心场景 auto_increment_id 无业务语义,无法优化查询
SaaS 多租户 tenant_id 天然的隔离边界 region 大租户和小租户数据量差异大
日志系统 timestamp + source 按时间范围查询 + 均匀分布 log_level 基数太低(5 个值)

四、跨分片查询的六种方案

分库分表最大的工程代价不是拆分本身,而是拆分之后的跨分片查询。当一个查询涉及多个分片的数据时,你需要在应用层解决数据库层不再提供的能力。

4.1 方案一:Scatter-Gather(扇出-聚合)

最直接的方案:把查询发送到所有分片,收集结果,在应用层合并。

class ScatterGatherExecutor:
    def __init__(self, shard_connections: dict):
        self.shards = shard_connections

    async def execute(self, query: str, params: tuple = ()) -> list[dict]:
        """并行查询所有分片,合并结果"""
        tasks = [
            self._query_shard(name, conn, query, params)
            for name, conn in self.shards.items()
        ]
        results = await asyncio.gather(*tasks)
        return [row for shard_result in results for row in shard_result]

    async def execute_with_order(
        self, query: str, params: tuple,
        order_key: str, limit: int, desc: bool = True
    ) -> list[dict]:
        """带排序和分页的跨分片查询:每个分片返回 limit 条,再全局排序截断"""
        all_results = await self.execute(query, params)
        all_results.sort(key=lambda x: x[order_key], reverse=desc)
        return all_results[:limit]

性能特征:延迟等于最慢的分片的响应时间。分片数越多,命中慢节点的概率越高。适合分片数量少(4-16 个)且查询频率不高的场景。

4.2 方案二:全局二级索引(Global Secondary Index)

为非分片键字段建立一个全局索引,记录该字段值到分片键的映射。

class GlobalSecondaryIndex:
    """
    全局二级索引:将 merchant_id -> [user_id] 的映射
    存储在独立的索引库中
    """
    def __init__(self, index_db, shard_router):
        self.index_db = index_db
        self.router = shard_router

    def build_index(self, merchant_id: str, user_id: int, order_id: str):
        """写入订单时,同步更新全局索引"""
        self.index_db.execute(
            "INSERT INTO idx_merchant_order (merchant_id, user_id, order_id) "
            "VALUES (%s, %s, %s)",
            (merchant_id, user_id, order_id)
        )

    def query_by_merchant(self, merchant_id: str, limit: int = 20) -> list:
        """按商户查订单:先查索引,再定向查分片"""
        # 第一步:从全局索引获取 user_id 列表
        index_rows = self.index_db.execute(
            "SELECT user_id, order_id FROM idx_merchant_order "
            "WHERE merchant_id = %s ORDER BY order_id DESC LIMIT %s",
            (merchant_id, limit)
        ).fetchall()

        # 第二步:按 user_id 分组,定向查对应分片
        shard_groups = {}
        for row in index_rows:
            shard = self.router.get_shard(str(row["user_id"]))
            shard_groups.setdefault(shard, []).append(row["order_id"])

        # 第三步:并行查各分片
        results = []
        for shard, order_ids in shard_groups.items():
            rows = self._query_shard(shard, order_ids)
            results.extend(rows)
        return results

性能特征:读路径增加一次索引查询,但避免了全分片扫描。写路径需要维护索引的一致性(同步写或异步写,取决于一致性要求)。索引库本身也可能需要分片。

4.3 方案三:数据冗余(Denormalization)

按不同的查询维度,将同一份数据冗余存储到不同的分片方案中。

class DenormalizedWriter:
    """
    双写策略:订单按 user_id 分片存一份,
    按 merchant_id 分片再存一份
    """
    def __init__(self, user_shard_router, merchant_shard_router):
        self.user_router = user_shard_router
        self.merchant_router = merchant_shard_router

    def create_order(self, order: dict):
        # 主表:按 user_id 分片
        user_shard = self.user_router.get_shard(str(order["user_id"]))
        self._insert(user_shard, "orders", order)

        # 冗余表:按 merchant_id 分片
        merchant_shard = self.merchant_router.get_shard(order["merchant_id"])
        self._insert(merchant_shard, "orders_by_merchant", order)

    def _insert(self, shard: str, table: str, data: dict):
        conn = self._get_connection(shard)
        columns = ", ".join(data.keys())
        placeholders = ", ".join(["%s"] * len(data))
        conn.execute(
            f"INSERT INTO {table} ({columns}) VALUES ({placeholders})",
            tuple(data.values())
        )

性能特征:读性能最好——每种查询模式都有针对性的分片方案。代价是写入量翻倍、存储翻倍、数据一致性维护复杂(两份数据可能出现不一致)。

4.4 方案四:CQRS + 物化视图

命令查询职责分离(Command Query Responsibility Segregation,CQRS):写入走分片数据库,查询走独立的读模型。

class CQRSOrderService:
    def __init__(self, shard_router, read_store):
        self.shard_router = shard_router
        self.read_store = read_store  # Elasticsearch / MongoDB

    def create_order(self, order: dict):
        # 写入分片数据库
        shard = self.shard_router.get_shard(str(order["user_id"]))
        self._write_to_shard(shard, order)

        # 异步发布事件,由消费者更新读模型
        self._publish_event("order_created", order)

    def query_orders(self, filters: dict, page: int, size: int) -> dict:
        """复杂查询走读模型(如 Elasticsearch)"""
        return self.read_store.search(
            index="orders",
            body={
                "query": {"bool": {"filter": self._build_filters(filters)}},
                "sort": [{"created_at": "desc"}],
                "from": (page - 1) * size,
                "size": size,
            },
        )

    def _build_filters(self, filters: dict) -> list:
        es_filters = []
        for field, value in filters.items():
            if isinstance(value, dict) and "range" in value:
                es_filters.append({"range": {field: value["range"]}})
            else:
                es_filters.append({"term": {field: value}})
        return es_filters

性能特征:读写完全解耦,读模型可以针对查询模式优化。代价是数据存在延迟(最终一致性),系统复杂度显著增加。

4.5 方案五:CDC 同步到分析存储

变更数据捕获(Change Data Capture,CDC):监听数据库的 binlog,将变更实时同步到分析型存储。

# Debezium CDC 连接器配置(简化版)
apiVersion: kafka.strimzi.io/v1beta2
kind: KafkaConnector
metadata:
  name: orders-cdc-connector
spec:
  class: io.debezium.connector.mysql.MySqlConnector
  config:
    database.hostname: shard-0.mysql.internal
    database.port: "3306"
    database.include.list: "order_db"
    table.include.list: "order_db.orders"
    topic.prefix: "cdc.orders"
# CDC 消费者:将变更写入 ClickHouse 做分析查询
class CDCConsumer:
    def __init__(self, clickhouse_client):
        self.ch = clickhouse_client
        self.consumer = KafkaConsumer(
            "cdc.orders.order_db.orders",
            bootstrap_servers="kafka:9092",
            value_deserializer=lambda m: json.loads(m.decode("utf-8")),
        )

    def run(self):
        for message in self.consumer:
            payload = message.value["payload"]
            op = payload["op"]  # c=create, u=update, d=delete
            if op in ("c", "u"):
                self.ch.execute("INSERT INTO orders_analytics VALUES", [payload["after"]])
            elif op == "d":
                self._delete(payload["before"]["order_id"])

性能特征:对主库零侵入,延迟通常在秒级。适合报表、运营分析等对实时性要求不高的场景。需要维护 CDC 管道的可靠性。

4.6 方案六:应用层 Join

在应用代码中手动实现 Join 逻辑。

class ApplicationJoin:
    """应用层 Join:先查订单,再查商户信息,手动关联"""

    async def get_orders_with_merchant(self, user_id: int, limit: int = 20):
        # 第一步:查订单(路由到特定分片)
        order_shard = self.order_router.get_shard(str(user_id))
        orders = await self._query(
            order_shard,
            "SELECT * FROM orders WHERE user_id = %s "
            "ORDER BY created_at DESC LIMIT %s",
            (user_id, limit),
        )
        if not orders:
            return []

        # 第二步:收集 merchant_id,按分片分组并行查询
        merchant_ids = list(set(o["merchant_id"] for o in orders))
        shard_groups = {}
        for mid in merchant_ids:
            shard = self.merchant_router.get_shard(mid)
            shard_groups.setdefault(shard, []).append(mid)

        tasks = [
            self._query(shard, f"SELECT * FROM merchants WHERE merchant_id IN ({','.join(['%s']*len(ids))})", tuple(ids))
            for shard, ids in shard_groups.items()
        ]
        results = await asyncio.gather(*tasks)
        merchant_map = {m["merchant_id"]: m for r in results for m in r}

        # 第三步:应用层组装
        for order in orders:
            order["merchant"] = merchant_map.get(order["merchant_id"], {})
        return orders

性能特征:灵活,但代码复杂度高。每多一层 Join,就多一轮网络往返。适合 Join 维度少且数据量可控的场景。

4.7 六种方案对比

方案 延迟 一致性 复杂度 适用场景
Scatter-Gather 高(取决于分片数) 强一致 低频查询、分片数少
全局二级索引 中(多一次查询) 最终一致或强一致 固定的非分片键查询
数据冗余 最终一致 查询模式固定且有限
CQRS + 物化视图 低(读模型) 最终一致 复杂查询、全文搜索
CDC 到分析存储 高(秒级延迟) 最终一致 报表、分析类查询
应用层 Join 强一致 Join 维度少、数据量小

五、Vitess:YouTube 的 MySQL 分片方案

Vitess 是 YouTube 在 2010 年前后开发的 MySQL 分片中间件,2018 年成为 CNCF 项目。它的核心目标是:让应用层像使用单个 MySQL 一样使用一个分片集群。

5.1 核心架构

Vitess 的架构由三个核心组件组成:

VTGate:无状态的查询路由层。应用连接 VTGate 而不是直接连 MySQL。VTGate 解析 SQL,根据分片规则把查询路由到正确的 VTTablet。

VTTablet:每个 MySQL 实例前面有一个 VTTablet 进程。它负责连接池管理、查询重写、事务管理、健康检查。VTTablet 是 Vitess 的”sidecar”。

Topology Service:存储分片元数据、分片到 VTTablet 的映射关系、主从拓扑信息。底层可以用 etcd、ZooKeeper 或 Consul。

graph TB
    subgraph 应用层
        A1[App Instance 1]
        A2[App Instance 2]
        A3[App Instance 3]
    end

    subgraph Vitess 代理层
        VG1[VTGate]
        VG2[VTGate]
    end

    subgraph 拓扑服务
        TOPO[Topology Service<br/>etcd / ZooKeeper]
    end

    subgraph "Shard -80 (前半段)"
        VT1[VTTablet Primary]
        M1[(MySQL Primary)]
        VT1R[VTTablet Replica]
        M1R[(MySQL Replica)]
        VT1 --> M1
        VT1R --> M1R
    end

    subgraph "Shard 80- (后半段)"
        VT2[VTTablet Primary]
        M2[(MySQL Primary)]
        VT2R[VTTablet Replica]
        M2R[(MySQL Replica)]
        VT2 --> M2
        VT2R --> M2R
    end

    A1 --> VG1
    A2 --> VG1
    A3 --> VG2
    VG1 --> TOPO
    VG2 --> TOPO
    VG1 --> VT1
    VG1 --> VT2
    VG2 --> VT1
    VG2 --> VT2

    M1 -.-> |"复制"| M1R
    M2 -.-> |"复制"| M2R

5.2 分片模型

Vitess 使用 VSchema(Vitess Schema)定义分片规则:

{
  "sharded": true,
  "vindexes": {
    "hash_user_id": {
      "type": "hash"
    },
    "order_id_lookup": {
      "type": "consistent_lookup",
      "params": {
        "table": "order_id_lookup",
        "from": "order_id",
        "to": "user_id"
      },
      "owner": "orders"
    }
  },
  "tables": {
    "orders": {
      "column_vindexes": [
        {
          "column": "user_id",
          "name": "hash_user_id"
        },
        {
          "column": "order_id",
          "name": "order_id_lookup"
        }
      ]
    }
  }
}

Vitess 的 Vindex(Virtual Index)机制是它区别于简单分片中间件的关键。Primary Vindex 决定数据落在哪个分片;Secondary Vindex(如 consistent_lookup)提供非分片键到分片键的映射,相当于自动维护全局二级索引。

5.3 在线 DDL

Vitess 内置了 Online DDL 能力,支持在不锁表的情况下做表结构变更:

-- Vitess Online DDL:使用 vitess 策略
ALTER /*vt+ strategy=vitess */ TABLE orders ADD COLUMN coupon_id BIGINT;

-- 查看 DDL 进度
SHOW VITESS_MIGRATIONS LIKE '%orders%';

Vitess 的 Online DDL 底层可以使用 gh-ost 或 pt-online-schema-change,也可以使用 Vitess 自己实现的 VReplication 策略。

5.4 连接池管理

VTTablet 对 MySQL 的连接池做了精细管理。即使有上千个应用实例连到 VTGate,VTGate 到每个 VTTablet 的连接数是受控的,VTTablet 到 MySQL 的连接数更是严格限制的。这解决了”连接池耗尽”问题。

5.5 局限性


六、TiDB:分布式 SQL 数据库

TiDB 是 PingCAP 开发的开源分布式 SQL 数据库,目标是兼容 MySQL 协议的同时实现透明的水平扩展。它的核心理念是:让应用层完全不需要关心分片逻辑

6.1 核心架构

TiDB 的架构分为三层:

TiDB Server:无状态的 SQL 计算层。负责 SQL 解析、查询优化、执行计划生成。可以水平扩展,多个 TiDB Server 实例之间不需要通信。

TiKV:分布式键值存储引擎。数据按 Region(默认 96MB)切分,每个 Region 是一个 Raft Group,通过 Raft 协议在多个副本之间保持一致性。

PD(Placement Driver):集群的”大脑”。负责 Region 的调度(分裂、合并、迁移)、时间戳分配(TSO,用于 MVCC)、负载均衡。

应用层(MySQL 兼容)
        │
  ┌─────▼──────┐  ┌──────▼─────┐
  │ TiDB Server │  │ TiDB Server│   无状态 SQL 层
  └─────┬──────┘  └──────┬─────┘
        │                │
  ┌─────▼────────────────▼─────┐
  │          PD Server          │   调度 + TSO
  └─────┬────────────────┬─────┘
  ┌─────▼──────┐  ┌──────▼─────┐
  │  TiKV Node  │  │  TiKV Node │   Raft 副本 KV 存储
  └────────────┘  └────────────┘

6.2 自动分片机制

与手动分库分表不同,TiDB 的分片是自动的:

  1. 数据按主键范围切分为 Region(每个 Region 约 96MB)
  2. 当一个 Region 大小超过阈值时,PD 自动将其分裂为两个 Region
  3. 当某个 TiKV 节点负载过高时,PD 自动将部分 Region 迁移到其他节点
  4. 整个过程对应用层完全透明
-- 在 TiDB 中,你可以像使用单机 MySQL 一样写 SQL
-- 不需要指定分片键,不需要路由逻辑

CREATE TABLE orders (
    order_id BIGINT PRIMARY KEY AUTO_RANDOM,
    user_id BIGINT NOT NULL,
    merchant_id VARCHAR(32) NOT NULL,
    amount DECIMAL(10, 2) NOT NULL,
    status TINYINT NOT NULL DEFAULT 0,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_user_id (user_id),
    INDEX idx_merchant_id (merchant_id)
);

-- 跨分片查询由 TiDB 自动处理
SELECT o.order_id, o.amount, m.merchant_name
FROM orders o
JOIN merchants m ON o.merchant_id = m.merchant_id
WHERE o.user_id = 10086
ORDER BY o.created_at DESC
LIMIT 20;

注意 AUTO_RANDOM 关键字——这是 TiDB 特有的,它在自增 ID 的高位插入随机值,避免写入热点集中在同一个 Region。

6.3 HTAP:TiFlash

TiDB 通过 TiFlash 组件实现 HTAP(Hybrid Transactional/Analytical Processing):

-- 为 orders 表创建 TiFlash 副本
ALTER TABLE orders SET TIFLASH REPLICA 1;

-- 分析查询自动路由到 TiFlash
-- TiDB 的优化器会根据查询特征自动选择 TiKV 或 TiFlash
SELECT
    DATE(created_at) AS order_date,
    COUNT(*) AS order_count,
    SUM(amount) AS total_amount,
    AVG(amount) AS avg_amount
FROM orders
WHERE created_at >= '2025-01-01'
GROUP BY order_date
ORDER BY order_date;

6.4 局限性与不适用场景


七、CockroachDB:云原生分布式 SQL

CockroachDB 由前 Google 工程师开发,设计理念来源于 Google Spanner。它的核心特性是:全局一致性、自动分片、地理分区。

7.1 核心架构

CockroachDB 采用对等架构(Peer-to-Peer),没有专门的协调节点:

-- CockroachDB 示例:创建带地理分区的表
CREATE TABLE orders (
    order_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id INT NOT NULL,
    region STRING NOT NULL,
    amount DECIMAL(10, 2) NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    INDEX idx_user_region (user_id, region)
) LOCALITY REGIONAL BY ROW;

-- 配置区域:不同地区的数据优先存储在距离最近的数据中心
ALTER DATABASE order_db SET PRIMARY REGION "us-east1";
ALTER DATABASE order_db ADD REGION "eu-west1";
ALTER DATABASE order_db ADD REGION "ap-southeast1";

7.2 序列化隔离

CockroachDB 默认提供序列化隔离级别(Serializable Isolation),这是 SQL 标准中最强的隔离级别。这意味着所有事务的执行效果等同于某个串行顺序,不存在读偏斜(Read Skew)、写偏斜(Write Skew)等异常。

相比之下,MySQL 的默认隔离级别是可重复读(Repeatable Read),TiDB 默认是快照隔离(Snapshot Isolation)。

7.3 地理分区

CockroachDB 的 Geo-Partitioning 允许你控制数据的物理存储位置,满足数据合规要求(如 GDPR)并降低跨地域访问延迟。通过 LOCALITY REGIONAL BY ROW 和分区级别的 Zone 配置,可以指定特定地区的数据只存储在对应的数据中心。例如,将欧洲用户的数据约束在 eu-west1 区域,亚洲用户的数据约束在 ap-southeast1 区域。

7.4 CockroachDB vs TiDB

维度 CockroachDB TiDB
协议兼容 PostgreSQL MySQL
架构 对等节点,无中心 分层架构(TiDB + TiKV + PD)
默认隔离级别 Serializable Snapshot Isolation
存储引擎 Pebble(RocksDB 分支) RocksDB(TiKV)
地理分区 原生支持 有限支持(Placement Rules)
HTAP 有限 TiFlash 列存引擎
开源协议 BSL(Business Source License) Apache 2.0
社区生态 北美为主 亚太为主
运维复杂度 中(对等架构更简单) 高(组件多)

八、NewSQL vs 传统分库分表

到这里,我们已经看了传统分库分表和三种 NewSQL 方案。核心问题是:NewSQL 能否让我们彻底告别分库分表?

8.1 全面对比

维度 传统分库分表 Vitess TiDB CockroachDB
应用侵入性 高(需要改代码) 低(SQL 兼容) 极低(MySQL 兼容) 极低(PostgreSQL 兼容)
跨分片查询 需要自己实现 部分支持 完全支持 完全支持
分布式事务 需要自己实现 2PC(性能差) Percolator 模型 序列化隔离
单点查询延迟 极低(直连 MySQL) 低(多一跳) 中(2-5ms) 中(2-5ms)
水平扩展 手动(加分片) 半自动 自动 自动
运维复杂度 高(每个分片独立管理) 高(组件多) 高(组件多)
最小部署成本 低(2-4 个 MySQL) 高(6+ 节点) 高(3+ 节点)
已有 MySQL 兼容性 完全兼容 高(少数不兼容) 不兼容(PostgreSQL)
生态成熟度 最高
适用数据规模 TB 级 TB-PB 级 TB-PB 级 TB-PB 级

8.2 选择指南

选传统分库分表,如果:

选 Vitess,如果:

选 TiDB,如果:

选 CockroachDB,如果:

8.3 迁移路径

从单机 MySQL 迁移到分库分表或 NewSQL,没有一蹴而就的方案。以下是常见的迁移路径:

常见的迁移路径从单机 MySQL 出发,依次经过垂直拆分(按业务域分库)和读写分离(主从复制),然后分为三个方向:传统分库分表(ShardingSphere 或自研)、Vitess(MySQL 分片中间件)、TiDB(全量迁移,MySQL 兼容)。如果技术栈基于 PostgreSQL,可以考虑直接迁移到 CockroachDB。数据量较小时,也可以跳过中间步骤直接迁移到 TiDB 或 CockroachDB。


九、迁移策略

不管选哪条路径,数据迁移都是风险最高的环节。以下四种策略可以组合使用。

9.1 双写模式(Dual Write)

同时写入新旧两套系统,读取仍然走旧系统。验证新系统数据正确后,再切换读流量。

class DualWriteProxy:
    def __init__(self, old_db, new_db, read_from: str = "old"):
        self.old_db = old_db
        self.new_db = new_db
        self.read_from = read_from

    def write(self, query: str, params: tuple):
        self.old_db.execute(query, params)
        try:
            self.new_db.execute(query, params)
        except Exception as e:
            logger.error(f"Dual write to new DB failed: {e}")
            self._record_failed_write(query, params)

    def read(self, query: str, params: tuple):
        if self.read_from == "new":
            return self.new_db.execute(query, params)
        if self.read_from == "compare":
            old_result = self.old_db.execute(query, params)
            new_result = self.new_db.execute(query, params)
            if old_result != new_result:
                logger.warning(f"Data mismatch: {query}")
            return old_result
        return self.old_db.execute(query, params)

9.2 影子流量(Shadow Traffic)

将生产流量复制一份到新系统,但不使用新系统的返回结果。目的是验证新系统在生产负载下的行为和性能。核心逻辑是:主流程走旧系统并返回结果,同时用线程池异步将同样的查询发到新系统,比对返回结果并记录差异指标(match/mismatch/error)。影子流量不影响主流程延迟,但能暴露新系统的兼容性和性能问题。

9.3 灰度切换(Gradual Cutover)

按用户或流量比例逐步切换到新系统。

class GradualCutover:
    def __init__(self, old_db, new_db, feature_flag_client):
        self.old_db = old_db
        self.new_db = new_db
        self.flags = feature_flag_client

    def get_db(self, user_id: int):
        """根据灰度策略决定使用新库还是旧库"""
        cutover_percentage = self.flags.get_int("db_cutover_percentage", 0)

        if user_id % 100 < cutover_percentage:
            return self.new_db
        else:
            return self.old_db

    def execute(self, user_id: int, query: str, params: tuple):
        db = self.get_db(user_id)
        return db.execute(query, params)

切换节奏建议:

  1. 先切 1% 的流量,观察 24 小时
  2. 提高到 5%,再观察 24 小时
  3. 提高到 20%,观察一周
  4. 提高到 50%,观察一周
  5. 切到 100%,保留旧库 30 天作为回退

9.4 回滚方案

迁移必须有回滚方案。回滚方案的关键是:保持旧系统随时可以接管读写

回滚步骤:(1)通过配置中心将 write_target 和 read_target 切回旧系统;(2)将新系统中产生的增量数据(通过 updated_at 时间戳筛选)同步回旧系统,使用 UPSERT 语义避免冲突;(3)验证旧系统数据完整性后,停止新系统的流量接入。在双写期间,旧系统始终保持可用状态,确保任何时刻都可以执行回滚。


十、工程案例:电商订单系统的分库分表历程

以下是一个中型电商平台订单系统从单库到分库分表的完整历程,过程中遇到的问题和决策逻辑具有普遍参考价值。

10.1 阶段一:单库单表(第 1-12 个月)

系统初期,一个 MySQL 实例,一张 orders 表。月订单量 20 万,表中数据 200 万行。查询性能稳定在 5ms 以内。这个阶段不需要任何额外操作。

10.2 阶段二:读写分离(第 12-18 个月)

业务增长,月订单量涨到 100 万。orders 表达到 1200 万行。部分复杂查询(运营报表、商户对账)开始影响主库性能。

方案:增加两个只读从库,报表查询走从库。

-- 从库配置
CHANGE MASTER TO
    MASTER_HOST='master.mysql.internal',
    MASTER_PORT=3306,
    MASTER_AUTO_POSITION=1;

-- 应用层路由
-- 写操作 → 主库
-- 读操作 → 从库(需要容忍几百毫秒的复制延迟)

问题:主从复制延迟在高峰期达到 3-5 秒,导致用户下单后立即查询看不到新订单。

解决:关键路径的读操作(如”刚下单后的订单详情页”)强制走主库;非关键路径(如”历史订单列表”)走从库。

10.3 阶段三:垂直拆分(第 18-24 个月)

月订单量涨到 300 万。orders 表达到 4000 万行。连接池开始紧张——订单服务、支付服务、物流服务、运营后台都连同一个数据库。

方案:按业务域拆库。

-- 拆分后,跨库查询需要在应用层做 Join
-- 原来的 SQL:
SELECT o.*, p.pay_status, s.tracking_no
FROM orders o
JOIN payments p ON o.order_id = p.order_id
JOIN shipments s ON o.order_id = s.order_id
WHERE o.user_id = 10086;

-- 拆分后变成三次查询 + 应用层组装
-- 查询 1:order_db
SELECT * FROM orders WHERE user_id = 10086;
-- 查询 2:payment_db
SELECT * FROM payments WHERE order_id IN (...);
-- 查询 3:logistics_db
SELECT * FROM shipments WHERE order_id IN (...);

教训:垂直拆分后,原来数据库层面的外键约束和 JOIN 都没了。团队花了两周时间重构了跨库查询逻辑,还引入了数据一致性校验的定时任务。

10.4 阶段四:水平分片(第 24-30 个月)

月订单量涨到 800 万。order_db 中的 orders 表突破了 8000 万行。即使走了索引,按 user_id 查询的 P99 延迟也达到了 500ms。主库的写入 TPS 在高峰期达到 15000,开始出现锁等待。

关键决策

  1. 分片键选择:user_id。分析表明 75% 的查询带 user_id 条件,用户之间的数据量分布相对均匀。
  2. 分片策略:哈希分片,hash(user_id) % 16,共 16 个分片。
  3. 分片中间件:Apache ShardingSphere-Proxy。
  4. order_id 设计:在 order_id 中嵌入 user_id 信息,确保按 order_id 查询也能定位到正确的分片。
# 订单 ID 生成规则
def generate_order_id(user_id: int) -> str:
    timestamp = int(time.time() * 1000)  # 毫秒时间戳
    shard_hint = user_id % 16            # 分片标识
    sequence = next_seq()                 # 序列号
    # 格式:时间戳(13位) + 分片标识(2位) + 序列号(4位)
    return f"{timestamp}{shard_hint:02d}{sequence:04d}"

def extract_shard_from_order_id(order_id: str) -> int:
    return int(order_id[13:15])

数据迁移过程

  1. 搭建 16 个 MySQL 分片实例
  2. 使用 DTS(Data Transmission Service)全量同步旧库数据到各分片
  3. 开启增量同步(binlog 复制)
  4. 切换写流量到新分片集群(双写一周后单写)
  5. 验证数据一致性后,下线旧库

遇到的问题

10.5 经验总结

阶段 数据规模 方案 核心收益 核心代价
单库 <1000 万行 无需操作 简单
读写分离 1000-3000 万行 主从复制 读性能提升 复制延迟
垂直拆分 3000-5000 万行 按业务域分库 连接隔离 跨库 JOIN
水平分片 >5000 万行 哈希分片 16 库 写入扩展 跨分片查询

最大的教训是:分库分表的难度不在”分”,在”分完之后的查询和运维”。如果重新来过,团队会认真评估 TiDB——在他们的数据规模(亿级行)下,TiDB 可以省去大量分片中间件的开发和运维成本。


十一、三种分片策略与传统分片 vs NewSQL 综合对比

11.1 分片策略对比

维度 范围分片 哈希分片 目录分片
数据分布均匀性 差(容易倾斜) 可控(手动调整)
范围查询 高效(定位分片) 差(全分片扫描) 取决于目录设计
写入热点 容易出现 几乎没有 取决于分配策略
扩容难度 低(新增范围) 高(数据迁移) 低(更新目录)
实现复杂度 中(需要维护目录)
数据归档 方便(按范围迁移) 困难
单点故障风险 有(目录服务)
适用场景 时序数据、日志 用户数据、订单 多租户、不均匀数据

11.2 传统分库分表 vs NewSQL 总体对比

维度 传统分库分表 NewSQL(TiDB / CockroachDB)
开发成本 高(改造代码、处理跨分片逻辑) 低(SQL 兼容,几乎不改代码)
运维成本 高(每个分片独立备份恢复、监控) 中(集群化管理,但分布式运维复杂)
硬件成本 低到中 中到高(最小部署节点多)
查询能力 受限(跨分片查询需要自己实现) 完整(支持跨节点 JOIN、子查询)
事务能力 受限(分布式事务需要自己实现) 完整(原生分布式事务)
单点查询延迟 极低(直连单机 MySQL) 略高(网络开销 + 分布式协调)
扩缩容 手动且有风险 自动且在线
生态成熟度 最高(MySQL 生态) 在增长中
团队技能要求 MySQL DBA + 分片中间件经验 分布式数据库运维经验
数据规模上限 实际 TB 级 设计上 PB 级
风险 分片键选错难以回退 数据库本身是新技术,稳定性仍在验证

十二、总结

回到开头的三个问题:

分库分表的时机——当单表数据量超出引擎舒适区、索引优化到头、写入吞吐触及单节点上限时,且垂直拆分和读写分离已经做过。信号不止一个指标,而是多个指标同时恶化。

分库分表的策略——哈希分片是最常用的策略,数据分布均匀但牺牲范围查询能力。范围分片适合时序数据。目录分片灵活性最高但复杂度也最高。分片键的选择比分片策略更重要——它决定了后续所有查询的效率。

NewSQL 能否替代分库分表——在大多数场景下,TiDB 和 CockroachDB 可以显著降低分库分表的工程复杂度。但它们不是银弹:单点查询延迟略高、最小部署成本更高、分布式系统的运维需要专业能力。如果你的数据规模在 TB 以下、查询模式简单、团队对 MySQL 运维经验丰富,传统分库分表可能仍然是更务实的选择。

选择的关键不在于”哪个技术更先进”,而在于”你的团队能运维好哪个方案”。

下一篇我们讨论消息队列架构——当数据库扩展解决了存储层的问题后,系统的瓶颈往往转移到服务间的通信和解耦上。


参考资料

  1. Vitess 官方文档,https://vitess.io/docs/,Vitess 分片模型、VSchema、Online DDL 等核心功能的官方参考。
  2. TiDB 官方文档,https://docs.pingcap.com/,TiDB 架构设计、SQL 兼容性、TiFlash HTAP 等内容的官方参考。
  3. CockroachDB 官方文档,https://www.cockroachlabs.com/docs/,CockroachDB 架构、地理分区、序列化隔离等特性的官方参考。
  4. Shlomi Noach,“Vitess: Sharding MySQL at Scale”,PlanetScale Blog,Vitess 核心开发者对 Vitess 分片原理的深入解读。
  5. Dongxu Huang et al.,“TiDB: A Raft-based HTAP Database”,VLDB 2020,TiDB 架构的学术论文,详细描述了 Raft 协议在 TiKV 中的应用。
  6. Rebecca Taft et al.,“CockroachDB: The Resilient Geo-Distributed SQL Database”,SIGMOD 2020,CockroachDB 架构的学术论文。
  7. Apache ShardingSphere 官方文档,https://shardingsphere.apache.org/,Java 生态下主流的分库分表中间件。
  8. Martin Kleppmann,“Designing Data-Intensive Applications”,O’Reilly 2017,第六章 Partitioning 对分片策略的系统性分析。
  9. Debezium 官方文档,https://debezium.io/documentation/,Change Data Capture 工具,用于分片间数据同步。
  10. Sugu Sougoumarane,“Vitess at YouTube: Managing Tens of Millions of QPS”,KubeCon 2019,YouTube 使用 Vitess 管理大规模 MySQL 集群的实战分享。

同主题继续阅读

把当前热点继续串成多页阅读,而不是停在单篇消费。

2026-04-13 · architecture

【系统架构设计百科】Slack 架构:实时协作的工程挑战

Slack 每天为超过一千万活跃用户提供实时消息服务,峰值时段同时维持数百万条 WebSocket(全双工通信协议)长连接。一条消息从发送到被同一频道所有成员看到,端到端延迟通常控制在 200 毫秒以内。这套系统并非一蹴而就:它从一个 PHP 单体应用起步,历经数次关键重构,逐步演变为以 Hack、Go、Java 为核…

2026-04-13 · architecture

【系统架构设计百科】架构质量属性:不只是"高可用高性能"

需求评审时写下的'高可用、高性能、高并发',到了架构设计阶段几乎无法落地——因为它们不是可执行的需求。本文从 SEI/CMU 的质量属性理论出发,用 stimulus-response 场景模型把模糊需求变成可量化、可验证的架构约束,并拆解属性之间的冲突与联动关系。

2026-04-13 · architecture

【系统架构设计百科】告警策略:如何避免"狼来了"

大多数团队的告警系统都在制造噪声而不是传递信号。阈值告警看似直观,实则产生大量误报和漏报,值班工程师在凌晨三点被叫醒,却发现只是一次无害的毛刺。本文从告警疲劳的工业数据出发,拆解基于 SLO 的多窗口燃烧率告警算法,深入 Alertmanager 的路由、抑制与分组机制,结合 PagerDuty 的告警疲劳研究和真实工程案例,给出一套可落地的告警策略设计方法。

2026-04-13 · architecture

【系统架构设计百科】复杂性管理:架构的核心战场

系统复杂性是架构腐化的根源——本文从 Brooks 的本质复杂性与偶然复杂性划分出发,结合认知负荷理论与 Parnas 的信息隐藏原则,系统阐述复杂性的来源、度量与控制手段,并给出可操作的架构策略


By .