Redis分布式锁在刷新微信access_token中的应用

    access_token是公众号开发中调用各接口所需的全局唯一凭证。access_token有效期为2小时,需要定时刷新,重复获取会导致上次获取的access_token失效。一个公众号的access_token限制为每天2000个,耗尽后当日将无法进行接口调用。
    access_token可通过以下接口的调用来获取:
    https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=APPID&secret=APPSECRET
    其中APPID和APPSECRET 可在公众号管理后台查看。

    在实际应用中,通常将获取到access_token存入redis缓存中,过期则重新获取。如果服务采用多节点的分布式部署,一个节点发现access_token过期并更新的期间,另一个节点也发现它过期了并且更新,这样B的更新会导致A的失效,A又会重新获取导致B的失效,如此反复,迅速耗尽2000个access_token。

    针对上述问题,可采取以下方案:

  1. 提供2个方法,分别是getAccessToken() 和freshAccessToken()。
       其中getAccessToken()方法即直接从redis中获取,获取不到则返回空。
       freshAccessToken()方法中,使用Redis作为分布式锁,使得一个线程在重新获取access_token时,其他线程等待,解决了上述分布式部署情况下出现的问题。更新后存入redis缓存。
  2. 在业务方法中,如发送模板消息时需先获取access_token。方案如下:调用getAccessToken(),如果获取不到再调用freshAccessToken() 刷新access_token。之后使用该access_token作为参数调用发送模板消息接口。
       需要注意的一点是,可能会出现获取时access_token还是有效的,但是调用发送模板消息接口时已失效,此时模板消息接口会返回如下错误码:
    INVALID_ACCESSTOKEN(40001, “invalid access_token”), //无效的access_token
    EXPIRED_ACCESSTOKEN(42001, “expired access_token”); //access_token超时

   所以需要对模板消息接口的返回值进行判断,如果返回上述2个错误码,需再次调用freshAccessToken() 获取新的access_token并重新调用模板消息发送接口。

   从上述方案可以看出,Redis作为分布式锁的使用是解决该问题的关键。
   Redis作为分布式锁利用的是SETNX命令(SETNX key value),只有当该 key 不存在时,才会设置成功。另外由于Redis采用单线程模型,可以确保同一时间只有一个线程成功设置该key,即同一时间只有一个线程获取到锁。
   解锁即利用Redis的DEL命令(DEL key1) 删除上文中的key。

   加锁和解锁代码如下:

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;


/**
 * redis分布式锁工具类
 * Created by qiyanli on 2019/11/4.
 */
@Component
@Slf4j
public class RedisLockUtils {

    @Autowired
    RedisTemplate<String, String> redisTemplate;

    /**
     * 加锁
     *
     * @param key
     * @param value 当前时间+超时时间
     * @return
     */
    public boolean lock(String key, String value) {
        //获取到锁返回true
        Boolean isLock = redisTemplate.opsForValue().setIfAbsent(key, value);
        if (isLock) {
            return true;
        }

        //如果锁已经过期
        String currentValue = redisTemplate.opsForValue().get(key);
        if (!StringUtils.isEmpty(currentValue) && Long.valueOf(currentValue) < System.currentTimeMillis()) {
            //获取上一个锁的时间,并设置新锁的时间
            String oldValue = redisTemplate.opsForValue().getAndSet(key, value);
            if (!StringUtils.isEmpty(oldValue) && oldValue.equals(currentValue)) {
                log.info("锁过期并返回true");
                return true;
            }
        }
        return false;
    }

    /**
     * 解锁
     *
     * @param key
     * @param value
     */
    public void unlock(String key, String value) {
        try {
            String currentValue = redisTemplate.opsForValue().get(key);
            if (!StringUtils.isEmpty(currentValue) && currentValue.equals(value)) {
                redisTemplate.opsForValue().getOperations().delete(key);
            }
        } catch (Exception e) {
            log.error("redis分布式锁,解锁异常, {}", e.getMessage());
        }
    }

}

注:
1)lock()方法即加锁,使用 当前时间+超时时间 作为value。setIfAbsent(key, value) 即SETNX命令对应的java实现。由于value即锁的过期时刻,通过当前时间与value比较即可知该锁是否已过期。如果已经过期,则调用getAndSet(key, value) 获取旧值并设置新值。
!StringUtils.isEmpty(oldValue) && oldValue.equals(currentValue) 判断可以确保这期间没有被其他线程设置新值。
2)unlock() 方法即解锁,!StringUtils.isEmpty(currentValue) && currentValue.equals(value) 判断可以确保删除的是自己的锁,避免误将其他线程解锁。

   获取access_token和刷新access_token 的代码如下:

import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.client.RestTemplate;

import javax.annotation.Resource;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;

/**
 * Created by qiyanli on 2019/9/19.
 */
@Slf4j
@Service
public class WechatServiceImpl implements WechatService {

    @Resource
    private RemoteUrlConfig remoteUrlConfig;

    @Resource
    private RedisTemplate<String, String> redisTemplate;

    @Resource
    private RestTemplate restTemplate;

    @Resource
    private RedisLockUtils redisLockUtils;

    @Override
    public String getAccessToken() {
        try {
            //从redis中获取accessToken
            String accessTokenInRedis = redisTemplate.opsForValue().get(remoteUrlConfig.getAccessTokenKey());
            if (!StringUtils.isEmpty(accessTokenInRedis)) {
                return accessTokenInRedis;
            }
        } catch (Exception e) {
            log.error("getAccessTokenFromRedis exception :{}", e);
        }
        return null;
    }

    @Override
    public String freshAccessToken() {
        //从redis中获取老accessToken
        String oldAccessToken = redisTemplate.opsForValue().get(remoteUrlConfig.getAccessTokenKey());

        //分布式锁
        long currentTimeMills = System.currentTimeMillis();
        String redisLockValue = String.valueOf(currentTimeMills + remoteUrlConfig.getRedisLockExpire());
        final boolean lock = redisLockUtils.lock(remoteUrlConfig.getRedisLockKey(), redisLockValue);

        if (lock) {
            try {
                return getAccessTokenFromRemote();
            } catch (Exception e) {
                log.error("getAccessTokenFromRemote error:{}", e);
            } finally {
                redisLockUtils.unlock(remoteUrlConfig.getRedisLockKey(), redisLockValue);
            }
        } else {
            //获取不到锁的线程,休眠100ms再从redis中取,如果取出的和旧的不同说明已更新。循环判断5次
            int failCount = 1;
            while (failCount <= 5) {
                // 等待100ms重试
                try {
                    Thread.sleep(100l);
                    String newAccessToken = redisTemplate.opsForValue().get(remoteUrlConfig.getAccessTokenKey());
                    if (!StringUtils.isEmpty(newAccessToken) && newAccessToken != oldAccessToken) {
                        return newAccessToken;
                    }
                } catch (InterruptedException e) {
                    log.error("freshAccessToken sleep InterruptedException:{}", e);
                } finally {
                    failCount++;
                }
            }
        }
        return null;
    }

    @Override
    public String getAccessTokenFromRemote() {
        String url = remoteUrlConfig.getAccessTokenUrl();
        String appId = remoteUrlConfig.getOfficialAccountAppId();
        String appSecret = remoteUrlConfig.getOfficialAccountAppsecret();
        String getAccessTokenUrl = String.format(url, appId, appSecret);
        log.info("getAccessToken url:{}", getAccessTokenUrl);
        String result = restTemplate.getForObject(getAccessTokenUrl, String.class);
        log.info("getAccessToken response :{}", result);

        //正常情况返回:{"access_token":"ACCESS_TOKEN","expires_in":7200},异常情况返回错误码,如:{"errcode":40013,"errmsg":"invalid appid"}
        JSONObject json = JSON.parseObject(result);
        String accessToken = json.getString("access_token");
        if (!StringUtils.isEmpty(accessToken)) {
            redisTemplate.opsForValue().set(remoteUrlConfig.getAccessTokenKey(), accessToken, remoteUrlConfig.getAccessTokenExpiration(), TimeUnit.SECONDS);
            return accessToken;
        }
        return null;
    }

}

注:
1) getAccessToken() 即获取access_token,直接从Redis中获取,获取不到返回空。
2) freshAccessToken() 即刷新access_token。由于该方案中Redis 存储的key没有设置过期时间,因此失效时间作为value存储在redis中,实现锁的过期机制。调用lock()获取锁,获取成功则调微信接口获取token,并在finally块中解锁。对于获取不到锁的线程,休眠100ms再从redis中取,如果取出的和旧的不同说明已更新,最多循环5次。

   调微信接口获取access_token代码如下,获取后存入Redis:

 @Override
    public String getAccessTokenFromRemote() {
        String url = remoteUrlConfig.getAccessTokenUrl();
        String appId = remoteUrlConfig.getOfficialAccountAppId();
        String appSecret = remoteUrlConfig.getOfficialAccountAppsecret();
        String getAccessTokenUrl = String.format(url, appId, appSecret);
        log.info("getAccessToken url:{}", getAccessTokenUrl);
        String result = restTemplate.getForObject(getAccessTokenUrl, String.class);
        log.info("getAccessToken response :{}", result);

        //正常情况返回:{"access_token":"ACCESS_TOKEN","expires_in":7200},异常情况返回错误码,如:{"errcode":40013,"errmsg":"invalid appid"}
        JSONObject json = JSON.parseObject(result);
        String accessToken = json.getString("access_token");
        if (!StringUtils.isEmpty(accessToken)) {
            redisTemplate.opsForValue().set(remoteUrlConfig.getAccessTokenKey(), accessToken, remoteUrlConfig.getAccessTokenExpiration(), TimeUnit.SECONDS);
            return accessToken;
        }
        return null;
    }

参考文献:
https://baijiahao.baidu.com/s?id=1623086259657780069&wfr=spider&for=pc

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值