内容平台的数据库分库分表实践:按用户、按内容还是按时间的决策矩阵
一、"双十一晚上的分表策略失效了":分库分表的误判案例
某图文内容平台按user_id % 128做了16库×8表的分库分表。上线半年后,发现分片严重不均衡:粉丝超过100万的大V有50人,他们的单表数据量是普通用户的500倍。"大V表"的QPS是"普通表"的30倍,但因为按user_id哈希,无法将大V单独迁移到高性能实例上。
这就是分库分表中最经典的错误:以开发者的视角均匀分片,忽略了业务的幂律分布。
二、三种分片策略的深度对比
三、分库分表中间件与路由实现
使用ShardingSphere实现两级分片:
# ShardingSphere配置示例 dataSources: ds_0: url: jdbc:mysql://10.0.1.1:3306/content_db_0 ds_1: url: jdbc:mysql://10.0.1.2:3306/content_db_1 rules: - !SHARDING tables: articles: actualDataNodes: ds_${0..1}.articles_${0..15} databaseStrategy: standard: shardingColumn: user_id shardingAlgorithmName: db_inline tableStrategy: standard: shardingColumn: user_id shardingAlgorithmName: table_inline user_articles: actualDataNodes: ds_${0..1}.user_articles_${0..7} tableStrategy: standard: shardingColumn: article_id shardingAlgorithmName: article_table_inline shardingAlgorithms: db_inline: type: INLINE props: algorithm-expression: ds_${user_id % 2} table_inline: type: INLINE props: algorithm-expression: articles_${user_id % 16}但大V需要特殊处理("热点隔离"策略):
public class HotspotAwareShardingAlgorithm implements StandardShardingAlgorithm<Long> { private static final Set<Long> HOT_USERS = loadHotUsers(); private static final String HOT_DS = "ds_hot"; // 高性能实例 @Override public String doSharding(Collection<String> availableTargetNames, PreciseShardingValue<Long> shardingValue) { Long userId = shardingValue.getValue(); // 大V数据路由到专用高性能实例 if (HOT_USERS.contains(userId)) { return HOT_DS; } // 普通用户按哈希路由 int dbIndex = (int) (userId % 2); return "ds_" + dbIndex; } private static Set<Long> loadHotUsers() { // 从配置中心动态加载大V列表 // 可以通过Redis缓存,定期更新 return Sets.newHashSet(1001L, 2002L, 3003L); } } // 自定义ID生成器(分片友好的雪花变体) public class ShardAwareIdGenerator { private final int shardId; private final SnowflakeIdGenerator snowflake; public long generateArticleId() { long baseId = snowflake.nextId(); // 将分片ID编码到article_id中(低8位) return (baseId << 8) | (shardId & 0xFF); } public static int extractShardId(long articleId) { return (int) (articleId & 0xFF); } }跨分片查询的路由策略:
public class CrossShardQueryRouter { public List<Article> searchArticles(String keyword, int page, int size) { // Step 1: 先查ES获取article_id列表(含user_id信息) List<ArticleHit> hits = elasticsearchService.search(keyword, page, size); // Step 2: 按分片分组 Map<String, List<Long>> shardGroups = hits.stream() .collect(Collectors.groupingBy( hit -> { int shardId = ShardAwareIdGenerator.extractShardId( hit.getArticleId() ); return "ds_" + shardId; }, Collectors.mapping(ArticleHit::getArticleId, Collectors.toList()) )); // Step 3: 并发查询各分片 List<CompletableFuture<List<Article>>> futures = shardGroups.entrySet() .stream() .map(entry -> CompletableFuture.supplyAsync( () -> querySingleShard(entry.getKey(), entry.getValue()), shardQueryExecutor )) .collect(Collectors.toList()); // Step 4: 合并结果 return futures.stream() .map(CompletableFuture::join) .flatMap(Collection::stream) .collect(Collectors.toList()); } private List<Article> querySingleShard(String dataSource, List<Long> articleIds) { // 使用对应的数据源执行IN查询 String sql = "SELECT * FROM articles WHERE article_id IN (:ids)"; return namedJdbcTemplates.get(dataSource) .query(sql, Map.of("ids", articleIds), articleRowMapper); } }四、分库分表的五个决策陷阱
陷阱一:过早分库。数据量<1亿行时,分区表(MySQL Partition)+ 读写分离即可,不需要分库分表。分库分表带来的分布式事务、跨片JOIN、全局ID生成的复杂性远大于分区表。
陷阱二:分片键与查询模式不匹配。按user_id分片后,运营查询"昨日新发布的文章Top100"就需要扫描所有分片(全表扫描×分片数)。如果这种查询高频出现,应该用ES作为查询入口,而非直接查MySQL。
陷阱三:分片数不可变。128→256的分片扩容意味着全量数据重新哈希——这是一个数TB数据的迁移工程。建议在上线初期就使用一致性哈希(如Ketama算法),扩容时只需迁移约1/N的数据。
陷阱四:全局自增主键的灾难。分库后AUTO_INCREMENT不能用了,必须切换为雪花算法或号段模式。如果遗漏了这个切换而继续使用自增ID,两个分片会产生相同的ID——数据库本身不会报错,但代码里的ID冲突会产生诡异Bug。
陷阱五:分布式事务的幻影。跨分片的"用户A关注了用户B,同时增加A的关注数和B的粉丝数"需要分布式事务。GTS/Seata的AT模式能解决但性能开销是单机事务的2-5倍。
五、总结
分库分表不是性能优化的第一步——先用分区表、读写分离、索引优化、垂直拆分。当这些手段都用尽、单表数据量仍超过5000万行或单库QPS超过5000时,才开始考虑水平分片。
分片键的选择只有一个标准:查询时最常使用的WHERE条件字段。如果你的查询80%都带user_id,那就按user_id分;如果50%带user_id、50%带article_id,那就两张表各分各的——冗余一张索引表。
本文属于「行业场景与项目复盘」系列,系统对比内容平台分库分表策略的决策矩阵与实践陷阱。