简单谈谈秒杀的减库存

前言

作为之前在 秒杀系统简单实现 一文的补充……

本文就简,不考虑各种高级优化……仅仅立足减库存这个操作谈谈我的理解……

0x01 秒杀操作

在讨论各种优化之前,先捋一下这个操作的逻辑。

一开始,用户们从前端点击秒杀按钮,操作传入服务端处理,在 Java Web 中,最后是由一个 Servlet 来处理的。

Servlet 默认是 单例多线程。(当然也可以设置为多例)

需要注意的是,在 Java Web 开发的各种框架中,比如 Spring,不管是 Controller 层的实例还是 Service 层的实例,本质都是 Servlet。

所以说这里最后还是由 Servlet 来处理。

单例多线程是什么意思呢?这里不多说,讲讲它的效果。

比如在进行秒杀操作时,前端 可能 会有很多个用户同时进行秒杀,每一个秒杀操作都是一个 HTTP 请求,这数个 HTTP 请求(秒杀操作)到了服务端,全部由一个 Servlet 实例来处理——这就是单例。而每一个 HTTP 请求都对应一个线程——这就是多线程。

不过注意,这里的多线程并非无限制创建的。

首先要知道,Servlet 是运行在 Servlet 容器里的。

参考 Tomcat

实际上,Servlet 容器会通过调度线程(Dispatchaer Thread) 管理一个线程池,通过调度线程池中等待执行的线程(Worker Thread)给请求者实现多线程。

也就是说,Servlet 的多线程还是由线程池管理的,只不过一般来说,用户不可见。(因为这个线程池由 Servlet 容器管理)

0x02 事务控制

我们回到秒杀。秒杀这个操作至少包含两步:

  1. 减库存
  2. 插入购买信息到订单表

一般这两步会写在 Service 层。我们看下伪代码 :

1
2
3
4
5
6
public SkillService{
Kill(){
减库存;
插入信息到订单表
}
}

注意,这里有两个步骤,而上面提到 Servlet 的执行是多线程的,那么这里就可能会出现数据不一致问题。

为什么?

因为这里多线程并发执行秒杀操作时,不能保证操作的 原子性。

比如?

为了保证数据一致(原子性),我们可以使用事务。

事务是啥?

比如 Spring 中,我们可以通过使用注解为方法开启事务支持。

1
2
3
4
5
@Transactional
Kill(){
减库存;
插入信息到订单表;
}

事务具有 ACID 四大特性,而其中的 隔离性 是通过锁机制来实现的。

当进入被事务控制的这个方法时,会对数据进行加锁。

使用了事务,就代表里面的操作是一个整体了。(原子操作)

进入这个开启了事务支持的方法体,就会获取方法体中用到的数据的锁。

0x03 锁

并发的 HTTP 请求(反映到 Servlet 就是多线程),在进行秒杀操作时执行事务,通过竞争数据库的锁来执行秒杀操作,从而保证了数据的安全。

什么是锁

到这一步,不考虑超大的并发量,已经能够保证秒杀安全地执行了。

InnoDB 默认行级锁,也支持表级锁。

MyISAM 默认 表级索,不支持行级锁 --> 不支持事务

分布式事务

……

继续优化。

0x04 存储过程

注意,上面是在 Service 层开启事务。

事务里面有两个操作,需要与数据库交互两次。

我们可以把两个操作通过一条 SQL 来执行。

怎么做呢?用存储过程。

什么是存储过程?

怎么使用?

打个比方,这里有两袋苹果,之前我拿上一袋回家,再过来拿另一袋回家。现在我则拿着两袋就往家里跑。

具体怎么回事我还不是很清楚

这里通过存储过程优化了事务行级锁持有的时间。本来在 Service 执行的操作,我们放到 SQL 层来做,在更底层执行,效率会高一点。

数据库里,行级锁是什么?

更底层效率更高一点,为什么?

(因为封装到更抽象的层次,会加上许多为了方便用户操作但对实际执行效率没影响的行为。)

但是不管是哪里,都并不建议大量使用存储过程。(逻辑确实很简单时,可以一用。)

阿里巴巴 Java 开发手册 甚至禁止使用 存储过程。

为什么?

存储过程没有版本控制,版本迭代的时候要更新很麻烦。存储过程如果和外部程序结合起来用,更新的时候很难无感升级,可能需要停服。存储过程不利于将来分库分表。存储过程的功能不一定够强大,业务扩展之后可能会发现无法继续用存储过程实现了。存储过程可能无法和许多中间件、ORM 库一起使用。某些特殊的兼容 MySQL 的实现可能根本就不支持存储过程,那就更不用说了。

……

知乎 — 为什么阿里巴巴 Java 开发手册里要求禁止使用存储过程?

0x05 锁竞争

我们现在是通过数据库层面的锁来保证秒杀的安全,但是当并发量上来后,大量的线程竞争锁,会导致数据库的吞吐量受到影响。注意,是数据库的吞吐量。也就是说,一个商品的竞争,影响了整个数据库的性能。(影响到其它的商品)

为什么?

数据库竞争锁会导致 TPS 下降,RT 上升,数据库的吞吐量受影响。

那么如何解决呢?

可以通过把热点数据隔离出来,使之不影响其他数据。

但这治标不治本。

我们从另一个角度思考一下。


从头梳理。

大量的并发请求来了,操作时为了避免数据安全问题,我们使用了锁机制。

锁,是为了保证同一时刻只有一个线程读写数据。

而锁竞争,现在会导致严重的问题。

那我们能不能不使用锁,又能保证同一时刻只有一个线程读写数据呢?


叮咚!如果能通过某种方式把并发的请求转换成串行执行就可以达到了。

如何转换?可以使用一个队列来对请求排队。

0x06 排队——并发转串行

PS:Redis 就是通过把请求串行执行实现的并发。

同样,可以在数据库层排队,也可以在服务层排队。在数据库层——这个更底层的层面排队,效率肯定更高。

阿里的数据库团队开发了 InnoDB 的补丁程序(patch),可以在数据库层上对单行记录做到并发排队。

另外,应用层只能单机排队,数据库层则可以全局排队。

排队与锁竞争都需要等待,为什么排队效率就要好点?


排队当然要使用队列,回到前面,Servlet 是单例多线程,多线程环境下应该使用并发包下的队列

比如 :

1
java.util.concurrent.ConcurrentLinkedQueue

搜索资料时,许多博客都说「对请求进行排队」。

一个很容易想到的操作便是使用拦截,在 HTTP 请求进入 Controller 层之前把 HttpRequest 压入队列排队。如下:

1
ConcurrentLinkedQueue<HttpRequest> que = new ConcurrentLinkedQueue<HttpRequest>();

不过, 如果把 Http 请求放到队列,就会导致产生大量未释放的 HTTP 长连接。

这样不太好,实际使用,是把 秒杀数据 进行排队。

在秒杀操作时,我们其实只需要 HttpRequest 提供的相关的秒杀信息,比如 商品 ID,用户 ID……并不需要持有整个 HTTP 请求。

0x07 异步——消息队列

不错,现在排队了,但是现在有个最重要的问题我们还没考虑。

回头看一下。

无论怎么写代码,最终的目的是给用户提供良好的体验。

但是用户在秒杀界面等待着服务端执行完秒杀后返回的秒杀结果,这一个最影响体验的操作还没有得到解决。

现在的秒杀,就相当于我去我们村里的信用社办理业务,大家都在窗口前排着队,前面的人办完业务才能轮到后面的人。

但是想象一下大城市里的银行,用户先领取一个排队号,然后坐到一旁玩手机,等到窗口呼叫用户的排队号码时,用户才上前去办理业务……

这就是 消息队列 的概念。

我们把秒杀的数据放入一个消息队列,然后直接向前端返回信息,告诉用户「我们先帮你排队执行秒杀,待会告诉你结果,你先去干其他的事吧。」

真棒。用户现在可以去干其他事了。

过一阵子,用户可以通过主动查询或者被动通知,了解到自己的秒杀结果。

比如可以使用前端轮询

不过这里值得注意的是,消息队列最主要的功能是提供了一个异步执行的操作。

消息队列的使用将秒杀真正的操作与这个用户的行为解耦了!

为什么要强调主要是为了解耦,下面会提到。

同时,消息队列也能够把秒杀数据进行排队。

0x08 RabbitMQ

再重复一下前面提到的,Servlet 是单例多线程,引入消息队列后,多线程把 秒杀数据 放入消息队列就完事了,然后告诉用户我们正在帮你排队。具体的秒杀执行逻辑则由消息队列来做。

稍微准确点,实际是消息队列提供的 消费者 按照队列的顺序,把 队列 里的秒杀数据拿出来,对秒杀数据执行秒杀的逻辑。

OK!

下面拿具体的消息队列框架 RabbitMQ 谈谈。

在 RabbitMQ 中,默认 单线程串行消费

也就是单线程,串行来执行队列里的秒杀数据来秒杀。

这样子在理论上,秒杀操作就无需加入事务控制了。

因为现在已经是串行,不会产生竞争啦!

这里面当然还有很多细节,比如如果抛出异常、秒杀执行失败,是继续尝试执行呢?还是直接秒杀失败?

又比如既然引入了消息队列,那当请求来了时就可以直接判断队列中的秒杀数据的个数与库存比较,直接拒绝用户的秒杀操作……

0x09 消息积压

不过引入消息队列,又有可能遇到消息队列的一些问题……

比如消息过多,造成消息积压。

一种解决方式就是引入多线程处理消息。(当然消息队列自己还有多消费者等等细节)

可是引入多线程了,那是不是又会有线程安全的问题呢?

确实如此。


测试了一下,发现 消费者使用 多线程,的确会有数据不一致问题。

我用 int 和 AtomicInteger 做了个测试。

当 RabbitMQ 默认单线程时,int 数据正常计算。

当多线程时,int 数据计算不正常,但是 AtomicInteger 计算正常。


所以我在前面提到消息队列只是用于解耦,并不能保证线程安全。(虽然默认 单线程串行执行 肯定是线程安全的)

如果使用多线程,那就又回到并发的问题了……

使用事务?

……

再往后就没继续思考了……

到此为止。