幂等的概念
幂等是一个数学与计算机科学概念,在数学中,幂等可使用这样的函数表达式表示:f(x) = f(f(x))。比如求绝对值的函数:Abs(val) = Abs(Abs(val))。
在计算机软件中,程序接口的幂等性是指其任意多次被调用执行,所产生的效果及影响与被调用执行一次所产生的效果及影响相同。拥有幂等性的接口或方法,可以使用相同参数重复执行,而不用担心会发生异常情况。比如我们实现一个“签到”功能的接口,最终执行数据库表的操作可能如下:
UPDATE user_sign SET signed=true WHERE user_id=xxxx;
此操作只是把签到表 user_sign 中表示是否签到的字段 signed 设置为 true,对于这样一个接口所执行的操作,无论是执行1次还是多次,效果都是一样,数据也并不会产生差异。
还有,在现在常见的分布式场景下,由于网络延迟和单点故障等因素,多个业务服务的数据保证最终一致性是一种常见做法,这就要求服务提供者在处理重复调用时需要给出相同的预期响应,且不会对数据造成破坏。也就是服务提供者提供的API需要具有幂等性。
在软件系统中,接口方法的幂等性的现实意义在很大程度上是指,系统的数据满足业务要求,符合业务预期结果,且不会产生冗余的、差错的数据,也是保证系统稳定性的因素之一。
需要幂等的业务场景
场景一:首先讲一个我早些年遇到的一个问题:在一个表单页面填写数据,之后点击“提交”按钮,数据库保存表单所提交的数据,页面跳转到数据列表页面。再去点击浏览器上的“刷新”按钮,此时列表页面会多出一条与刚刚提交的数据一样的数据。(注:当时使用Struts、Jsp等技术,表单对应的数据库表主键是数据库自增的,其他表单字段未设置唯一索引)。
这个问题的原因是当用户点击浏览器“刷新”按钮时,实际是再次提交了“表单”页面,导致后台程序保存数据的接口方法再次被调用,保存了重复的数据。本质上来讲,这就是一个幂等性问题,“保存”操作的接口不具备幂等性,造成接口被多次调用后,数据库出现了冗余数据。
当时工作经验还较少,我是从用户交互的角度去解决这个问题的:当提交表单成功后页面跳转到列表页面,用户再刷新浏览器的时候,实际上用户想要刷新的是列表页面,调用的应该是数据列表的查询接口。具体做法是:在列表页面(list.jsp)的HTML body标签中写上:
onLoad=”window.url=xxx.do?action=selectList”
也就是说,在列表页面加载的时候,就将浏览器的window对象的url设置为数据列表查询接口的url。这样,当用户在列表页面刷新的时候,刷新的是数据列表的查询接口。
再延伸一点,为什么当不在列表页面增加onLoad函数,用户刷新操作刷新的是表单提交的接口URL呢? 因为用户在表单页面点击“提交”按钮的时候,浏览器window对象记住了本次操作,而数据保存成功后跳转到列表页面是在后台程序进行的操作,浏览器window对象记录的最新请求依旧是提交表单的操作,所依即使我们看到的是列表页面,执行刷新操作时,浏览器发出的还是“提交”表单的请求。
但这种解决方案只能算是一种临时方案,并未从根本上解决数据保存接口的幂等性问题。从接口幂等性的角度解决此问题,有两种方法。
方法一
① 数据库表设计方面,除主键外,还需要对必要的业务字段设置唯一性索引。
② 程序实现方面,在数据保存操作insert执行之前,先根据表单字段查询数据库,如果查询结果存在,则直接跳转到列表页面;如果查询结果不存在,再执行insert操作后跳转到列表页面。
方法二
① 数据库表增加一个字段存储唯一性标识ID字段,这个ID可用于唯一标识每一次提交表单的请求。
② 在进入Form表单页面之前,先生成一个唯一性ID,在Form表单页面使用hidden隐藏域保存这个ID值,提交表单时,将该ID值和其他表单业务字段一起作为参数传入后端,执行保存操作时,先查询数据库表是否有这个ID标识对应的记录。没有则保存后跳转列表页面,有则表示表单已被提交过,直接跳转到列表页面。
方法二跟方法一有些类似,区别是其引入了与业务无关的ID标识字段。这也是解决幂等性问题的通用思想:用全局唯一ID标识每一次请求或调用,然后过滤已处理过的请求或调用。
还有其他的一些需要幂等性的场景,比如:
场景二:转账业务,调用转账接口时,由于网络超时或者其他原因,用户未接收到响应结果,此时用户可能再次尝试转账操作,那么就需要幂等性保证用户不会进行重复转账。
场景三:消息中间件(MQ)的消息队列中可能存在重复消息,消费者读取消息时会存在重复消费的情况。
场景四:服务异常中止,重启服务后需要重新执行一批任务(这并不是一个具体的业务场景,但是一种我们容易理解的问题场景。也说明了我们在系统设计开发的时候,要更全面地考虑幂等性问题,它是系统稳定性的重要因素之一)。
针对不同的系统不同的场景幂等性也没有绝对银弹, 下面介绍一些相对通用的方法。
幂等性的几种解决方案
1、token方案
① 在分布式环境中,可以借助redis,当接口调用方发起调用前先生成一个防重token(可使用UUID生成或其他的ID生成机制产生的一个唯一性ID),并把此token存放到redis。
② 在调用下游接口时,把token传参过去,可通过请求头传参。
③ 下游接口在执行业务逻辑时,首先取出token并判断是否存在于redis中,若存在则执行业务,同时从redis中删除token。若不存在则表示相同的请求已调用过,直接返回重复标识给调用端。
token方案类似于上面提到的问题场景一的处理方案二,不同的是引入了redis。在此方案中需要注意的是,下游接口应在业务逻辑处理前执行从redis获取token、与入参token比较、从redis删除token等操作,且要保证这三个操作的原子性,通常使用LUA脚本处理。
实现的伪代码如下:
//入参传递过来的token
String token = "xxxx";
String tokenKey = "some-business-key";
//LUA脚本保证token获取、比对token、删除token操作的原子性. 1:验证成功; 0:验证失败
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
Long result = (Long) redisTemplate.execute(new DefaultRedisScript<Long>(script, Long.class), Arrays.asList(tokenKey), token);
if (result == 0) {
//验证失败,返回调用端
return ...;
} else {
//验证成功,执行业务逻辑
doSomething();
}
2、防重表方案
单独创建一张防重表,专门用于防重校验。例如在MQ消息消费场景中,为了保证消息处理的幂等性,可将处理过的消息放到消费日志表(如:tb_consumer_log),该表存储消息相关的字段,例如:id、group、tag、key、消息实体、消费状态等。消费者处理一条消息时,先到这个表进行查询,判断是否被消费过。只有未被消费过的消息才进行处理,处理完成后,将该消息的相关字段存入此消费日志表。通常将这个消费日志表称为防重表或幂等表。
在一些高并发场景下,防重表也用来与业务表配合使用,将对防重表和业务表的操作放到同一个数据库事务中,防重表通常存放有唯一性要求的业务字段,设置唯一性索引。通过数据库事务保证去重处理,当同时往防重表和业务表写入数据时,如果唯一索引冲突,操作失败,则表示重复操作,相应的数据也会回滚。
3、悲观锁与乐观锁方案
数据库悲观锁是指在高并发场景下,如果要对某一个数据进行更新,首先锁定该数据,使用SELECT...FOR UPDATE语法。
在MySQL数据库中,SELECT...FOR UPDATE与InnoDB存储引擎一起使用(因为支持数据库事务),则查询检查的行将被写锁定,直到当前事务结束。
例如,电商系统中,我们需要更新用户积分。伪代码如下:
//开始事务
Begin Transaction;
//查询出用户记录,并锁住这条记录
SELECT * FROM tb_user WHERE user_id = 1001 FOR UPDATE;
//更新积分
UPDATE tb_user SET user_score = user_score + 100 WHERE user_id = 1001;
Commit;
这个例子中,user_id需要是主键或者唯一索引,否则会锁定整个表。此外,如果事务处理中的逻辑复杂,耗时较长,会造成大量请求等待,存在性能问题。
数据库乐观锁通常是在业务表中增加一个无业务意义的version字段,在更新数据前先查询出version字段,在更新操作时,将version字段作为条件,同时将version+1。还是更新用户积分的例子,伪代码如下:
//更新积分前,先查询出version字段。Version字段需要传参到调用端,再传递到被调用端
SELECT user_id, version FROM tb_user WHERE user_id = 1001;
//假设第一次更新,查询出的version为0. 执行更新操作,将version作为条件同时version+1
UPDATE tb_user SET user_score = user_score + 100, version = version + 1 WHERE user_id = 1001 AND version=0;
判断UPDATE操作影响的行数,如果影响行数为0,则表示version字段已被更新过,当前的请求是重复请求,可直接返回成功。
4、分布式锁方案
处理业务逻辑之前首先获取分布式锁,获取成功则执行业务逻辑,获取失败则不执行业务逻辑。此种情况适用于服务部署了多个实例,多个实例有可能同时调用同一个接口的情况。
还有,前面讲的防重表方案实际上是利用了数据库的分布式锁特性。某些需要更高性能业务场景下,调用业务接口前可以将唯一性业务标识通过setNx命令存入redis,若存入成功则进行业务处理,若存入失败则表示是重复的调用。
小结
幂等性是系统稳定性的重要因素之一,特别是对于分布式系统,接口的幂等性显得尤为重要。在进行系统设计与开发时,需要全面考量,重点关注。很多情况下,幂等性问题不单单是技术问题,而是需要站在业务语义的角度思考与设计,选择最合适的处理方案。而幂等性问题处理的根本思路有以下要点:
- 每一次请求/调用的唯一标识
- 数据库层面的控制:业务字段的唯一索引
- 执行业务处理前先查询确认(先查后写)
- 锁机制:数据库锁(乐观锁、悲观锁)、分布式锁