文章目录
一、问题描述
相信很多后端开发人员都会遇到这个问题,客户端偶尔会频繁请求后端服务,有时请求数据也基本相同,如果后端没有做好服务幂等性或访问频率限制很容易出现意想不到的问题。最近就遇到这样一个的问题,客户端监听一个第三方APP的数据变更,一旦该APP数据变更了,客户端就会上报变更相关的全部数据给服务端,可能第三方APP仅仅是更新一下用户登录/登出时间。然而,现在第三方APP将原本可以合并的数据变更拆分成了多次变更(当然,也可能只是第三方APP多次从他们服务端拉取数据),我们的客户端未对这类信息做过多处理,而是直接上报给服务端,导致服务端在几毫秒之内就会收到多条几乎同样的请求,这可能就是是很多开发同学眼中的并发问题了(其实不然),服务端会根据请求给客户端下发处理指令,客户端模拟人工操作第三方APP(外挂),由于客户端请求时间间隔非常短,服务端在短时间下发多条指令是非常危险的,极易被风控检测,下面附上客户端请求的日志截图。
图1(客户端请求日志)
二、问题排查与定位
由于最初的业务系统的设计与开发我都未参与,对者整个业务流程的完整链路也是后面慢慢摸索清楚的,最初对业务的改动也局限于熟悉业务同学的指导,业务同学将该问题归结于并发问题,并前后对该段业务代码逻辑做了不下于3次的调整,貌似每次调整之后效果都微乎其微,后面也就没有对该问题进行更进一步的跟踪了。
1、开发环境模拟线上请求(复现问题)
- 抓取线上请求数据,通过Charles代理模拟100次请求(并发量设置为10),验证代码中基于Redisson实现的Redis分布式锁是否有效,多次试验结果为:100次请求中有90次以上都能成功地获取及释放Redis分布式锁,请求配置及部分试验数据结果如下:
图2(并发请求Redis分布式锁)
- 修改代码逻辑循环100次获取和释放Redis分布式锁,查看Redis锁成功锁住请求的概率,结果如下:
图3(循环获取Redis分布式)
- 再次修改代码,直接调用服务端封装的Redis set 命令redisClient.set(String key, String value, String nxxx, String expx, Integer time),查看请求set成功和失败情况,实现结果为:仅有第一次请求能成功设置Redis数据,并返回"OK",其余在5min内的时间里的请求均返回null,测试代码修改如下:
图4(Redis set 命令控制服务访问频率)
2、Redis分布式锁相关代码分析
下图可以看出,代码中使用的是基于Redisson实现的Redis分布式锁,相关代码比较简单,锁获取及释放逻辑主要分以下几步:
a.创建Redisson连接实例;
b.根据lockKey获取锁实例(非公平锁);
c.通过调用lock.tryLock()方法尝试获取锁,锁获取失败会自旋一段时间继续获取,直至锁获取成功或等待获取锁时间超时,详细流程可查阅源码或参考:[基于Redis实现分布式锁-Redisson使用及源码分析](https://cloud.tencent.com/developer/article/1350312);
d.执行业务逻辑,释放锁(注意:如果业务逻辑相对简单,锁会很快释放);
图5(Redis分布式锁相关代码)
3、问题定位
a.通过上述分析及线下数据模拟测试可以发现基于Redisson实现的分布式锁每次从加锁到释放平均耗时约为2毫秒,而从图一的日志中可见个销号好友添加时客户端会在很短时间内多次请求服务端,且请求数据除时间戳之外基本相同,这多次请求的时间间隔也略有差别,从几毫秒到几分钟不等,这写时间间隔一般都会大于业务线程持有锁的时间,即,问题所在;
b.分析及数据验证发现线上业务锁不住的问题为,锁持有时间太短,小于两次客户端请求时间间隔,通过代码可以看到Redisson锁中有过期时间的概念,但这个过期时间指的是所释放失败后,默认锁持有最大时间,并不是真正的锁会持有的时间;事实上,该问题并不是Redisson分布式锁锁不住的问题,而是业务场景需求与锁的功能不匹配的问题,业务场景要求的应该是幂等,即相同请求多次访问返回结果应该相同,电商业务中存在很多业务场景(例如:支付,退款,优惠券发放等等);
c.锁主要用于解决并发问题,什么问题才是并发问题,百度百科中的解释:并发,在操作系统中,是指一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理机上运行,但任一个时刻点上只有一个程序在处理机上运行,可见客户端在几毫秒到几分钟内以相同的请求参数多次请求服务端,未必会产生并发,可能会是并行(线上存在多台服务器且每个服务器又有多个内核)或串行(同一服务器同一内核上先后执行多条指令);
4、测试数据及问题验证
- Redis分布式锁获取及释放时长验证:经过大量数据测试开发环境中锁获取和释放平均时间分别为:1.12ms和0.98ms,这一结果和机器有很大关系,实验测试过程中未执行任何业务逻辑,由于文章篇幅原因仅截取部分实验数据,实验数据可以看出Redis无论是加锁还是释放锁耗时基本都不会超过1ms,性能非常高,结果如下表1(表中数据单位均为:毫秒(ms)):
表1:
before lock | after lock | after release | cost of get lock | cost of release lock |
---|---|---|---|---|
1562235688109 | 1562235688109 | 1562235688110 | 0 | 1 |
1562235688110 | 1562235688111 | 1562235688112 | 1 | 1 |
1562235688112 | 1562235688112 | 1562235688113 | 0 | 1 |
1562235688113 | 1562235688114 | 1562235688115 | 1 | 1 |
-------- | ----- | -------- | ----- | ----- |
1562235688235 | 1562235688236 | 1562235688236 | 1 | 0 |
1562235688236 | 1562235688237 | 1562235688238 | 1 | 1 |
1562235688238 | 1562235688239 | 1562235688240 | 1 | 1 |
1562235688240 | 1562235688240 | 1562235688241 | 0 | 1 |
1562235688241 | 1562235688242 | 1562235688243 | 1 | 1 |
1562235688243 | 1562235688244 | 1562235688244 | 1 | 0 |
- 业务并行问题验证,图6中可以看出,两个请求被分发到了不同服务器上了,结果如下:
图6(业务并行问题验证)
- 服务端封装的Redis set 命令 redisClient.set(String key, String value, String nxxx, String expx, Integer time)执行时长测试,经过一定量的数据测试得到开发环境中封装后的Redis set 命令执行平均时间为:0.75ms,这个结果比Redis分布式锁获取时间更小,原因很简单,锁的获取和释放会有一些代码逻辑及判断逻辑的执行,这部分代码的执行时有成本的,不过Redis锁获取和set命令的执行两者差距也并不悬殊,文章篇幅有限,部分实验结果如下表2(表中数据单位均为:毫秒(ms)):
表2:
before set | after set | cost of set |
---|---|---|
1562243878700 | 1562243878701 | 1 |
1562243878701 | 1562243878701 | 0 |
1562243878701 | 1562243878702 | 1 |
1562243878702 | 1562243878703 | 1 |
1562243878703 | 1562243878704 | 1 |
-------- | ----- | -------- |
1562243878772 | 1562243878773 | 1 |
1562243878773 | 1562243878774 | 1 |
1562243878774 | 1562243878775 | 1 |
1562243878775 | 1562243878776 | 1 |
三、原因分析及思考
- 业务需求、流程完整链路逻辑及对Redis分布式锁理解不够深入,业务面临的问题实质上并非严格意义上的并发问题,Redis向来以高性能著称,单机写入量可达10w+/s,Redis分布式锁性能也比较高,业务逻辑如果相对简单中间业务阻塞情况下,整个锁持有时间可能仅有几毫秒,当客户端两次请求时间间隔大于Redis锁持有时间时,锁对该情形已然失效,即便是将Redis由分布式换成单点或使用基于ZooKeeper实现的分布式锁同样不能解决业务问题,锁一般应用于多个线程同一时刻操作同一份数据(写数据)的场景,锁具有以下两个特性:1、高效性:无论是加锁还是释放锁都应当非常高效的完成,否则锁将会失去其存在的意义,成为性能上的瓶颈;2、瞬时性:锁在业务中不应当长时间持有,锁的持有过程是瞬时的,在多线程高并发场景中锁如果被一个线程长时间持有,会导致其他线程阻塞,甚至更严重的死锁以至于服务器CPU飙升宕机,从而整个服务都将不可用;
- 开发人员面对线上问题时,首先要做的是定位问题产生的根本原因,然后确定方案解决问题;确定方案时需要对方案进行深入研究及测试是否能满足业务上的需求,在互联网发达的今天,很多问题网络上都能找到解决方案,但只有清楚的梳理好真正的问题,你找到的解决方案才可能是真正的解决方案,否则,你可能只是再一次错误的“解决”问题,这个或许就像我们考试中的审题一样,如果题目都看错了,答错的几率可想而知;
- 问题的根节点和解决方案都找到了,对解决方案的验证也是必不可少的,对线上用户相关的数据所有开发都应当心存敬畏,不要随便轻易用线上的真实数据做测试,因为有些代码逻辑带来的损失可能超乎你的想象,为了安全尽量做到在开发环境把能测试的流程都完整的走一遍,对于开发环境无法覆盖又存在疑问的点需要想好灰度方案,做到及时止损;回到上述Redis锁不能解决业务的问题,倘若使用时在线下测试一下很容易就能发现问题,更换Redis锁是不能解决问题的,问题点在于锁的持有时间短,实际业务上需要的是幂等或客户端访问频率限制;
四、解决方案
问题根源找到了,解决方案就很简单了,针对业务需求文中提供以下三种解决方案:
-
Redis分布式锁不主动释放,等待锁超时后自动释放,该种方案仅适用低并发对性能要求不高的业务场景,若请求量较高会导致大量资源被占用,影响服务器性能及服务能力,具体做法见下图7:
图7(注释锁释放代码)
-
使用Redis set(String key, String value, String nxxx, String expx, Integer time)命令做客户端访问频率限制或简单幂等逻辑判断,该方案适用请求消息体很容易判断是否相同及高并发场景,但若果key设置的不合理很可能会误杀请求(注意:Redis set命令中有5个参数,需要清楚每个参数的意义,需要同步(原子性)设置过期时间,避免非原子性设置过期时间失败导致key无法过期问题),具体做法如下图8:
图8(Redis set 命令访问频率限制)
![Redis set 命令访问频率限制](https://img-blog.csdnimg.cn/20190706220323609.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3h3eV9oZHU=,size_16,color_FFFFFF,t_70 -
业务上保证幂等性,每次客户端请求过来都去数据库查询数据记录,判断请求是否已处理过,若处理过直接返回处理结果;否则处理请求;该方案适用于客户端偶发多次请求或业务重试等场景(延迟消息重试)等场景,对于高并发场景频繁查询数据库会存在性能瓶颈,因为这类幂等性查询要排除数据库主从延迟情况只能走主库查询;
五、名词释义
对于并发和并行的概念相信很多人都容易混淆,并行就是同时(同一个时间点)执行,是真正意义上的并行执行;并发只是线程的交替执行,有可能存在串行的情况;在单核CPU的系统,线程只能是并发的,而不能支持并行,并行执行只能存在与多核CPU的系统[3];
- 并发:指一个时间段中有几个程序都处于已启动运行到运行完毕之间,且这几个程序都是在同一个处理机上运行,但任一个时刻点上只有一个程序在处理机上运行。
- 并行:指一组程序按独立异步的速度执行,无论从微观还是宏观,程序都是一起执行的。并发是指:在同一个时间段内,两个或多个程序执行,有时间上的重叠(宏观上是同时,微观上仍是顺序执行)。
六、参考文献
1.https://cloud.tencent.com/developer/article/1350312/
2.https://www.runoob.com/redis/redis-tutorial.html/
3.https://blog.csdn.net/u014427391/article/details/85019834/
4.https://blog.csdn.net/qq_27825451/article/details/78850336/