欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

对分布式系统课程秒杀项目的再思考

程序员文章站 2022-06-21 18:40:44
...

项目源码: https://github.com/AlexanderChiuluvB/DistrubutedSystemProject

本项目经历过一次架构的迭代,核心思想是把保证数据安全的机制从基于mysql乐观锁机制迁移到redis/zookeeper基于分布式锁机制,默认只要redis减库存成功就给用户返回秒杀成功的消息,但在实际应用场景中这种方法是有问题的:并没有考虑到如何缓存和数据库的最终一致性。可能redis减库存成功了,但实际上mysql并没有成功创建订单。即这种方法来业务逻辑上是存在漏洞的。 另外,还有其他一系列的欠缺思考的地方值得讨论,
于是本文从性能角度,业务逻辑角度,分布式一致性角度和架构耦合性,可扩展性角度分析2019秋复旦大学分布式系统秒杀课程项目的一些可改进的地方。

核心解决问题:

如何解决Kafka消费失败导致的缓存数据库不一致?

如何解决Kafka重复消费问题?

可以从哪些角度提高系统性能?

1.性能角度

1.1 从分布式锁的性能角度思考

初始版本的解决超卖关键是: Mysql基于版本号的乐观锁机制

Redis预存放各种商品的库存,实际场景是不可能的,比较淘宝有千千万万种商品对吧。实际中最多存放热点商品的数据,一开始Redis都是空的,要自己去Mysql发起一个查询请求再把商品初始库存写到Redis。

基本业务逻辑: 秒杀订单到达Redis->判断库存是否足够->消息队列入队->Mysql基于乐观锁更新

缺点: Redis的作用只是判断库存是否足够,访问Redis的时候没有保证原子性,导致大量的无用请求获得的版本号是一样的(例如同时有5000个HTTP请求访问Redis,这5000个都从Redis得知库存充足,假设5000个请求出队,同时访问Mysql,得知的版本号都是x,那么其实只有第一个得到访问到Mysql的请求能够更新成功,其他都会更新失败),大量无效请求最终会经过Kafka而落到Mysql,性能瓶颈是在Kafka的消费速度和Mysql的访问速度(基于磁盘IO,肯定慢)

改进版本解决超卖关键是: 基于Redis的分布式锁orZooKeeper的分布式锁

可以尝试比较Redis分布式锁和Zookeeper分布式锁的性能,先简单介绍一下其原理:

1.基于setnx()和expire()设置分布式锁

基于setnx(set if not exist)的特点,当缓存里key不存在时,才会去set,否则直接返回false。如果返回true则获取到锁,否则获取锁失败。

以上实现方式存在几个问题:

1、这把锁没有失效时间,一旦解锁操作失败,就会导致锁记录一直在Redis中,其他线程无法再获得到锁。

2、这把锁只能是非阻塞的,无论成功还是失败都直接返回。

3、这把锁是非重入的,一个线程获得锁之后,在释放锁之前,无法再次获得该锁,因为使用到的key在Redis中已经存在。无法再执行put操作。

当然,同样有方式可以解决。

  • 没有失效时间?
    我们再用expire命令对这个key设置一个超时时间来避免。但是这里看似完美,实则有缺陷,当我们setnx成功后,线程发生异常中断,expire还没来的及设置,那么就会产生死锁。

解决方法:官方推荐lua脚本来实现setnx()和expire()实现原子性

  • 非阻塞?
    while重复执行。

  • 非可重入?
    在一个线程获取到锁之后,把当前主机信息和线程信息保存起来,下次再获取之前先检查自己是不是当前锁的拥有者。

但是,失效时间我设置多长时间为好?如何设置的失效时间太短,方法没等执行完,锁就自动释放了,那么就会产生并发问题。如果设置的时间太长,其他获取锁的线程就可能要平白的多等一段时间。

2.Redission 实现分布式锁原理

https://mp.weixin.qq.com/s?__biz=MzU0OTk3ODQ3Ng==&mid=2247483893&idx=1&sn=32e7051116ab60e41f72e6c6e29876d9&chksm=fba6e9f6ccd160e0c9fa2ce4ea1051891482a95b1483a63d89d71b15b33afcdc1f2bec17c03c&scene=21#wechat_redirect

3.基于RedLock算法

假设有5个Redis客户端:

(1) 获取当前时间;

(2) 尝试从5个相互独立redis客户端获取锁;

(3) 计算获取所有锁消耗的时间,当且仅当客户端从多数节点获取锁,并且获取锁的时间小于锁的有效时间,认为获得锁;

(4) 重新计算有效期时间,原有效时间减去获取锁消耗的时间;

(5) 删除所有实例的锁

如果5个节点有2个宕机,此时锁的可用性会极大降低,首先必须等待这两个宕机节点的结果超时才能返回,另外只有3个节点,客户端必须获取到这全部3个节点的锁才能拥有锁,难度也加大了。

如果出现网络分区,那么可能出现客户端永远也无法获取锁的情况,介于这种情况,下面我们来看一种更可靠的分布式锁zookeeper锁。

4.ZooKeeper实现分布式锁算法原理

Zookeeper简单介绍下特性:

数据模型:

永久节点:节点创建后,不会因为会话失效而消失

临时节点:与永久节点相反,如果客户端连接失效,则立即删除节点

顺序节点:与上述两个节点特性类似,如果指定创建这类节点时,zk会自动在节点名后加一个数字后缀,并且是有序的。

监视器(watcher):

当创建一个节点时,可以注册一个该节点的监视器,当节点状态发生改变时,watch被触发时,ZooKeeper将会向客户端发送且仅发送一条通知,因为watch只能被触发一次。

根据zookeeper的这些特性,我们来看看如何利用这些特性来实现分布式锁:

创建一个锁目录lock

希望获得锁的线程A就在lock目录下,创建临时顺序节点

获取锁目录下所有的子节点,然后获取比自己小的兄弟节点,如果不存在,则说明当前线程顺序号最小,获得锁

线程B获取所有节点,判断自己不是最小节点,设置监听(watcher)比自己次小的节点(只关注比自己次小的节点是为了防止发生“羊群效应”)

线程A处理完,删除自己的节点,线程B监听到变更事件,判断自己是最小的节点,获得锁。

分布式锁总结:

1.Zk不依靠超时时间释放锁;可靠性高;系统要求高可靠性时,建议采用zookeeper锁,但是性能会比缓存锁性能要差一些,因为要频繁地创建删除节点。

2.实现一个分布式锁,要考虑的因素有:
高效地上锁解锁性能
故障时能够保证成功解锁
可重入
最好阻塞(但是阻塞的话性能会下降,最好结合业务考虑)
获取锁释放锁保证原子性,不能多个线程同时获得锁

3.基于分布式锁的优化,可以借鉴ConcurrentHashMap使用分段锁来保证并发安全来思考这个问题,就是可以采取分段加锁的策略,那么可以支持多个线程并发访问分布式锁,缺点就是业务逻辑会变得更复杂。

具体可参考 基于分布式锁的分段锁优化思路

1.2 从消息队列来思考

主要解决问题:

1.2.1 如何避免消息积压?即如何提高消费的速度?

1.2.2 如何解决消息队列幂等性问题?避免重复消费?

1.用mysql保证主键ID唯一性,每一条秒杀成功的消息都会对应一条订单业务消息,那么这个订单消息肯定有一个订单号,那么可以以订单号作为mysql订单表的主键,通过唯一主键来保证不会发生重复消费的可能性。

2.给每一条消息引入一个全局ID,并增加消息应用状态表(message_apply),通俗来说就是个账本,用于记录消息的消费情况,每次来一个消息,在真正执行之前,先去消息应用状态表中查询一遍,如果找到说明是重复消息,丢弃即可,如果没找到才执行,同时插入到消息应用状态表(同一事务)。

1.3 从最终的性能瓶颈Mysql来思考

主要解决问题:

如何减少Mysql的流量压力?
如何进行读写分离与负载均衡?

1.4 从Redis缓存角度来思考

主要解决问题:

有没有做缓存预热?
怎么解决缓存雪崩?
怎么解决缓存穿透?
怎么解决缓存击穿?

讲到这里,我们项目实现的是只要Redis减库存成功了就可以直接给下单的用户返回订单秒杀成功的响应,可是如果Redis减库存成功了,请求最终没有发送到Mysql怎么办?就举个简单的例子,Redis减库存成功,这时候Kafka崩了,那么同步Mysql的请求最终发送不到Mysql。那么缓存的数据和Mysql的数据其实是不一致的对吧。那所以Redis预减库存成功之后,返回给客户端的应该是"排队中",或者"预减库存", 而不是立即返回"秒杀成功"。只有确保Mysql持久化秒杀订单的时候,才给用户返回秒杀成功的请求。所以这就需要考虑如何保证Redis和Mysql的最终一致性!接下来讨论这个问题。

2.Redis,Kafka,Mysql数据保持一致性(怎么保证数据的一致性)

我们使用Redis分布式锁来解决超卖的项目业务逻辑:
1.Redis减库存,用分布式锁保证不会超卖,减库存成功后发送订单消息到Kafka
2.Mysql消费Kafka发来的消息,进行减库存的持久化和插入订单表的操作

也就是说,Redis在这里作为外界读取数据的唯一地方,Redis是作为存储来使用的。本质上是先更新缓存再异步更新数据库,好处是实时性比较好,坏处是不能保证DB和Cache的双写一致性。

问题来了:

1.如果减库存成功了,发送到消息队列之前宕机了,即怎么保证减库存后一定发送到消息队列?

我的解决思路1:要保证消息成功发送到消息队列的时候,Redis再执行减库存操作。

把访问Redis拆分为:

请求减库存: 请求确认是否可以减库存,然后尝试发送消息到Kafka,Kafka提供发送消息确认机制,只有阻塞等待收到发送成功通知之后,才执行减库存操作。也就是说把异步发送改为同步发送

执行减库存: 只有在Kafka收到发送消息的确认之后,Redis才可以执行减库存(分布式锁起作用)操作。

问题:

1.有大量请求进队,对Kafka消费压力比较大
2.异步改同步,吞吐量会下降

我的解决思路2: 把减库存成功生成的订单请求写到一个本地消息表中,通过定时扫描来查看这个表是否有消息没有被发送

我的解决思路3: 发送失败后开个线程通知Redis进行回滚

2.如果mysql成功从kafka拉取数据即消费成功,然后进行mysql本地事务操作的时候宕机了,或者写mysql的线程断了,这就导致了缓存库存减了,但是数据库库存没有减。那么重新启动之后如何继续正确地消费数据?

我的解决思路: 也可以建立一个本地消息表,消费成功的时候把订单消息写入本地消息表,然后mysql定时去查询这个本地消息表看有没有消息被遗漏持久化。

总结而言,我认为这种架构关键在于如何保证kafka消息队列的消息不丢失不重复,从而保证Mysql和Redis的数据一致性。

那么Kafka是如何保证消息队列的消息不丢失不重复呢

消费端或者生产端重复消费: 可以建立一个本地消息表(可以理解为去重表)。通过定时扫描的方法

消费端丢失数据:关闭自动提交偏移量的做法,改为手动提交。

enable.auto.commit=false
只有在消息被完整处理之后再手动提交位移。

生产端丢失数据

ack机制能够保证数据的不丢失。

如果ack=0,说明是异步发送,不理会消息是否发送成功。

如果ack=1,说明是同步发送,只保证leader符本接收到了数据的确认消息,replica异步拉取消息

如果ack=-1,也就是让消息写入leader和所有的副本,ISR列表中的所有replica都返回确认消息

ack确认机制设置为 “all” 即所有副本都同步到数据时send方法才返回, 以此来完全判断数据是否发送成功, 理论上来讲数据不会丢失。

Other Possible Solution:

ref:分布式事务

解决思路1: 利用Kafka事务特性,提供exactly-once语义支持 即事务消息解决方案

这里借鉴了这篇文章介绍柔性事务

如何保证如果业务操作成功,那么由这个业务操作所产生的消息一定要成功投递出去。

对分布式系统课程秒杀项目的再思考

1.首先发送一个事务消息,消息队列把这个消息状态标记为prepared,注意这个时候这条消息消费者是无法消费到的。

2.执行业务代码逻辑,如本地数据库事务操作,本项目指的是竞争分布式锁减库存

3.确认发送消息,这个时候,RocketMQ将消息状态标记为可消费,这个时候消费者,才能真正的保证消费到这条数据。

如果确认消息发送失败了怎么办?RocketMQ会定期扫描消息集群中的事务消息,如果发现了Prepared消息,它会向消息发送端(生产者)确认。RocketMQ会根据发送端设置的策略来决定是回滚还是继续发送确认消息。这样就保证了消息发送与本地事务同时成功或同时失败。

如果消费失败了怎么办? 消费消息涉及到提交offset到zookeeper那里,那么可以选择手动提交,只有在成功消费数据的时候才提交offset。如果消费失败允许重试。如果是消费成功了,但是执行后续的mysql更新失败了,那就用本地消息表来解决。

解决思路2: 使用2PC,3PC,TCC分布式事务,用用户代码来补偿一致性 TCC事务解决方案

对每一个操作,注册一个对应的确认和撤销操作。

Try 阶段主要是对业务系统做检测及资源预留

Confirm 阶段主要是对业务系统做确认提交,Try阶段执行成功并开始执行 Confirm阶段时,默认 Confirm阶段是不会出错的。即:只要Try成功,Confirm一定成功。

Cancel 阶段主要是在业务执行错误,需要回滚的状态下执行的业务取消,预留资源释放。

以本项目为例:

[Redis减库存]
Try:
   检查Redis库存,即查看库存是否充足;
   从库存中扣减,并将状态置为“处理中”;
   预留扣减资源,将从Redis发送一个成功下单的请求到Mysql这个事件存入消息或者日志中;
Confirm:
 不做任何操作;
Cancel:
   恢复库存;
  从日志或者消息中,释放扣减资源。

[Mysql处理业务]
Try:
 检查访问库存;
Confirm:
   读取日志或者消息,mysql减库存,插入订单消息;
   从日志或者消息中,释放扣减资源;
Cancel:
 不做任何操作。

TCC模型对业务的侵入强,改造的难度大

解决思路3: 参考本地事务表,在生产消费端都建立去重表,通过定时扫描来找到发送或者消费失败的消息,即本地消息表解决方案

核心思想是将分布式事务拆分成本地事务进行处理

消息生产方,需要额外建一个消息表,并记录消息发送状态。消息表和业务数据要在一个事务里提交,也就是说他们要在一个数据库里面。然后消息会经过MQ发送到消息的消费方。如果消息发送失败,会进行重试发送。

消息消费方,需要处理这个消息,并完成自己的业务逻辑。此时如果本地事务处理成功,表明已经处理成功了,如果处理失败,那么就会重试执行。如果是业务上面的失败,可以给生产方发送一个业务补偿消息,通知生产方进行回滚等操作。

生产方和消费方定时扫描本地消息表,把还没处理完成的消息或者失败的消息再发送一遍。

下面是网上参考的其他解决cache和DB双写不一致的解决方案,cache侧重作为"缓存",解决超卖应该还是要靠DB的乐观锁。以下方案也叫"Cache aside"

1 读:先判断是否有缓存数据,如果没有从数据库加载数据到缓存
2.写:无论是先删缓存再更新数据库,还是先更新数据库再删缓存都会存在问题。

假如是先删缓存,在更新数据库:

如果删缓存失败了就不更新数据库,如果删缓存成功了,但是在更新数据库前一个读请求来了,他就会去读数据库,导致把一个脏的数据写入到缓存。

解决方法可以采取延时双删:

(1)先淘汰缓存
(2)再写数据库(这两步和原来一样)
(3)休眠一定时间,再次淘汰缓存 这么做,可以将休眠时间内所造成的缓存脏数据,再次删除。

如果Mysql采用的是读写分离怎么办?
同样采用延时双删,只不过休眠时间要加上主从同步的时间。

但是假如双删也失败了,那么可以给数据加一个超时的时间,那么至少可以保证在这个超时时间内Cache和DB是不一致的。

如果先更新数据库,再删缓存:
如果更新数据库成功了,删缓存失败了,也会导致数据不一致。

3.成本

4.项目的耦合性,可扩展性

相关标签: 分布式系统