概述
概述: 介绍高并发场景中非常容易出现的设计错误以及如何避免超卖和如何提高系统负载能力。
涉及知识点:
1.多进程、多线程,进程互斥
2.数据库乐观锁、悲观锁
3.缓存数据库
4.服务器性能衡量指标
简介
高并发秒杀抢购系统设计介绍了在高并发场应用景中最容易出现的两个系统设计问题,一个涉及多进程多线程下的互斥问题,另一个涉及内存式缓存数据库的应用,具体内容涉及数据库乐观锁和悲观锁、队列的应用和高并发系统的四个衡量指标(并发数、QPS、TPS、响应时间)。
讲述内容(40分钟)
有同学提问什么是高并发,是什么样的场景才会高并发。
高并发是指在比较短的时间内有大量的访问者访问目标系统,系统负载饱和或者过载宕机。高并发的应用,我们应该都有用过或者见过,比如京东、淘宝、天猫、亚马逊的秒杀抢购还有12306的抢票。
我们在体验这些应用轻松点击按钮的时候,可能并不会像到这种高并发系统背后的技术实现难度。高并发系统都存在这几种问题,高并发读、高并发写、访问高峰突发性、反馈结果的即时性。
在抢购的时候,尤其是抢购火车票的时候,我们经常会疯狂的刷库存,几亿用户产生非常大的高并发读;
高并发的发生时间具有突发性,比如8点开始抢购、12306早上6点开始售票;
秒杀抢购成功会在短时间带来高并发的写,需要将订单数据写入后端数据库系统;
秒杀抢购成功后要将抢购结果反馈给用户,这个时间一定不能太长,一天时间肯定不行,一个小时也不行,五分钟恐怕也会有开骂,这个时间一般是在十秒以内。
这种高并发系统最容易出现的问题有两个,一个是超卖、一个是系统宕机。当然具体项目要解决的问题不止这两个,还会有防刷、防作弊等等。今天我只抽取了其中这两个最重要的问题,也是最一些新手非常容易出现的问题。
首先说说超。超卖一般就是因为程序设计的问题,导致最终实际订单数超过实际库存数量,比如要销售的库存数量为100个,最终有效订单是200个,超卖出100个。
如果给你设计一个抢购系统,最容易想的处理思路是什么样的?看看是不是这样:
1、当有人来购买时,取出库存数据;
2、判断库存是否够;
3、如果够减掉库存,保存到数据库中;
4、反馈提示用户抢购成功。
这样的思路是不是很合情合理?应该很正确,不会有问题,是吗?
举例来看看。我们有数据库表记录商品库存,我们看看伪代码的实现。
在Web服务器中,处理动态请求一般都会采用多进程多线程,这种逻辑的代码如果在多进程或者多线程的情况下会有什么问题?
首先要理解一下,我们用多进程或者多线程跑这段程序,怎么才能看出是超卖了。超卖的结果库存一定是负的吗?答案是否定,超卖最终库存不一定是负的,有可能是0,有可能是负的,也有可能是正的。为什么?目标系统的负载一般不是长时间高负载,它具有短时突发性,有可能在高峰过后,活跃用户少不形成高并发场景,最后结束的数值就可能是0也有可能是大于0。下面有一段PHP按上面逻辑编写的代码,在多线程的情况下我们测试看看。设置库存100个,线程100个相当用户100个,先想一下库存数是多少就代表了会超卖?对,大于0一定就是超卖了。看下执行结果,库存大于0。也就是说100用户已经成功下单,库存应该是0并且系统显示已经抢光了才正确,但是还有剩余库存,也就说后来用户还可以下单,那么结果是一定超卖了。
这样的程序逻辑是不行的,那么我们想办法改进一下。问题是出在了对库存读取没有锁定,导致后续进程(或者线程)读出了同样的库存。我们可以使用数据库中的悲观锁来解决这个问题。
什么是悲观锁?悲观锁就是在事务处理过程中,将数据锁定。锁定的是整个事务过程,也就是库存的整个读写过程。下面有一个利用mysql行级锁实现的简化悲观锁。
begin;
select * from xxx where xx for update;
update xxx set xxx
commit;
rollback;
按这个处理逻辑,用PHP多线程进行模拟看看结果。
看结果,最终库存为0,因此不会超卖,理论上是可行的,是一个可选方案。但是,在突发高并发环境下并不能去使用。在有数据库应用的系统,在高负载情况下,各个组成部分,例如Web服务器(Nginx、Apache、IIS)、缓存数据库(Redis、Memcached)等等,数据库系统一般是最先达到负载饱和,也就是说前端访问压力直接穿透到数据库会让数据库最早出现访问瓶颈,最终数据库响应变慢,查询慢、写入慢甚至是服务无响应、宕机。在这种情况下,重启服务器也不能解决问题。因为在前端的访问压力在数据库重启后瞬间又传导到数据库,而且这种情况下,前端访问压力可能更大。我们是不是有这种操作系统,如果抢购系统迟迟没有响应,是不是会狂刷库存然后狂点下单?对,这种情况下会导致服务器产生更高的负载。这里有个词叫“雪崩效应”。什么是雪崩效应,在我们计算机应用系统里面因为某一台计算机产生错误异常导致应用系统服务异常,因而可能会让应用系统中的所有计算机崩溃,这种就是雪崩效应,非常恐怖,让应用系统彻底无法使用。
因为数据库会首先出现访问瓶颈,那我们再改进一下,改进的重点就是减缓数据库出现瓶颈的时间,因此引进缓存数据库,将操作非常频繁的数据在缓存中进行,然后将数据异步写回数据库。现在常用的缓存数据库,如Redis、Memcached,都是内存缓存,缓存的数据都是存放在内存中进行运算。将数据从传统数据库中拿到内存中运算,速度一定是很快,但是同时还要注意到要解决数据锁的问题,否则还会出现刚开始“超卖”的问题。
在缓存数据库中解决锁的问题,缓存数据库提供了两种解决方式。
第一个是乐观锁,第二个是队列。
先说什么是乐观锁。乐观锁,是在需要加锁的数据上增加版本号,每次读取时同时读取版本号,当有更新是缓存中的版本号会发生变化,当提交更新时会比较读取时的版本号和最新的版本号,如果版本号不同则更新失败,版本号相同则更新成功。有乐观锁一张示意图。
中间这条线是缓存中数据版本号,先看不会导致冲突的情况。当A计算机读取数据时,读取到的版本是1,A计算机处理完数据后将数据写回缓存,缓存中的版本号变为2;B计算机这个时候也读取了缓存中的数据,这个时候版本号是2,B计算机处理完数据将数据写回缓存,缓存中的版本号变为3。在看下冲突的情况,也是经常发生的情况,A、B两个计算机同时读取了数据拿到当时版本号为1,然后B计算机处理完数据,要将数据写到缓存,写的时候A计算没有提交请求,数据版本号依旧为1,B计算拿到的版本号也是1,因此成功提交数据到缓存,然后缓存中的数据版本号变为2,A计算机这个时候要提交数据,它当时拿到的数据版本号是1,可提交数据时缓存中的数据版本号已经变为了2,因此提交失败。如果A计算机想要成功提交数据,需要怎么办?对,再获取一次数据拿到版本号,将数据处理后再进行写入,可以是个循环直到写入成功为止。
乐观锁的常见应用是在我们常用的版本管理工具中,如SVN和Git。
在缓存中解决锁的问题,还有一种方式是队列,这种方式是将并行改串行。队列是什么样子的,先进先出,线性依次处理,用这种方式可以避免锁的问题。
下面是一段乐观锁实现的伪代码,分析看一下,对应实现逻辑可以用各种语言实现,效果都一样。
为了要成功提交数据,这里用了一个循环,循环体里面,首先是获取数据版本号,读取数据,进行库存判断,处理数据,数据写回缓存的时候进行判断,直到成功为止。按这个实现逻辑,用PHP实现了一段多线程程序,依旧是100个商品库存,100个线程,我们看运行结果。执行最终结果库存是0,不会超卖。
再来说一下队列的应用。在用户抢购后将抢购请求放入到队列中,服务器后端常驻服务进程处理队列中的任务。后端语言可以根据实际需要选用,不限语言。后端的处理程序要完成的处理任务大都是这几部分,处理抢购结果、记录抢购日志、抢购结果回写数据库。其中尤其要注意日志的记录,可能会在必要的时候起到非常重要的作用,日志一般是记录中文本中,介于内存和数据库之间。
抢购下单后,前端一般都是采用异步等待,响应时间一般要控制在10秒以内,前端可以Ajax等待回调或者定时获取抢购结果。
队列的应用就不具体再说,按照最先的处理思路都可以,就是因为队列是将并行改为串行,将请求一个一个依次处理。
下面再介绍几个高负载系统经常用到的几个衡量指标。有四个:
第一个是并发数、并发用户数;
第二个是QPS;
第三个是TPS;
第四个是响应时间和平均响应时间。