第一次遇到数据库性能瓶颈时,我正维护着一个日活50万用户的电商平台。某个促销日凌晨,MySQL主库CPU直接飙到100%,订单表数据量突破2000万行,简单的用户查询都要3秒以上响应。这就是典型的单库单表架构的瓶颈——当数据量和并发量达到一定规模,传统架构就会成为系统发展的绊脚石。
分库分表本质上是通过数据分散存储来突破单机资源限制的架构方案。在PHP生态中,虽然Laravel、ThinkPHP等框架提供了便捷的ORM,但面对真正的海量数据时,我们需要更底层的解决方案。不同于Java生态中成熟的ShardingSphere、MyCat等中间件,PHP在分库分表领域往往需要开发者自己"造轮子"。
水平拆分(Horizontal Partitioning)是我们最常用的方式,比如将用户表user拆分为user_0到user_9十个表。我在实际项目中验证过,当单表超过500万行时,即使有索引,查询性能也会明显下降。而垂直拆分(Vertical Partitioning)则是按列拆分,比如把用户基本信息和扩展信息分开存储。
水平拆分典型场景:
选择分片键是分库分表最关键的决策点。去年我们重构一个社交平台时,最初按用户ID分片,结果发现热门用户的动态查询导致严重的热点问题。后来改为"用户ID+月份"的复合分片键,性能提升了6倍。
常见分片策略对比:
| 策略类型 | 实现方式 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 哈希取模 | user_id % 10 | 分布均匀 | 扩容困难 | 用户表、订单表 |
| 范围分片 | 按时间范围 | 易于扩容 | 可能热点 | 日志表、交易记录 |
| 目录路由 | 维护映射表 | 灵活可控 | 维护成本高 | 特殊业务场景 |
在纯PHP环境中,我们可以扩展PDO实现分库分表逻辑。这是我常用的基础封装:
php复制class ShardingPDO extends PDO {
private $connections = [];
public function __construct(array $config) {
$this->shardConfig = $config['sharding'];
}
protected function getShardConnection($shardKey) {
$shardId = $this->calculateShardId($shardKey);
if (!isset($this->connections[$shardId])) {
$dsn = sprintf('mysql:host=%s;dbname=%s',
$this->shardConfig['hosts'][$shardId],
$this->shardConfig['db_prefix'].$shardId
);
$this->connections[$shardId] = new PDO($dsn, $user, $pass);
}
return $this->connections[$shardId];
}
public function query($sql, $shardKey) {
$conn = $this->getShardConnection($shardKey);
return $conn->query($sql);
}
}
对于Laravel项目,可以通过自定义Connection类实现分库分表:
php复制// 在DatabaseServiceProvider中注册
$this->app->singleton('db.sharding', function() {
return new ShardingConnection(
config('database.sharding')
);
});
// 自定义Connection类
class ShardingConnection extends Illuminate\Database\Connection {
public function table($table, $shardKey = null) {
if ($shardKey !== null) {
$table = $this->getShardTable($table, $shardKey);
}
return parent::table($table);
}
}
自增ID在分库分表环境下会导致冲突,我们通常采用以下方案:
这是我优化过的Snowflake PHP实现:
php复制class Snowflake {
const EPOCH = 1609459200000; // 2021-01-01
private $machineId;
public function __construct($machineId) {
$this->machineId = $machineId & 0x3FF;
}
public function generate() {
$ts = (int)(microtime(true)*1000) - self::EPOCH;
$seq = mt_rand(0, 4095);
return ($ts << 22) | ($this->machineId << 12) | $seq;
}
}
当我们需要查询多个分片的数据时(如查询某用户所有订单),通常有三种方案:
php复制$results = [];
$promises = [];
foreach ($shards as $shard) {
$promises[] = $client->queryAsync("SELECT * FROM orders_$shard WHERE user_id=?", [$userId]);
}
$responses = GuzzleHttp\Promise\unwrap($promises);
foreach ($responses as $resp) {
$results = array_merge($results, $resp->getData());
}
在PHP中实现分布式事务通常采用TCC模式:
php复制try {
// Try阶段
$orderService->tryCreateOrder($data);
$inventoryService->tryReduceStock($items);
// Confirm阶段
$orderService->confirmCreateOrder();
$inventoryService->confirmReduceStock();
} catch (Exception $e) {
// Cancel阶段
$orderService->cancelCreateOrder();
$inventoryService->cancelReduceStock();
throw $e;
}
对于不需要强一致性的场景,我们可以采用:
去年我们系统经历了从16分片到64分片的扩容,总结出以下经验:
php复制// 写入旧分片
$oldShard->insert($data);
// 同时写入新分片
$newShard->insert($data);
// 后台任务校验数据一致性
完善的监控体系应包括:
sql复制-- 在各分片上收集慢查询
SELECT * FROM mysql.slow_log
WHERE query_time > 1
ORDER BY start_time DESC LIMIT 100;
JOIN操作灾难:早期我们尝试跨分片JOIN,结果一个查询拖垮整个集群。解决方案是提前做好数据冗余。
分片策略变更:从哈希分片改为范围分片时,忘记更新路由逻辑,导致大量查询走错分片。现在我们会先进行双路由校验。
过度分片:曾将200万数据的表分成128个分片,反而增加了管理复杂度。经验法则是:单表500万行以下不必分片。
索引失效:分片后忘记在各分片上建立相同索引,导致全表扫描。现在我们的迁移脚本会自动同步索引结构。
分库分表是PHP高级开发者必须掌握的架构技能,但就像我的架构师常说的:"不要为了分库分表而分库分表"。当单表数据不超过500万、QPS低于2000时,合理的索引优化和缓存策略往往更有效。