服务幂等以及常用实现方式
现在稍具规模的网站和大型应用都不再是单机模式,而是分布式应用,基于多机的集群构建的应用,这样服务能力就可以基本实现横向扩容(scale out),不会像单机模式下的纵向扩容(scale up)会受到单机服务能力上限的限制。另外,随着“微服务”概念的火爆,很多应用在构建之初就已经走在了分布式的路线上了,所以就目前行业的发展来看,基于分布式的应用会越来越普遍,甚至变成常态。加上docker这些容器技术的出现,应用分布式化的工具也越来越成熟。
分布式的复杂性
众所周知,构建分布式应用所面临的复杂度远远超出集中式的单一应用,导致复杂性的因素有很多,在此只提其中一点:网络的不可靠性。在单一进程内部,对一个函数的调用,结果只有两种——成功和失败,失败的情况下,调用者可以决定做一些事情弥补。但是在跨进程的调用中,对一个远程(也可以在同一个节点上)进程上运行的函数调用除了会得到成功和失败,还会有第三种的情况——超时,这个现象被称为分布式的三态。这也是困扰分布式应用构建的最核心因素之一,很多分布式应用的复杂度之所以上升这么多也是因为三态之中的超时引起的。
简单看看超时给我们带来的困扰,进程A调用进程B上的函数f,对于成功和失败的结果,相信和单机下一样,进程A都可以进行很好地的处理,因为结果是很明确的。如果进程A调用f之后,在允许的等待最大时间内没有返回结果,就是调用超时了,此时进程A能做什么?其实进程A什么都做不了,因为超时是一个不明确的结果——成功和失败都有可能。详细解释下可能的情况:
成功的情况:进程A把数据通过网络传输到进程B上,f执行成功,通 网络返回执行结果给进程A,可是网络不太好,传输失败了,进程A并 未在指定时间内收到结果,认为超时了。 失败的情况:情况和成功的情况差不多,只是f执行失败了,但是结 果依然传输失败,进程A也认为执行超时了。 未执行的情况:进程A的数据发送到进程B所在的节点过程中网络失败 了,或者发送到了进程B所在的机器上,但是进程B没有消费掉在TCP 网络层的数据等等 由此可见,进程A对于超时确实无能为力,有太多的可能存在的情况了。但是分布式协作过程中又必须解决这个问题,不然分布式应用是没意义的,这种情况下,一般会采用让进程A尝试重试——即重复发起之前的调用。但是这样也可能会带来问题,因为超时的那次调用可能已经成功了,再次以同样的参数调用f会不会带来额外的问题?这就引出本文的主角——幂等性。
幂等性
幂等性本来是一个数学概念,在计算机方面用来表示对同一个过程应用相同的参数多次和应用一次产生的效果是一样,这样的过程即被称为满足幂等性。
有了这个概念之后,假如之前的f是满足幂等性的,那么是不是意味着进程A在调用f超时之后,可以继续重复调用f多次?这样最起码进程A可以在超时情况下做一些促进事情正向发展的努力。
所以这种方式是分布式节点间常用的方式,那么如何保证幂等呢?
如何实现幂等性
在考虑实现幂等之前,先看看有哪些操作是天然幂等的,以SQL为例。update tab1 set col1 = 1 where id = 2这样的更新语句,无论执行多少次结果都是不受影响的,所以是幂等的。update tab1 set col1 = col1 + 1 where id = 2这样的更新语句会随着每次更新不断变化,所以不是幂等的。所以在考虑之前,先识别出幂等和非幂等操作。
业务系统实现幂等的通用方式:一般是排重表校验,在业务操作所在的库建一张小表,名称暂时搞成dup_forbidden,核心字段就一个biz_id,并且在这个字段上建立一个unique index,其他字段可以根据业务需求来扩充。那么原来的业务f实现幂等的伪代码如下:
begin transaction; count = insert ignore dup_forbidden (...biz_id...) value(...biz_id...) if (count > 0) { f(biz_id) } commit;
可以认为这是一套业务系统实现幂等的模板做法,通过insert ignore返回值来判断是否已经执行过了,但是针对不同的情况可能还有变化。使用事务的目的是为了保证f和dup_forbidden的操作同时成功和失败。本质上来看,dup_forbidden表就是通过unique index来屏蔽对f的多次调用,事实上很多业务已经存在dup_forbidden表的功能。
考虑如下场景:在一个面向交易的分布式应用中,支付子系统完成了支付功能,支付子系统通知订单子系统,通知的方式无非是调用订单子系统的一个函数f而已,只是调用的方式分为同步和异步。无论是同步还是异步,f都可能存在超时,所以为了支持重试,f必须是幂等的。f会首先根据传入的订单号来查找订单,检查订单状态。如果是已经支付,就会直接返回成功。如果是待支付状态,那么会尝试锁定(悲观锁和乐观锁)订单,修改状态,指定其他操作,其中锁定只是为了防止并发操作。伪代码实现如下:
begin transaction; count = update deal_tab set status = paid where id = xx_id and status = unpaid if (count > 0) { f(xx_id) } commit;
从这个例子可以看出deal_tab订单表本身已经可以作为dup_forbidden表的作用了,所以insert防重操作变成update来实现,当然这个是乐观锁的版本。悲观锁的版本如下:
begin transaction; deal = select * from deal_tab where id = xx_id for update if (deal.status == paid) { return true; } else if (deal.status = unpaid) { f(xx_id) } commit;
当然基于悲观锁的做法对于高并发的系统是不建议的,毕竟长时间锁定记录会降低系统的TPS。
当然,所有这些方案都是基于业务存在唯一的业务编号来设计实现的,可能会存在完全没有业务编号的吗?答案是it depends。即使没有完全唯一的编号,我们也可以人为生成编号,比如调用方负责生成调用编号,同一个调用编号发起的多次调用都被视为一次调用,既可以作为唯一键来排重。事实上,这种情况确实比较少!
总结
业务系统实现幂等性的方式基本确定。系统关键接口的幂等性为以后系统的长期发展,特别是往分布式方向发展打下了很好的根基,可以大大简化分布式应用的构建复杂度。