透明、灰度、兼容&半强制

最近重构了一个公司业务的一个模块,涉及的东西有点多,从需求评审到技术评审,再到各端沟通,花了很大功夫才确定架构和技术方案,最后开发提测灰度上线,清洗数据等等,整个重构经历了很长时间。现在一切尘埃落定,我也静下心来把工作中的思考和探索记录下来。业务不能说的太详细,用一些泛泛的概念,比如新业务逻辑,新业务数据,旧业务逻辑,旧业务数据,业务主体等等。

透明

我很喜欢透明这个词,在软件领域的意思就是关注应该关注的东西,不应该关注的东西应该是透明的、看不到的。
原业务是后端提供一个 HTML,前端各渠道各自编写相同逻辑处理 HTML 里面的信息,进而展示给用户。这样的设计真的是非常痛苦,首先各端工作量增加,其次耦合性太高,最重要的是需要使用 iframe 或引入 jquery 来执行逻辑。重构之后由后端处理完 HTML 的信息,传给各端一个纯展示用的 HTML,对于各端来说这个 HTML 里的内容是透明的,他们不必关注,只需要展示就可以了。
另一个是 HTML 里面有很多需要填写的关键信息夹杂在内容之中,在不同的 HTML 中有可能有相同的关键信息,原业务对相同的关键信息用相同 ID 的 input 标签作为区分,后端再根据 ID 来存取所填的值。这样导致每创建一个新的 HTML 都要开发人员编写并保存一个完整页面,并且确保其中关键信息的填空的 ID 和约定一致。重构之后 HTML 的文本内容和关键信息分离,文本内容相当于纯文案,关键信息则是 key-value 列表,由后端装填到一个 HTML 模板中即可,这样一来,这些业务无关的内容对于后端和前端来说都是透明的,文本内容和关键信息的修改不需要投入开发人员。

灰度

重构一个正在运行的服务,新旧逻辑和数据不互通,不能保证同一时间迁移数据的,而且变更内容过多,影响范围较大,一定要有一个灰度方案,保证出现问题能够把影响面降到最小,新的服务能够逐步稳定地替代旧服务。
首先是灰度范围的选择,从业务的哪一层面来划分灰度范围,选取的颗粒度不能太大也不能太小,根据业务来寻找一个层面进行划分。
然后要考虑如何方便地调整灰度范围直至全量,以及如何保证后续新的业务主体自动进入灰度范围,我在数据库的字典表用一条数据记录灰度范围,也就是一些类型 ID 用逗号拼接成的字符串,代码逻辑会根据业务主体判断其类别是否在灰度范围,当灰度范围调整时,更新这条数据,当这条数据为空字符串时,代码判断为全量切换,即所有业务主体走新的业务逻辑,保证后续新增的类型自动进入灰度范围。
最后是灰度策略的制定,灰度范围外的业务如何处理,在灰度范围内但没有新业务基础数据的怎么处理,其他上下游业务如何保证不受灰度策略的影响。我在这次重构中,灰度范围外的业务主体仍然按照旧的业务逻辑处理,加到了灰度范围中但没有新业务基础数据的,仍然按照旧业务逻辑处理。新的业务保证上下游数据的正常交互,就像一条河流在中间分成了两条河道,对应新旧协议,然后又汇聚回原河流。

兼容

在涉及很多端,不能保证各端同一时间上线,尤其是 APP 端不能强制更新的情况下,旧的接口和逻辑要能够保持正常运行,旧的接口可以调用新的服务,而新的接口也要能够调用旧的服务。也就是说在使用灰度方案时,要保证新旧接口都可以正常工作,并根据灰度范围来正确调用新旧服务。
举个例子,这次重构中,旧业务逻辑是前端根据占位符填充的某些内容,新接口无论新旧业务数据,统一直接在后端填充好了,为了让旧接口兼容并正常展示新业务数据,我在旧接口返回新业务数据时 hack 了相应的占位符,仍然走前端填充的方式。

半强制

在设计了灰度和兼容两个关键点之后,带来另一个业务上必须考虑的问题,就是用户太懒。因为我们有灰度和兼容方案,所以使用者感觉旧的业务还可以使用,便不愿意迁移到新的业务,而且对于他们来说,新旧业务并没有什么不同。
所以我们的半强制策略就是,进入灰度范围的业务主体将不能修改和展示旧业务数据,只有编辑新业务数据的入口,旧业务数据可以走旧业务逻辑完成整个流程,一旦编辑好新业务的基础数据,该业务主体将走新业务逻辑。

Spring Mybatis 动态数据源

Annotation 和 Enum

我们首先需要一个注解和枚举类来标识和定义数据源

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
public @interface DataSource {
    DataSourceEnum value() default DataSourceEnum.MASTER;
}
public enum DataSourceEnum {
    MASTER, SLAVE
}

DynamicDataSourceAspect

然后监听 service 层接口,获取数据源标识,切点前执行 before() 方法,切点后执行 after() 方法。(xml配置省略)

public class DynamicDataSourceAspect {

    public void before(JoinPoint point) {
        Method targetMethod = ((MethodSignature) point.getSignature()).getMethod();
        DataSource dataSource = targetMethod.getAnnotation(DataSource.class);
        if (dataSource != null) {
            DynamicDataSourceHolder.putDataSource(dataSource.value());
        } else {
            DynamicDataSourceHolder.putDataSource(DataSourceEnum.MASTER);
        }
    }

    public void after(JoinPoint point) {
        DynamicDataSourceHolder.clearDataSource();
    }
}

DynamicDataSourceHolder

我们需要一个 ThreadLocal 来保存当前取到的数据源,这里我们用了一个 Stack,因为可能 A 方法调用了 B 方法,执行完 B 方法后上下文要还原到 A 的数据源来继续执行 A 方法后续语句。因此需要一个先进先出的数据结构来存放数据源。

public class DynamicDataSourceHolder {

    private static final ThreadLocal<Stack<DataSourceEnum>> holder = new ThreadLocal<>();

    public static void putDataSource(DataSourceEnum dataSource) {
        Stack<DataSourceEnum> dataSourceEnums = holder.get();
        if (null == dataSourceEnums) {
            dataSourceEnums = new Stack<>();
            holder.set(dataSourceEnums);
        }

        dataSourceEnums.push(dataSource);
    }

    public static DataSourceEnum getDataSource() {
        Stack<DataSourceEnum> dataSourceEnums = holder.get();
        if (null == dataSourceEnums || dataSourceEnums.isEmpty()) {
            return null;
        }
        return dataSourceEnums.peek();
    }

    public static void clearDataSource() {
        Stack<DataSourceEnum> dataSourceEnums = holder.get();
        if (null == dataSourceEnums || dataSourceEnums.isEmpty()) {
            return;
        }
        dataSourceEnums.pop();
    }
}

DynamicDataSource

Spring 提供 AbstractDataSource 这个抽象类,我们继承这个类实现自己的动态 DataSource,我们我们先看看这个类的主要代码

//有 set 和 get 方法
private Map<Object, Object> targetDataSources;
//有 set 和 get 方法
private Object defaultTargetDataSource;
//在 afterPropertiesSet() 方法中复制自 targetDataSources
private Map<Object, DataSource> resolvedDataSources;
//在 afterPropertiesSet() 方法中复制自 defaultTargetDataSource
private DataSource resolvedDefaultDataSource;

@Override
public void afterPropertiesSet() {
    //复制 targetDataSources -> resolvedDataSources
    //复制 defaultTargetDataSource -> resolvedDefaultDataSource
}

//获取 DataSource 连接
@Override
public Connection getConnection() throws SQLException {
    return determineTargetDataSource().getConnection();
}

//根据 Key 获取指定 DataSource
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;
}

//重写此方法,动态选择 Key
protected abstract Object determineCurrentLookupKey();

上面方法最后三个方法是重点,调用顺序 getConnection() -> determineTargetDataSource() -> determineCurrentLookupKey(),从一个 Map 中根据 key 来获取对应的 DataSource,最后这个抽象方法是需要我们重写的。

public class DynamicDataSource extends AbstractRoutingDataSource {

    @Setter
    private Object writeDataSource; //写数据源
    @Setter
    private Object readDataSources; //读数据源

    @Override
    public void afterPropertiesSet() {
        if (this.writeDataSource == null) {
            throw new IllegalArgumentException("Property 'writeDataSource' is required");
        }
        setDefaultTargetDataSource(writeDataSource);
        Map<Object, Object> targetDataSources = new HashMap<>();
        targetDataSources.put(DataSourceEnum.MASTER.name(), writeDataSource);
        targetDataSources.put(DataSourceEnum.SLAVE.name(), readDataSources);
        setTargetDataSources(targetDataSources);
        super.afterPropertiesSet();
    }

    @Override
    protected Object determineCurrentLookupKey() {
        DataSourceEnum dataSourceEnum = DynamicDataSourceHolder.getDataSource();
        if (dataSourceEnum == null) {
            return DataSourceEnum.MASTER.name();
        } else {
            return dataSourceEnum.name();
        }
    }
}

DynamicDataSource 的配置文件如下

<bean id="masterDataSource" class="org.apache.commons.dbcp.BasicDataSource">...</bean>
<bean id="slaveDataSource" class="org.apache.commons.dbcp.BasicDataSource">...</bean>
<bean id="dataSource" class="me.snowhere.datasource.DynamicDataSource">
    <property name="writeDataSource" ref="masterDataSource"/>
    <property name="readDataSources" ref="slaveDataSource"/>
    <property name="defaultTargetDataSource" ref="masterDataSource"/>
</bean>
<bean id="sqlSessionFactory" class="org.mybatis.spring.SqlSessionFactoryBean">
    <property name="dataSource" ref="dataSource"/>
</bean>

其他

上面只是实现了一个最简单的,可以工作的动态数据源,并不适合放到工程中,因为还有很多地方需要扩展:
1. 切面和注解。除了注解方法,我们还可以设置注解到类上以及接口上,并在 DynamicDataSourceAspect 中按照优先级依次寻找方法、类、接口上的注解,确定数据源标识。
2. 动态数据源。在 DynamicDataSource 中,我们只有一个主数据源和从数据源,我们可以把从数据源改为 List,在 targetDataSources.put() 时的 key 为 DataSourceEnum.SLAVE.name() + index,并设置一个属性来标识选择从库的策略,比如随机或轮询,来确定 index 的值选择具体从库。实现一主多从的架构。

另外还有一些其他需要注意的问题:
1. 直接用 this 调用当前对象的方法不会被切面拦截,配置的数据源标识也就无效
2. 事务不能跨数据源

Redis 实现分布式锁

编程中多线程并发和锁的相关应用,尤其是现在应用都是部署在多台服务器上,分布式锁尤其重要。我们可以用 redis 来非常简单地实现分布式锁。其核心思想就是将对锁的操作转换为 redis 操作时,要保障原子性。

用到的两个关键 redis 操作 SETNXGETSET

基础设计

首先让我们自己来构思如何用 redis 实现一个分布式锁。
很简单,在 redis 中存放一个键值对,key 标识锁,value 做其他用途。
当一个线程需要同步操作时:

  1. 首先从 redis 中获取指定 key 的值,
  2. 如果返回 null,说明空闲,当前线程设置 keyvalue 值;如果获取该 key 时如果有值,说明是加锁状态,某个线程正在执行同步操作,当前线程应该挂起等待一段时间再执行。
  3. 当同步操作完成后再删除此 key

这时就要考虑并发情况下对锁的操作了,比如加锁操作包含两步 redis 操作,获取和设值,如果并发情况下两个线程先后交叉执行这两步操作,就会出问题。

  1. 线程一获取 key 的值为 null
  2. 线程二获取 key 的值为 null
  3. 线程一设置 value 为 value1,并认为自己获取到了锁。
  4. 线程二设置 value 为 value2, 并认为自己获取到了锁。

所以我们需要将这两步合为一步,成为原子操作,便可消除这个问题,这就需要上面提到的 SETNX

SETNX key value
将 key 的值设为 value ,当且仅当 key 不存在。
若给定的 key 已经存在,则 SETNX 不做任何动作。
SETNX 是『SET if Not eXists』(如果不存在,则 SET)的简写。
设置成功,返回 1 。设置失败,返回 0 。

很简单,返回 1 取到锁;返回 0 没有取到锁。

异常状况

上面其实已经解决了分布式锁的最基本的问题,包括对锁的操作。下面,我们就要考虑一些特殊情况了,比如某个线程挂掉或网络问题或其他原因导致没有释放锁,这样一来所有线程就陷入了死锁,为了解决这个问题,我们可以为锁设置超时时间,value 值设置为 当前时间戳+过期时长,当其他线程无法获取锁时,可以查看 value 值是否已过期,如果过期,则可以删除掉 value ,执行上面的争用锁的操作。

  1. 获取 key 的值 value 不为 null,说明有锁
  2. 查看 value 的值是否过期
  3. 如果过期,删除 key
  4. key 值设置为自己的 value,认为获取到锁。

这也会出现上面两个线程交叉执行出现的问题,所以我们需要保障 redis 操作的原子性。
我们需要 GETSET 操作:

将给定 key 的值设为 value ,并返回 key 的旧值(old value)。
当 key 存在但不是字符串类型时,返回一个错误。

当我们判断 value 值过期之后,我们直接用 GETSET 设置新值。如果返回值为原 value ,说明我们设置成功,并获取了锁,如果返回值不为原 value,说明有其他线程抢到了锁。

可重入锁

简单来说,可重入锁就是一个线程获取某个锁之后,可以再次获取该锁。为了实现这一特性,我们可以在 value 值中加入一些标识,比如 UUID,可以让线程确认是不是自己持有的锁。

架构中的数据校验与处理

最近做一些东西,需要前后端协调,遇到一个问题是表单提交中,某个字段我们需要确保它是唯一的,比如注册时的用户名字段username

1.前端
我选择用ajax来向后端校验username的唯一性。绑定了输入框的blur事件,当输入框失去焦点时校验。

$('#username').blur(function () {
    $.getJson('url',{username:$('#username').val()},function(response){
        //如果不唯一,提示,并禁止表单提交
    });
});

2.后端
防御式编程核心思想就是”假设输入都是非法的”。这个思想在前后端编程中尤其重要,你永远不要相信网络那头传过来的数据。所以当用户提交表单时,我们需要在后端同样对数据校验,在校验username的唯一性时,我将所有的username作为List放在redis中。
尤其是在前端ajax校验时,会有大量请求,使用redis可以提高效率,减少数据库负担。

public boolean uniqueUserName(String username) {
    List<String> usernames = Redis.use().lrange("usernames",0,-1);
    return usernames == null || !usernames.contains(username) ;
}

3.数据库
在并发的可能性下,数据库可以作为我们最后一道防线,非常稳固。我在数据库用户表中,为username字段建立的unique索引。保证字段唯一性。

ALTER TABLE `user`
ADD UNIQUE INDEX `username` (`username`) ;

综上所述,即使构建一个小工程,也要缜密思考并详细设计每一个环节和流程,在工程架构的每一层都需要有独立处理数据的能力,又要联系上下游的数据传递和错误处理。

后续:当我测试Mysql的唯一索引这层保障时,发现抛出异常,因为后端配置了500页面,返回消息实体是页面,然而前端用的ajax请求,并不能解析消息,也不能直接跳转。所以错误处理和数据传递出现了问题。后来发现了jquery可以配置全局ajax错误处理函数,十分方便,之后无论前端跳转请求还是ajax请求,后端如果发生异常,都可以处理。

$.ajaxSetup({
    error: function () {
        alert('/(ㄒoㄒ)/~~哎呀,服务器有问题了,请刷新后再试试');
    }
});