前情提要

众所不周知:矿小圈使用Sa-Token进行鉴权服务,为了防止”黑客”绕过Gateway网关直接访问内部微服务,使用Same-Token进行网关请求鉴定,拿到相同的Same-Token才能请求非网关服务

为了保证Same-Token不被”非法分子”破解,Same-Token采用定时刷新策略,每隔10分钟就会更新一次,因此,想要获取Same-Token并不是一件容易的事情

BUG现形

单例模式下,使用SpringBoot的@Scheduled是一件非常”爽”的事情,如下很容易定义了一个定时任务每隔10分钟刷新一次Same-Token

1
2
3
4
@Scheduled(cron = "0 0/10 * * * ?")
public void refreshToken() {
SaSameUtil.refreshToken();
}

但是,在分布式、微服务场景下,同一个类型的服务可能会部署多个实例以达到高可用,于此情景下,再直接使用@Scheduled会导致同一个任务被重复多次调用

解决方案

如果我们不希望同一任务重复调用,就要用到分布式任务调度技术,市面上常见的有XXL-JOB、Quartz、ElasticJob、scheduleX,不过这些都需要引入新的服务,目前矿小圈就仅有这一个分布式任务,所以我手动实现了一个简易的类似于ShedLock的Redis分布式锁,实现分布式定时任务调度

RedisLockUtil是我自定义的Redis分布式锁工具类,核心代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
public class RedisLockUtil {
private final RedisTemplate<String, String> redisTemplate;
private long LOCK_EXPIRE_TIME;
private TimeUnit LOCK_EXPIRE_TIME_UNIT;

private RedisLockUtil(RedisTemplate<String, String> redisTemplate, long expireTime, TimeUnit expireTimeUnit) {
this.redisTemplate = redisTemplate;
this.LOCK_EXPIRE_TIME = expireTime;
this.LOCK_EXPIRE_TIME_UNIT = expireTimeUnit;
}

public static RedisLockUtil create(RedisTemplate<String, String> redisTemplate, long expireTime, TimeUnit expireTimeUnit) {
return new RedisLockUtil(redisTemplate, expireTime, expireTimeUnit);
}

public static RedisLockUtil create(RedisTemplate<String, String> redisTemplate) {
return new RedisLockUtil(redisTemplate, 30, TimeUnit.SECONDS);
}

public void setLockExpireTime(long expireTime, TimeUnit expireTimeUnit) {
this.LOCK_EXPIRE_TIME = expireTime;
this.LOCK_EXPIRE_TIME_UNIT = expireTimeUnit;
}

/**
* 尝试获取锁
* @param lockKey 锁的 Key
* @param requestId 请求标识(用于区分不同实例线程)
* @param expireTime 过期时间(秒)
* @return 是否加锁成功
*/
public boolean acquireLock(String lockKey, String requestId, long expireTime, TimeUnit expireTimeUnit) {
Boolean success = redisTemplate.opsForValue().setIfAbsent(lockKey, requestId, expireTime, expireTimeUnit);
return Boolean.TRUE.equals(success);
}

/**
* 尝试获取锁(默认请求标识)
* @param lockKey 锁的 Key
* @param expireTime 过期时间(秒)
* @return 是否加锁成功
*/
public boolean tryLock(String lockKey) {
return acquireLock(lockKey, "1", LOCK_EXPIRE_TIME, LOCK_EXPIRE_TIME_UNIT);
}

此工具类需要用户自己传入RedisTemplate,acquireLock方法通过Redis原子操作setIfAbsent实现锁的获取,如果锁不存在并设置成功就拿到了锁,然后可以执行任务,如果锁存在设置不会成功,获取锁失败,就不能执行任务,requestId是请求标识,可以实现锁的续期与释放,防止其他实例(线程)偷锁,目前Same-Token的刷新用不到锁的续期与释放,故使用tryLock将requestId,设置为固定的”1”,相当于Boolean的True,也就是有实例(线程)拿到锁了

以下是改进后的SameToken刷新任务:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@Component
@Slf4j
public class SaSameTokenRefreshTask {
private final RedisLockUtil redisLockUtil;
private static final String LOCK_KEY = "Authorization:var:same-token-refresh-lock";
@Autowired
SaSameTokenRefreshTask(@Qualifier("saRedisTemplate") RedisTemplate<String, String> saRedisTemplate) {
this.redisLockUtil = RedisLockUtil.create(saRedisTemplate, 5, TimeUnit.MINUTES);
}

// 每隔 10 分钟刷新一次 Same-Token
@Scheduled(cron = "0 0/10 * * * ?")
public void refreshToken() {
if (redisLockUtil.tryLock(LOCK_KEY)) {
log.info("🔒 拿到刷新锁,开始刷新 Same-Token");
SaSameUtil.refreshToken();
} else {
log.info("🔒 Same-Token 刷新锁已被占用,跳过刷新");
}
}
}

每隔10分钟,各个服务会争夺Redis分布式锁,拿到锁的才可以执行刷新操作,如此就只会有一个服务执行刷新任务,不会重复刷新Same-Token,拿到锁5分钟后,锁会被自动释放,不会导致死锁发生

实测如下:

[scheduling-1] c.a.auth.task.SaSameTokenRefreshTask : 🔒 Same-Token 刷新锁已被占用,跳过刷新
[scheduling-1] c.a.auth.task.SaSameTokenRefreshTask : 🔒 拿到刷新锁,开始刷新 Same-Token
[scheduling-1] c.a.auth.task.SaSameTokenRefreshTask : 🔒 Same-Token 刷新锁已被占用,跳过刷新

源码获取

此文章用到的所有代码及案例均可从Github开源项目FlyingForum(矿小圈后端)获取,如果对你有帮助,劳烦您为此项目点一个Star支持作者,如果您发现此项目有任何Bug或者改进之处,欢迎提Issue和PR

RedisLockUtil工具类:项目common模块下的utils包

Same-Token定时任务:项目auth模块下的task包