并发问题
周末的噩梦
那是平台上线后的第一个周末。
我正在咖啡馆休息,突然手机震动个不停:
【用户反馈】用户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
什么是”丢失更新”问题?在点赞场景中,还有哪些情况会导致丢失更新?
丢失更新(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
比较悲观锁和乐观锁的区别,以及各自的适用场景。
悲观锁 vs 乐观锁:
| 特性 | 悲观锁 | 乐观锁 |
|---|---|---|
| 思想 | 假设会发生冲突,先加锁 | 假设不会冲突,提交时检查 |
| 实现 | SELECT FOR UPDATE | 版本号/时间戳 |
| 性能 | 冲突多时性能好 | 冲突少时性能好 |
| 锁等待 | 会阻塞其他事务 | 不会阻塞,失败重试 |
| 死锁 | 可能发生 | 不会发生 |
悲观锁实现:
数据设计要点
- 查询目标是快速定位状态、任务或资源,避免在关键路径上做大范围扫描。
乐观锁实现:
数据设计要点
- 查询目标是快速定位状态、任务或资源,避免在关键路径上做大范围扫描。
适用场景:
悲观锁适用:
- 冲突概率高(如秒杀、抢红包)
- 写多读少
- 数据一致性要求极高
- 示例:库存扣减、账户余额
乐观锁适用:
- 冲突概率低
- 读多写少
- 可以容忍重试
- 示例:文章编辑、用户信息更新
点赞场景分析:
冲突概率:
- 同一篇文章同时被多个用户点赞的概率:低
- 同一用户对同一文章重复点赞:会被唯一索引拦截
结论:点赞场景适合使用乐观锁或原子更新练习 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 配置:
配置要点
- 配置表达的是环境差异和运行参数,不是业务规则本身。
点赞场景最佳实践:
-
使用默认隔离级别(REPEATABLE READ)
- MySQL 默认就是 REPEATABLE READ
- 配合原子更新,不会有问题
-
关键操作使用原子更新
数据设计要点
- 这里关注数据模型和约束关系,不需要记住具体语法。 单条语句本身就是原子的,不受隔离级别影响
- 如果使用悲观锁
数据设计要点
- 查询目标是快速定位状态、任务或资源,避免在关键路径上做大范围扫描。 需要事务包裹,且要注意死锁问题
结论:点赞功能不需要特别调整隔离级别,使用默认的 REPEATABLE READ 即可。
练习 4
如何设计压力测试,验证并发点赞的正确性?
压力测试设计:
测试目标:
- 验证并发点赞时数据的一致性
- 测试系统的吞吐量和响应时间
- 找出系统瓶颈
测试工具:
- 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
如果要在数据库层面保证点赞和取消点赞的幂等性,如何设计?
幂等性定义:
同一操作执行多次,结果与执行一次相同。
点赞幂等性需求:
- 多次调用点赞接口,结果应该相同(已点赞状态)
- 多次调用取消点赞接口,结果应该相同(未点赞状态)
方案一:INSERT ON DUPLICATE KEY UPDATE
数据设计要点
- 核心是在
article_likes里保存业务事实,而不是把规则散落在应用逻辑里。- 索引服务于高频查询,重点是缩小扫描范围,而不是堆更多字段。
- 关键字段包括
article_id、user_id、created_at、updated_at、INSERT,它们决定后续查询和管理能力。
优点:
- 一条 SQL 完成操作
- 幂等性由数据库保证
- 记录点赞/取消的时间
缺点:
- 需要维护状态字段
- 点赞数统计需要过滤 status = 1 的记录
方案二:REPLACE INTO
数据设计要点
- 关键字段包括
REPLACE,它们决定后续查询和管理能力。
优点:
- 语法简单
- 幂等
缺点:
- 实际上是 DELETE + INSERT
- 会重置自增 ID
- 会触发 DELETE 触发器
方案三:应用层幂等
优点:
- 逻辑清晰
- 易于理解和维护
- 可以返回详细的状态信息
缺点:
- 需要额外的查询
- 性能稍低
方案四:使用数据库唯一索引 + 异常捕获
推荐方案:
根据业务场景选择:
- 简单场景:方案三(应用层幂等)
- 高性能场景:方案一(INSERT ON DUPLICATE KEY UPDATE)
- 需要审计日志:方案一(记录状态变化时间)
完整示例(方案一 + 点赞数维护):
数据设计要点
- 索引服务于高频查询,重点是缩小扫描范围,而不是堆更多字段。
- 查询目标是快速定位状态、任务或资源,避免在关键路径上做大范围扫描。
- 关键字段包括
INSERT,它们决定后续查询和管理能力。
思考题
-
如果使用分库分表,如何保证点赞数的一致性?
-
如何实现”点赞去重”功能,防止恶意刷赞?
-
如何设计一个支持”点赞历史记录”和”点赞趋势分析”的系统?
💡 提示:这些问题没有标准答案,建议结合实际情况深入思考。
