冒险的开始:小明的第一次并发危机
第1章:冒险的开始:小明的第一次并发危机
深夜的警报
那是十月的最后一个周五,北京的夜晚已经带着几分寒意。小明揉了揉发酸的眼睛,看了一眼电脑右下角的时间——23:47。办公室里只剩下他一个人,键盘的敲击声在空旷的办公区里显得格外清晰。
小明是一名刚刚入职三个月的Java后端工程师,在一家中型电商公司工作。他的第一个任务,是维护一个看似简单的库存管理系统。系统功能很直白:用户下单时扣减库存,用户退货时增加库存,再加上一堆常规的增删改查。这个任务本该是新人练手的“Hello World”级别项目,主管当时拍着他的肩膀说:“小明,这个系统很简单,你肯定能搞定。”
此刻的小明可不觉得简单。
屏幕上,运维监控平台正在疯狂报警。红色的警告信息一条接一条地弹出来,像极了恐怖片里不断闪烁的警报灯。订单系统的错误率在过去半小时内从0.1%飙升到了15%,数据库连接池已经耗尽,更多的请求正在排队等待。与此同时,用户投诉群里的消息也在不断刷新:“为什么我下单成功但库存变成负数了?”“我根本没抢到票,为什么扣了我两次钱?”“订单状态怎么是已支付但显示库存不足?”
小明的手心开始冒汗。他的第一反应是检查自己的代码——那个他亲手写的,看起来逻辑清晰、结构合理的库存扣减代码。他盯着屏幕,试图找出问题所在,但代码明明没有任何语法错误,逻辑也是他仔细推敲过的。为什么会出这么严重的问题?
就在这时,他的手机响了。是主管老张的电话。
“小明,我看到报警了,你在线吗?”
“在在在!张哥,这怎么回事啊?我代码检查了好几遍,没发现问题啊!”小明的声音带着一丝颤抖。
电话那头沉默了两秒,然后传来老张平静但略带严肃的声音:“这是并发问题。你明天来我办公室一趟,我们好好聊聊。”
并发问题。这四个字在小明脑海中转了好几圈,却完全摸不着头脑。他学过操作系统,知道进程和线程是怎么回事,但“并发问题”是什么意思?为什么他的代码在单用户测试时完美无缺,一到生产环境就变成了这副模样?
那一夜,小明没有睡好。他躺在床上,脑子里全是那些红色的报警信息和用户愤怒的投诉。他暗暗发誓,一定要搞清楚到底发生了什么。
第二天上午,在主管老张的办公室里,一场关于并发的冒险就此展开。而这场冒险,将带领小明——也将带领你——从一个全新的角度重新认识计算机系统,理解那些在课堂上可能只是匆匆带过、但在实际工作中却无处不在的关键概念。
什么是并发:看似同时发生的事情
“小明,我先问你一个问题,”老张端着一杯茶,靠在椅子上,“你觉得什么是并发?”
小明想了想,说:“就是……同时做很多事情?比如我可以一边听歌一边写代码?”
老张点点头:“这是日常生活中的并发,很好理解。但在计算机系统中,并发的含义要更精确一些。”
老张在白板上画了一个简单的图:
```mermaid graph TD A[单个处理器] --> B[时间片1: 任务A] B --> C[时间片2: 任务B] C --> D[时间片3: 任务A] D --> E[时间片4: 任务B]
F[两个处理器] --> G[处理器1: 任务A] F --> H[处理器2: 任务B] G --> I[真正同时执行] H --> I
style A fill:#e1f5fe style F fill:#e8f5e8 ```
“在计算机科学中,并发(Concurrency) 指的是多个任务在同一个时间段内同时处于执行状态。”老张解释道,“注意,是'同一个时间段',而不是'同一个时刻'。”
小明皱起眉头:“这两个有区别吗?”
“区别很大。”老张喝了口茶,“在单核处理器时代,CPU同一时刻只能执行一个任务。但是它可以在不同任务之间快速切换,快到让人类感觉它们是在同时运行。比如你一边听音乐一边浏览网页,音乐播放和网页渲染其实是在交替使用同一个CPU核心,只不过切换得太快,你感觉不到。”
“这就是所谓的'伪并行'或者'时分复用'?”小明想起了操作系统课上学过的知识。
“没错。”老张赞许地看了小明一眼,“但在多核处理器时代,情况就不一样了。如果你有多个核心,确实可以在同一个时刻真正同时执行多个任务,这就叫'并行(Parallelism)'。”
小明掏出手机,打开了任务管理器:“等等,让我看看我的电脑有几个核心……8个!这8个核心可以真正同时工作?”
“严格来说,你看到的是8个逻辑处理器。现在的CPU大多支持超线程技术,每个物理核心可以模拟成两个逻辑核心。但即使不考虑这个,拥有多个物理核心的CPU确实能够实现真正的并行执行。”
老张在白板上补充写道:
> 并发(Concurrency):多个任务在时间上重叠执行,可能是伪并行(单核CPU时间片轮转)或真并行(多核CPU同时执行)。强调的是任务之间的组织和协调关系。 > > 并行(Parallelism):多个任务在同一时刻真正同时执行,只有在多核系统或分布式系统中才能实现。强调的是同时执行的能力。
小明盯着这两个定义,若有所思:“所以……我之前遇到的问题,是因为有多个用户同时访问系统,导致我的代码需要处理'并发'的情况?”
“基本上对了。”老张点点头,“但还不够准确。问题不仅仅是'同时访问',而是当多个操作同时访问和修改同一个数据时,产生的'数据竞争'问题。”
数据竞争。小明第一次听到这个词,心跳不由得加快了几分。
高并发系统:不只是很多人同时访问
“在深入数据竞争之前,我们需要先理解另一个概念——高并发系统。”老张打开电脑,调出了一份PPT。
“一提到高并发,很多人第一反应就是'很多人同时访问'。这个理解没有错,但太浅了。”
老张指着PPT上的数字说:“让我们看一些真实的数据。假设你的电商网站在做双十一促销:
- 每秒有10000个用户发起请求
- 每个请求需要访问数据库3次
- 每次数据库操作平均耗时5毫秒
我们来算一笔账。”
老张在纸上写下一个算式:
$$\text{每秒数据库操作数} = 10000 \times 3 = 30000 \text{次/秒}$$
$$\text{单核处理能力} = \frac{1000\text{ms}}{5\text{ms}} = 200 \text{次/秒}$$
“如果只用单核来处理这些数据库操作,你需要150个核心才能扛住这10000 QPS(Queries Per Second,每秒查询数)的压力。这还只是简单的数据库操作,如果加上业务逻辑计算、缓存读写、消息队列处理等,实际需要的计算资源会更多。”
小明瞪大了眼睛:“150个核心!那岂不是需要很多服务器?”
“现在的服务器确实可以配备很多核心,但你还需要考虑网络带宽、磁盘IO、内存容量等各种资源。”老张继续说道,“更重要的是,'高并发'不仅仅是数字上的'多',还包括以下维度:”
> 高并发的多维度定义: > > 1. 数量维度:同时处理大量请求的能力(QPS、TPS) > 2. 速度维度:每个请求的响应时间要求(毫秒级、微妙级延迟) > 3. 一致性维度:在分布式环境下保证数据正确性 > 4. 可用性维度:系统持续提供服务的能力(99.99%可用性意味着每年宕机时间不超过52分钟) > 5. 可扩展维度:系统能够随负载增长而线性扩展
小明一边听一边记,心里暗暗佩服老张的专业素养。他第一次意识到,“高并发”这三个字背后,竟然藏着这么多复杂的工程挑战。
“在你的库存系统里,”老张话锋一转,“虽然用户数量可能没有达到双十一那种量级,但'同时'这个概念本身就是相对的。即使只有两个人在同一秒钟内下单,对于你的系统来说,也是并发场景。”
小明点点头,终于理解了问题的本质:不是用户数量多少的问题,而是系统必须具备处理“同时发生的事件”的能力。
数据竞争:看不见的幽灵
“接下来,我们来聊今天最重要的话题——数据竞争(Race Condition)。”老张的表情变得严肃起来,“这也是导致你昨晚系统崩溃的直接原因。”
Race Condition。这个英文词汇像一道闪电,划破了大明脑海中的迷雾。他想起在学校里学操作系统时,老师确实提过这个词,但当时只是轻描淡写地带过,说这是“一种需要避免的情况”。现在看来,这个“情况”造成的问题远比想象中严重。
“让我用一个最经典的例子来说明,”老张在白板上画了一个简图,“抢票系统。”
小明突然想起来,他之前写的库存扣减逻辑,本质上就是一个抢票/抢商品的过程。
“假设我们有一张演唱会门票,只剩最后一张。现在有两个用户——用户A和用户B——同时访问系统,都想抢这张票。”老张说,“从业务逻辑上来说,只能有一个人成功抢到票。但在你的代码中,可能会发生这样的情况:”
```mermaid sequenceDiagram participant U1 as 用户A的请求 participant Server as 服务器 participant DB as 数据库
Note over U1,DB: 时间线(从上到下)
U1->>Server: 查询库存:还剩1张 Server-->>U1: 返回:库存=1
U1->>Server: 发起下单请求 Note over Server: (此时CPU切换处理用户B的请求)
DB-->>Server: 返回:库存=1(还没更新)
U1->>DB: 扣减库存:1-1=0 DB-->>Server: 更新成功:库存=0
Server->>DB: 查询库存:0张 DB-->>Server: 返回:库存=0
Server->>DB: 扣减库存:0-1=-1 DB-->>Server: 更新成功:库存=-1 ❌ ```
小明盯着这个图,突然明白了什么:“我看到了!用户A查询的时候库存是1,用户B查询的时候库存也是1,因为他们都在对方扣减之前查询。然后他们都执行了扣减操作,导致库存变成了负数!”
“完全正确。”老张露出了一丝欣慰的笑容,“这就是最典型的数据竞争——多个线程或进程对共享资源进行读写操作时,由于操作的执行顺序不确定,导致最终结果与预期不符。”
老张在白板上写下正式的定義:
> 数据竞争(Race Condition):两个或多个操作同时访问共享资源,至少有一个操作是写操作,且这些操作的执行顺序会影响最终结果的情况。 > > 关键词:同时访问 + 至少一个写操作 + 结果不确定
“在你的代码中,”老张继续说道,“你可能是这样写的:”
小明打开笔记本电脑,调出了他的代码:
```java // 小明原来的库存扣减代码(有问题) public Result decreaseStock(Long productId, Integer quantity) { // 第一步:查询当前库存 Product product = productMapper.selectById(productId); Integer currentStock = product.getStock();
// 第二步:检查库存是否充足 if (currentStock < quantity) { return Result.fail("库存不足"); }
// 第三步:扣减库存 Integer newStock = currentStock - quantity; product.setStock(newStock); productMapper.updateById(product);
return Result.success("扣减成功"); } ```
老张看了一眼代码,叹了口气:“问题非常明显。你的代码分为三步:查询、检查、修改。这三步之间存在时间间隙,在高并发场景下,这个间隙就是数据竞争的温床。”
老张在白板上画了一个更详细的时序图,解释问题发生的具体过程:
```mermaid graph LR subgraph 时间轴 T1[时刻T1] T2[时刻T2] T3[时刻T3] T4[时刻T4] end
subgraph 用户A的操作 A1["查询库存=1"] --> A2["检查:1≥1 ✓"] A2 --> A3["计算:1-1=0"] A3 --> A4["更新库存=0"] end
subgraph 用户B的操作 B1["查询库存=1"] --> B2["检查:1≥1 ✓"] B2 --> B3["计算:1-1=0"] B3 --> B4["更新库存=0"] end
style T1 fill:#ffcccc style T4 fill:#ffcccc ```
“当用户A执行到'计算'步骤时,CPU可能切换去处理用户B的请求。此时用户B查询到的库存仍然是1(因为用户A还没有真正更新到数据库),于是用户B也认为自己可以扣减。两个用户的扣减操作都成功了,但库存从1变成了0,而不是我们期望的-1(如果允许超卖)或者报错(如果不允许超卖)。”
小明恍然大悟:“原来如此!这就是为什么库存会变成负数,也可能出现超卖的情况。那如果我不检查库存,直接扣减呢?比如这样:”
```java // 尝试优化:直接扣减 public Result decreaseStock(Long productId, Integer quantity) { // 直接执行SQL扣减 int rows = productMapper.decreaseStock(productId, quantity);
if (rows == 0) { return Result.fail("库存不足或商品不存在"); } return Result.success("扣减成功"); } ```
对应的SQL可能是:
```sql UPDATE product_stock SET stock = stock - #{quantity} WHERE product_id = #{productId} AND stock >= #{quantity} ```
老张点点头:“这是一个改进。你把'查询-检查-更新'的三步操作合并成了一步数据库操作。但是……”
他话锋一转:“这个方案在单机环境下可能工作得不错,但如果你的系统部署了多个实例,或者使用了数据库主从分离,这个问题依然可能出现。”
小明有些困惑:“为什么?我已经用一条SQL解决问题了啊?”
“因为数据库连接池。”老张解释道,“当你的应用部署了多个节点时,每个节点都有自己的数据库连接池。当两个请求分别落在不同节点上时,它们可能同时向数据库发送UPDATE语句。如果数据库的隔离级别设置不当,或者UPDATE语句不是原子的(虽然大多数现代数据库的UPDATE是原子的,但执行顺序不确定可能导致问题),仍然可能出现竞态条件。”
小明意识到,事情比他想象的要复杂得多。
经典案例:那些因数据竞争导致的灾难
“其实,数据竞争导致的问题比你想象的更常见,也更严重。”老张调出了几个真实案例的新闻报道,“让我们看看业界发生过的一些事故。”
案例一:阿里的超卖风波
2014年双十一期间,某商家因为系统bug,库存设置错误,导致商品被超卖了几万件。最终平台不得不花费数百万元进行赔偿,并向用户道歉。
虽然官方没有公布具体的技术细节,但从现象推测,极有可能是在高并发场景下,库存扣减操作没有做好原子性保证。
案例二:Twitter的重复发推问题
2019年,Twitter出现了一个奇怪的bug:部分用户发现自己发送的推文被重复发布了多次。这个问题的根本原因之一,就是服务器端在处理请求时,对某些操作的并发控制不当。
案例三:医疗系统的剂量错误
更严重的案例发生在医疗领域。某些医院的信息系统在计算药品库存时,因为数据竞争问题,导致库存数量不准确。严重时,可能出现护士以为还有库存、但实际已经没有药品的情况,危及患者安全。
小明看完这些案例,后背发凉。他想起自己昨晚的库存系统,虽然不至于造成医疗事故那样的严重后果,但如果真的出现了超卖,涉及到退款、补偿等一系列问题,公司的损失和用户的不满都是实打实的。
“数据竞争问题之所以可怕,”老张总结道,“是因为它具有以下特点:”
> 数据竞争的危害特征: > > 1. 隐蔽性强:在低并发环境下几乎不会出现,只有在流量高峰时才会暴露 > 2. 难以复现:由于竞争条件具有随机性,测试环境可能无法稳定复现 > 3. 破坏性大:一旦发生,影响的都是核心业务数据,如库存、余额等 > 4. 定位困难:问题表象可能是库存为负、余额错误,但根本原因隐藏在代码逻辑中
小明默默点头,这些特点完美解释了他昨晚的遭遇——为什么在测试环境完美运行的代码,一到生产环境就崩溃了。
深入理解:为什么会出现数据竞争
“在理解了现象之后,我们需要从更深层次理解为什么会发生数据竞争。”老张拿出一张纸,开始画一个经典的示意图。
“让我们回到计算机硬件层面。你知道CPU和内存之间有什么关系吗?”
小明回忆起计算机组成原理的课程:“CPU从内存读取数据,处理后再写回内存?”
“没错。但你可能不知道的是,CPU的运算速度和内存的读写速度之间,差距是巨大的。”老张在纸上写下一组数字:
> 计算机组件速度对比(大约数据): > > - CPU执行一条指令:约0.3纳秒(0.3×10⁻⁹秒) > - CPU访问一级缓存:约1纳秒 > - CPU访问二级缓存:约3-4纳秒 > - CPU访问主内存:约100纳秒 > - SSD读取:约100微秒(100,000纳秒) > - 机械硬盘读取:约10毫秒(10,000,000纳秒) > > 从内存读取一个字(Word)到CPU的时间,足够CPU执行几百条指令。
“这就引出了一个关键问题:CPU和内存速度不匹配。如果CPU每次运算都要等待内存读写完成,那CPU大部分时间都在空闲。”
小明想起这个问题:“所以有了缓存?”
“对,现代CPU都有多级缓存。”老张在纸上补充道,“L1缓存最快,但容量最小;L2和L3缓存容量更大,但速度稍慢。当CPU需要读取数据时,会先检查缓存;如果缓存中没有,才会去访问内存。”
```mermaid graph TD su