
PHP数据库水平拆分实战:从单表千万到分库分表的平滑演进
大家好,我是源码库的技术博主。今天想和大家分享一个我在实际项目中遇到的真实案例——如何通过水平拆分来解决单表数据量过大的性能问题。记得那是在一个电商项目中,我们的商品表已经积累了近2000万条数据,查询响应时间从最初的几十毫秒飙升到了几秒钟,用户投诉接踵而至。
为什么选择水平拆分?
当我们面对单表数据量过大时,通常有几种解决方案:优化索引、读写分离、垂直拆分和水平拆分。经过仔细分析,我们发现商品表的结构相对简单,但数据量确实太大。垂直拆分无法解决根本问题,而读写分离只能缓解读压力。最终我们决定采用水平拆分,按照商品ID进行分片,将数据分布到多个数据库实例中。
这里有个踩坑提示:在选择分片键时一定要谨慎!我们最初尝试按照创建时间分片,结果发现某些时间段的数据特别集中,导致负载不均衡。后来改用了商品ID的哈希值,分布就均匀多了。
技术选型与架构设计
我们选择了MySQL作为数据库,使用PHP的PDO扩展进行数据库操作。为了减少对业务代码的侵入性,我们决定在数据访问层实现分片逻辑,而不是依赖中间件。
整个架构设计如下:
// 分片配置
$shardConfig = [
'shard_count' => 4,
'shard_db_prefix' => 'shop_db_',
'shard_table_prefix' => 'product_'
];
我们将数据分散到4个数据库实例,每个实例包含16个商品表,总共64个分片。这样设计既保证了扩展性,又避免了分片数量过多带来的管理复杂度。
分片算法的核心实现
分片算法的好坏直接决定了系统的性能和可维护性。我们采用了一致性哈希算法,确保在增加或减少分片时,数据迁移的量最小。
class ShardManager {
private $shardConfig;
private $virtualNodes = 160; // 虚拟节点数
public function __construct($config) {
$this->shardConfig = $config;
}
public function getShardInfo($productId) {
$hash = crc32($productId);
$shardIndex = $hash % ($this->shardConfig['shard_count'] * $this->virtualNodes);
$dbIndex = floor($shardIndex / $this->virtualNodes);
$tableIndex = $shardIndex % 16;
return [
'db' => $this->shardConfig['shard_db_prefix'] . $dbIndex,
'table' => $this->shardConfig['shard_table_prefix'] . $tableIndex
];
}
}
这个算法确保了数据分布的均匀性,即使某个分片出现故障,也只会影响部分数据。
数据访问层的改造
这是最核心的部分,我们需要对现有的数据访问层进行改造,使其能够根据商品ID自动路由到正确的分片。
class ProductDAO {
private $shardManager;
private $dbConnections = [];
public function __construct(ShardManager $shardManager) {
$this->shardManager = $shardManager;
}
public function getProduct($productId) {
$shardInfo = $this->shardManager->getShardInfo($productId);
$db = $this->getConnection($shardInfo['db']);
$sql = "SELECT * FROM {$shardInfo['table']} WHERE product_id = ?";
$stmt = $db->prepare($sql);
$stmt->execute([$productId]);
return $stmt->fetch(PDO::FETCH_ASSOC);
}
public function insertProduct($productData) {
$shardInfo = $this->shardManager->getShardInfo($productData['product_id']);
$db = $this->getConnection($shardInfo['db']);
$sql = "INSERT INTO {$shardInfo['table']} (product_id, name, price) VALUES (?, ?, ?)";
$stmt = $db->prepare($sql);
return $stmt->execute([
$productData['product_id'],
$productData['name'],
$productData['price']
]);
}
private function getConnection($dbName) {
if (!isset($this->dbConnections[$dbName])) {
$this->dbConnections[$dbName] = new PDO(
"mysql:host=localhost;dbname={$dbName}",
'username',
'password'
);
}
return $this->dbConnections[$dbName];
}
}
这里有个重要的经验:一定要做好连接池管理,避免每个请求都创建新的数据库连接,否则系统性能会急剧下降。
数据迁移策略
对于已有的2000万数据,我们采用了双写方案进行平滑迁移:
#!/bin/bash
# 数据迁移脚本示例
# 步骤1:开启双写
echo "开启双写模式..."
php enable_double_write.php
# 步骤2:分批迁移数据
echo "开始数据迁移..."
for i in {0..63}; do
php migrate_shard_data.php $i
echo "分片 $i 迁移完成"
done
# 步骤3:验证数据一致性
echo "验证数据一致性..."
php verify_data_consistency.php
# 步骤4:切换读写
echo "切换读写到新分片..."
php switch_to_shards.php
整个迁移过程我们花了3天时间,期间系统保持正常服务。这里要特别注意:迁移过程中要做好数据一致性校验,我们就是因为漏掉了一个校验步骤,导致部分数据需要重新迁移。
分页查询的挑战与解决方案
水平拆分后,分页查询变得复杂。传统的LIMIT分页在分片环境下无法直接使用,我们采用了以下方案:
public function getProductsByPage($page, $pageSize, $conditions = []) {
// 第一步:并行查询每个分片
$promises = [];
for ($i = 0; $i shardConfig['shard_count']; $i++) {
$promises[] = $this->queryShardAsync($i, $conditions);
}
// 第二步:合并结果并排序
$allResults = [];
foreach ($promises as $promise) {
$results = $promise->getResult();
$allResults = array_merge($allResults, $results);
}
// 第三步:内存中分页
usort($allResults, function($a, $b) {
return $b['create_time'] - $a['create_time'];
});
$start = ($page - 1) * $pageSize;
return array_slice($allResults, $start, $pageSize);
}
这种方案在数据量不大时效果很好,但当分页很深时性能会下降。对于深分页场景,我们后来改用了基于游标的分页方案。
监控与运维
分库分表后,监控变得尤为重要。我们为每个分片建立了独立的监控:
# 监控脚本示例
#!/bin/bash
# 检查分片连接
for i in {0..3}; do
mysql -h 127.0.0.1 -P 3306 -u monitor -p密码 -e "SELECT 1" shop_db_$i
if [ $? -ne 0 ]; then
echo "分片 shop_db_$i 连接异常" | mail -s "数据库告警" admin@example.com
fi
done
# 检查分片数据量
for i in {0..3}; do
for j in {0..15}; do
count=$(mysql -N -h 127.0.0.1 -P 3306 -u monitor -p密码 -e "SELECT COUNT(*) FROM product_$j" shop_db_$i)
echo "分片 shop_db_${i}.product_$j 数据量: $count"
done
done
总结与建议
经过这次水平拆分实战,我们的系统性能得到了显著提升,商品查询响应时间从秒级降回了毫秒级。总结几点经验:
1. 分片键的选择至关重要,要保证数据分布均匀
2. 迁移过程要谨慎,做好数据一致性校验
3. 分页查询需要特殊处理,避免性能瓶颈
4. 监控要到位,及时发现和处理问题
水平拆分确实能解决大数据量的性能问题,但也会带来额外的复杂度。建议在单表数据量达到500万以上时开始考虑分片,不要等到性能问题严重时才动手。
希望我的经验对大家有所帮助,如果在实践中遇到问题,欢迎在评论区交流讨论!

评论(0)