主库读写分离,大家应该都很熟悉了。这里就不用多说。主要是应用在读多写少的业务模型中,降低主库的查询压力。
但是相应的,会带来新的问题,最核心的问题是:从库延迟抖动的时候,数据查不到怎么办?有的人会说,那就指定主库吧,但是就算非核心流程也不能因为数据库抖动而带来的流程不通或者数据异常的问题。所以,本文结合shardingJDBC+mybatis实现从库延迟数据查不到自动降级主库的问题。
先看下sharding主从源码
主从路由装饰器
/**
* Route decorator for master-slave.
*/
public final class MasterSlaveRouteDecorator implements RouteDecorator<MasterSlaveRule> {
@Override
public RouteContext decorate(final RouteContext routeContext, final ShardingSphereMetaData metaData, final MasterSlaveRule masterSlaveRule, final ConfigurationProperties properties) {
if (routeContext.getRouteResult().getRouteUnits().isEmpty()) {
// 获取路由的数据源名称
String dataSourceName = new MasterSlaveDataSourceRouter(masterSlaveRule).route(routeContext.getSqlStatementContext().getSqlStatement());
RouteResult routeResult = new RouteResult();
routeResult.getRouteUnits().add(new RouteUnit(new RouteMapper(dataSourceName, dataSourceName), Collections.emptyList()));
return new RouteContext(routeContext.getSqlStatementContext(), Collections.emptyList(), routeResult);
}
// 分库分表+读写分离模式下,在计算完数据分片库路由(数据源实际是主从规则名称)后,
// 还需要根据主从配置,替换为真实的数据源,因此需要先进行删除,再添加真实数据源
Collection<RouteUnit> toBeRemoved = new LinkedList<>();
Collection<RouteUnit> toBeAdded = new LinkedList<>();
for (RouteUnit each : routeContext.getRouteResult().getRouteUnits()) {
if (masterSlaveRule.getName().equalsIgnoreCase(each.getDataSourceMapper().getActualName())) {
toBeRemoved.add(each);
String actualDataSourceName = new MasterSlaveDataSourceRouter(masterSlaveRule).route(routeContext.getSqlStatementContext().getSqlStatement());
toBeAdded.add(new RouteUnit(new RouteMapper(each.getDataSourceMapper().getLogicName(), actualDataSourceName), each.getTableMappers()));
}
}
routeContext.getRouteResult().getRouteUnits().removeAll(toBeRemoved);
routeContext.getRouteResult().getRouteUnits().addAll(toBeAdded);
return routeContext;
}
可以看出来路由的核心在于MasterSlaveDataSourceRouter
/**
* Data source router for master-slave.
*/
@RequiredArgsConstructor
public final class MasterSlaveDataSourceRouter {
private final MasterSlaveRule masterSlaveRule;
/**
* Route.
*
* @param sqlStatement SQL statement
* @return data source name
*/
public String route(final SQLStatement sqlStatement) {
if (isMasterRoute(sqlStatement)) {// 需要路由到主库(SQL中包含锁例如select for update、非select、通过hint指定主库路由)
MasterVisitedManager.setMasterVisited();
return masterSlaveRule.getMasterDataSourceName();
}
return masterSlaveRule.getLoadBalanceAlgorithm().getDataSource(// 根据负载均衡算法计算要访问的数据源
masterSlaveRule.getName(), masterSlaveRule.getMasterDataSourceName(), new ArrayList<>(masterSlaveRule.getSlaveDataSourceNames()));
}
private boolean isMasterRoute(final SQLStatement sqlStatement) {
return containsLockSegment(sqlStatement) || !(sqlStatement instanceof SelectStatement) || MasterVisitedManager.isMasterVisited() || HintManager.isMasterRouteOnly();
}
private boolean containsLockSegment(final SQLStatement sqlStatement) {
return sqlStatement instanceof SelectStatement && ((SelectStatement) sqlStatement).getLock().isPresent();
}
}
路由规则很简单,关键判断是不是查主库在于
containsLockSegment(sqlStatement) || !(sqlStatement instanceof SelectStatement) || MasterVisitedManager.isMasterVisited() || HintManager.isMasterRouteOnly();
判断是否有锁||是否是查询语句||是否主库访问(可以理解为sharding自己用的)||是否设置强制主库(可以理解为给使用者用的)
PS:这里也可以说下MasterVisitedManager和HintManager的区别,主要是一个会自动释放threadLocal,一个不会。
这里我们也能发现sharding在控制路由的模块并没有开放扩展,所以,这里只能自己去实现。
有几种设计方案:
1.拦截dao层(简单,但是需要自己判断是否是查询语句,判断错的话,update执行2次那就炸了)
2.通过mybatis拦截器巧妙的实现(这块需要考虑能不能获取到判断条件)
为了不用实现自行判断update操作,我选择了第二种方案。
实现方式如下:
优点:最简单的解决主从读写分离中,延迟带来的空指针问题,不用改业务代码。
缺点:会增加一次从库查询,如果正常业务就会经常查出来空数据,或者并发量特别高的场景不太适合。
@Intercepts({
@Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class}),
@Signature(type = Executor.class, method = "query", args = {MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class, CacheKey.class, BoundSql.class}),
@Signature(type = Executor.class, method = "queryCursor", args = {MappedStatement.class, Object.class, RowBounds.class}),
})
public class MybatisMSPlugin implements Interceptor {
@Override
public Object intercept(Invocation invocation) throws Throwable {
//如果激活事务直接跳过,事务会自动查主库
boolean synchronizationActive = TransactionSynchronizationManager.isActualTransactionActive();
if (synchronizationActive || !shardingJdbcConfig.isMsEnable()) {
if (log.isDebugEnabled()) {
log.debug("msEnable:{}", shardingJdbcConfig.isMsEnable());
}
return invocation.proceed();
}
MappedStatement mappedStatement = (MappedStatement) invocation.getArgs()[0];
//加强判断是否是查询语句
final SqlCommandType sqlCommandType = mappedStatement.getSqlCommandType();
if (log.isDebugEnabled()) {
log.debug("sqlCommandType:{}", sqlCommandType);
}
if (!sqlCommandType.equals(SqlCommandType.SELECT)) {
return invocation.proceed();
}
//与sharding保持一致,判断如果是查主库,直接执行返回
log.info("isMasterVisited:{};isMasterRouteOnly:{};", MasterVisitedManager.isMasterVisited(), HintManager.isMasterRouteOnly());
if (MasterVisitedManager.isMasterVisited() || HintManager.isMasterRouteOnly()) {
return invocation.proceed();
}
//从库操作,判断返回值是否为空或者list是否为空,如果没有数据,切换到主库再次查询
final Object result = invocation.proceed();
if (log.isDebugEnabled()) {
log.debug("result:{}", result);
}
final Executor executor = (Executor) invocation.getTarget();
//判断是否为sharding数据源。这里是可选的,因为也可以在dao层直接指定数据源来绕过sharding的读写分离,比如有些场景需要必须查主库
final boolean isShardingDataSource = isShardingDataSource(executor.getTransaction().getConnection());
if (isShardingDataSource && (result == null || (result instanceof Collection && CollectionUtils.isEmpty((Collection<?>) result)))) {
try (final HintManager instance = HintManager.getInstance()) {
log.info("从库未命中,再次查询主库");
instance.setMasterRouteOnly();
// 清理mybatis一级缓存,要不然没法再次查询
executor.clearLocalCache();
String daoName = getDaoName(mappedStatement.getId());
metricsUtil.counter("dao_ms_demotion", "dao_name", daoName);
return invocation.proceed();
} catch (Exception e) {
log.error("", e);
}
}
return result;
}
private boolean isShardingDataSource(Connection connection) {
return connection instanceof MasterSlaveConnection;
}
}