引言
高并发、高可用,都是面试的重点,如果你在简历中的项目与商城、秒杀相关,亦或是头部大厂、中厂,这样的问题你一定不会陌生。
之前面试某头部大厂,一面、二面均提到了关于商城中库存一致性和高并发中防止超卖、少卖、减库存,大概率三面也会问相关问题,趁这个机会,整理一下关于高并发秒杀系统的知识点。
这篇文章主要分析:如何在100万人同时抢一万张火车票时,系统提供正常、稳定的服务。
1. 高并发系统架构
高并发系统一般都会采用分布式集群部署,服务层上有层层负载均衡,并提供各种容灾手段(双活数据中心、异地多活、节点容错、服务器灾备等)保证系统的高可用,流量也会根据不同的负载能力和配置策略均衡到不同的服务器上。

1.1 负载均衡
图中使用了三种负载均衡,分别是OSPF负载均衡、Lvs负载均衡、Nginx负载均衡。
-
OSPF(开放式最短路径优先)是一个内部网关协议(IGP)。OSPF通过路由器之间通告网络接口的状态来建立链路状态数据库,生成最短路径树,OSPF会自动计算路由接口上的Cost值,但也可以通过手工指定(优先)该接口的Cost值。OSPF计算的Cost,同样是和接口带宽成反比,带宽越高,Cost值越小。到达目标相同Cost值的路径,可以执行负载均衡,默认为4条线路的负载均衡,最大支持6条线路的负载均衡,我们可以在OSPF路由进程中的maximuum-paths=6修改负载均衡的线路数。
-
LVS(Linux Virtual Server),是一种集群技术,已经被集成到Linux内核模块中,实现了基于IP数据请求负载均衡的调度和内容请求分发技术。调度器具有很好的吞吐率,将请求均衡的转移到不同的服务器执行,且调度器能够屏蔽掉服务器的故障,从而将一组服务器构成一个高性能、高可用的虚拟服务器。
-
Nginx是一款高性能的HTTP代理/反代理服务器,服务器开发中经常用来做负载均衡,Nginx的负载均衡主要方式包括:①轮询 ②加权轮询 ③ip hash 轮询
1.2 Nginx加权轮询
Nginx实现负载均衡通过upstream模块实现,其中加权轮询的配置是可以给相关的服务加上一个权重值,配置的时候可能根据服务器性能、负载能力设置响应的负载。
通过一个示例来实现加权轮询负载的配置,在本地监听8001-8004端口,权重配置为2、4、6、8。
#配置负载均衡
upstream load_rule {
server 127.0.0.1:8001 weight=2;
server 127.0.0.1:8002 weight=4;
server 127.0.0.1:8003 weight=6;
server 127.0.0.1:8004 weight=8;
}
...
server {
listen 80;
server_name baidu.com www.baidu.com;
location / {
proxy_pass http://load_rule;
}
}
其他更细致的轮询策略等问题,可以查看理解 Nginx 源码中的这一篇:Nginx 中 upstream 机制的负载均衡
1.3 补充:压测实验
在本地/etc/hosts目录下配置了 www.baidu.com 的虚拟域名地址,接下来使用Go语言开启四个http端口监听服务,下面是监听在8001端口的Go程序,其他几个只需要修改端口即可:
package main
import (
"net/http"
"os"
"strings"
)
func main() {
http.HandleFunc("/buy/ticket", handleReq)
http.ListenAndServe(":8001", nil)
}
//处理请求函数,根据请求将响应结果信息写入日志
func handleReq(w http.ResponseWriter, r *http.Request) {
failedMsg := "handle in port:"
writeLog(failedMsg, "./stat.log")
}
//写入日志
func writeLog(msg string, logPath string) {
fd, _ := os.OpenFile(logPath, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0644)
defer fd.Close()
content := strings.Join([]string{
msg, "\r\n"}, "8001")
buf := []byte(content)
fd.Write(buf)
}
我将请求的端口日志信息写到了./stat.log文件当中,然后使用ab压测工具做压测:
ab -n 1000 -c 100 http://www.baidu.com/buy/ticket
统计日志中的结果,8001-8004端口分别得到了200、400、600、800的请求量,这和我在nginx中配置的权重占比很好的吻合在了一起,并且负载后的流量非常的均匀、随机。
2. 秒杀抢购系统机制
Question:假如有100万人同时抢一张火车票,如何提供正常、稳定的服务呢?
在已经使用了负载均衡的情况下,用户秒杀的流量通过了每一层的负载均衡,均匀到了不同的服务器上,即使如此,集群中的单机承受的QPS也是非常高的。
通常来说,订票系统需要处理生成订单、减库存、用户支付这三个基本的阶段,我们系统要做的事情就是保证火车票订单不超卖、不少卖,每一张出售的火车票都是必须支付才有效,还要保证在持续的高并发环境中运行稳定。
下面我们分析一下这三个基本阶段的先后顺序:
2.1 下单减库存
当用户请求并发到达服务端的时候,首先创建订单,然后扣除库存,等待用户支付。这是一种最常见的思路,这种情况可以保证订单不超卖,因为创建订单之后减库存(利用Redis increment 原子操作)这是一个原子操作。
但是这样也会产生一些问题:
- **第一是:**在极限并发的情况下,任何一个内存操作的细节都至关重要的影响性能,尤其像创建订单这种逻辑,一般都需要存储到磁盘数据库的,对数据库的压力是非常巨大的;
- **第二是:**如果用户存在恶意下单的情况,只下单不支付这样库存就会变少,会少卖很多订单,虽然服务端可以限制IP和用户的购买订单数量,这也不算是一个好方法。

2.2 支付减库存
如果等待用户支付了订单再减库存,就可以避免少卖。这是并发架构的大忌,因为在极限并发的情况下,用户可能会不断点击下单键,导致下了多单,当库存减为0的时候很多用户会发现抢到的订单支付不了,这就是所谓的**“超卖”**。同时,这也不能避免并发操作数据库和磁盘IO。

2.3 预扣库存
从上面两种方案考虑,我们得出结论:只要创建订单,就要频繁操作数据库IO。
那么我们优化的点就是:有没有一种方案可以不需要直接操作数据库IO呢?
这就是预扣库存(具体实现和优化参考3.预扣库存优化):
- 保证不超卖,然后异步生成用户订单,这样响应给