目录

秒杀方案设计

防止秒杀重复排队

多线程超卖问题

分布式锁

分布式锁应该具备哪些条件

基于数据库的实现方式

基于数据库表

基于数据库排他锁

总结

基于 Redis 的实现方式

Redis 实现分布式锁优点

命令介绍

实现思想

总结

基于ZooKeeper的实现方式

分析

实现

总结

分布式锁总结

解决超卖问题的实现方案

下单后延迟5分钟未支付取消订单回滚库存

RabbitMQ 延迟队列

实现思路


在电商中,商品秒杀是比较常见的业务场景。以京东为例,如下图:

在这种场景下,系统需要面对高强度的访问,同时还要处理验证用户资格和商品库存等复杂业务逻辑,那我们应该如何应对这种高并发且需要处理复杂业务逻辑的场景的呢?这便是本文探讨的问题。

秒杀方案设计

秒杀技术实现核⼼思想是运⽤缓存减少数据库瞬间的访问压力!最简单的思路是在读取商品详细信息时运⽤缓存,当⽤户点击抢购时减少缓存中的库存数量,当库存数为0时或活动期结束时,同步到数据库。 产⽣的秒杀预订单也不会⽴刻写到数据库中,⽽是先写到缓存,当⽤户付款成功后再写⼊数据库。但是这样的思路并未考虑其中⼀些问题,例如并发状况容易产⽣的问题。我们看看下⾯这张思路更严谨的图:

上图中,首先秒杀商品通过审核后存入 Mysql 数据库,在秒杀微服务中,我们设置定时任务访问数据库,并将秒杀商品放入 redis 数据库中。然后用户访问秒杀商品详情页下单时,会先判断用户是否登录,如果未登录则转向登录页,已登录则访问秒杀微服务进行排队抢单,将订单数据和用户数据先存入一个 redis 队列中,然后提示用户正在排队。在排队期间,使用多线程异步操作依次取出 redis 队列中的数据,根据数据检查账号是否异常、24小时内是否购买过该商品、是否存在未支付的秒杀订单、该秒杀商品是否还有库存、该秒杀商品抢购人数是否达到了上限等,在这些工作完成后,再进行下单减库存操作,在下单减库存的同时,将订单ID,用户ID,秒杀商品ID发送给RabbitMQ 延迟队列,通过微服务监听该队列,如果5分钟后未支付则删除该订单并回滚库存。在下单完成后,状态查询微服务会监听订单状态,如果下单成功则用户访问支付微服务进行支付,支付成功后删除 Redis 相关数据并将用户订单信息存入 Mysql 数据库。

防止秒杀重复排队

在上图的排队过程中,用户可能发送重复请求导致重复排队,通常我们不允许用户重复购买秒杀商品的,即使可以购买,我们也只允许用户原价购买商品。

解决方案是用户每次抢单的时候,⼀旦排队,我们设置⼀个自增值,让该值的初始值为1,每次进⼊抢单的时候,对它进行递增,如果值>1,则表明已经排队,不允许重复排队,如果重复排队,则对外抛出异常或进行其他业务处理。

在上面的场景中,我们可以采用 Redis 的 Hash 结构存储用户和对应的排队值。

多线程超卖问题

下订单这里,我们⼀般采用多线程下单,但多线程中我们又需要保证用户抢单的公平性,也就是先抢先下单。上图是这样实现的,⽤户进入秒杀抢单,如果用户复合抢单资格,只需要记录用户抢单数据,存入队列,多线程从队列中进行消费即可,存入队列采用左压,多线程下单采用右取的方式。

但是在审视秒杀过程中,操作⼀般都是比较复杂的,⽽且并发量特别⾼,比如,检查当前账号操作是否已经秒杀过该商品,检查该账号是否存在刷单⾏为,记录⽤户操作日志等。这时可能就会产生一个超卖问题。

什么叫超卖问题呢?例如,我们多个线程同时下单,多个线程同时判断是否有库存,如果只剩⼀个,则都会判断有库存,此时会导致超卖现象产⽣,也就是⼀个商品下了多个订单的现象。解决超卖问题可以使⽤分布式锁的方案

分布式锁

为了保证⼀个方法或属性在高并发情况下的同⼀时间只能被同⼀个线程执行,在传统单体应用单机部署的情况下,可以使⽤ Java 并发处理相关的 API(如 ReentrantLock 或 Synchronized )进⾏互斥控制。在单机环境中,Java 中提供了很多并发处理相关的 API。但是,随着业务发展的需要,原单体单机部署的系统被演化成分布式集群系统后,由于分布式系统多线程、多进程并且分布在不同机器上,这将使原单机部署情况下的并发控制锁策略失效,单纯的 Java API 并不能提供分布式锁的能⼒。为了解决这个问题就需要⼀种跨 JVM 的互斥机制来控制共享资源的访问,这就是分布式锁要解决的问题!

分布式锁应该具备哪些条件

1.    在分布式系统环境下,⼀个方法在同⼀时间只能被⼀个机器的⼀个线程执行;
2.    高可用(多节点)的获取锁与释放锁;
3.    高性能的获取锁与释放锁;
4.    具备可重入特性,即同一个线程在获取锁之后可以多次获取该锁;
5.    具备锁失效机制,防止死锁;
6.    具备非阻塞锁特性,即没有获取到锁将直接返回获取锁失败。

基于数据库的实现方式

基于数据库表

基于数据库的实现方式的核心思想是:在数据库中创建⼀个表,表中包含方法名等字段,并在方法名字字段上创建唯⼀索引,想要执行某个方法,就使用这个方法名向表中插⼊数据,成功插⼊则获取锁,执行完成后删除对应的行数据释放锁。

(1)创建⼀个表:

DROP TABLE IF EXISTS `method_lock`;
CREATE TABLE `method_lock` (
`id` int(11) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
`method_name` varchar(64) NOT NULL COMMENT '锁定的⽅法名',
`desc` varchar(255) NOT NULL COMMENT '备注信息',
`update_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
PRIMARY KEY (`id`),
UNIQUE KEY `uidx_method_name` (`method_name`) USING BTREE
) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8 COMMENT='锁定中的⽅法';

(2)想要执行某个方法,就使用这个方法名向表中插入数据:

INSERT INTO method_lock (method_name, desc) VALUES ('methodName', '测试的methodName');

因为我们对method_name 做了唯⼀性约束,这⾥如果有多个请求同时提交到数据库的话,数据库会保证只有⼀个操作可以成功,那么我们就可以认为操作成功的那个线程获得了该方法的锁,可以执行方法体内容。

(3)成功插⼊则获取锁,执⾏完成后删除对应的⾏数据释放锁:

delete from method_lock where method_name ='methodName';

上⾯这种简单的实现有以下几个问题:

  • 这把锁强依赖数据库的可用性,数据库是⼀个单点,⼀旦数据库挂掉,会导致业务系统不可用。
  • 这把锁没有失效时间,⼀旦解锁操作失败,就会导致锁记录⼀直在数据库中,其他线程无法再获得到锁。
  • 这把锁只能是非阻塞的,因为数据的 insert 操作,⼀旦插入失败就会直接报错。没有获得锁的线程并不会进入排队队列,要想再次获得锁就要再次触发获得锁操作。
  • 这把锁是非重入的,同⼀个线程在没有释放锁之前无法再次获得该锁。因为数据中数据已经存在了。

当然,我们也可以有其他方式解决上面的问题。

  • 数据库是单点?搞两个数据库,数据之前双向同步。一旦挂掉快速切换到备库上。
  • 没有失效时间?只要做⼀个定时任务,每隔一定时间把数据库中的超时数据清理⼀遍。
  • 非阻塞的?搞⼀个 while 循环,直到 insert 成功再返回成功。
  • 非重入的?在数据库表中加个字段,记录当前获得锁的机器的主机信息和线程信息,那么下次再获取锁的时候先查询数据库,如果当前机器的主机信息和线程信息在数据库可以查到的话,直接把锁分配给他就可以了。

基于数据库排他锁

除了可以通过增删操作数据表中的记录以外,其实还可以借助数据库中自带的锁来实现分布式的锁。
我们还用刚刚创建的那张数据库表。可以通过数据库的排他锁来实现分布式锁。 基于 MySql 的InnoDB 引擎,可以使用以下方法的伪代码来实现加锁操作:

public boolean lock() {
    //关闭 jdbc 连接的自动提交
    connection.setAutoCommit(false);
    while(true) {
        try {
            // result 是 jdbc 根据语句查询的结果
            result = select * from methodLock where method_name=xxx for update;
            return true;
        }catch (Exception e) {

        }
        sleep(1000);
    }
    return false;
}

 在查询语句后面增加 for update ,数据库会在查询过程中给数据库表增加排他锁。

当某条记录被加上排他锁之后,其他线程无法再在该行记录上增加排他锁。

注意:InnoDB 引擎在加锁的时候,只有通过索引进行检索的时候才会使用行级锁,否则会使用表级锁。这里我们希望使用行级锁,就要给 method_name 添加索引,值得注意的是,这个索引⼀定要创建成唯⼀索引,否则会出现多个重载方法之间无法同时被访问的问题。重载方法的话建议把参数类型也加上

我们可以认为获得排它锁的线程即可获得分布式锁,当获取到锁之后,可以执行方法的业务逻辑,执行完方法之后,再通过以下方法解锁:

public void unlock() {
    connection.commit();
}

通过 connection.commit() 操作来释放锁。

这种方法可以有效的解决上面提到的无法释放锁和阻塞锁的问题。

  • 阻塞锁? for update 语句会在执行成功后⽴即返回,在执行失败时⼀直处于阻塞状态,直到成功。
  • 锁定之后服务宕机,无法释放?使用这种⽅式,服务宕机之后数据库会自己把锁释放掉。

但是还是无法直接解决数据库单点问题。

这⾥还可能存在另外⼀个问题,虽然我们对 method_name 使用了唯⼀索引,并且显示使用 for update 来使用行级锁。但是,MySql 会对查询进⾏优化,即便在条件中使用了索引字段,但是否使用索引来检索数据是由 MySQL 通过判断不同执行计划的代价来决定的,如果 MySQL 认为全表扫效率更⾼,比如对⼀些很小的表,它就不会使用索引,这种情况下 InnoDB 将使用表锁,⽽不是行锁。如果发⽣这种情况就悲剧了。。。

还有⼀个问题,就是我们要使⽤排他锁来进行分布式锁的lock,那么⼀个排他锁⻓时间不提交,就会占用数据库连接。⼀旦类似的连接变得多了,就可能把数据库连接池撑爆

总结

使用数据库来实现分布式锁有两种方式,这两种方式都是依赖数据库的⼀张表,⼀种是通过表中的记录的存在情况确定当前是否有锁存在,另外⼀种是通过数据库的排他锁来实现分布式锁。

数据库实现分布式锁的优点

  • 直接借助数据库,容易理解。

数据库实现分布式锁的缺点

  • 会有各种各样的问题,在解决问题的过程中会使整个方案变得越来越复杂。
  • 操作数据库需要⼀定的开销,性能问题需要考虑。
  • 使⽤数据库的行级锁并不⼀定靠谱,尤其是当我们的锁表并不大的时候。

基于 Redis 的实现方式

Redis 实现分布式锁优点

(1)Redis有很高的性能;

(2)Redis命令对此支持较好,实现起来比较方便

命令介绍

在使用 Redis 实现分布式锁的时候,主要就会使用到以下三个命令。

(1)SETNX

SETNX key val:当且仅当key不存在时,set⼀个key为val的字符串,返回1;若key存在,则什么都不做,返回0。

(2)expire

expire key timeout:为key设置⼀个超时时间,单位为second,超过这个时间锁会⾃动释放,避免死锁。

(3)delete

delete key:删除key

实现思想

(1)获取锁的时候,使⽤ setnx 加锁,并使⽤ expire 命令为锁添加⼀个超时时间,超过该时间则自动释放锁,锁的 value 值为⼀个随机⽣成的 UUID,通过此在释放锁的时候进行判断。
(2)获取锁的时候还设置⼀个获取的超时时间,若超过这个时间则放弃获取锁。
(3)释放锁的时候,通过 UUID 判断是不是该锁,若是该锁,则执⾏ delete 进行锁释放。

总结

可以使用缓存来代替数据库来实现分布式锁,这个可以提供更好的性能,同时,很多缓存服务都是集群部署的,可以避免单点问题。并且很多缓存服务都提供了可以用来实现分布式锁的方法,比如Tair 的 put 方法,redis 的 setnx 方法等。并且,这些缓存服务也都提供了对数据的过期自动删除的⽀持,可以直接设置超时时间来控制锁的释放。

使用缓存实现分布式锁的优点

  • 性能好,实现起来较为方便。

使用缓存实现分布式锁的缺点

  • 通过超时时间来控制锁的失效时间并不是十分的靠谱。

基于ZooKeeper的实现方式

分析

ZooKeeper 是⼀个为分布式应用提供⼀致性服务的开源组件,它内部是⼀个分层的⽂件系统目录树结构,规定同⼀个目录下⽂件名不能重复。基于 ZooKeeper 实现分布式锁的步骤如下:
1. 创建⼀个目录 mylock;
2. 线程 A 想获取锁就在 mylock 目录下创建临时顺序节点;
3. 获取 mylock ⽬录下所有的子节点,然后获取比自己小的兄弟节点,如果不存在,则说明当前线程顺序号最小,获得锁;
4. 线程 B 获取所有节点,判断自己不是最小节点,设置监听比自己小的节点;
5. 线程 A 处理完,删除自己的节点,线程 B 监听到变更事件,判断自己是不是最小的节点,如果是则获得锁。

来看下 Zookeeper 是否满足分布式锁的要求

  • 锁无法释放?使用 Zookeeper 可以有效的解决锁无法释放的问题,因为在创建锁的时候,客户端会在 ZK 中创建⼀个临时节点,⼀旦客户端获取到锁之后突然挂掉(Session连接断开),那么这个临时节点就会自动删除掉。其他客户端就可以再次获得锁。
  • 非阻塞锁?使用 Zookeeper 可以实现阻塞的锁,客户端可以通过在 ZK 中创建顺序节点,并且在节点上绑定监听器,⼀旦节点有变化,Zookeeper 会通知客户端,客户端可以检查自己创建的节点是不是当前所有节点中序号最小的,如果是,那么自己就获取到锁,便可以执行业务逻辑了。
  • 不可重入?使用 Zookeeper 也可以有效的解决不可重入的问题,客户端在创建节点的时候,把当前客户端的主机信息和线程信息直接写⼊到节点中,下次想要获取锁的时候和当前最小的节点中的数据比对⼀下就可以了。如果和自己的信息⼀样,那么自己直接获取到锁,如果不⼀样就再创建⼀个临时的顺序节点,参与排队。
  • 单点问题?使用 Zookeeper 可以有效的解决单点问题,ZK 是集群部署的,只要集群中有半数以上的机器存活,就可以对外提供服务。

使⽤ ZK 实现的分布式锁好像完全符合了我们对⼀个分布式锁的所有期望。但是,其实并不是, Zookeeper 实现的分布式锁其实存在⼀个缺点,那就是性能上可能并没有缓存服务那么高。因为每次在创建锁和释放锁的过程中,都要动态创建、销毁瞬时节点来实现锁功能。ZK 中创建和删除节点只能通过 Leader 服务器来执行,然后将数据同不到所有的 Follower 机器上。
其实,使⽤ Zookeeper 也有可能带来并发问题,只是并不常见而已。考虑这样的情况,由于网络抖动,客户端和 ZK 集群的 session 连接断了,那么 zk 以为客户端挂了,就会删除临时节点,这时候其他客户端就可以获取到分布式锁了。就可能产生并发问题。这个问题不常见是因为 zk 有重试机制,⼀旦zk集群检测不到客户端的⼼跳,就会重试,多次重试之后还不行的话才会删除临
时节点。(所以,选择⼀个合适的重试策略也比较重要,要在锁的粒度和并发之间找⼀个平衡。)

实现

可以直接使⽤zookeeper第三⽅库Curator客户端,这个客户端中封装了⼀个可重⼊的锁服务。

总结

 Curator 提供的 InterProcessMutex 是分布式锁的实现。acquire 方法用户获取锁,release 方法用于释放锁。

使⽤ Zookeeper 实现分布式锁的优点

  • 有效的解决单点问题,不可重⼊问题,非阻塞问题以及锁无法释放的问题。实现起来较为简单。

使⽤Zookeeper实现分布式锁的缺点

  • 性能上不如使用缓存实现分布式锁。 自己编写实现方案需要对 ZK 的原理有所了解,比较复杂

分布式锁总结

上⾯几种方式,哪种方式都无法做到完美。就像 CAP ⼀样,在复杂性、可靠性、性能等方面无法同时满足,所以,根据不同的应用场景选择最适合自己的才是王道。

  • 从方案理解的难易程度角度(从低到高):数据库 > 缓存 > Zookeeper
  • 从实现的复杂性角度(从低到高):Zookeeper >= 缓存 > 数据库
  • 从性能角度(从高到低):缓存 > Zookeeper >= 数据库
  • 从可靠性角度(从高到低):Zookeeper > 缓存 > 数据库

解决超卖问题的实现方案

Redisson是Redis官⽅推荐的Java版的Redis客户端。它提供的功能⾮常多,也⾮常强大,此处我们使用它的分布式锁功能,来避免超卖问题。

在下单的异步方法中,我们通过其 RedissonClient 类提供的 getLock() 生成锁,然后使用获得的锁 lock 进行加锁 lock(),处理完下单业务后使用获得的锁 lock 释放锁 unlock()。这样一来,在一个线程进行下单操作时,其他线程无法进行操作,这样就避免了超卖问题。

下单后延迟5分钟未支付取消订单回滚库存

在秒杀业务中,我们通常不允许用户长期持有订单不支付,以免用户恶意下单占库存。该功能我们通常使用 MQ 的延迟队列实现。如下图:

 本文中我们采用 RabbitMQ实现延迟队列,RabbitMQ 入门可见该文章RabbitMQ入门_fgba的博客-CSDN博客

RabbitMQ 延迟队列

RabbitMQ实现延迟队列可以借助 TTL+死信队列实现。

死信队列,英⽂缩写:DLX 。Dead Letter Exchange(死信交换机),当消息成为Dead message后,可以被重新发送到另⼀个交换机,这个交换机就是 DLX。

 消息成为死信的三种情况:
1. 队列消息长度到达限制;
2. 消费者拒接消费消息,basicNack/basicReject,并且不把消息重新放⼊原目标队列,requeue=false;
3. 原队列存在消息过期设置,消息到达超时时间未被消费;

队列绑定死信交换机:

给队列设置参数: x-dead-letter-exchange 和 x-dead-letter-routing-key

实现思路

我们在订单的微服务中创建 TTL 队列 mq.order.queue.ttl,DLX死信队列 mq.order.queue.dlx,并设置 TTL 队列 mq.order.queue.ttl 的死信队列为 mq.order.queue.dlx,在用户下单时,向 TTL 队列发送延迟 5 分钟取消订单消息,消息内容为订单 ID,同时在订单微服务,我们设置一个 RabbitListener 监听 DLX 死信队列的消息,当 mq.order.queue.ttl 队列中的消息成为死信时,我们获取该消息并根据消息中的订单 ID 获取订单状态,如果未支付则删除 Redis 中的该订单并回滚库存。

Logo

为开发者提供学习成长、分享交流、生态实践、资源工具等服务,帮助开发者快速成长。

更多推荐