不要重复它!幂等性的设计

原文:不要重复它!幂等性的设计

面向对象编程中有个重要的原则:Don’t repeat yourself!(不要重复你自己,简称DRY),指系统中的每一部分都必须有一个单一的、明确的、权威的代表,劝导程序员不要写重复代码,做重复设计。由此引申出来一个概念:Don’t repeat it!(不要重复它)也非常有趣,指面向用户提供的公共方法或者接口服务不会对同一件事情重复去做。如果以前已经计算过这个结果,那么直接返回它,如果以前已经执行了这个事务,那么直接跳过这个事务。也就是说,软件设计要支持幂等性

幂等是一个数学概念,指一个元素无论自乘多少次,都与其本身相同,即1^n=1。在编程中引申的概念是,一个方法或服务任意执行多次所产生的影响均与理想的一次执行的影响相同。“理想”指的是不出错,这是当然的,如果你的服务在执行过程中出错,并且没有办法回滚到执行之前的状态,那么你就不能拒绝下一次的相同请求。幂等不仅仅是为了拦截用户偶然的多次请求,无论相同的请求在明天出现,还是在一年后出现,都不应该重做一次。

理解是否需要幂等性,要比知道如何设计幂等性重要的多。幂等性的主要实现思路是,给请求构造唯一识别码,接口在执行时根据这个识别码决定事务执行是否需要跳过。这个识别过程可能并不简单,但是仔细思考总会有办法,而如果一开始就没有意识到接口需要幂等性,可能会引来大麻烦。我们看一个案例。内容管理系统中有一个给用户发放代金券的操作页面,采编运营人员手上握有一批数十万个用户的清单,这些用户都需要发放代金券。他们找到批量提交入口,上传清单文件,点击确定,但是遗憾的是,页面等待一段时间后报错“服务端运行异常”,于是它们选择重试,重新提交这批清单,可惜仍然报错,这样反复了4次,最后无奈将这个问题反馈给开发人员。我接到这个问题时,惊讶地发现数据库在那一时段居然有上百万条代金券发放记录!这是重复提交所致,很明显入库代金券的接口并没有对每一个insert语句判重,导致相同的记录重复了4次。我问他们为什么要这样做,他们说当时系统报错,并不知道发送成功了。

由此可见,操作支持幂等性,已经完成的工作不会重复去做,这是我们的用户直观认为的事实,我们有什么理由不这样设计呢?我找到了导致代金券发放接口异常的原因,这是在代金券全部入库完毕之后,调用外部通知接口出的错,此时这个外部接口无法访问。有很多种方式可以修复这个报错,比如捕获并忽略异常,异步地调用接口不管其执行是否成功,或者是返回更准确的错误消息,让用户知道是什么错误,提醒他代金券已经发放完成。但是这都不解决根本问题,我们的用户总是能找到各种理由重试,比如重复点击按钮确保服务器收到请求,或者认为太久没有响应就是失败因而再次提交一遍。如果接口不具备幂等性,这样的重复请求将是一场噩梦。

那么如何给发放代金券的接口实现幂等呢?首先应该思考,能识别是同一个请求的标志是什么。用户的id并不一定可以作为唯一标识,因为不排除给同一用户多次发放代金券的可能,结合代金券id也不行,因为多次发放的也可以是相同的代金券。由前端生成唯一id,传送给后端,后端将这个id作为数据库的主键,那么就天然地拥有了判重的机制。这个思路没问题,关键在于唯一id的生成算法,它不可以由客户端随机生成,这样每次请求仍然是不同的。它应该通过客户端侧的用户所提供数据的各种细节(比如清单文件名,用户id,代金券id,活动主题id),创建唯一的散列值,当然最好的情况是提供数据的清单本身就包含了这个散列值流水号,这样不管什么时间什么方式以这批清单文件中的数据发起请求,接口的入库程序对每一个请求都只做一次。

很多场景都需要幂等性,它不仅能作用在对数据的变更,也能作用在对数据的查询。不一定是因为接口会产生副作用,所以才需要拦截重复的请求,如果查询很耗时,消耗系统资源,那么也不应该重复去做。也可以理解为,对cpu的消耗以及对用户请求的阻塞本身就是一种副作用,对于用户来说,最好直接返回结果,除此之外的一切皆不必须。如果程序能够预测到将要查询的内容相比之前不会有异,那么应该返回上次缓存的结果,而不是再来一次,如果查询速度相当快,缓存不起作用,但如果查询很耗时,缓存能对接口的响应速度起到决定性的作用。比如下面的程序:

public int hashCode() {
    int h = hash;
    if (h == 0 && !hashIsZero) {
        h = isLatin1() ? StringLatin1.hashCode(value)
                       : StringUTF16.hashCode(value);
        if (h == 0) {
            hashIsZero = true;
        } else {
            hash = h;
        }
    }
    return h;
}

这段代码摘自Java的String类的hashCode实现代码。方法中使用了hash和hashIsZero变量来缓存哈希值,这两个变量是String类中唯二的非final字段,基本类型赋值的原子性使得它不会产生并发问题。不是说String类是不可变吗?也没错,虽然其内部细节仍会改变,但是这两个字段对外部是完全不可见的。

让方法执行多次的总和时间与执行一次的时间相同,这算是对幂等性概念的延展。程序员不喜欢做重复的事情,机器也不喜欢,虽然hashCode方法执行地并不慢,但是使用缓存之后将算法的复杂度由O(n)变为了O(1)。多次执行hashCode方法的总时间是n+1+1+…≈n,和执行一次hashCode方法的时间差不多,这正是幂等性的体现。缓存是一个比幂等性更大的话题,应用领域也非常之广,不过我们发现这两者之间还是存在共通性——都使得相同的计算过程只发生一次。

虽然有上述的一番建议,但这并不意味着在任何情况下都应该实现幂等性。如果幂等性的实现比较复杂,而实际情况可以接收一定数据的重复(或者就是希望重复),那么就没有必要这么做,因为有时候,幂等性的实现给代码添加了一定的复杂度,尤其是在面对并发请求的时候。

现实是,最不想遇到的往往是最常遇到的,并发请求无处不在,而且还都需要支持幂等性。举个例子来说,有一个抽奖接口,用户凭借一个抽奖流水号去抽奖,接口需要限制一个流水号只能抽奖一次。一般来说,把流水号作为数据库的主键或唯一约束是最简单也是最有效的解决幂等性的方法,只要抛出异常,并且捕获到这是主键冲突异常,那么就可以认为这次请求是重复的,不用再继续执行了。但是如果没有数据库,或者数据库不能做插入,又应该如何处理呢?我们经常在代码中这么实现:

@PostMapping("/doSomeThing")
public String doSomeThing(String serialCode, @RequestBody User user) {
    if (redisService.read(serialCode) == null) {
        // do some thing
        redisService.set(serialCode, "1", 3600 * 24);
    }

    return "your result";
}

这种做法并不安全,在并发环境中,可能出现多个线程执行完read方法但未执行到set方法的情况,“do some thing”这部分的代码仍可能被相同的serialCode重复执行。

如果你的数据库是以update方式做更新的(现在更流行insert做更新,面对大规模的数据效率更高),可以考虑使用乐观锁。新增一个operate_version字段总是可以的,在update语句中携带operate_version字段做条件查询:

update tb_user_draw 
set price = price + 10, operate_version = operate_version +1 
where user_id = '10010' and operate_version = 1

sql执行后,operate_version被更新为 2,这样再次以operate_version = 1作为条件的更新将不再生效,因为operate_version = 1的数据已经不存在,这样就能实现更新的幂等。像mysql,oracle这样的数据库系统,在update执行过程中都是内部加锁的,因此不用担心并发执行问题。

如果没有数据库,数据只存在于redis服务器,可以将if-and-set模式代码搬运到redis内部来实现。redis服务器内部处理用户请求的线程只有一个,如果设计redis lua脚本以支持if-and-set模式将获得天然的并发安全性。

总的来说,无论是数据库层面实现幂等性,还是靠分布式缓存实现,或者其他的什么方式,都是基于现有的架构基础之上的。如果方便在数据库中实现就在数据库中实现,如果方便用缓存实现就用缓存实现,良好的软件架构应该不碍于基于其实现幂等,不要为了实现幂等性而搭建一个缓存服务器,或设计一套全新的机制。

一次执行就成功是理想情况,如果出错了怎么办?用户期望不断重试以得到正确的结果,你的接口应该也要做到这一点,即支持可重入。接口的执行结果不是只有成功和失败两种状态,也存在中间态,尤其是一些调用外部服务且操作不可逆的接口。如果接口在执行过程中失败了,如果不能恢复成执行之前的状态,那也务必让它落在中间态上,下一次的请求,直接从这个中间态开始继续之后的流程。

知道幂等性的作用,也知道了如何实现幂等性,这就足够了吗?世上没有万能的解药,如果面对的是金融级的业务,单单依靠幂等性拯救不了坏的设计。我就遇到过这样的教训。某智能云平台提供了图像增强功能,按请求次数付费,我们的项目对接了这个服务。整个流程并不简单,我需要先将本地图片资源上传到云平台的对象存储服务器,再以图片对象地址作为参数调用请求图片增强接口,然后定期轮询查询图片增强结果,最后将增强后的图片保存到我们的服务器上。“请求图片增强接口”这一步操作是付费的,而且重复请求重复付费!为了不让已经请求过的图片再次请求,在数据库中建立一个状态标志位,如果已经发起请求则设为1,并且代码中没有第二处改变这个字段的update语句。整个运行过程是通过定时任务进行的,是单线程的,没有并发问题,这样一看非常完美,的确,如果操作不失误的话。

然而在一次调试时,我误把这个状态标志位当成启动开关批量重置了,过了几秒钟我才反应过来,然而此时请求已经全部通过定时任务提交到云平台上,如果没猜错的话,这些都会再重新收费一遍,这可麻烦了。我第一时间登录他们的管理平台,找到请求队列,因为执行一次分析还是要耗费一段时间的,如果能即时撤销的话,只是重复了几条记录影响不大,然而,面对上千条在队列里的请求,管理平台没有提供批量撤销功能!最可气的是,单个撤销功能简直是反人类的交互模式,每次只显示10条,要撤销第100条数据,需要点击查看更多10次,一但操作一次撤销,重新刷新页面重新来过!只能换个方法,我查阅API文档,看到有取消查询的接口,我很高兴的打开IDE,却惊讶地发现这个接口没有供应在SDK上(我使用他们提供的SDK来发送请求,而不是调用原生API),也就是就,如果我要取消查询,需要重新对接,显然已经来不及了。最终我还是没能挽回这次的失误。

虽然云平台在某些方面做的不够人性化,但是我的糟糕的设计才是根本原因。我将所有的砝码都压在了单一的状态标志位上了,却不曾想过,万一有一天它失效了该怎么办。既然查询结果保存在数据库中,为什么在Where语句中不带上这个这段,只在没有结果的时候才提交请求;为什么不用redis存储这个冗余状态位,用几千个key的容量换取多一层防护应该是划算的吧;最重要的是,为什么API提供了取消接口,却没有对接,按照正常逻辑的确用不上,但是一个能解决异常逻辑的系统才算得上是鲁棒的系统啊!

我们应该认识并理解幂等性,设计时不要忽视它,实践时也不要把它当作金槌子。用户决定我们设计的接口是否要承诺一个幂等性,不重复做一件事情,是用户期望看到的还是不期望看到的呢?最小惊讶原则指导我们,设计接口以符合用户的直观预期,而不是让用户查阅一大堆文档之后才发现自己理解错了。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值