2000年Eric Brewer教授提出CAP猜想,2年后CAP猜想被Seth Gilbert和Nancy Lynch从理论上证明。CAP是Consitency(强一致性)、Availability(可用性)、Partition tolerance(网络分区容忍性)三个不同维度的组合体,如图1所示。
在分布式系统中,CAP定律中的三者只能同时满足二者(如图1所示):CP、AP、AC模型。进一步分析,AC模型并不真正的存在,脱离P(分布式环境)谈AC都是耍流氓。我们以多机房数据库同步更新的场景来分析下为什么CAP定律中三者不能同时满足,如图2所示,用户通过机房一的数据访问层写入数据到MySQL主库,并通过网络把此数据同步到机房二的MySQL从库。在此场景下,CAP对应的含义为:C为机房一的MySQL数据库主节点更新,那么机房二的MySQL数据从节点也要更新;A为必须保证MySQL主从两个数据节点都是可用的;P为当机房一和机房二主从节点出现网络分区,必须保证系统对外可用。对于机房一的写请求,一旦机房一和机房二出现网络分区(即网络断开),此时写请求无法成功写入到机房二的MySQL从库,就会导致写请求无法返回,即A(可用性)无法满足。大家可以思考一个问题,假设机房一和机房二的网络终究会恢复,用户侧也能够容忍机房网络恢复的时间一直等待,那么CAP定律是否同时满足?
我们来看三个典型的业务场景:业务场景一:在秒杀的场景下,只允许用户购买一件商品;业务场景二:用户下单成功后会产生下单消息,在订单消息响应应答模式下会发送多条消息到MQ中,下游在对MQ中订单消息进行消费时,需要对此订单消息进行去重;业务场景三:在用户对商品下单后,订单状态变为待支付,在某一时刻用户正在对该订单做支付操作,商家对该订单进行改价操作,如何保证操作的数据一致性。
通过业务需求剖析业务背后的本质,是架构师需要具备的核心能力之一。这三个业务场景背后共性的本质是什么?业务场景一需要对用户进行并发控制,也就是需要对用户ID进行串行化操作处理,防止用户重复下单;业务场景二需要对订单消息中的订单ID进行串行化操作处理,防止下游对订单消息重复消费;业务场景三需要对订单ID进行串行化操作处理,防止出现数据的不一致性。既然三个场景都需要对共享资源进行串行化处理,问题转化为锁处理的问题。如果是单机环境通过本地锁的方式可以优雅解决(如图3),在分布式的环境下,服务冗余部署多份,不同的请求由不用的冗余服务来处理,本地锁将不能很好的工作,需要分布式锁进行处理(如图4)。
3.分布式锁本质
提到分布式锁,大家能够想到基于Redis来实现,锁的本质是对共享资源串行化处理。Redis内部采用唯一线程的串行化处理请求恰好满足锁的使用场景。那么基于Redis如何具体实现分布式锁?我们从具体命令和架构两个维度进行分析。
在具体命令实现侧,有两种方式,第一采用Redis SetNX(Set if Not eXsits)命令,此命令在指定的Key不存在时,为Key设置指定的值。SETNX Key Value命令设置成功返回1,设置失败,返回0。比如在业务场景一,用户ID为1009,此时Key为1009,Value 可以随意填写,例如100。当两个服务同时调用调用SETNT 1009 100命令申请锁时,Redis保证只有一个服务能够成功申请到锁。对于锁我们需要加上锁使用的时间,确保锁的公平性,最坏情况下,其他服务能够有机会申请到这把锁。为了达到这个目的,需要对锁进一步添加过期时间,使用EXPIRE Key seconds命令,设置生存时间,比如为用户ID1009设置10S的生存时间:EXPIRE 1009 10。由于SETNX命令和EXPIRE命令是两条命令,需要保证他们同时执行成功。Redis提供了事务的处理方式:采用【MULTI;多个执行命令;EXEC;】LUA脚本执行语句组。业务场景一可以如下进行事务处理:
MULTI;
SETNX 1009 100;
EXPIRE 1009 10;
EXEC;
上述具体命令实现较为复杂,在Redis 2.6.12及以上的版本,可以采用Set Key Value NX PX milliseconds命令方式,此命令在指定的Key不存在时,为Key设置指定的值,并设置生存时间。其中NX参数表示指定的Key不存在时,再设置指定的值;PX参数表示生存时间,单位为毫秒。业务场景一采用Set命令,具体调用命令如下:Set 1009 100 NX PX 10000。
在架构设计侧,第一种方式采用Redis单机方式(如图5),当服务S1和服务S2同时申请锁(Set 1009 100 NX PX 10000)简称为L1时,Redis单机会按照接受到请求先后顺序的处理方式,保证S1申请到锁L1,S2申请锁L1失败。(假设S1先申请锁L1,S2后申请锁L1)。Redis单机模式存在单点隐患,一旦Redis宕机,内存中的锁全部丢失,Redis再次启动,假如此时服务S1还在使用锁L1,服务S2又再次申请锁L1,就会申请成功,此时就会出现同一时刻2个服务同时都拿到同一把锁的尴尬局面。
第一种方式问题在于Redis的单机模式,通过使用Redis集群的主从模式来解决Redis单机模式的数据丢失问题。第二种方式如同6所示,在业务场景一,当服务S1和服务S2同时在Redis主节点申请锁L1时,服务S1申请到锁L1。通过Redis主从集群的数据同步机制会异步同步给Redis从节点,Redis从节点也拥有了锁L1。假设Redis主节点挂掉,由于Redis集群的Sentinal的哨兵监控和主从切换机制,此时Redis集群的从节点会提升为新Redis集群的主节点,继续对外提供锁申请服务,使得锁申请服务继续正常进行。大家思考一个极端的场景如图7所示,服务S1刚在Redis集群主节点申请到锁L1,锁L1还未同步到Redis集群从节点,此时Redis集群主节点挂掉,根据Redis集群的Sentinal哨兵机制,会把从节点提升为新Redis集群的主节点,而服务S2继续在新Redis集群的主节点申请锁L1,那么服务S2就会成功申请到锁L1。那么再次出现服务S1和服务S2在此时同时申请到锁L1的情况。那么为何会造成这样的情况,让我们从架构的本质(CAP模型)来深入分析下原因。
我们要保证同一把分布式锁的申请在同一时刻只能有一个服务拿到此锁,因此从CAP模型底层分析,分布式锁是CP模型。而Redis集群的主从模式是AP模型。也就是说从架构设计哲学层面来看,分布式锁选用Redis集群的主从模式就是不优雅的,从而导致了上述一系列问题的出现。
但是,当在百度里搜索分布式锁,有很多的实现方案是基于Redis集群。为什么会是这样?我们继续深入分析。一切脱离场景谈架构都是耍流氓,特别是脱离业务场景。业务场景分为2类:追求数据强一致性场景、追求数据最终一致性场景。数据强一致性场景比如金融、电商交易等,使用分布式锁时需要使用CP模型,不然就会出现支付去重失败等重大问题,此时公司离破产只差用户一个大请求;数据最终一致性场景比如微信发消息等,在使用分布式锁时使用AP模型较优雅,比如对用户发送消息(今晚有空吗?约个饭)的去重,极端情况下使用分布式锁去重失败,也就是消息发送到对方2次,反而会增加彼此之间的感情,本来要拒绝邀请的,由于收到2次邀请消息,结果就不好意思拒绝了。
4.分布式锁设计与实践
分布式锁存储选型至关重要,以下对比了Redis、ZooKeeper、etcd等存储模型,如图8所示。通过以上的分析,对于数据一致性要求高的业务场景需使用CP型的存储模型。ZooKeeper多锁实现使用创建临时节点和Watch机制,在执行效率、扩展能力以及社区活跃度等方面低于etcd,因为选用基于etcd作为分布式锁的存储模型。
分布式锁的架构设计如图8所示,由etcd存储集群、分布式锁客户端、监控平台等三部分构成。其中etcd集群负责锁的申请、续租、释放等操作处理,分布式锁客户端通过HTTP API对etcd集群进行操作,从而使得微服务调用方能够申请锁、对锁进行续租、对锁探活、锁操作的监控等。在部署层面,etcd集群至少需要部署3台,分布式锁客户端以SDK的方式嵌入到微服务中。
从架构设计哲学层面分析,分布式锁本质上是CP模型。一切脱离场景谈架构设计都是耍流氓,因此我们需要针对业务场景的不同,选用优雅的分布式锁实现,在追求数据强一致性的业务场景中,选用CP存储模型,在追求数据最终一致性的业务场景中,选用AP存储模型。