背景

有一天DBA反馈数据库慢查较多,需要进行优化。否则,为了保护数据库,DBA就会将慢查SQL Kill掉。

开始考虑慢查优化前,我们首先搞清楚什么是慢查。

慢查询是指一个SQL语句的执行时间超过指定阀值。这个阀值通常结合业务来设定的,我们目前是100ms。

一个慢查SQL的危害有多大?

l  拖慢整个系统的访问速度,并间接影响相关联的系统。

l  对于一个高频率的SQL,一旦出现慢查,当大量流量打到DB时,极有可能导致数据库资源耗尽,DB对外不可用。

l  在一个高流量网站中,大量请求处理延时,几秒内就可能导致应用资源被耗尽,服务对外不可用,分布式环境下甚至会引起雪崩。

因此,慢查必须引起足够重视。



常见SQL优化策略

为了使SQL高效运行,通常,我们会采用以下策略进行优化:

  1. DB存储引擎选择,数据库表设计,字段类型选择等。
  2. 编写高效的SQL语句。
  3. DB配置的优化,如缓冲池的配置等。
  4.  数据库索引。
  5. 在DB之上加一层缓存。
  6. 主从读写分离。
  7. 数据库拆分,水平拆分和垂直拆分。
  8. 抛弃关系型数据库,转向Hbase等NoSQL数据库。

这里每一个主题都会涉及比较多的内容,本文不会对每个主题做深入分析。本文主要谈谈使用数据库读写分离架构优化SQL查询的方法。



认识读写分离

一主多从,读写分离,主从同步,是一种常见的数据库架构。其中:

l  主库,承担全部的写业务,部分读业务

l  从库,承担大部分读业务

l  主从之间通过某种机制进行数据同步,如mysql的binlog



读写分离是必须吗?

不尽然。首先我们看看读写分离架构需要解决的问题:

  • 连接池隔离,每个数据源使用自己的连接池
  • 为了保证高可用,读连接池需要实现故障自动转移
  • 业务接受主从同步延迟

这意味着选择数据库读写分离架构,就必须接受和解决它所带来的问题。



读写分离究竟适合什么场景?

读多写少的业务场景,读已经成为性能瓶颈,使用数据库索引、缓存等方案无法有效解决问题时,可考虑支持读写分离架构。

当前我们的数据库架构支持读写分离架构,原因是:

  • 主库保护机制,超过100ms的SQL直接Kill;
  • 主库高峰期压力较大;
  • 部分SQL已有较高效的索引,高峰期SQL执行时间仍然会超过阀值;
  • 部分业务场景,通过索引或缓存优化已无法满足需求,如分页列表查询,各种查询条件随意组合;
  • 从库SQL执行时间的阀值设置得相对较高。

从库支持,目的是为了提升读性能,分担主库压力,保障业务正常运行。但是,当系统运行中遇到一个慢查时,我们首先应该考虑优化SQL逻辑,分析SQL执行计划,看能否从SQL本身做优化;或者考虑引入缓存来优化读性能,实际上更多场景下建议使用缓存架构来加强系统读性能,替代数据库主从分离架构。当然,缓存架构也有它的问题,假设缓存挂了或发生缓存穿透,流量达到DB,极有可能导致DB雪崩。



读写分离方案思考

数据库读写分离的方案还是比较多的,各有优缺点。下面列举了几个方案供参考:



中间件转发

通过mysql中间件做主从集群,如Mysql Proxy、Amoeba、Atlas等,应用层请求通过中间件完成转发。

优点:对应用透明

缺点:需要代理,增加网络等性能开销



应用层支持

应用层通过路由数据源实现读写分离,通过AOP+注解来动态选择数据源。这是推荐的方案。

优点:无需中间件,轻量级

缺点:耦合度高



数据库驱动支持

可以使用mysql驱动自带的replicationDriver来实现。replicationDriver支持两个connection,一个是masterConnection,一个是slaveConnection。当把connection的readonly设为true时,当前连接就会切换为slaveConnection,否则为masterConnection。

优点:对应用透明,无需中间件

缺点:需要DB支持Replication协议



读写分离实战



Spring动态数据源支持

数据库层面主从架构的支持已经由DBA配置好了,我主要谈谈应用层面多数据源的支持。以Spring boot为例,说明Spring对动态数据源的支持。

spring-jdbc中有这样一个类AbstractRoutingDataSource,它扩展至javax.sql.DataSource,我们来看看它获取connection的源码:

@Override
public Connection getConnection() throws SQLException {
    return determineTargetDataSource().getConnection();
}

@Override
public Connection getConnection(String username, String password) throws SQLException {
    return determineTargetDataSource().getConnection(username, password);
}

它首先会通过调用determineTargetDataSource方法来决定获取connection的目标数据源,继续深入:

protected DataSource determineTargetDataSource() {
    Assert.notNull(this.resolvedDataSources, "DataSource router not initialized");
    Object lookupKey = determineCurrentLookupKey();
    DataSource dataSource = this.resolvedDataSources.get(lookupKey);
    if (dataSource == null && (this.lenientFallback || lookupKey == null)) {
        dataSource = this.resolvedDefaultDataSource;
    }
    if (dataSource == null) {
        throw new IllegalStateException("Cannot determine target DataSource for lookup key [" + lookupKey + "]");
    }
    return dataSource;
}

首先调用determineCurrentLookupKey方法拿到一个lookupKey,每个数据源对应一个唯一的Key,然后到数据源路由中查找对应的数据源。我们再看看determineCurrentLookupKey方法的实现:

protected abstract Object determineCurrentLookupKey();

这是一个抽象方法,意味着支持扩展。奈何没有提供向resolvedDataSources写入数据的入口,再看看内部是如何向resolvedDataSources中写入数据的,源码如下:

@Override
public void afterPropertiesSet() {
    if (this.targetDataSources == null) {
        throw new IllegalArgumentException("Property 'targetDataSources' is required");
    }
    this.resolvedDataSources = new HashMap<Object, DataSource>(this.targetDataSources.size());
    for (Map.Entry<Object, Object> entry : this.targetDataSources.entrySet()) {
        Object lookupKey = resolveSpecifiedLookupKey(entry.getKey());
        DataSource dataSource = resolveSpecifiedDataSource(entry.getValue());
        this.resolvedDataSources.put(lookupKey, dataSource);
    }
    if (this.defaultTargetDataSource != null) {
        this.resolvedDefaultDataSource = resolveSpecifiedDataSource(this.defaultTargetDataSource);
    }
}

由afterPropertiesSet方法可知,它会在bean属性初始化之后,将targetDataSources中的数据复制到resolvedDataSources,而AbstractRoutingDataSource中也提供了设置targetDataSources的入口。

基于此,项目中支持多数据源就不是梦了。当创建DataSource这个bean时,我们将主从数据源作为独立的bean加入targetDataSources,并重写determineCurrentLookupKey,实现动态key获取。这样JDBC就可以根据不同的数据源配置创建不同的数据库connection了。



重点代码分析

扩展AbstractRoutingDataSource,实现自己的数据源实现:

public class DynamicDataSource extends AbstractRoutingDataSource {
    @Override
    protected Object determineCurrentLookupKey() {
        return DynamicDataSourceContextHolder.getDataSourceType();
    }
}

多数据源配置:

@Bean(name = DataSourceConstant.DATASOURCE_NAME_MASTER)
@Primary
@ConfigurationProperties(prefix = DataSourceConstant.PROFILE_PREFIX_MASTER)
public DataSource dataSourceMaster() {
    return DataSourceBuilder.create().build();
}

@Bean(name = DataSourceConstant.DATASOURCE_NAME_SLAVE)
@ConfigurationProperties(prefix = DataSourceConstant.PROFILE_PREFIX_SLAVE)
public DataSource dataSourceSlave() {
    return DataSourceBuilder.create().build();
}

@Bean(name = DataSourceConstant.DATASOURCE_NAME)
public DataSource dataSource(@Qualifier(DataSourceConstant.DATASOURCE_NAME_MASTER) DataSource master,
                             @Qualifier(DataSourceConstant.DATASOURCE_NAME_SLAVE) DataSource slave) {
    DynamicDataSource dynamicDataSource = new DynamicDataSource();
    dynamicDataSource.setDefaultTargetDataSource(master);
    dynamicDataSource.setTargetDataSources(ImmutableMap.of(DataSourceConstant.DATASOURCE_NAME_MASTER, master,
            DataSourceConstant.DATASOURCE_NAME_SLAVE, slave));
    
    DynamicDataSourceContextHolder.setDefaultDataSource(DataSourceConstant.DATASOURCE_NAME_MASTER);
    DynamicDataSourceContextHolder.addDataSource(DataSourceConstant.DATASOURCE_NAME_MASTER);
    DynamicDataSourceContextHolder.addDataSource(DataSourceConstant.DATASOURCE_NAME_SLAVE);
    return dynamicDataSource;
}

当有一天数据库架构改为一主多从时,只需要修改上面这部分代码,增加bean配置即可。此外,为了方便运行时动态切换数据源,我们通过自定义注解 + AOP的方式来实现。

@Aspect
@Order(-1) 
public class DynamicDataSourceAspect {
    protected static final Logger logger = LoggerFactory.getLogger(DynamicDataSourceAspect.class);

    public boolean preValidate(JoinPoint point, TargetDataSource targetDataSource) {
        return Boolean.TRUE;
    }

    @Before("@annotation(targetDataSource)")
    private void changeDataSource(JoinPoint point, TargetDataSource targetDataSource) throws Throwable {
        String dsName = targetDataSource.name();
        if (dsName == null || dsName.length() <= 0) {
            DynamicDataSourceContextHolder.setDefaultDataSourceType();
            return;
        }
        if (!DynamicDataSourceContextHolder.containsDataSource(dsName)) {
            logger.warn("DataSource not exist,use default {} > {}", dsName, point.getSignature());
            DynamicDataSourceContextHolder.setDefaultDataSourceType();
            return;
        }
        if (!preValidate(point, targetDataSource)) {
            return;
        }
        if (!DynamicDataSourceContextHolder.isDefaultDataSource(dsName)) {
            logger.info("change DataSource salve : {} > {}", dsName, point.getSignature());
        }
        DynamicDataSourceContextHolder.setDataSourceType(dsName);
    }

    @After("@annotation(targetDataSource)")
    private void restoreDataSource(JoinPoint point, TargetDataSource targetDataSource) {
        DynamicDataSourceContextHolder.clearDataSourceType();
    }
}

这里没有把DynamicDataSourceAspect声明为bean,主要方便后期扩展。

@Bean
@Primary
public DynamicDataSourceAspect initDataSourceAspect() {
    return new DynamicDataSourceAspect();
}

全部源码下载:https://github.com/JeffreyHy/daily-code,找到com.huang.datasource即可!


从库故障自动转移

为了保证高可用,当从库故障时,希望将从库的流量自动切换到主库,待从库恢复之后再切换回来。有很多方案可以实现从库故障切换,我们的实现是接入配置中心,设置一个开关来控制从库切换。只需扩展DynamicDataSourceAspect并重写preValidate方法,实现自己的开关逻辑即可。