Java多线程并发数据错乱了,接口幂等性如何设计?
2022-03-22 08:13:37来源:石杉的架构笔记
今天给大家聊聊线上系统的接口幂等问题,以及如何通过分布式锁来保障接口的幂等性,同时会给大家分享一下我们在基于分布式锁实现接口幂等性的时候,一些生产实践经验的积累。
首先给大家说说,假如说要是我们线上系统的核心接口要是没有幂等性保障机制的话,可能会出现什么情况?
其实非常简单,假设你有一个系统,他有一个接口,这个接口接受请求的时候假设会在数据库里插入一条数据,正常情况下一个用户对这个接口发起一次请求应该就只有一条数据,结果可能某一天你会发现这个用户通过这个接口插入了多条数据。
如下图 1 所示:
图 1
初版防重代码那么为什么会这样呢?其实我们一般这类系统接口,但凡是写的稍微好一点的,都会在接口里加入防重代码。
就是会有代码判断一下,当前你要写入的这条数据是否存在,如果他要是不存在的话,就会进行插入,但是如果他存在的话就不会允许你重复插入的。
这种防重代码如下所示:
public void business(Request request) { // 1、先根据请求参数在db里查询一下这条数据 Data data = findData(request); // 2、如果这条数据在db里已经存在了,此时就直接返回了 if(data != null) { return; } // 3、如果要是这条数据在db里不存在,此时就会执行数据插入逻辑了 insertData(request); }
结合上面这段代码的防重逻辑,我们可以看下图 2 的运行逻辑展示:
图 2
在插入数据之前一定会先根据请求参数查询这条数据,如果查询到了,则此时直接返回不会重复插入,但是如果没有查询到这条数据,则此时会插入这条数据。
那么大家可能问题来了,那既然都已经有这个防重逻辑了,即使你用相同的请求参数重复多次调用这个接口插入数据,也不应该重复插入数据啊!
按说确实是这样子的,但是凡事总有例外,那就是大名鼎鼎的瞬时重试+多线程并发问题。
瞬时重试+多线程并发问题分析下面我们给大家解释一下,在上述的代码防重逻辑下,如果要是短时间内用户用相同的请求参数重复的发起了两次请求,为什么会穿透防重逻辑,在数据库里插入两条一样的数据。
大家要打起且精神来,仔细来看这个过程了, 首先用户可能会因为过于激动、手抖或者是网络抽风等各种原因,在一瞬间发起两次请求参数完全相同的请求。
如下图 3 所示:
图 3
接着呢,这两个请求到了我们的系统后,其实是分别由一个线程来处理的,不管你是用 tomcat 部署提供的 controller 接口,还是基于 dubbo 提供的 rpc 接口,其实每个请求过来都是由一个独立的线程来处理的。
如下图 4 所示:
图 4
接着呢,这两个线程会并发的运行相同的一段代码逻辑,就是先根据请求参数查询这条数据是否存在,存在就返回,不存在就进行插入。
这个时候可能会出现一个问题,因为是多线程并发,所以很可能这两个线程会同时执行数据查询逻辑,但是他们俩同时执行数据查询逻辑的时候,有一个问题,那就是此时数据库里没数据啊!
所以说,这两个线程并发运行,完全可能会同时发现从数据库里查询出来的数据是空的。
如下图 5 所示:
图 5
然后这个时候,两个线程既然发现自己查询到的数据都是空的,那当然都可以去插入数据了。
所以此时这两个线程会基于这个请求参数分别插入一条数据,而这条数据其实对于业务来说是完全重复的,因为请求参数是完全相同的。
如下图 6 所示:
图 6
这个时候就会导致本次数据重复问题了,针对这种情况,我们一般把这种接口称之为没有幂等性。
因为如果一个接口是有幂等性的,其实对于这个接口如果说用相同的参数发起请求,那肯定是只会有一条数据,不可能会有重复数据的,这才叫做幂等性。
而现在的问题是,这个接口用相同的请求参数发起多次,结果数据有重复了,此时接口就没有幂等性。
数据库唯一索引实现幂等针对上述这种接口幂等问题,其实比较简单的一种解决方案,就是基于我们依赖的数据库去实现幂等。
也就是说,用数据库的唯一索引来实现即可,如果我们要是基于请求中的某一个或者多个业务字段组成一个唯一索引,那么其实你要往数据库中用相同参数插入重复数据,那就是不可能的。
因为数据库层面就会阻止你插入的,唯一索引会确保这一点,你要重复插入,他就会抛异常。
如下 7 所示:
图 7
分布式锁实现幂等但是很多时候我们会发现一个问题,那就是我们可能不一定说每次都可以依赖数据库的 唯一索引实现这种幂等性。
因为有可能你在业务逻辑里,除了依赖数据库以外,还依赖了别的服务接口,或者是 elasticsearch、redis 等多种数据存储,也可能是依赖了数据库中的多张表里的数据,你不可能每张表都做一个唯一索引来确保幂等性。
所以对于有复杂业务逻辑的接口来说,要确保幂等性,往往需要引入一个关键组件,那就是分布式锁。
所谓的分布式锁,意思就是依赖外部的某个系统来加一把锁,锁加了以后后续还可以释放这把锁,现在比较常见的分布式锁实现主要是依赖 redis 和 zookeeper 这两个来实现的,我们这里就以 redis 分布式锁来举例说明。
先往简单了说,我们可以在接口的入口代码处,基于 redis 加一把分布式的锁,这个时候只有一个线程可以成功加锁。
加锁之后,就这一个线程就可以去查询这条数据是否存在,如果不存在,就可以插入一条数据进去,然后再释放锁,在这个过程中,另外一个线程因为获取不到 redis 分布式锁,所以只能干等着。
如下图 8 所示:
图 8
等第一个线程加锁,然后查询数据,发现数据不存在,接着插入一条数据,最后释放锁之后,接着第二个线程就才能得到机会再次加锁,接着第二个线程加锁后查询数据,发现数据已经存在了,此时他就会直接返回,不会重复插入数据了。
如下图 9 所示:
图 9
如上图,大家可以发现,只要在核心接口的入口处加一把分布式锁,就可以实现多线程并发下,复杂业务逻辑不会被重复执行了,而且不依赖数据库某个表的唯一索引,只要基于 redis 实现加锁和释放锁就可以了。
而至于 redis 分布式锁是如何实现的,就不在本文的讨论中了,我们这次主要是给大家先分析一下线上系统接口的幂等问题,当没有幂等性的时候,接口是如何在多线程并发场景下出现数据重复问题的。
总结然后我们分析了,如果要是基于数据库表加一个唯一索引,就可以实现接口幂等了 ,可是如果业务逻辑过于复杂,有很多数据存储,或者涉及很多表,此时就不能单单依赖一个唯一索引了,需要依靠在接口入口处加分布式锁,然后才可以解决复杂接口的幂等性。