某次应用上线后,作为一个极其负责的开发者,对生产应用,即App进行功能验收。发现用户输入的敏感词内容没有被检测出来,一开始还以为是前端没有对接后端提供的接口。秉着怀疑他人前先怀疑自己的意识和职业素养,查看起ELK日志来,发现如下报错日志:
敏感词返回结果:{"errcode":40001,"errmsg":"invalid credential, access_token is invalid or not latest, could get access_token by getStableAccessToken, more details at https://mmbizurl.cn/s/JtxxFh33r rid: 657fb5df-054a7b70-55249500"}
敏感词检测逻辑方法源码如下:
private int sensitiveCheck(String content) {
try {
String accessToken = redisTemplate.opsForValue().get("ACCESS_TOKEN_CACHE:" + configService.getAppIdSelf());
if (null == accessToken) {
accessToken = WxUtils.getAccessToken(configService.getAppIdSelf(), configService.getAppSecretSelf());
redisTemplate.opsForValue().set(RedisConstants.ACCESS_TOKEN_CACHE_PREFIX + configService.getAppIdSelf(), accessToken, 7200, TimeUnit.SECONDS);
}
return WxUtils.sensitiveCheck(accessToken, content);
} catch (Exception e) {
log.error("sensitiveCheck failed: ", e);
// 网络异常等暂不考虑
return 0;
}
}
从Redis里获取到Access-Token,然后拿着这个Access-Token去请求微信提供的敏感词检测接口,参考官方给出的API文档。
注意到敏感词检测方法封装成组件,其源码如下:
/**
* 敏感词检测
*
* @param token access_token
* @param content content
* @return errcode
*/
public static int sensitiveCheck(String token, String content) throws Exception {
Map<String, Object> paramMap = new HashMap<>(1);
paramMap.put("content", content);
String result = HttpUtils.post("https://api.weixin.qq.com/wxa/msg_sec_check?access_token=" + token, JsonUtil.beanToJson(paramMap));
log.info("敏感词返回结果:{}", result);
JSONObject jsonObject = JSONObject.parseObject(result);
return jsonObject.getInteger("errcode");
}
注意到上面代码里面的log日志正好是ELK查询到的应用日志。
回到报错提示:invalid credential, access_token is invalid or not latest
。简单分析下,不难得出结论:Redis里缓存的Access-Token过期,拿着过期的Token去请求微信的接口,于是有此报错。因为是生产问题,首要任务是解决问题;问题出现时间越短,其影响面越小。其次才去分析问题,从根源上杜绝问题。
抱着这个想法,通过客户端连接到生产环境的Redis,找到并删除Redis Key:ACCESS_TOKEN_CACHE:
。
生产环境验证,问题消失。
那为啥Redis里缓存的Access-Token会过期呢?
不得而解。事情太多,杂乱无章。暂且搁置。埋下不定时炸弹。
时隔一个月!!!!!!!!!!!!
某次在使用生产App时,再次发现敏感词出现问题,即用户输入的昵称里显然没有敏感词,却提示无法保存用户信息。
这说明自动化测试的极度重要性!!
于是开始继续排查这个40001问题。
在使用微信提供的几乎所有接口(功能)之前都需要先得到微信的校验,校验通过之后才能免费(或付费)使用微信提供的功能。
怎么校验呢?先调用获取Access-Token接口,传参AppId和AppSecret,参考官方给出的API文档。
/**
* 获取 access_token, 公众号和小程序均可以使用AppID和AppSecret调用本接口来获取access_token
* ...
*
* @param appId appId
* @param secret secret
*/
public static String getAccessToken(String appId, String secret) throws Exception {
String url = "https://api.weixin.qq.com/cgi-bin/token?grant_type=" + GRANT_TYPE + "&appid=" + appId + "&secret=" + secret;
return JSONObject.parseObject(HttpUtils.get(url)).getString(ACCESS_TOKEN);
}
关于微信接口的一些理论储备(有些是事后反思回顾才搞清楚的,):
getAccessToken
这一套接口方法一开始是用于微信公众号,后来可以复用到微信小程序也就是说,我们应用层,很有必要把Access-Token缓存下来,无论是出于业务并发量的考虑(一般而言,请求微信外部接口的耗时,肯定比微服务之间Feign请求,数据库查询请求等耗时大),还是出于代码可维护性(功能复用,相同或相似的业务逻辑代码组件化)考虑。都需要增加Redis缓存功能,以及组件化util方法。
打开报错日志里面提到的链接https://mmbizurl.cn/s/JtxxFh33r ,这是一个【短链接】,简单来说,根据URL我们完全分析不出这个URL包含的任何意义。此短链接对应的完整URL为:https://developers.weixin.qq.com/miniprogram/dev/OpenApiDoc/mp-access-token/getStableAccessToken.html
这个微信文档说的啥啊?我的应用里根本就没有调用https://api.weixin.qq.com/cgi-bin/stable_token
接口,且此接口的报错Error Code里也并没有40001。command + F
搜索才能发现点东西:
access_token 泄漏紧急处理
使用强制刷新模式以间隔30s发起两次调用可将已经泄漏的 access_token立即失效,同时正常的业务请求可能会返回错误码40001(access_token过期),请妥善使用该策略。其次,需要立即排查泄漏原因,加以修正,必要时可以考虑重置 appsecret;
也就是说,40001是就是access_token过期,和Redis组件无关。
同一个微信AppId,不区分测试和生产环境,测试环境在调用微信的getAccessToken接口时(并且此时生产环境也有最大过期时间为7200秒,即2小时的缓存数据),生产的access-token就会在5分钟(参考文档)后过期,如果在这最大2小时内,生产上也在走同个业务需要使用access-token,发现缓存内有一个已经生效的access-token,生产就会报错40001。
缓存还是很有必要的,之前看到微信的文档也提过建议使用的。TODO,待补充。
继续看官方API文档:
目前access_token的有效期通过返回的expires_in来传达,目前是7200秒之内的值。中控服务器需要根据这个有效时间提前去刷新新access_token。在刷新过程中,中控服务器可对外继续输出的老access_token,此时公众平台后台会保证在5分钟内,新老access_token都可用,这保证了第三方业务的平滑过渡;
5分钟后失效,极端情况下,测试环境和生产轮流请求同一个AppId的token。测试环境请求后,生产的就会失效,反之亦然。
这个【中控服务器】难道可以跨环境同步数据??
private int sensitiveCheck(String content) {
try {
String accessToken = redisTemplate.opsForValue().get(RedisConstants.ACCESS_TOKEN_CACHE_PREFIX + configService.getAppIdSelf());
if (null == accessToken) {
accessToken = WxUtils.getAccessToken(configService.getAppIdSelf(), configService.getAppSecretSelf());
redisTemplate.opsForValue().set(RedisConstants.ACCESS_TOKEN_CACHE_PREFIX + configService.getAppIdSelf(), accessToken, 7200, TimeUnit.SECONDS);
}
int code = WxUtils.sensitiveCheck(accessToken, content);
if (code == Constants.TOKEN_EXPIRED_CODE) {
// token已失效,需再次请求,并缓存
accessToken = WxUtils.getAccessToken(configService.getAppIdSelf(), configService.getAppSecretSelf());
redisTemplate.opsForValue().set(RedisConstants.ACCESS_TOKEN_CACHE_PREFIX + configService.getAppIdSelf(), accessToken, 7200, TimeUnit.SECONDS);
return WxUtils.sensitiveCheck(accessToken, content);
} else {
return code;
}
} catch (Exception e) {
log.error("sensitiveCheck failed: ", e);
// 网络异常等暂不考虑
return 0;
}
}
所以,行文至此,这次时间跨度长达一个月的生产事故,根源在于引入Redis??在于引入组件化复用思想??
在这之前,我们微服务有30个。微服务数量太多,一定程度上意味着开发工作量的增加。
更为致命的是一个模块业务拆分成两个应用。举例来说,有这么一个支付模块,支付涉及到各种各样的支付方式,如微信,支付宝支付,这些支付逻辑会放在Payment-Service里,这就是一个应用,记为payment-A
。
支付肯定会涉及到一些定时补偿任务,以及消费Kafka消息的业务功能。这又是一个应用!!记为payment-B
,且payment-B
肯定会引用payment-A
提供的jar包。
如果payment-A
里面的代码有逻辑调整,并发布上线,那我要不要一起发布上线payment-B
应用呢?
扯远了。上面的问题的答案是:要!!!读者们可以思考一下为什么。
另外,后来我把payment-B
里的逻辑代码移入到payment-A
里,并下线payment-B
应用。
总之,我想表达的意思是,在若干个微服务里,零零散散看到调用微信提供的功能或API的代码。有的是直接copy自其他某个应用;有的是用原生HttpClient
写十几行甚至几十行代码,有的是另写一套HttpUtils.post()
方法(基于HttpClient),有的是使用Spring提供的restTemplate.exchange()
方法。
这谁能忍??作为一个代码洁癖强迫症。
事情多,肯定不是借口。
通过work-around解决问题,远远算不上真正解决问题,只是在给自己制造麻烦,埋下不定时炸弹。
ELK以及Prometheus对于监控生产业务健康良态运行,起到举足轻重的作用。专业的程序员一定要极度重视ELK等日志监控体系。毕竟,测试用例不可能百分百覆盖到业务所有功能及模块。事实上,哪怕真的覆盖到1000%(没写错,百分之一千)的业务功能模块,也无法保证生产服务的健康运行。君不见,服务器异常重启屡见不鲜,数据中心遭遇断电事故偶有发生。
扯远了。
ELK监控业务的前提是应用日志记录的规范性。上面源代码里的敏感词返回结果:
那一行,显然需要进行微信返回responseCode判断,正常的响应使用info日志级别,非正常的响应使用ERROR日志级别。
除了日志级别外,使用AOP记录Controller层接口的requestBody和responseBody非常有必要。如果请求量比较大(如healthCheck接口每隔1s就请求一次这种),或response比较大的接口(打印responseBody会占用服务器资源),可考虑使用@Pointcut进行排除:
@Pointcut("within(com.aaaa.backend.provider.controller.HealthController))")
public void exclude() {
}
@After("webLog() && !exclude()")
public void doAfter() {
log.info("=== End ===");
}
另外,在调用第三方或外部接口时,一定要明确外部接口正常的响应状态码是什么,异常的响应状态码是什么,并记录ERROR或WARN级别的日志。
还有其他一些日志规范。