前言
在上一篇中我们讲了通用优惠券系统的设计,这篇主要是以优惠券重构后,我们现有系统接入到该通用优惠券系统过程中遇到的数据迁移与一致性问题相关的思考与实践。我们早期的优惠券系统使用的是ckv的存储,后来为了统一,全部改为使用redis储存了,这里首先一个数据迁移点是 ckv----->redis的迁移,另一个数据迁移点是上海redis----->深圳redis。之所以会有redis --->redis的迁移,主要是刚开始我们redis是和别人混部,选择了一个上海的机房,由于整个服务几乎都部署在广深地区,所以需要迁回来,并且单独一个redis集群存储,不在混部。
数据迁移的一般方案
对于数据迁移来说,一般双写肯定是少不了的。因为整个服务的存储切换并不是一瞬间就能完成的,切换的过程中必然会存在服务A写了储存B,然后读存储C的可能性,如果不双写,写入到存储B的数据在存储C将读不到。
那么现在的问题就是怎么双写呢??这里提供几种思路
1,服务A写完储存B了之后,接着写存储C
2,服务A写完储存B之后,异步写存储C(发一条消息出来)
3,服务A只写存储B,然后监听存储B的写记录,抛出一条消息出来,异步写存储C
双写的策略是加上了,但是怎么保证数据的一致性呢?写了存储B成功之后,再写存储C就一定能写成功吗,如果不成功,那两边的数据就不一致,读到了不一致的数据,又该怎么办?
对于第一种方案,如果存储支持分布式事务(如mysql),是可以做到强一致性的,我们可以通过事务的机制保证储存B和存储C要么同时成功,要么同时失败。
对于第二种方案,可以勉强保证最终一致性,写完储存B之后,异步消息去写存储C,消息的机制,可以保证一条消息只要不被commit,就能一直重复消费。但是这里可能存在服务A发送消息失败的情况
对于第三种方案,可以比较稳妥的保证最终一致性。这里是监听存储B的写记录,比如mysql的binlog,redis的aof文件等。特别是binlog,腾讯云提供了一套dts服务,将mysql的写入记录发一条kafka消息出来,供服务监听的服务消费。。
实战之我们的解决方案
前面我们说了,我们有两次的数据迁移,那我们的数据迁移是怎么一个过程呢?我们的迁移步骤如下:
1)服务上线先双写,并关闭灰度开关,全部读老的存储B
2)数据迁移,将存储B的数据迁移到存储C
3)打开灰度开关,部分读存储B,部分读存储C
4)全量读存储C,停止双写
这里最值得探讨的就是第三步,灰度期间,部分读存储B,部分读存储C的过程。此处以灰度期间用户的读写操作为例进行探讨。
写操作
上图是一个写请求的双写过程。当一个写请求过来的时候,我们先写存储B(老的存储),如果写失败了,直接返回失败,否则同步写存储C(新的存储),如果存储C写失败了,抛出一条kafka消息,异步写存储C。只要写存储B成功了,不管写存储C是成功还是失败,我们都返回成功。
为什么要存储C又有同步,又有异步??
这样做的目的是优先保证数据的强一致性,如果保证不了强一致性(同步写存储C失败)再以最终一致性兜底。
为什么要保证强一致性,不直接最终一致性呢,且同步调用必要会有时间延迟?
同步写存储C的目的是为了体验考虑,在我们的业务中,如果只用异步消息写存储C的话,会出现存储B写成功了,但是立马查存储C,查不到,然后需要等待一定的时间才能查到。这样再我们的业务中会出现一些不好的体验,比如在我们的优惠券详情页用户点领取优惠券,然后会立马查该用户优惠券状态,如果写B,读C,那么给用户的体验就是领取之后没有任何反应,也不知道是领取成功了还是失败了,需要等一会,然后刷新下才行。
读操作
读操作这里主要是讨论灰度期间的读操作,(服务上线后最开始的只读存储B和最终的效果只读存储C没什么可讨论,从一个数据源读,没有一致性的问题)对于非灰度的用户,我们还是读老的存储B,对于灰度的用户,读新的存储C,那么在读新的存储C的过程中可能会出现读不到的问题(前面同步写失败,小概率事件,然后改走异步消息,但是异步消息还没写或者甚至于发消息失败)。如果读不到我们会再去读存储B,然后再将存储B的数据返回。
这种方式存在两个了问题
1)如果发消息成功了还好,最终我们会从存储C读到数据,但是如果发消息失败呢,存储C就永远没有数据了。
2)即使从存储C读到了数据,如果是最终一致性兜底,可能存储B的数据和存储C的数据是不一致的。
更优的读操作
对于灰度的用户,新老数据都要读,并且读到之后对新老数据进行比对,验证,如果新老数据一致那很完美,随便返回一份数据就行,如果读到的新老数据不一致,那还需要去回写存储C,让B,C保证一致。
当然啦,这里依然还会点问题,如果回写的时候失败了怎么办?
这里我们还有更进一步的兜底神器,对账。具体来说,写个对账脚本,定时去做储存B和存储C的数据对账,如果不一致,可以通过对账脚本来兜底,将两边的数据保证一致。
其实对于第二种读方式还是很蛋疼的,虽然说每次都能保证读到的数据是正确的,但是每次都进行了双读,也确实很蛋疼。所以在业务中,我们采用的是第一种灰度读策略。
为什么我们可以采用第一种可以呢?第一种读我们上面也讨论了可能存在两个问题
1)读不到
2)读到的不一致的数据
对于读不到又分两种情况,一是消费延迟了读不到,二消息发送失败了读不到。由于读不到我们会读老存储,所以对用户来说,是无感知的。而且我们优先保证的是强一致性,因为消费延迟导致的读不到概率很小,而消息发送失败导致的读不到概率就更小了,对于我们这种并发量不是特别大的业务,几乎等于不存在。
对于读到的数据不一致的问题,这个稍微麻烦点。但是从业务的角度来说,也是能忍受的。比如说用户先领用了一张优惠券,然后用户紧接着使用了这样优惠券,如果使用写操作写存储B成功,但是还没写存储C,这个时候两边读取到的状态是不一样的。表现出来的现象是用户优惠券状态真实是已使用,但是用户看到的是已领取未使用。但是这里的影响也仅仅是短暂的看到表现不一致而已,如果用户再次使用该优惠券,双写的时候写存储B就会失败,因为存储B里面的状态是已使用,不可能让已使用状态的优惠券再次使用。
后记
本篇主要探讨了下数据迁移的一般方法,以及数据迁移过程中,对于可能存在的数据不一致问题与相应的解决方案。以及我们服务采用的数据迁移与一致性策略。