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。
针对上述问题,可采取以下方案:
- 提供2个方法,分别是getAccessToken() 和freshAccessToken()。
其中getAccessToken()方法即直接从redis中获取,获取不到则返回空。
freshAccessToken()方法中,使用Redis作为分布式锁,使得一个线程在重新获取access_token时,其他线程等待,解决了上述分布式部署情况下出现的问题。更新后存入redis缓存。 - 在业务方法中,如发送模板消息时需先获取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