⭐⭐⭐ Spring Boot 项目实战 ⭐⭐⭐ Spring Cloud 项目实战
《Dubbo 实现原理与源码解析 —— 精品合集》 《Netty 实现原理与源码解析 —— 精品合集》
《Spring 实现原理与源码解析 —— 精品合集》 《MyBatis 实现原理与源码解析 —— 精品合集》
《Spring MVC 实现原理与源码解析 —— 精品合集》 《数据库实体设计合集》
《Spring Boot 实现原理与源码解析 —— 精品合集》 《Java 面试题 + Java 学习指南》

摘要: 原创出处 geekhalo 「geekhalo」欢迎转载,保留摘要,谢谢!


🙂🙂🙂关注微信公众号:【芋道源码】有福利:

  1. RocketMQ / MyCAT / Sharding-JDBC 所有源码分析文章列表
  2. RocketMQ / MyCAT / Sharding-JDBC 中文注释源码 GitHub 地址
  3. 您对于源码的疑问每条留言都将得到认真回复。甚至不知道如何读源码也可以请教噢。
  4. 新的源码解析文章实时收到通知。每周更新一篇左右。
  5. 认真的源码交流微信群。

在业务开发中,事务一致性核心在于“原子性”,则并发管理的核心在于“隔离性”。

  1. 原子性:一个业务操作被视为一个不可分割的逻辑单元,要么全部执行成功,要么全部失败回滚;
  2. 隔离性:并发业务操作之间要相互隔离,不能互相干扰;

1. 无处不在的并发

并发管理是指在多个用户同时访问、修改同一数据时,如何保证数据的准确性、一致性和完整性的一系列管理措施。

并发无处不在是指在当前的业务系统和应用程序中,几乎所有的操作都是并发的。无论是网络请求、数据库操作、I/O读写操作等,都可能在同一时刻被多个线程或进程同时执行。这意味着在业务开发中,必须充分考虑并发处理问题,避免出现数据竞争、死锁等问题,同时合理利用多线程、协程等技术来提高系统的性能和处理能力。

1.1. 常见业务流程

首先看以下流程:

这是一个聚合根更新操作,包括:

  1. 从 DB 中加载数据;
  2. 修改内存中的数据;
  3. 将变更更新到 DB;

也许还没有使用DDD,对聚合根不太熟悉,那再看一个流程:

这是一个更为通用的数据编辑流程,包括:

  1. 打开编辑页面,从 DB 中加载数据,完成数据展示;
  2. 通过 UI 界面对数据进行编辑;
  3. 点击保存按钮,将新的变更保存到 DB;

仔细对比这两张图,其实他们都在做同样的事情:

  1. 加载数据;
  2. 修改数据;
  3. 更新数据;

在这里便存在并发问题。

1.2. 并发问题

上面所提到的流程是否存在并发问题,仔细看下图:

同一个流程,操作同一数据,只是操作顺序不同,也会出现并发安全问题:

  1. Action2 首先加载数据 V1;
  2. Action1 其次也加载数据 V1;
  3. Action2 对数据进行修改,并成功保存变更后的数据 V2(V1 + Action2 = V2);
  4. Action1 对数据进行修改,并成功保存变更后的数据 V3 (V1 + Action1 = V3);

看起来没什么问题,但 V3 是业务期望的吗?V2 的变更又去哪里了呢?

此时,V2 被 V3 覆盖,V2 的变更丢失了。

如果还不清楚,明确业务操作为 count++,如下图所示:

对数据库的 count 进行累加操作

  1. Action2 首先加载数据 count: 1;
  2. Action1 其次也加载数据 count: 1;
  3. Action2 对count:1进行累加,获得新值 2,并成功保存 count:2;
  4. Action1 对count:1进行累加,获得新值 2,并成功保存 count:2;

操作完成后,最终结果为2。实际期望结果为3,Action2 的修改被 Action1 覆盖,导致一次累加操作被覆盖。

当然,这仅仅是同一流程下的并发问题,多流程间也存在并发问题:

对于同一记录,自增流程和设置流程并发执行,同样发生了写覆盖。

2. 局部串行

并发问题,只有在并发执行的情况下才会发生,对于同一数据如果不存在并发就不会出问题。

2.1. 线程方案

如下图所示:

订单流程中的核心操作:

  1. 扣库存
  2. 下单
  3. 支付成功

由于多个订单间不存在关系,可以并发执行;但同一订单,必须保障业务执行顺序。

什么是“局部串行”:

  1. 对于同一订单,需要保障顺序性;
  2. 对于不同订单可以并行执行;

其中分发器是核心,它连接订单事件和后台线程:

  1. 收到订单事件后,从消息体中获取订单号;
  2. 通过 订单号 % 线程数量,计算出事件运行的线程;
  3. 将事件提交到对应线程的处理队列进行处理;
  4. 这样统一订单只会由同一线程进行处理;

这样,相同订单号的订单事件均由同一个线程处理,从而保证局部串行化。不同订单之间,不存在相互影响,可以在多个线程中并行执行。

2.2. MQ 方案

当然,内存操作存在数据安全问题(重启任务会丢失),不少MQ也提供了相关功能,以 RocketMQ 的顺序消息为例,如下图所示:

  1. RocketMQ 将相同 shardingKey 的消息发送至固定的 partition;
  2. 后台处理线程从 partition 中获取消息并执行处理逻辑;
  3. 从而保证相同 shardingKey 的消息均由同一线程处理;

局部串行对性能存在一定影响,系统最大的并发量为 partition 数量。如果出现增加 Worker 节点无法提升系统吞吐时,需要扩展 partition 数量。

【备注】在系统做 rebalance 时,可能会出现短暂的消息混乱,通常情况下,业务是可接受的。如果必须保障强顺序,如 binlog 场景,只能使用一个 partition,但会极大的影响性能。

3. 最后写胜出

有些时候,写更新不依赖于之前的数据状态,只需使用最新数据进行覆盖即可,此时,并发管理也就变的非常简单。

如下图所示:

  1. Action1 将 name 更新为 “精英英语”;
  2. Action2 将 status 更新为 Enable;
  3. 两者对不同字段进行更新,并且相互间没有交集;

此时,不会出现并发问题。但由于时序问题,数据的最终状态以“最后更新”为准。

4. 原子指令

许多存储引擎对单条记录提供了原子操作,对于简单的场景,可以将并发控制委托给存储引擎进行管理。

比如在库存扣减的场景,可以使用 Redis 或 DB 的原子指令进行操作。

4.1. Redis

使用 Redis 的 incr 指令:

由于 redis 指令是单线程处理不存在并发问题,直接使用 incr key -1 质量对数量进行扣减。当然,这样可能会出现数量为负值情况,此时可以引入 LUA 脚本进行保障:

-- KEYS[1]: 库存键的名称,例如 stock:1001
-- ARGV[1]: 要扣减的数量
local stock = tonumber(redis.call('GET', KEYS[1]))

-- 判断扣减的数量是否大于库存数量
if stock < tonumber(ARGV[1]) then
return -1
end

-- 扣减库存,并返回剩余的库存数量
stock = stock - tonumber(ARGV[1])
redis.call('SET', KEYS[1], stock)

-- 返回剩余的库存数量
return stock

4.2. MySQL

同样的操作也可以在 MySQL 中操作,如下图所示:

也可避免扣减为 负值的情况,如下图所示:

新增对 count 的条件判断,通过操作结果控制不同的流程:

  1. 影响行数为1,代表操作成功;
  2. 影响函数为0,代表操作失败;

5. 乐观锁

当一个事务(线程)修改一个数据时,先记录下该数据的版本号,其他事务(线程)修改该数据时必须先检查版本号,只有版本号相同的事务(线程)才能修改数据。乐观锁通常使用CAS(Compare and Swap)操作实现,对并发性能影响较小,但是需要开发人员在代码中增加版本号检查的代码。

业务中使用最多的场景仍旧是 读-改-写,此时最佳处理方案便是乐观锁。

相对于数据更新,乐观锁方案只是增加了 version 判断,并未引入其他复杂性,对性能影响非常小。

  1. 在加载数据时获取当前的数据版本 vsn1;

  2. 操作完成后,将数据更新到DB时,指定更新的数据版本为 vsn1,并将最新的 vsn 更新为 vsn1+1;

  3. 根据操作结果进行判断:

    1. 更新成功,数据库数据未发生变化,不存在并发问题;
  4. 更新失败,数据库数据已经发生变化,此时可以告知用户对数据进行重新加载,并进行修改;

对于聚合根来说,这是数据更新最常见的并发保障机制。

6. 悲观锁

当一个事务(线程)正在使用某个数据时,其他事务(线程)就不能访问该数据,必须等待锁释放后才能访问。悲观锁能够保证数据的一致性,但是对并发性能影响比较大。

悲观锁是最后的办法,由于其对性能冲击较大,不到万不得已不要随便使用。

6.1. 数据库悲观锁

MySQL 提供 for update 指令,可以在查询数据时获取写锁,从而保证数据不会被破坏。

使用 for update 加载数据,操作如下:

for update 语句将对数据进行强制加锁,只有在事务提交后,锁才会释放。如图所示,for update 会对操作进行强制排序,最终使单条操作变成串行化,从而影响并发度最终影响系统性能。

6.2. 分布式锁

通常情况下分布式锁是一种特殊的悲观锁,在一些数据添加场景非常重要。

比如,在订单系统中,对于特价商品一个用户只能购买一次,如下图所示:

该流程存在并发问题,可能导致一个用户下单多次:

  1. 两个线程都成功加载用户的历史订单;
  2. 进行重复性校验,发现都没有购买该商品,从而进入生单流程;
  3. 两个线程完成订单对象构建,将数据保存到数据库;
  4. 最终,同一用户生成了两个订单,与业务预期不符;

由于是新增场景,没有什么资源可锁定,所以乐观锁方案无法落地,此时就需要引入分布式锁,如下图所示:

以 user 为单位申请分布式锁,保证同一用户只有一个线程能进行被保护流程,从而保证同一用户不会购买多次。

4. 小结

并发管理是一个高级话题,也是设计中的难点,一不小心就会出问题。让每个开发人员都成为并发高手又是一件不太现实的事,但,好在存在很多并发管理的成熟方案,业务开发者按照场景进行落地即可:

  1. 局部串行:适用于同一数据的修改需要串行处理;不同数据间可并行处理的场景;
  2. 最后写胜出:适用于不依赖于前值状态的更新操作,对数据进行全量覆盖的场景;
  3. 原子指令:适用于通过原子指令能完成业务场景,并且存储引擎也提供了对应支持;
  4. 乐观锁:适用于聚合根的更新场景,对性能影响极小,可以作为框架默认配置;
  5. 悲观锁:适用于最为严格的场景,需要强制串行,对性能影响极大,需谨慎选择;
文章目录
  1. 1. 1. 无处不在的并发
    1. 1.0.1. 1.1. 常见业务流程
    2. 1.0.2. 1.2. 并发问题
  • 2. 2. 局部串行
    1. 2.0.1. 2.1. 线程方案
    2. 2.0.2. 2.2. MQ 方案
  • 3. 3. 最后写胜出
  • 4. 4. 原子指令
    1. 4.0.1. 4.1. Redis
    2. 4.0.2. 4.2. MySQL
  • 5. 5. 乐观锁
  • 6. 6. 悲观锁
    1. 6.0.1. 6.1. 数据库悲观锁
    2. 6.0.2. 6.2. 分布式锁
  • 7. 4. 小结