由微信token和mysql锁说开去

前言

这篇文章的由头呢是关于微信token获取,大家都知道我们访问微信的一些接口是需要token的(这也算开放平台访问的一个标准了),而token是存在过期时间的,那么在token即将过期的时候,获取就会存在一个问题,因为如果你允许并发获取,就会存在在同一时间多次获取token,新的不断刷新覆盖旧的情况,导致我们业务不连贯甚至报错。对于单机服务来说,我们可以用同步块、barrier等工具来处理这个问题,那么对于多机部署的服务呢?其实也有很多处理方式,比方说分布式锁啦,单独拎一个服务做伪串行化啦,用调度服务定时提前刷新啦等等方法来处理这个问题。我这篇文章主要点是在没有redis、zk、etcd等中间件的基础上通过在mysql层应用它的事务和行锁来完成一个分布式锁的工作,以此处理这样的问题。

方案确定

针对之前说的这个问题,首先我们确定我们是要做的是个悲观锁,因为我们是要在发出token刷新请求之前就要锁定住我们的操作,如果是乐观锁的话先发起刷新请求再判断是否冲突显然不能解决我们的问题。然后在mysql层面,我们知道mysql默认的数据引擎innoDB是支持事务和行级锁,意味着两个客户端并发发起事务更新(update)同一行的时候是会导致一个客户端的事务被锁定等待直到另外一个客户端的事务完成或者回滚,这样的特性就非常适合用来做悲观锁了。我们可以增加一个中间状态字段来表示我们目前token的状态。
另外,我们知道在一个客户端事务成功获取到锁之后,他就会去操作获取token,完成更新token记录,提交事务,返回等一系列操作。当这个事务提交完成之后,之前因为数据库锁而等待的客户端才能继续执行,这时候就需要有一个机制来提醒这个客户端,token刷新操作已经完成,无需再去重复刷新了。这个机制我们可以通过在update更新时增加一个条件来达成,这个条件就是token值必须等于之前被判定过期的token值。为什么要这样呢?因为我们在update的时候,如果当前是第一个获取锁来执行更新的客户端事务,那么这个条件肯定为真,也就是说我们update出来的影响行数结果是1,这个结果就代表我们获取锁并且需要去执行更新。而如果当前不是第一个获取锁的客户端事务,那么在等待第一个事务完成并提交update的时候,因为这时候本身的token记录已经被第一个事务更新了,因此第二个事务update显然不会有任何影响的行,行数结果就是0。这时候就可以判定我们已经不需要再去刷新token了,因为已经其他事务刷新过了,这个我们可以通过下图来表达:


事务与锁解释

这样做还有一个好处是如果出现异常或者DB链接终端,可以通过事务本身的回滚机制来表示不会有客户端一直持有锁导致其他客户端一直阻塞的情况。不过这个方案有个问题是如果当并发量非常高的情况下,会有多个事务会话被hang住,导致DB和业务系统的性能不稳定,所以针对这种情况大家还是要区别对待,最好的是在每个服务器上做一个单机内的线程同步机制,避免大量update请求打到DB。

Have a try

确定了方案我们就来试试,首先看看数据表结果如下图:


数据库表结构

然后新增两条记录:


image.png

然后我们在工程中新增相应的dao和dto对象(这个就不贴了):
@Mapper
public interface TokenDao {

    @Select("select * from Token where app_id = #{appId}")
    Token selectByAppId(@Param("appId")String appId);

    @Update("update Token set status = #{status} where app_id = #{appId} and token = #{token}")
    int updateStatus(@Param("appId")String appId, @Param("token")String token, @Param("status")String status);

    @Update("update Token set token = #{token}, status = #{status} where app_id = #{appId} ")
    int updateToken(@Param("appId")String appId, @Param("token")String token, @Param("status")String status);
}

然后按照我们之前的设想来写一个service:

@Service
public class TokenService {

    @Resource
    private TokenDao tokenDao;

    @Transactional
    public String getToken(String appId){
        // 获取token信息
        Token token = tokenDao.selectByAppId(appId);
        // while循环检查token,慎用
        while(!checkValid(token)){
            // 更新中间状态,DB加锁
            int result = tokenDao.updateStatus(appId, token.getToken(), "2");
            // 更新成功,我是第一个,其他都给我等着
            if(result == 1){
                // 获取新的token
                String newToken = refreshToken(appId);
                // 更新token和状态
                tokenDao.updateToken(appId, newToken, "1");
                return newToken;
            }else{
                // 到了这儿说明我获取了锁,但是我没有权力更新,重新获取token信息用于判断
                token = tokenDao.selectByAppId(appId);
            }
        }
        return token.getToken();
    }

    private boolean checkValid(Token token){
        // 为了测试方便,状态1为有效,通过修改db可以触发重刷token
        return token.getStatus().equals("1");
    }

    private String refreshToken(String appId){
        return UUID.randomUUID().toString();
    }

}

然后咱们可以通过JUNIT或者自己写个controller来断点测试下。通过我本身的测试,了解到在updateStatus方法处是可以实现悲观锁的功能,但是后进来的客户端事务永远无法结束,这是为什么呢?因为在我们while循环最后的select中,虽然这时候记录已经被其他事务修改了,但是我们查出来的还是更新之前的记录?!这里的原因就牵扯到mysql事务的隔离级别了,详细信息大家可以查阅这篇文章。由于mysql默认的数据库隔离级别是可重复读,即对于事务中select读是不会更新版本号,是快照读,也就是说这时候我们无论select多少次,中间有多少其他事务做了更新,我们始终是按照事务开始时的快照版本号来读取记录,读到的的是相同的记录,也就是保证了可重复读。

Try again

对于这个问题,我能想到的解决方式就是在下一次select的时候一定要跳出事务,这样来保证我们不用受限于事务的可重复读限制,因此我对待代码做了以下改造:

@Service
public class TokenService {

    @Resource
    private TokenDao tokenDao;

    @Autowired
    private TokenRefreshService tokenRefreshService;

    public String getToken(String appId){
        // 首先select出token
        Token token = tokenDao.selectByAppId(appId);
        while(!checkValid(token)){
            // 进入事务加锁获取token
            TokenFetchStatus result = tokenRefreshService.tokenRefreshLock(token);
            // 如果获取成功
            if(result.isFetched()){
                return result.getToken();
            }else{
                // 重新select
                token = tokenDao.selectByAppId(appId);
            }
        }
        return token.getToken();
    }

    private boolean checkValid(Token token){
        return token.getStatus().equals("1");
    }

}
@Service
public class TokenRefreshService {

    @Resource
    private TokenDao tokenDao;

    @Transactional
    public TokenFetchStatus tokenRefreshLock(Token token){
        // 更新状态加锁
        int result = tokenDao.updateStatus(token.getAppId(), token.getToken(), "2");
        // 获取锁成功
        if(result == 1){
            String newToken = refreshToken(token.getAppId());
            tokenDao.updateToken(token.getAppId(), newToken, "1");
            return new TokenFetchStatus(newToken, true);
        }else{
          // 返回获取锁失败,退出在外层循环重新select判断
             return new TokenFetchStatus(null, false);
        }
    }

    private String refreshToken(String appId){
        return UUID.randomUUID().toString();
    }
}

这里我拆了两个类,本来的TokenService使用了一个无事务的方法,在里面调用了一个TokenRefreshService的事务方法。为什么要用分开的两个类而不是用一个类的两个方法呢?这个就涉及到Spring AOP的一个原理了。事务注解@Transactional实际上是使用AOP在方法上下文增加事务的语义,但是如果是在一个类中的两个方法互相调用是不会触发AOP的,因为执行方法的本身就不是AOP的代理类而是真正的实现类了,所以要分开两个类。
这时候我们再来试,发现就成功了。但是当我们用两个appId并发发起的时候,比方说分别以test和test2为appId的时候,讲道理这两个客户端事务是不应该互相阻塞的,但是,我发现,他们竟然互相阻塞了,这就意味着,这时候不是用的行锁,而是退化到了表锁,整个表都锁住了,这显然无论是行为还是性能上都是不能接受的,那咋办呢?加索引,我们只要对token和app_id两个字段加上一个组合的唯一索引(他们确实也是唯一),就能用到行锁了。我们试了下,确实test和test2两个应用不会互相阻塞了,但是当我们再回归到同一个appId的时候,意想不到的事情发生了。

Where amazing happens

当我再次使用同一个appId并发执行获取token的操作的时候,后面的那一个事务,返回了update fail due to mysql deadlock的异常。Nani?!死锁?!我是如何做到的。这时候我们在mysql 控制台上输入以下命令:

SHOW ENGINE INNODB STATUS;

就可以看到最近的死锁信息了:

------------------------
LATEST DETECTED DEADLOCK
------------------------
2019-01-07 17:09:35 3d54
*** (1) TRANSACTION:
TRANSACTION 1866, ACTIVE 4 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 2 lock struct(s), heap size 360, 1 row lock(s)
MySQL thread id 12, OS thread handle 0x16a0, query id 229 localhost 127.0.0.1 root updating
update Token set status = '2' where app_id = 'test' and token = 'f29350f5-be33-4319-a574-2279222e3de2'
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 6 page no 4 n bits 72 index `index` of table `test`.`token` trx id 1866 lock_mode X locks rec but not gap waiting
Record lock, heap no 2 PHYSICAL RECORD: n_fields 3; compact format; info bits 0
 0: len 4; hex 74657374; asc test;;
 1: len 30; hex 66323933353066352d626533332d343331392d613537342d323237393232; asc f29350f5-be33-4319-a574-227922; (total 36 bytes);
 2: len 8; hex 8000000000000001; asc         ;;

*** (2) TRANSACTION:
TRANSACTION 1865, ACTIVE 9 sec starting index read, thread declared inside InnoDB 5000
mysql tables in use 1, locked 1
4 lock struct(s), heap size 1184, 3 row lock(s), undo log entries 1
MySQL thread id 13, OS thread handle 0x3d54, query id 231 localhost 127.0.0.1 root Searching rows for update
update Token set token = 'c49272fc-eb23-4f58-8aa9-4b5f297ce84f', status = '1' where app_id = 'test'
*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 6 page no 4 n bits 72 index `index` of table `test`.`token` trx id 1865 lock_mode X locks rec but not gap
Record lock, heap no 2 PHYSICAL RECORD: n_fields 3; compact format; info bits 0
 0: len 4; hex 74657374; asc test;;
 1: len 30; hex 66323933353066352d626533332d343331392d613537342d323237393232; asc f29350f5-be33-4319-a574-227922; (total 36 bytes);
 2: len 8; hex 8000000000000001; asc         ;;

*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 6 page no 4 n bits 72 index `index` of table `test`.`token` trx id 1865 lock_mode X waiting
Record lock, heap no 2 PHYSICAL RECORD: n_fields 3; compact format; info bits 0
 0: len 4; hex 74657374; asc test;;
 1: len 30; hex 66323933353066352d626533332d343331392d613537342d323237393232; asc f29350f5-be33-4319-a574-227922; (total 36 bytes);
 2: len 8; hex 8000000000000001; asc         ;;

*** WE ROLL BACK TRANSACTION (1)

说实话,我第一次看的时候一脸懵逼(现在也不怎么样),不过很明确的就是事务(2)就是后到的一个事务,它执行到了第一个update获取锁那里,然后等待事务(2)的锁,而事务(2)执行到了更新token的那一句update,它获取了第一个锁(导致事务1阻塞)而等待第二个锁(鬼知道那是啥),导致了死锁,并且mysql选择了回滚第一个事务。 我看到这里猜测,是因为第二个update,在同一个表里面使用了不同的条件,导致索引没有正确触发,并没有精准定位到需要更新的那一条记录,导致获取了不该获取的多余的锁(我确实不是DBA,也不太熟悉了,纯属猜想,回头问问咱们DBA去)。那么我就试试,在第二个update上,增加一个条件,使得我两条update的条件一致:

@Mapper
public interface TokenDao {

    @Select("select * from Token where app_id = #{appId}")
    Token selectByAppId(@Param("appId")String appId);

    @Update("update Token set status = #{status} where app_id = #{appId} and token = #{token}")
    int updateStatus(@Param("appId")String appId, @Param("token")String token, @Param("status")String status);

    // 增加了一个oldToken的条件
    @Update("update Token set token = #{token}, status = #{status} where app_id = #{appId} and  token = #{oldToken}")
    int updateToken(@Param("appId")String appId, @Param("token")String token, @Param("status")String status, @Param("oldToken")String oldToken);
}

并修改我的代码:

    @Transactional
    public TokenFetchStatus tokenRefreshLock(Token token){
        int result = tokenDao.updateStatus(token.getAppId(), token.getToken(), "2");
        System.out.println("yes");
        if(result == 1){
            String newToken = refreshToken(token.getAppId());
            // 注意,这里增加了条件
            tokenDao.updateToken(token.getAppId(), newToken, "1", token.getToken());
            return new TokenFetchStatus(newToken, true);
        }else{
             return new TokenFetchStatus(null, false);
        }
    }

再来一次,bingo!但是很遗憾,之前思索的错误原因我还是没有完全解释清楚,如果有后续我会贴在这里的,也欢迎有经验的小伙伴能够分享一下!

结语

这篇文章以微信token获取重复刷新问题为引子,描述了一个基于mysql数据库行锁的一个解决方案,并在其中解决了一系列问题,总结起来的话一共有以下几点:

  • mysql为了保证可重复读select的时候会使用快照度的方式,而update则不然,这会导致同一个事务内部select和update结果不一致,即使select在update之后
  • AOP 需要在两个类之间调用才能够触发
  • 如果要使用行锁记得加索引
  • 同一个事务内多次update同一个表最好使用同样的条件触发同样的索引(存疑,至少实践结果如此)

你可能感兴趣的:(由微信token和mysql锁说开去)