高并发:从一个抢票系统说起
高并发系统实战:从锁到分布式一致性的工程实践
第 1 章:高并发——从一个抢票系统说起
1.1 开篇故事:一场"惨烈"的抢票大战
春节抢票的噩梦
每年春节前夕,中国大地上都会上演一场规模空前的"人类大迁徙"。这场迁徙的序幕,往往从一张小小的火车票开始。2018年的除夕前三天,张明(化名)早早地坐在电脑前,双手悬在键盘上方,眼睛死死盯着12306的抢票页面。
"还有3分钟...2分钟...1分钟..."
倒计时归零的瞬间,他的手指如机关枪般点击着刷新按钮。页面弹出一个旋转的加载图标——然后,这个图标转了整整30秒,最后显示出一行冰冷的文字:"系统繁忙,请稍后再试"。
这不是张明一个人的遭遇。据报道,那一年的春运抢票高峰期间,12306系统的日PV(页面访问量)突破了1500亿次,平均每秒承受超过173万次的访问请求。在这场没有硝烟的战争中,无数人和张明一样,经历着希望、失望、再希望的轮回。
> *"我抢了三天票,最后还是没抢到。等我终于登上页面,余票显示为0的那一刻,感觉比股票跌停还难受。"* —— 一位网友的评论
系统崩溃的那一刻
让我们把镜头拉回到技术层面。假设你是一位负责12306系统开发的工程师,此刻你正监控着系统的实时状态。大屏幕上,各种指标曲线正在疯狂跳动:
- **QPS(Queries Per Second,每秒查询数)**:从平时的几千飙升到百万级别
- **响应时间**:从毫秒级暴增到几十秒
- **错误率**:从0.01%飙升至30%以上
- **服务器CPU**:多台服务器同时逼近100%
突然,监控大屏变红,刺耳的告警声响起——系统崩溃了。
数据库连接池耗尽,大量请求堆积在队列中,用户体验断崖式下降。微博热搜上,"12306崩了"的话题迅速攀升。
这不是电影场景,而是每一个高并发系统都可能面临的真实挑战。
我们要解决什么问题
这个故事引出了本书要探讨的核心问题:当系统面临海量并发请求时,如何保证系统的正确性、性能和可用性?
抢票系统的挑战是典型的"卖超"问题:100张票,却可能有10万人同时抢购。如果处理不当,系统可能卖出200张票、300张票,甚至更多。这不仅损害了用户权益,更可能导致严重的经济损失和法律纠纷。
类似的问题场景还有很多:
| 场景 | 核心挑战 | 后果 | |------|----------|------| | 电商秒杀 | 库存超卖 | 超卖导致无法履约 | | 红包系统 | 并发扣减 | 红包金额被重复领取 | | 抢票系统 | 库存一致性 | 座位被多人占用 | | 账户转账 | 余额一致性 | 钱凭空变多或变少 |
这些场景有一个共同特征:多个请求竞争有限的资源,需要对共享状态进行正确、一致的管理。
这正是本书要深入探讨的主题。
1.2 高并发场景的识别与特征
什么是高并发
在深入讨论之前,我们需要明确一个核心概念:什么是高并发?
很多人认为高并发就是"访问量大",这个理解不够精确。高并发的本质是系统在单位时间内需要处理的请求数量超过了系统的处理能力上限,导致系统性能下降甚至崩溃。
这个定义中有三个关键要素:
1. 请求量:单位时间内的请求数量 2. 处理能力:系统在单位时间内能处理的请求上限 3. 临界状态:当请求量接近或超过处理能力时,系统进入高并发状态
用数学语言来描述,设 $R$ 为请求率(每秒请求数),$C$ 为系统容量(每秒可处理请求数),则:
- 当 $R < C$ 时,系统正常运行
- 当 $R \approx C$ 时,系统开始出现性能下降
- 当 $R > C$ 时,系统进入高并发危机状态
```mermaid graph LR A[请求率 R] --> B{与系统容量 C 比较} B -->|R < C| C[正常运行] B -->|R ≈ C| D[性能下降] B -->|R > C| E[高并发危机]
style C fill:#90EE90 style D fill:#FFD700 style E fill:#FF6B6B ```
典型高并发场景分析
高并发场景并非互联网的专利,在许多领域都有典型的应用。让我列举几个最常见也是最具挑战性的场景:
1. 电商秒杀
每年的"双11"、"618"等购物节,电商平台都会迎来流量洪峰。2021年双11期间,天猫平台的订单创建峰值达到58.3万笔/秒,这意味着系统需要在每一秒内处理近60万笔订单的创建请求。
秒杀场景的特点是:
- **请求量巨大且集中**:活动开始的一瞬间,流量会在极短时间内涌入
- **库存有限**:通常热门商品的库存远少于抢购人数
- **时间敏感性**:用户对响应时间非常敏感,等待超过几秒就会放弃
2. 抢红包系统
微信红包是另一个典型的高并发场景。春晚期间,一个红包可能同时被几亿人领取。2019年春晚,微信红包的峰值参与人数达到8.1亿。
红包系统的独特挑战在于:
- **资金安全**:金额计算必须精确,不能多一分也不能少一分
- **高可用**:红包领取不能出现故障,否则会严重影响用户体验
- **一致性要求**:红包金额不能被重复领取
3. 抢票系统(12306)
12306是世界上最复杂的票务系统之一。它需要处理:
- **海量数据**:全国范围的列车时刻表、余票信息
- **复杂业务逻辑**:中途票、余票计算、多段行程组合
- **地域特征**:热门线路与其他线路的访问量差异巨大
4. 直播平台
直播带货、在线教育等场景中,主播开播的一瞬间会涌入大量观众。在线人数可能从0瞬间飙升到几十万甚至上百万。
直播系统的特点:
- **流量突增**:没有任何预兆,流量可能在几秒内暴涨
- **互动密集**:弹幕、礼物、点赞等操作并发量大
- **实时性要求**:延迟必须控制在毫秒级
并发与并行的区别
在讨论高并发时,我们需要区分两个容易混淆的概念:并发(Concurrency)和并行(Parallelism)。
并发是指多个任务在同一时间段内交替执行。在单核CPU上,操作系统通过快速切换执行上下文(Context Switch),让多个任务看起来像是同时执行,但实际上任意时刻只有一个任务在真正运行。
并行是指多个任务在同一时刻真正同时执行。这需要多核CPU或多台计算机,每个处理单元独立执行自己的任务。
用一张经典的图来说明:
```mermaid graph TD subgraph 并发 (单核 CPU) A1[时间线] --> B1[任务A ████████░░░░░░░░░░░░░░] A1 --> C1[任务B ░░░░░░░░████████░░░░░░░░░░] A1 --> D1[任务C ░░░░░░░░░░░░░░░██████████] end
subgraph 并行 (多核 CPU) A2[时间线] --> B2[核心1 ████████████████████] A2 --> C2[核心2 ░░░░░░░░████████████░░░░] A2 --> D2[核心3 ░░░░░░░░░░░░░░░██████████] end ```
为什么这个区别很重要?
在传统的企业级应用中,我们往往通过提升单机的硬件配置(垂直扩展)来应对增长。当单机性能不足时,就面临两条路:
1. 垂直扩展(Scale Up):升级单机配置,增加CPU核心数、内存、磁盘等 2. 水平扩展(Scale Out):增加机器数量,通过集群来分担负载
然而,无论选择哪条路,都无法回避一个根本问题:当多个执行单元(线程、进程、机器)同时访问共享资源时,如何保证系统的正确性?
这就是高并发系统需要解决的核心问题。
1.3 并发问题的本质
资源竞争:僧多粥少的困境
高并发问题的本质是资源竞争。在任何系统中,资源都是有限的——CPU计算能力、内存容量、磁盘IO、网络带宽、数据库连接池、文件句柄......当请求数量超过资源的承载能力时,竞争就产生了。
以一个简化的抢票场景为例:
```python
这段代码看起来逻辑清晰,但存在严重问题
def buy_ticket(user_id, ticket_id):
查询余票
ticket = db.query("SELECT * FROM tickets WHERE id = %s", ticket_id)
if ticket.stock > 0:
有票,更新库存
db.execute("UPDATE tickets SET stock = stock - 1 WHERE id = %s", ticket_id) db.execute("INSERT INTO orders (user_id, ticket_id) VALUES (%s, %s)", user_id, ticket_id) return "购票成功" else: return "票已售罄" ```
这段代码在单线程环境下运行完美,但当1000个用户同时抢购最后一张票时,问题就来了。
状态一致性:数据的"薛定谔状态"
并发系统中的另一个核心问题是状态一致性。在单线程程序中,程序的状态是确定性的——任何时刻,变量的值都是明确已知的。但在并发环境中,同一个变量的值可能是"不确定"的,直到某一刻才能确定。
这种不确定性来源于指令重排(Instruction Reordering)和多级缓存架构。
在现代计算机中,为了提升性能,CPU会对指令进行重排,同时每个CPU核心都有自己独立的L1、L2缓存。当多个线程在不同的CPU核心上运行时,它们可能看到不同的数据"快照"。
用一个生活化的例子来说明:
> 想象一个图书馆,有A、B两本书,同一时刻张三在A馆阅读A书,李四在B馆阅读B书。此时,王五问:"现在A书在谁手上?"答案是"张三"。但如果张三把书放回书架后忘记通知李四,李四可能会继续认为A书在张三手上。
这就是缓存一致性(Cache Coherence)问题的简化版本。
竞态条件:时间差里的陷阱
竞态条件(Race Condition)是并发编程中最棘手的问题之一。它指的是程序的输出依赖于不可控的事件时序或线程调度。
竞态条件通常发生在以下场景:
1. 检查后执行(Check-Then-Act):先检查某个条件是否满足,再基于检查结果执行操作,但检查和操作之间可能存在时间窗口,其他线程可能在此期间改变了条件
2. 读取-修改-写入(Read-Modify-Write):读取一个值,修改它,然后写回,但其他线程可能在此期间也读取并修改了这个值
让我们看一个经典的竞态条件示例——计数器递增:
```python
竞态条件示例
counter = 0
def increment():
看似简单的三步操作
temp = counter # 1. 读取 temp = temp + 1 # 2. 修改 counter = temp # 3. 写入
假设两个线程同时执行
线程A: temp = counter (读到0)
线程B: temp = counter (也读到0)
线程A: temp = 1
线程B: temp = 1
线程A: counter = 1
线程B: counter = 1 # 期望counter=2,实际=1
```
```mermaid sequenceDiagram participant TA as 线程A participant TB as 线程B participant C as Counter (内存)
TA->>C: 读取 counter=0 TB->>C: 读取 counter=0 TA->>C: 写入 counter=1 TB->>C: 写入 counter=1 Note over C: 最终值=1,丢失一次递增! ```
1.4 为什么简单代码会崩溃
一个经典的计数器问题
让我们用一个更真实的场景来理解高并发问题的严重性。假设你负责开发一个商品秒杀系统,需要记录每个商品的已售数量。
你写下了这样的代码:
```java public class OrderService { private int soldCount = 0;
public void sellProduct(long productId) { // 查询商品信息 Product product = productRepository.findById(productId);
// 检查库存 if (product.getStock() > 0) { // 扣减库存 product.setStock(product.getStock() - 1); productRepository.save(product);
// 记录销售数量 soldCount++; // ← 问题出在这里
System.out.println("销售成功!已售:" + soldCount); } else { System.out.println("库存不足"); } } } ```
这段代码在压测中会暴露什么问题?让我们用JMeter模拟100个并发请求,看看会发生什么:
``` 预期结果:soldCount = 100 实际结果:soldCount = 73 # 丢失了27次递增! ```
问题的根源在于 `soldCount++` 这个操作不是原子的(Atomic)。在Java中,这看似一行代码,实际上对应三条字节码指令:
``` getstatic # 获取静态变量值到栈顶 iconst_1 # 常量1入栈 iadd # 执行加法 putstatic # 将结果写回静态变量 ```
当两个线程同时执行这三条指令时,就会发生经典的"丢失更新"问题。
时序图解析
让我用时序图详细展示这个问题的发生过程:
```mermaid sequenceDiagram participant T1 as 线程T1 participant T2 as 线程T2 participant CPU1 as CPU核心1 participant CPU2 as CPU核心2 participant MEM as 主内存
Note over T1,T2: 初始状态:soldCount = 0
T1->>CPU1: 读取 soldCount (值=0) T2->>CPU2: 读取 soldCount (值=0)
Note over CPU1: 本地缓存: count=0 Note over CPU2: 本地缓存: count=0
CPU1->>CPU1: count = count + 1 → 1 CPU2->>CPU2: count = count + 1 → 1
CPU1->>MEM: 写回 soldCount=1 CPU2->>MEM: 写回 soldCount=1
Note over T1,T2: 最终 soldCount=1,丢失一次更新!
rect rgb(255, 100, 100) Note over T1,T2: 这是并发编程中最经典的错误之一 end ```
为什么CPU和内存之间会不同步?
这涉及到计算机体系结构的深层知识。现代CPU为了追求极致性能,引入了多层缓存架构:
``` ┌─────────────────────────────────────────────┐ │ 内存 │ │ (主存) │ └─────────────────────────────────────────────┘ ↑ (总线访问,较慢) ↑ ┌─────────────────┴─────────────────┐ │ L3 缓存 (共享) │ └─────────────────┬─────────────────┘ ↑ ↑ ┌─────────┴─┐ ┌────┴────┐ │ L2 缓存 │ │ L2 缓存 │ └─────┬─────┘ └────┬─────┘ ↑ ↑ ┌─────┴─────┐ ┌─────┴─────┐ │ L1 缓存 │ │ L1 缓存 │ │ (CPU核心1) │ │ (CPU核心2) │ └───────────┘ └───────────┘ ```
每个CPU核心都有自己独立的L1和L2缓存,只有L3是所有核心共享的。当CPU核心需要读取数据时,会依次从L1、L2、L3缓存中查找,如果都没找到才去主内存读取。
这就导致了一个经典问题:**一个核心修改了某个变量的值,