如何建立秒杀系统?
一、秒杀系统场景
一个系统好坏的衡量可以从3个方面进行,用户、时间和空间。用户包括UI界面设计、交互设计以及用户并发量。时间包括系统响应时间、吞吐量。空间则是资源利用效率。
秒杀活动基于对限量商品的买卖,往往会形成短时间的高并发的情况。但最后只有少部分人能秒杀成功。
秒杀活动可以分为3个阶段:
- 秒杀前:用户不断刷新页面,向后台提交请求
- 秒杀时:大量并发消息涌入后台
- 秒杀后:大量订单处理,还有人退单
在这个过程中,最重要的是**!扣减库存!**,必须保证库存扣减的逻辑性。
二、线程安全
多个用户的请求以多线程的方式进行,多线程共享商品剩余数量amount。
如果不使用线程安全的技术,往往会造成实际购买数量大于存货的情况。我们用CountDownLatch
来模拟一下这种情况。
public class Main implements Runnable{
private static int threadNum = 1000; // 一共有1000个并发的请求
private final static CountDownLatch startSignal = new CountDownLatch(threadNum);
private static int amount = 2; //总共商品数量
private static int success = 0; //卖出的数量
public static void main(String[] args) throws InterruptedException{
Main task = new Main();
for(int i = 0;i < threadNum;i++) {
new Thread(task).start();
startSignal.countDown(); // 用CountDownLatch实现多个线程同时start
}
Thread.sleep(50);
System.out.println(success);
}
@Override
public void run() {
try {
startSignal.await();
if(amount > 0) {
System.out.println(Thread.currentThread().getName() + "秒杀成功,剩余商品数量" + --amount);
success++;
}
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
}
}
让我们打印输出结果来看看。
Thread-999秒杀成功,剩余商品数量0
Thread-4秒杀成功,剩余商品数量-5
Thread-2秒杀成功,剩余商品数量-4
Thread-5秒杀成功,剩余商品数量1
Thread-3秒杀成功,剩余商品数量-1
Thread-6秒杀成功,剩余商品数量1
Thread-0秒杀成功,剩余商品数量-2
Thread-1秒杀成功,剩余商品数量-3
8
哦,我的天!商品数量只有2个,但是却卖出了8个,真的是酿成大错!
这就是线程不安全,说的专业点就是
不提供数据访问保护,在多线程环境中对数据进行修改,会出现数据不一致的情况。
如何保证数据被保护呢?
可以有三种方式:
1、数据库锁;2、synchronized等;3、缓存;
三、数据库锁
数据库中保证线程安全,我们首先会想到加锁。在数据库中,锁按照范围分成行锁和表锁,按照性质分成排它锁和共享锁。
类型 | 性质 |
---|---|
行锁 | 只锁住一行的锁,在锁住的时候,其他线程不能再对它进行访问。 |
表锁 | 锁住整个表,在锁住的时候,其他线程不能再对它进行访问。 |
排它锁 | 其他线程无法再对它加锁 |
共享锁 | 只能读,不能修改。其他线程可以对它加共享锁 |
根据上表,排列组合一些就知道一共有4种情况,行排他锁、表排它锁、行共享锁、表共享锁。我们用Mysql来看看这到底是怎么一回事。
首先,Mysql中加共享锁是用lock in share mode
,加排他锁是用for update
首先我们先将事务自动提交关闭,否则mysql会认为每一句sql语句都是事务,就比较难模拟并发的情况了。
mysql> set autocommit=off;
建立book_info,并开启两个会话对其进行处理。
mysql> BEGIN;
mysql> SELECT * FROM book_info;
+---------+-----------+--------------------+-----------------------+---------------+-------+----------+-------+
| book_id | name | author | publish | ISBN | price | class_id | count |
+---------+-----------+--------------------+-----------------------+---------------+-------+----------+-------+
| 1111 | 活着 | 余华 | PP | 9787506365437 | 20.00 | 10001 | 10 |
| 1234 | Jane | Amy | PP | 123 | 23.12 | 102 | 23 |
| 2323 | 小王子 | 圣埃克苏佩里 | 人民文学出版社 | 9787020042494 | 39.50 | 10001 | 20 |
+---------+-----------+--------------------+-----------------------+---------------+-------+----------+-------+
3 rows in set (0.00 sec)
在会话1中,对某一行加排他锁。也就是book_id=1111的一行。
mysql> SELECT * FROM book_info where book_id=1111 for update;
+---------+--------+--------+---------+---------------+-------+----------+-------+
| book_id | name | author | publish | ISBN | price | class_id | count |
+---------+--------+--------+---------+---------------+-------+----------+-------+
| 1111 | 活着 | 余华 | PP | 9787506365437 | 20.00 | 10001 | 10 |
+---------+--------+--------+---------+---------------+-------+----------+-------+
1 row in set (0.00 sec)
此时在会话2中,如果想对这一行加锁或者增删改,会发现一直处于阻塞状态。直到会话1中commit为止。且任何涉及该行的操作都不能进行(比如选择author=余华也无法进行)。
mysql> select * from book_info where book_id=1111 for update;
对lock in share mode的模拟就不在这里贴出了。效果就是,多个会话可以同时访问某行,但是无法对改行进行修改。
对某一行加了锁,并不表示所有针对改行的操作都无法进行了!
最基本的select * from table;是可以进行的,注意后面我们没有主动要求上锁。
因为Mysql中,只有设计增删改的操作需要得到对象的锁,只是单纯的查是不需要获得锁的。
四、同步机制
用synchronized对方法或者代码块加锁。
public synchronized void run() {
// 之前的内容
}
也就是说,在进行run()
的时候必须得到锁,且一次只能有一个线程得到这把锁,保证了数据不会出错。
Thread-0秒杀成功,剩余商品数量1
Thread-999秒杀成功,剩余商品数量0
2
在java中,除了synchronized还有Lock。
此处就不得不说乐观锁、悲观锁了。
数据库可以通过增加version的方式实现乐观锁。
此处未完待续。。。
但是这两者的问题在于,锁太重了,处理速度太慢,无法抗住百万级别的流量访问。且流量直接打到后端,可能会造成系统的崩溃。
所以现在除了秒杀系统一般通过分层的思想。
五、秒杀系统
一般如下分层。
(来自网站,侵删)
虽然秒杀系统流量很高,但实际有效的是十分有限的。比如用户在秒杀前多次刷新,以及许多没买到票的请求完全不必直接作用在数据库上。所以我们每层都拦截无效的流量,可以有效地避免高并发可能造成的崩溃。
1. CDN Cache
缓存是系统快速响应中的一种关键技术,是一组被保存起来以备将来使用的东西。往往存在于高速部件与低速部件之间,例如CPU与内存之间存在着CPU缓存。
CDN全称Content Delivery Network。即在各地部署多套静态存储服务,本质上是空间换时间。
比如说,我去访问一个页面,我对服务器发出请求之后,服务器从数据库中调取内容,然后发送给我。如果一共有10000个请求,那么服务器要调取10000次内容。而数据库访问是比较慢的。所以这会使得系统响应速度非常慢。
有了CDN之后,将网页内容放在CDN节点的缓存中,访问时自动选择最近的节点内容,不存在再请求原始服务器。而缓存的访问速度是非常快的,从而实现了快速响应。并且不同的请求可以都直接读取缓存的内容,所以高并发也没问题。
CDN适合存储更新很少的静态内容,文件更新慢。
所以在我们的秒杀系统中,尽量将秒杀商品的页面设计成静态的,
除了秒杀按钮需要服务端进行判断,其他静态数据存储在CDN和浏览器缓存中。如此一来,多用户不断刷新就导致流量直接进入服务器。
其实在这一层之后,可以对同一个IP的访问次数做出限制。
比如一个IP的流量只能往后台一次,或者设置最小时间间隔。
防止刷票系统多次刷票。
2. Redis读写分离
Redis是单进程单线程的网络模型,处理所有的客户端连接请求,命令读写请求。
Redis提供的所有API操作,相对于服务端方面都是one by one执行的,
命令是一个接着一个执行的,不存在并行执行的情况。
我们将amount写入Redis队列,每一次访问是的amount数量-1。当amount数量为0之后,立即拦截所有的流量。
最后,下单请求只有一少部分可以被接受。
此处需要加入服务器集群。
3. Redis主从版
下单成功之后,进行信息校验。
依旧避免直接对数据库的访问。入库的时候,如果订单量比较大,直接写入数据库还是比较慢,所以利用Redis的消息队列组件,异步处理订单入库。只要内容进入了消息队列,就认为下单成功。
啊啊啊啊,Redis还有好多内容待补充!+异步
4. 数据控制
通过数据控制,在Redis读写分离时拦截大部分的请求。之后再确认订单之后,放入消息队列,异步下单。最后写入数据库。