在千万级数据量的PHP项目中,分库分表是绕不开的架构升级方案。去年我们电商平台的订单表突破3000万行时,单表查询延迟从50ms飙升到800ms,这就是典型的"单表瓶颈"症状。与简单的CRUD不同,分库分表会引发一系列连锁反应:
这些问题的复杂性在于,它们不是独立存在的——修改一个环节可能引发三个新问题。比如选择按用户ID分片后,商户后台需要汇总所有分片数据生成报表,这就产生了跨分片聚合查询的需求。
以电商系统为例,我们有几种常见选择:
| 分片键 | 适用场景 | 潜在问题 |
|---|---|---|
| 用户ID | C端用户高频查询 | 商户查询需跨分片 |
| 订单创建时间 | 适合时间序列查询 | 容易产生热点分片 |
| 地理区域 | 本地化服务场景 | 用户迁移导致数据搬迁 |
| 商户ID | B端商户后台操作 | 大商户产生数据倾斜 |
实战经验:我们最终采用"用户ID哈希+时间范围"的复合分片策略。用户维度查询走哈希分片,后台报表统计按时间范围并行查询各分片。这里有个关键细节:时间范围分片的间隔要大于业务查询的最长时间跨度(我们设置为90天)。
php复制class ShardStrategy {
const SHARD_COUNT = 16;
public static function getShardNo($userId): string {
$hash = crc32($userId);
$shardNo = $hash % self::SHARD_COUNT;
return 'order_db_' . str_pad($shardNo, 2, '0', STR_PAD_LEFT);
}
public static function getTableSuffix(DateTime $createTime): string {
$quarter = ceil($createTime->format('m') / 3);
return $createTime->format('Y') . 'q' . $quarter;
}
}
// 使用示例
$dbName = ShardStrategy::getShardNo('user_12345'); // 输出 order_db_07
$tableName = 'orders_' . ShardStrategy::getTableSuffix(new DateTime()); // 输出 orders_2023q3
重要提示:crc32在PHP中可能返回负整数,需要先做位运算处理:
$hash = crc32($userId) & 0xffffffff;
原版Snowflake的64位结构在PHP中会遇到整型溢出问题。我们调整后的方案:
code复制64位ID结构:
0 | 41位时间戳(毫秒) | 10位机器ID | 12位序列号
实现要点:
php复制class Snowflake {
private $machineId;
private $lastTimestamp = 0;
private $sequence = 0;
public function __construct() {
$this->machineId = $this->getMachineIdFromZK();
}
public function generateId(): string {
$timestamp = $this->getMillisecond();
if ($timestamp < $this->lastTimestamp) {
throw new RuntimeException("Clock moved backwards");
}
if ($timestamp == $this->lastTimestamp) {
$this->sequence = ($this->sequence + 1) & 0xFFF;
if ($this->sequence == 0) {
$timestamp = $this->tilNextMillis($this->lastTimestamp);
}
} else {
$this->sequence = 0;
}
$this->lastTimestamp = $timestamp;
return bcadd(
bcadd(
bcmul($timestamp, bcpow(2, 22)),
bcmul($this->machineId, bcpow(2, 12))
),
$this->sequence
);
}
}
我们在4核8G服务器上压测的结果:
| 方案 | QPS | 冲突概率 | 备注 |
|---|---|---|---|
| 数据库自增ID | 1,200 | 0% | 需要中心化服务 |
| Redis INCR | 8,500 | 0% | 依赖缓存持久化 |
| 雪花算法 | 15,000 | 0% | 需处理时钟回拨 |
| UUID v4 | 20,000+ | 0.0001% | 索引性能差,不适合做主键 |
处理商户订单统计的典型场景:
php复制class OrderReporter {
public function getQuarterlyStats(int $merchantId, string $yearQuarter): array {
$shards = $this->getAllShardConnections();
$promises = [];
// 发起并行查询
foreach ($shards as $shard) {
$promises[] = $shard->queryAsync(
"SELECT SUM(amount) as total, COUNT(*) as count
FROM orders_{$yearQuarter}
WHERE merchant_id = ?",
[$merchantId]
);
}
// 等待所有分片返回
$results = wait($promises);
// 内存聚合
return array_reduce($results, function($carry, $item) {
$carry['total_amount'] += $item['total'] ?? 0;
$carry['order_count'] += $item['count'] ?? 0;
return $carry;
}, ['total_amount' => 0, 'order_count' => 0]);
}
}
对于高频的跨分片查询,我们建立了专门的索引表:
sql复制CREATE TABLE order_index (
order_id BIGINT PRIMARY KEY,
user_id INT NOT NULL,
merchant_id INT NOT NULL,
create_time DATETIME NOT NULL,
shard_no CHAR(2) NOT NULL,
INDEX idx_merchant (merchant_id, create_time),
INDEX idx_user (user_id, create_time)
) ENGINE=InnoDB;
取舍分析:
我们设计的迁移窗口期方案:
全量同步阶段
增量双写阶段(持续3天)
php复制class OrderService {
public function createOrder(array $data): int {
$orderId = $this->oldDb->insert($data);
// 异步写入新分片
$this->queue->push(new ShardWriteJob([
'order_id' => $orderId,
'data' => $data
]));
return $orderId;
}
}
读流量切换
最终校验
通过业务标识实现渐进式迁移:
nginx复制location /api/order {
set $user_group 0;
if ($arg_user_id ~* "1[3-5]") {
set $user_group 1;
}
content_by_lua '
if ngx.var.user_group == 1 then
ngx.exec("@new_shard");
else
ngx.exec("@old_db");
end
';
}
某次促销活动导致00-03时段的订单集中到特定分片。应急方案:
php复制function getHotShard($baseShard, $orderId): string {
$slot = hexdec(substr(md5($orderId), 0, 2)) % 4;
return $baseShard . '_' . $slot;
}
跨分片扣减库存的经典问题。最终采用TCC模式:
php复制class InventoryService {
public function tryDeduct($itemId, $qty): bool {
// 预占库存
return $this->db->update(
"UPDATE inventory SET frozen = frozen + ?
WHERE item_id = ? AND available >= ?",
[$qty, $itemId, $qty]
);
}
public function confirmDeduct($itemId, $qty): void {
// 实际扣减
$this->db->update(
"UPDATE inventory SET
available = available - ?,
frozen = frozen - ?
WHERE item_id = ?",
[$qty, $qty, $itemId]
);
}
public function cancelDeduct($itemId, $qty): void {
// 释放预占
$this->db->update(
"UPDATE inventory SET frozen = frozen - ?
WHERE item_id = ?",
[$qty, $itemId]
);
}
}
分库分表后,传统监控完全失效。我们建立了三层监控:
分片健康度看板
跨分片调用追踪
php复制// 在DB层注入追踪ID
DB::listen(function($query) {
$traceId = Request::getTraceId();
Log::debug("[DB_TRACE][$traceId] " . $query->sql);
});
数据一致性校验
这套体系在上线后第3天就捕捉到某个分片的SSD故障,避免了数据损坏事故。