在大型互联网应用中,随着业务数据量的快速增长,单库单表的性能瓶颈日益凸显。以电商系统为例,一个日订单量百万级的平台,如果所有订单数据都存储在单一数据库表中,不仅查询效率低下,还会带来严重的锁竞争问题。这时候,分库分表就成了必选项。
传统分库分表方案最直接的实现方式,就是在业务代码中硬编码分片逻辑。比如:
java复制// 硬编码的分库分表示例(不推荐)
public void createOrder(Order order) {
int userId = order.getUserId();
int dbIndex = userId % 2; // 2个库
int tableIndex = userId % 6; // 6个表
// 根据不同索引选择不同数据源
DataSource dataSource = getDataSource(dbIndex);
// 拼接表名
String tableName = "order_" + tableIndex;
// 执行SQL
jdbcTemplate.update("INSERT INTO " + tableName + "...", ...);
}
这种实现方式存在三个致命问题:
我们采用面向切面编程(AOP)结合ThreadLocal的方案,可以实现分库分表逻辑与业务代码的完全解耦。核心设计思想是:
这种架构下,业务代码完全不需要感知分库分表的存在:
java复制// 优化后的业务代码(完全无分库分表痕迹)
public void createOrder(Order order) {
// 无需关心数据存储细节
orderMapper.insert(order);
}
首先创建一个分片切面类,使用Spring AOP的@Aspect注解:
java复制@Aspect
@Component
public class ShardingAspect {
private static final Logger logger = LoggerFactory.getLogger(ShardingAspect.class);
// 定义切入点:拦截所有Mapper接口的方法
@Pointcut("execution(* com.example.mapper.*.*(..))")
public void shardingPointcut() {}
// 环绕通知
@Around("shardingPointcut()")
public Object doSharding(ProceedingJoinPoint joinPoint) throws Throwable {
try {
// 1. 获取方法参数
Object[] args = joinPoint.getArgs();
// 2. 提取分片键(这里以userId为例)
Long userId = extractUserId(args);
// 3. 计算分库分表路由
calculateRoute(userId);
// 4. 执行原方法
return joinPoint.proceed();
} finally {
// 5. 清除ThreadLocal数据
ShardingContext.clear();
}
}
// 其他辅助方法...
}
在实际业务中,分片键的提取需要根据业务场景灵活处理。以下是几种常见情况的处理方式:
java复制private Long extractUserId(Object[] args) {
for (Object arg : args) {
// 情况1:参数本身就是分片键
if (arg instanceof Long) {
return (Long) arg;
}
// 情况2:参数对象中包含分片键字段
if (arg instanceof UserIdHolder) {
return ((UserIdHolder) arg).getUserId();
}
// 情况3:使用注解标记分片键参数
if (arg != null) {
Field[] fields = arg.getClass().getDeclaredFields();
for (Field field : fields) {
if (field.isAnnotationPresent(ShardingKey.class)) {
try {
field.setAccessible(true);
return (Long) field.get(arg);
} catch (IllegalAccessException e) {
logger.error("Failed to get sharding key", e);
}
}
}
}
}
throw new IllegalArgumentException("No sharding key found in method arguments");
}
路由计算需要考虑分库分表数量、数据分布均匀性等因素。以下是几种常见的路由算法:
java复制// 基础取模算法
int dbIndex = Math.abs(userId.hashCode()) % dbCount;
int tableIndex = Math.abs(userId.hashCode()) % tableCount;
java复制// 使用TreeMap实现一致性哈希环
private static final TreeMap<Long, Integer> hashRing = new TreeMap<>();
static {
// 初始化虚拟节点
for (int i = 0; i < dbCount; i++) {
for (int j = 0; j < VIRTUAL_NODES; j++) {
long hash = hash("SHARD-" + i + "-NODE-" + j);
hashRing.put(hash, i);
}
}
}
public static int getShardIndex(Long userId) {
long hash = hash(userId.toString());
SortedMap<Long, Integer> tail = hashRing.tailMap(hash);
if (tail.isEmpty()) {
return hashRing.get(hashRing.firstKey());
}
return hashRing.get(tail.firstKey());
}
java复制// 按用户ID范围分片
public static int getShardIndex(Long userId) {
if (userId >= 1_000_000 && userId < 2_000_000) {
return 0;
} else if (userId >= 2_000_000 && userId < 3_000_000) {
return 1;
}
// 其他范围...
}
创建一个线程安全的分片上下文类,用于存储和获取分片信息:
java复制public class ShardingContext {
private static final ThreadLocal<Integer> DB_INDEX = new ThreadLocal<>();
private static final ThreadLocal<Integer> TABLE_INDEX = new ThreadLocal<>();
private static final ThreadLocal<String> ORIGIN_TABLE_NAME = new ThreadLocal<>();
// 设置分库索引
public static void setDbIndex(int dbIndex) {
DB_INDEX.set(dbIndex);
}
// 设置分表索引
public static void setTableIndex(int tableIndex) {
TABLE_INDEX.set(tableIndex);
}
// 设置原始表名(用于MyBatis拦截器)
public static void setOriginTableName(String tableName) {
ORIGIN_TABLE_NAME.set(tableName);
}
// 获取方法类似...
// 清除所有上下文
public static void clear() {
DB_INDEX.remove();
TABLE_INDEX.remove();
ORIGIN_TABLE_NAME.remove();
}
}
在高并发环境下,ThreadLocal使用不当会导致严重的内存泄漏问题。我们需要建立多层防护:
java复制@Around("shardingPointcut()")
public Object doSharding(ProceedingJoinPoint joinPoint) throws Throwable {
try {
// 业务逻辑...
} finally {
ShardingContext.clear(); // 确保一定会执行清除
}
}
java复制@Component
public class ShardingContextFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
try {
chain.doFilter(request, response);
} finally {
ShardingContext.clear(); // 请求结束时再次清理
}
}
}
java复制@Scheduled(fixedRate = 3600000) // 每小时检查一次
public void monitorThreadLocalLeak() {
// 检查线程池中线程的ThreadLocalMap大小
// 如果发现异常增长,发出告警
}
java复制public class DynamicDataSource extends AbstractRoutingDataSource {
@Override
protected Object determineCurrentLookupKey() {
return ShardingContext.getDbIndex();
}
// 初始化配置
public void initDataSources(Map<Object, Object> targetDataSources) {
setTargetDataSources(targetDataSources);
setDefaultTargetDataSource(targetDataSources.get(0)); // 默认数据源
afterPropertiesSet();
}
}
为确保数据源可用性,需要实现健康检查机制:
java复制@Component
public class DataSourceHealthChecker {
@Autowired
private DynamicDataSource dynamicDataSource;
@Scheduled(fixedDelay = 300000) // 每5分钟检查一次
public void checkAllDataSources() {
Map<Object, DataSource> dataSources = dynamicDataSource.getResolvedDataSources();
dataSources.forEach((key, dataSource) -> {
try (Connection conn = dataSource.getConnection()) {
// 执行简单查询验证连接
conn.createStatement().execute("SELECT 1");
logger.info("DataSource {} is healthy", key);
} catch (SQLException e) {
logger.error("DataSource {} is down!", key, e);
// 触发告警或自动切换
}
});
}
}
java复制@Intercepts({
@Signature(type= StatementHandler.class,
method="prepare",
args={Connection.class, Integer.class})
})
public class TableNameInterceptor implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
StatementHandler handler = (StatementHandler) invocation.getTarget();
MetaObject metaObject = SystemMetaObject.forObject(handler);
// 获取原始SQL
String sql = (String) metaObject.getValue("delegate.boundSql.sql");
// 替换表名
String newSql = replaceTableName(sql);
metaObject.setValue("delegate.boundSql.sql", newSql);
return invocation.proceed();
}
private String replaceTableName(String sql) {
String originTable = ShardingContext.getOriginTableName();
String newTable = originTable + "_" + ShardingContext.getTableIndex();
return sql.replace(originTable, newTable);
}
}
为提高SQL替换的准确性,可以使用SQL解析器:
java复制private String replaceTableNameWithParser(String sql) {
SQLStatementParser parser = SQLParserUtils.createSQLStatementParser(sql, JdbcConstants.MYSQL);
SQLStatement stmt = parser.parseStatement();
stmt.accept(new SQLASTVisitorAdapter() {
@Override
public boolean visit(SQLExprTableSource x) {
if (x.getTableName().equalsIgnoreCase(ShardingContext.getOriginTableName())) {
x.setName(ShardingContext.getOriginTableName() + "_" + ShardingContext.getTableIndex());
}
return true;
}
});
return stmt.toString();
}
java复制// 精确拦截需要分片的方法(避免拦截所有Mapper方法)
@Pointcut("execution(* com.example.mapper.OrderMapper.*(..)) || " +
"execution(* com.example.mapper.UserMapper.*(..))")
public void shardingPointcut() {}
java复制private static final LoadingCache<Long, RouteInfo> routeCache = CacheBuilder.newBuilder()
.maximumSize(100000)
.expireAfterWrite(10, TimeUnit.MINUTES)
.build(new CacheLoader<Long, RouteInfo>() {
@Override
public RouteInfo load(Long userId) {
return calculateRoute(userId);
}
});
通过Micrometer暴露监控指标:
java复制@Bean
public MeterRegistryCustomizer<MeterRegistry> metricsCommonTags() {
return registry -> {
registry.config().commonTags("application", "sharding-service");
// 路由计算耗时
Timer.builder("sharding.route.calculation.time")
.description("Time spent on route calculation")
.register(registry);
// 数据源切换统计
Counter.builder("sharding.datasource.switch.count")
.description("Number of datasource switches")
.register(registry);
};
}
当无法从方法参数中提取分片键时,可以采用降级策略:
java复制private Long extractUserIdWithFallback(Object[] args) {
try {
Long userId = extractUserId(args);
if (userId == null) {
throw new IllegalArgumentException("Sharding key is null");
}
return userId;
} catch (Exception e) {
// 降级策略1:使用默认分片
if (useDefaultSharding) {
return DEFAULT_USER_ID;
}
// 降级策略2:轮询选择分片
else if (useRoundRobin) {
return roundRobinSelector.next();
}
// 降级策略3:抛出业务异常
else {
throw new BusinessException("Sharding key is required");
}
}
}
当目标数据源不可用时,可以自动降级:
java复制public class FaultTolerantDataSource extends AbstractDataSource {
@Override
public Connection getConnection() throws SQLException {
int retry = 0;
while (retry < maxRetry) {
try {
return determineTargetDataSource().getConnection();
} catch (SQLException e) {
retry++;
if (retry == maxRetry) {
// 最后一次尝试失败后,降级到默认数据源
return getDefaultDataSource().getConnection();
}
// 等待指数退避时间
Thread.sleep(Math.min(1000, 100 * (1 << retry)));
}
}
}
}
分片不均匀问题:
跨分片查询问题:
分布式事务问题:
在我们的电商平台压测中,分库分表方案带来了显著性能提升:
| 场景 | QPS (单库) | QPS (分库分表) | 提升幅度 |
|---|---|---|---|
| 订单创建 | 1,200 | 6,500 | 441% |
| 订单查询 | 2,800 | 15,000 | 435% |
| 用户订单历史 | 1,500 | 8,200 | 446% |
这套分库分表路由组件在实际项目中已经稳定运行3年,支撑了日均10亿级的订单处理量。核心优势在于其非侵入式的设计,使得业务开发团队可以完全专注于业务逻辑,而无需关心底层数据分布细节。