你负责的电商订单系统上线两年了。订单表(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 甚至更高。你已经做了以下优化但效果有限:
- 覆盖索引(Covering Index)已经加了
- 查询计划(EXPLAIN)确认走了索引
- 分页查询已经改成了游标分页(Keyset Pagination)
- buffer pool 调到了物理内存的 70%-80%
如果这些都做了,延迟依然在恶化,那大概率是数据量本身的问题。
1.3 信号三:写入吞吐触及单节点瓶颈
单个 MySQL 实例的写入吞吐量受限于多个因素:redo log 刷盘频率、binlog 同步方式、锁竞争强度。在标准 SSD 硬件上,单实例的写入 TPS(Transactions Per Second)通常在 5000-20000 之间,取决于事务复杂度和隔离级别。
当写入请求排队、锁等待时间增长、主从复制延迟持续上升时,说明单节点的写入能力已经到了极限。
1.4 信号四:存储容量触及上限
单实例的数据文件超过 2TB 后,备份恢复时间可能需要数小时,在线 DDL 风险急剧上升。
1.5 信号五:连接池耗尽
MySQL 单实例活跃连接数受限于 CPU 核数和内存。当多个应用实例共享数据库、连接池频繁打满时,需要通过分库来分散连接压力。
1.6 不该分的时候
以下情况说明你还不需要分库分表:
- 慢查询是因为没有合适的索引,加了索引就好了
- 数据量还在百万级,读写压力可以通过读写分离(主从复制)解决
- 问题出在应用层(N+1 查询、缺少批量操作),不是数据库层
- 业务复杂度高但数据量不大,分库分表的维护成本远超收益
- 还没尝试过垂直拆分(把不同业务表拆到不同数据库实例)
经验法则:先做垂直拆分(按业务域拆库),再做读写分离,最后才考虑水平分片(分表)。每一步都应该在上一步的优化空间用尽之后再推进。
二、三种分片策略
水平分片(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")) # 不同用户大概率落在不同分片优点:
- 数据分布均匀,不会出现热点分片
- 路由计算简单高效,O(1) 时间复杂度
缺点:
- 范围查询无法定位到特定分片,必须扫描所有分片(Scatter-Gather)
- 扩容(增减分片数量)时,简单取模方案需要迁移大量数据
- 一致性哈希(Consistent Hashing)可以缓解扩容问题,但实现更复杂
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)优点:
- 灵活性最高——可以随时调整任意键的分片归属
- 支持不均匀分配——可以按分片容量动态分配
- 数据迁移只需更新目录映射,不需要修改路由逻辑
缺点:
- 每次数据访问都需要先查目录,多一次网络往返
- 目录服务成为单点故障(SPOF),需要高可用保障
- 目录数据本身的存储和维护增加了系统复杂度
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_count3.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 局限性
- Vitess 不支持所有 MySQL 语法,复杂的跨分片 JOIN、子查询支持有限
- 分布式事务使用两阶段提交(2PC),性能开销大
- 运维复杂度高——VTGate、VTTablet、Topology Service 都需要部署和监控
- 学习曲线陡峭,VSchema 的配置和调优需要深入理解 Vitess 内部机制
六、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 的分片是自动的:
- 数据按主键范围切分为 Region(每个 Region 约 96MB)
- 当一个 Region 大小超过阈值时,PD 自动将其分裂为两个 Region
- 当某个 TiKV 节点负载过高时,PD 自动将部分 Region 迁移到其他节点
- 整个过程对应用层完全透明
-- 在 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):
- TiKV 是行存引擎,擅长 OLTP(事务处理)
- TiFlash 是列存引擎,擅长 OLAP(分析查询)
- TiFlash 通过 Raft Learner 机制实时同步 TiKV 的数据,延迟通常在毫秒级
-- 为 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 局限性与不适用场景
- 单行延迟:TiDB 的单行点查延迟通常在 2-5ms,而优化良好的 MySQL 可以做到亚毫秒。对延迟极度敏感的场景(如高频交易),TiDB 不是最佳选择。
- 资源消耗:最小部署需要至少 3 个 TiKV 节点 + 3 个 PD 节点 + 2 个 TiDB Server,硬件成本远高于单机 MySQL。
- 兼容性:虽然兼容大部分 MySQL 语法,但存储过程、触发器、部分函数的支持有差异。
- 运维复杂度:分布式系统的运维难度远高于单机数据库。网络分区、节点故障、Region 热点等问题需要专业的运维能力。
七、CockroachDB:云原生分布式 SQL
CockroachDB 由前 Google 工程师开发,设计理念来源于 Google Spanner。它的核心特性是:全局一致性、自动分片、地理分区。
7.1 核心架构
CockroachDB 采用对等架构(Peer-to-Peer),没有专门的协调节点:
- 每个节点都可以接收 SQL 请求
- 数据按 Range(类似 TiDB 的 Region)切分,每个 Range 通过 Raft 协议复制到多个节点
- 使用 HLC(Hybrid Logical Clock)实现全球范围的事务一致性
-- 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 选择指南
选传统分库分表,如果:
- 团队对 MySQL 运维经验丰富,不想引入新的数据库
- 查询模式简单,90% 以上的查询都带分片键
- 对单点查询延迟要求极高(亚毫秒级)
- 预算有限,不想承担 NewSQL 的硬件成本
选 Vitess,如果:
- 已有大量 MySQL 基础设施,不想全部迁走
- 需要逐步分片,而不是一次性重构
- YouTube 级别的规模(千亿行级数据量)
- 团队有 Kubernetes 运维能力
选 TiDB,如果:
- 需要 MySQL 兼容的分布式数据库
- 有跨分片 JOIN、复杂查询的需求
- 需要 HTAP 能力(OLTP + OLAP 混合负载)
- 亚太地区,社区支持和商业支持更容易获取
选 CockroachDB,如果:
- 技术栈基于 PostgreSQL
- 有多地域部署需求,需要数据本地化
- 对事务隔离级别要求高(Serializable)
- 面向全球用户的 SaaS 应用
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% 的流量,观察 24 小时
- 提高到 5%,再观察 24 小时
- 提高到 20%,观察一周
- 提高到 50%,观察一周
- 切到 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 万行。连接池开始紧张——订单服务、支付服务、物流服务、运营后台都连同一个数据库。
方案:按业务域拆库。
- order_db:订单核心表(orders、order_items)
- payment_db:支付表(payments、refunds)
- logistics_db:物流表(shipments、tracking)
- user_db:用户表(users、addresses)
-- 拆分后,跨库查询需要在应用层做 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,开始出现锁等待。
关键决策:
- 分片键选择:user_id。分析表明 75% 的查询带 user_id 条件,用户之间的数据量分布相对均匀。
- 分片策略:哈希分片,hash(user_id) % 16,共 16 个分片。
- 分片中间件:Apache ShardingSphere-Proxy。
- 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])数据迁移过程:
- 搭建 16 个 MySQL 分片实例
- 使用 DTS(Data Transmission Service)全量同步旧库数据到各分片
- 开启增量同步(binlog 复制)
- 切换写流量到新分片集群(双写一周后单写)
- 验证数据一致性后,下线旧库
遇到的问题:
- 跨分片分页查询:运营后台需要”按时间倒序查看所有订单”。16 个分片各返回 20 条再合并排序,效率很低。最终用 CDC + Elasticsearch 解决了运营查询。
- 分布式唯一 ID:自增 ID 在分片环境下会冲突。改用 Snowflake 算法生成全局唯一 ID。
- 分片扩容:从 16 片扩到 32 片时,需要迁移约 50% 的数据。使用一致性哈希可以减少迁移量,但团队当时选了简单取模,导致扩容时迁移了大量数据。这是后来最后悔的决策之一。
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 运维经验丰富,传统分库分表可能仍然是更务实的选择。
选择的关键不在于”哪个技术更先进”,而在于”你的团队能运维好哪个方案”。
下一篇我们讨论消息队列架构——当数据库扩展解决了存储层的问题后,系统的瓶颈往往转移到服务间的通信和解耦上。
参考资料
- Vitess 官方文档,https://vitess.io/docs/,Vitess 分片模型、VSchema、Online DDL 等核心功能的官方参考。
- TiDB 官方文档,https://docs.pingcap.com/,TiDB 架构设计、SQL 兼容性、TiFlash HTAP 等内容的官方参考。
- CockroachDB 官方文档,https://www.cockroachlabs.com/docs/,CockroachDB 架构、地理分区、序列化隔离等特性的官方参考。
- Shlomi Noach,“Vitess: Sharding MySQL at Scale”,PlanetScale Blog,Vitess 核心开发者对 Vitess 分片原理的深入解读。
- Dongxu Huang et al.,“TiDB: A Raft-based HTAP Database”,VLDB 2020,TiDB 架构的学术论文,详细描述了 Raft 协议在 TiKV 中的应用。
- Rebecca Taft et al.,“CockroachDB: The Resilient Geo-Distributed SQL Database”,SIGMOD 2020,CockroachDB 架构的学术论文。
- Apache ShardingSphere 官方文档,https://shardingsphere.apache.org/,Java 生态下主流的分库分表中间件。
- Martin Kleppmann,“Designing Data-Intensive Applications”,O’Reilly 2017,第六章 Partitioning 对分片策略的系统性分析。
- Debezium 官方文档,https://debezium.io/documentation/,Change Data Capture 工具,用于分片间数据同步。
- Sugu Sougoumarane,“Vitess at YouTube: Managing Tens of Millions of QPS”,KubeCon 2019,YouTube 使用 Vitess 管理大规模 MySQL 集群的实战分享。
同主题继续阅读
把当前热点继续串成多页阅读,而不是停在单篇消费。
【系统架构设计百科】Slack 架构:实时协作的工程挑战
Slack 每天为超过一千万活跃用户提供实时消息服务,峰值时段同时维持数百万条 WebSocket(全双工通信协议)长连接。一条消息从发送到被同一频道所有成员看到,端到端延迟通常控制在 200 毫秒以内。这套系统并非一蹴而就:它从一个 PHP 单体应用起步,历经数次关键重构,逐步演变为以 Hack、Go、Java 为核…
【系统架构设计百科】架构质量属性:不只是"高可用高性能"
需求评审时写下的'高可用、高性能、高并发',到了架构设计阶段几乎无法落地——因为它们不是可执行的需求。本文从 SEI/CMU 的质量属性理论出发,用 stimulus-response 场景模型把模糊需求变成可量化、可验证的架构约束,并拆解属性之间的冲突与联动关系。
【系统架构设计百科】告警策略:如何避免"狼来了"
大多数团队的告警系统都在制造噪声而不是传递信号。阈值告警看似直观,实则产生大量误报和漏报,值班工程师在凌晨三点被叫醒,却发现只是一次无害的毛刺。本文从告警疲劳的工业数据出发,拆解基于 SLO 的多窗口燃烧率告警算法,深入 Alertmanager 的路由、抑制与分组机制,结合 PagerDuty 的告警疲劳研究和真实工程案例,给出一套可落地的告警策略设计方法。
【系统架构设计百科】复杂性管理:架构的核心战场
系统复杂性是架构腐化的根源——本文从 Brooks 的本质复杂性与偶然复杂性划分出发,结合认知负荷理论与 Parnas 的信息隐藏原则,系统阐述复杂性的来源、度量与控制手段,并给出可操作的架构策略