并发问题

周末的噩梦

那是平台上线后的第一个周末。

我正在咖啡馆休息,突然手机震动个不停:

【用户反馈】用户ID: 1234
问题:点赞按钮点了没反应,数字没变化

【用户反馈】用户ID: 5678
问题:点赞数显示不对,刷新后数字变了

【用户反馈】用户ID: 9012
问题:取消点赞后,数字反而增加了

我心头一紧:出问题了!

问题复现

我赶紧打开电脑,查看日志:

2023-07-15 14:23:15.234 [INFO] 用户 1234 点赞文章 567
2023-07-15 14:23:15.237 [INFO] 用户 5678 点赞文章 567
2023-07-15 14:23:15.240 [INFO] 更新文章 567 点赞数: 101 -> 102
2023-07-15 14:23:15.243 [INFO] 更新文章 567 点赞数: 101 -> 102

等等,有问题!

两个用户同时点赞,但点赞数都从 101 更新到了 102,应该是 103 才对!

我查看了那篇文章的数据:

数据设计要点

  • 查询目标是快速定位状态、任务或资源,避免在关键路径上做大范围扫描。

数据设计要点

  • 查询目标是快速定位状态、任务或资源,避免在关键路径上做大范围扫描。

数据库里显示:点赞记录有 103 条,但 like_count 字段只有 102!

数据不一致了!

问题分析

我画了张图,分析并发场景:

时间线:
T1: 用户A读取 like_count = 100
T2: 用户B读取 like_count = 100
T3: 用户A插入点赞记录 (成功)
T4: 用户B插入点赞记录 (成功)
T5: 用户A更新 like_count = 100 + 1 = 101
T6: 用户B更新 like_count = 100 + 1 = 101

最终结果:
- 点赞记录:2 条 ✅
- like_count: 101 ❌ (应该是 102)

丢失了一次更新!

这是经典的”丢失更新”问题。

为什么会丢失?

问题在于第 2、3 步:

  • 步骤 2 读取的是”旧值”
  • 步骤 3 基于旧值计算新值
  • 多个请求并发时,都读取同一个旧值
  • 后执行的更新会覆盖前面的更新

尝试修复

方案 1:使用事务

我想到的第一个方案:用事务包裹所有操作。

测试一下:

但是,真的没问题吗?

我加大了并发量:

事务并不能完全解决这个问题!

为什么事务不够?

事务 A:
1. SELECT * FROM article_likes WHERE article_id = 1 AND user_id = 100
2. INSERT INTO article_likes ...
3. UPDATE articles SET like_count = like_count + 1 WHERE id = 1
   (此时读取 like_count,然后 +1)

事务 B(同时执行):
1. SELECT * FROM article_likes WHERE article_id = 1 AND user_id = 101
2. INSERT INTO article_likes ...
3. UPDATE articles SET like_count = like_count + 1 WHERE id = 1
   (也读取 like_count,然后 +1)

关键问题:
- UPDATE ... SET like_count = like_count + 1 虽然是原子操作
- 但如果有两个事务同时更新,MySQL 会锁行
- 第二个事务等待第一个事务释放锁
- 第一个事务提交后,第二个事务基于新值再 +1
- 所以理论上应该正确...

等等,让我再看看代码…

这是对的!那为什么还会丢失更新?

我查看了数据库隔离级别:

数据设计要点

  • 查询目标是快速定位状态、任务或资源,避免在关键路径上做大范围扫描。

原来如此!

在 READ-COMMITTED 隔离级别下,可能发生幻读和不可重复读,导致并发问题。

方案 2:悲观锁(SELECT FOR UPDATE)

我决定用悲观锁,在读取时就锁定记录。

原理:

  • SELECT ... FOR UPDATE 会锁定查询到的行
  • 其他事务如果要更新这行,必须等待锁释放
  • 避免了并发更新问题

测试:

但是,性能如何?

我做了压力测试:

并发 50 个请求:
- 平均响应时间:150ms
- 吞吐量:约 300 请求/秒

并发 100 个请求:
- 平均响应时间:350ms
- 吞吐量:约 280 请求/秒

并发 200 个请求:
- 平均响应时间:800ms
- 吞吐量:约 250 请求/秒

性能下降了!

原因:

  • 大量请求在等待锁
  • 数据库连接被占用
  • 响应时间变长

方案 3:原子更新(推荐)

我重新审视了问题,发现其实不需要读取值再更新:

原子更新的优势:

  • 数据库层面完成计算,不需要读取原值
  • MySQL 保证单条 UPDATE 语句的原子性
  • 不需要锁行(或锁时间极短)
  • 性能更好

完整流程:

压力测试:

并发 50 个请求:
- 平均响应时间:30ms  (之前 150ms)
- 吞吐量:约 1500 请求/秒 (之前 300 请求/秒)

并发 100 个请求:
- 平均响应时间:50ms  (之前 350ms)
- 吞吐量:约 1800 请求/秒 (之前 280 请求/秒)

并发 200 个请求:
- 平均响应时间:100ms (之前 800ms)
- 吞吐量:约 1900 请求/秒 (之前 250 请求/秒)

性能提升了 5-7 倍!

其他并发问题

问题 1:重复点赞

用户快速点击两次点赞按钮:

请求 A:检查未点赞 → 插入点赞记录
请求 B:检查未点赞 → 插入点赞记录 (重复!)

结果:插入两条相同的点赞记录

解决方案:

利用数据库的唯一索引:

数据设计要点

  • 索引服务于高频查询,重点是缩小扫描范围,而不是堆更多字段。
  • 关键字段包括 INSERT,它们决定后续查询和管理能力。

代码中捕获异常:

问题 2:点赞数溢出

极端情况:点赞数超过 INT 最大值

INT 最大值:2,147,483,647
如果点赞数超过这个值,会溢出!

解决方案:

使用 BIGINT:

数据设计要点

  • 这是一次表结构演进:随着业务能力增加,把新状态、新时间点或新归属关系补进数据模型。

BIGINT 最大值:9,223,372,036,854,775,807(922 亿亿)

这辈子都不可能溢出!

问题 3:数据一致性恢复

如果已经出现数据不一致,如何修复?

数据设计要点

  • 查询目标是快速定位状态、任务或资源,避免在关键路径上做大范围扫描。

定期对账:

课后练习

练习 1

什么是”丢失更新”问题?在点赞场景中,还有哪些情况会导致丢失更新?

参考答案(3 个标签)
并发控制丢失更新数据库事务

丢失更新(Lost Update)定义

两个或多个事务同时读取同一数据,然后基于读取的值进行更新,后提交的事务会覆盖先提交事务的更新,导致先提交事务的更新”丢失”。

点赞场景中的其他丢失更新情况

场景 1:取消点赞并发

用户 A:点赞
用户 B:取消点赞

时间线:
T1: A 读取 like_count = 100
T2: B 读取 like_count = 100
T3: A 更新 like_count = 101
T4: B 更新 like_count = 99

期望:100 + 1 - 1 = 100
实际:99 ❌

场景 2:同时点赞和取消

用户 A:对文章 1 点赞
用户 B:对文章 1 取消点赞

如果并发执行:
- A: INSERT INTO article_likes (成功)
- B: DELETE FROM article_likes (成功)
- A: UPDATE like_count + 1
- B: UPDATE like_count - 1

可能导致:点赞记录正确,但计数错误

解决方案对比

方案实现复杂度性能一致性
悲观锁
乐观锁
原子更新
Redis INCR最高最终一致

推荐:使用原子更新(数据库层面)或 Redis INCR(性能最好)。

练习 2

比较悲观锁和乐观锁的区别,以及各自的适用场景。

参考答案(3 个标签)
悲观锁乐观锁并发控制

悲观锁 vs 乐观锁

特性悲观锁乐观锁
思想假设会发生冲突,先加锁假设不会冲突,提交时检查
实现SELECT FOR UPDATE版本号/时间戳
性能冲突多时性能好冲突少时性能好
锁等待会阻塞其他事务不会阻塞,失败重试
死锁可能发生不会发生

悲观锁实现

数据设计要点

  • 查询目标是快速定位状态、任务或资源,避免在关键路径上做大范围扫描。

乐观锁实现

数据设计要点

  • 查询目标是快速定位状态、任务或资源,避免在关键路径上做大范围扫描。

适用场景

悲观锁适用

  • 冲突概率高(如秒杀、抢红包)
  • 写多读少
  • 数据一致性要求极高
  • 示例:库存扣减、账户余额

乐观锁适用

  • 冲突概率低
  • 读多写少
  • 可以容忍重试
  • 示例:文章编辑、用户信息更新

点赞场景分析

冲突概率:
- 同一篇文章同时被多个用户点赞的概率:低
- 同一用户对同一文章重复点赞:会被唯一索引拦截

结论:点赞场景适合使用乐观锁或原子更新

练习 3

MySQL 的事务隔离级别有哪些?点赞功能应该使用哪种隔离级别?

参考答案(3 个标签)
MySQL事务隔离级别并发控制

MySQL 四种隔离级别

隔离级别脏读不可重复读幻读性能
READ UNCOMMITTED最高
READ COMMITTED
REPEATABLE READ
SERIALIZABLE最低

问题解释

脏读:读到其他事务未提交的数据
不可重复读:同一事务中两次读取结果不同(其他事务修改并提交了数据)
幻读:同一事务中两次读取的记录数不同(其他事务插入或删除了数据)

点赞功能分析

操作 1:INSERT INTO article_likes
操作 2:UPDATE articles SET like_count = like_count + 1

需求:
- 避免脏读:不能读到未提交的点赞记录
- 可以容忍不可重复读:点赞数变化是正常的
- 可以容忍幻读:新记录出现是正常的

推荐:READ COMMITTED 或 REPEATABLE READ

实际配置

数据设计要点

  • 查询目标是快速定位状态、任务或资源,避免在关键路径上做大范围扫描。

Spring Boot 配置

配置要点

  • 配置表达的是环境差异和运行参数,不是业务规则本身。

点赞场景最佳实践

  1. 使用默认隔离级别(REPEATABLE READ)

    • MySQL 默认就是 REPEATABLE READ
    • 配合原子更新,不会有问题
  2. 关键操作使用原子更新

    数据设计要点

  • 这里关注数据模型和约束关系,不需要记住具体语法。 单条语句本身就是原子的,不受隔离级别影响
  1. 如果使用悲观锁

    数据设计要点

  • 查询目标是快速定位状态、任务或资源,避免在关键路径上做大范围扫描。 需要事务包裹,且要注意死锁问题

结论:点赞功能不需要特别调整隔离级别,使用默认的 REPEATABLE READ 即可。

练习 4

如何设计压力测试,验证并发点赞的正确性?

参考答案(3 个标签)
压力测试并发测试测试设计

压力测试设计

测试目标

  • 验证并发点赞时数据的一致性
  • 测试系统的吞吐量和响应时间
  • 找出系统瓶颈

测试工具

  • JMeter:图形化压力测试工具
  • Locust:Python 编写的负载测试工具
  • Apache Benchmark (ab):简单命令行工具
  • 自写脚本:Python + threading

测试用例设计

用例 1:并发点赞同一篇文章

用例 2:同一用户重复点赞

用例 3:点赞和取消点赞并发

用例 4:压力测试(性能)

测试报告模板

=== 点赞功能压力测试报告 ===

测试环境:
- 服务器:4核 8GB
- 数据库:MySQL 8.0
- 并发用户:100
- 总请求数:10000

测试结果:
- 成功率:99.95%
- 平均响应时间:45ms
- 95% 响应时间:120ms
- 吞吐量:2200 req/s

数据一致性:
- 点赞记录数:正确
- 点赞数字段:正确
- 未发现丢失更新

结论:
✅ 系统在 100 并发下运行稳定
✅ 数据一致性得到保证
⚠️ 建议在 200 并发下进一步测试

练习 5

如果要在数据库层面保证点赞和取消点赞的幂等性,如何设计?

参考答案(3 个标签)
幂等性数据库设计并发控制

幂等性定义

同一操作执行多次,结果与执行一次相同。

点赞幂等性需求

  • 多次调用点赞接口,结果应该相同(已点赞状态)
  • 多次调用取消点赞接口,结果应该相同(未点赞状态)

方案一:INSERT ON DUPLICATE KEY UPDATE

数据设计要点

  • 核心是在 article_likes 里保存业务事实,而不是把规则散落在应用逻辑里。
  • 索引服务于高频查询,重点是缩小扫描范围,而不是堆更多字段。
  • 关键字段包括 article_iduser_idcreated_atupdated_atINSERT,它们决定后续查询和管理能力。

优点

  • 一条 SQL 完成操作
  • 幂等性由数据库保证
  • 记录点赞/取消的时间

缺点

  • 需要维护状态字段
  • 点赞数统计需要过滤 status = 1 的记录

方案二:REPLACE INTO

数据设计要点

  • 关键字段包括 REPLACE,它们决定后续查询和管理能力。

优点

  • 语法简单
  • 幂等

缺点

  • 实际上是 DELETE + INSERT
  • 会重置自增 ID
  • 会触发 DELETE 触发器

方案三:应用层幂等

优点

  • 逻辑清晰
  • 易于理解和维护
  • 可以返回详细的状态信息

缺点

  • 需要额外的查询
  • 性能稍低

方案四:使用数据库唯一索引 + 异常捕获

推荐方案

根据业务场景选择:

  1. 简单场景:方案三(应用层幂等)
  2. 高性能场景:方案一(INSERT ON DUPLICATE KEY UPDATE)
  3. 需要审计日志:方案一(记录状态变化时间)

完整示例(方案一 + 点赞数维护)

数据设计要点

  • 索引服务于高频查询,重点是缩小扫描范围,而不是堆更多字段。
  • 查询目标是快速定位状态、任务或资源,避免在关键路径上做大范围扫描。
  • 关键字段包括 INSERT,它们决定后续查询和管理能力。

思考题

  1. 如果使用分库分表,如何保证点赞数的一致性?

  2. 如何实现”点赞去重”功能,防止恶意刷赞?

  3. 如何设计一个支持”点赞历史记录”和”点赞趋势分析”的系统?

💡 提示:这些问题没有标准答案,建议结合实际情况深入思考。