这个问题本质上是在问:多个请求同时修改同一条数据,怎么避免并发覆盖?

例如数据库:balance = 100,两个请求同时执行:

A:余额 - 30
B:余额 - 50

比如 A 的伪代码是:

// 获取到 id 为 1 的余额
// SELECT balance FROM account WHERE id = 1;
// 然后代码中执行余额减30
balance = balance - 30

//...

// 又更新了余额为70
// UPDATE account SET balance = 70 WHERE id = 1;

B 同样读取 100,最终:

A 减30 balance 变成了 70
B 减50 balance 变成了 50

但是 balance 最终正确的结果应该是 20。因此,我们需要避免这种并发修改的问题。

第一种方案:直接让数据库做原子更新

这是我最推荐面试先说的方案。

不要:

SELECT balance ...
UPDATE balance ...

而是直接:

UPDATE account
SET balance = balance - 30
WHERE id = 1
  AND balance >= 30;

因为:balance = balance - 30 是在数据库内部完成的。即使两个请求并发执行,数据库也会进行并发控制。比如还是最之前的例子,如果我们这样设计 SQL 语句,那最终就会让 100 先减 30 再减 50,最终的结果就是 20。

对于这种简单的数值增减、计数器、库存扣减场景,这是最优先考虑的方案。

第二种方案:悲观锁

为什么要叫悲观锁呢?主要就是因为这种加锁情况,我们会以一种更加悲观的态度来看待,会假设大部分情况都会并发冲突。所以在操作前就会直接上锁。

如果修改逻辑比较复杂,可以使用数据库行级锁或表级锁来实现(像数据库中的 select … for update 就是典型的悲观锁):

SELECT balance
FROM account
WHERE id = 1
FOR UPDATE;

UPDATE account
SET balance = 70
WHERE id = 1;

在事务里面:

BEGIN

SELECT ... FOR UPDATE

计算业务逻辑

UPDATE account ...

COMMIT

这样同一时间只有一个事务可以拿到这条记录的排他锁。例如:

A 获取锁
↓
A 修改
↓
A 提交
↓
B 获取锁
↓
B 修改

适合:修改逻辑复杂,而且必须保证串行修改的场景。总结就是并发冲突多,写多读少的场景。

优点就是:保证强一致性,无需重试。缺点就是:并发高的时候锁竞争比较严重;加锁成本高,性能低,容易引发死锁。

第三种方案:乐观锁

这种方案就和上面的悲观锁完全相反,是以一种乐观的态度进行处理的,也就是假设并发冲突的场景大概率不会存在。所以他在一开始不加锁,而是获取版本号,等到更新的时候,再根据版本号去校验,看看数据是否被修改,如果已经被修改,那么这次更新失败,只能重试。

例如数据库字段 version = 10,先查询:

SELECT balance, version
FROM account
WHERE id = 1;

得到:

balance = 100
version = 10

更新:

UPDATE account
SET balance = 70,
    version = 11
WHERE id = 1
  AND version = 10;

如果返回:

affected_rows = 1

说明更新成功。

如果:

affected_rows = 0

说明已经被其他请求修改了。然后需要重试。

这就是乐观锁。适合并发冲突少、读多写少的场景。除了 update 字段,很多时候我们也可以采用时间戳作为版本号。

优点就是:无锁性能高。缺点就是:冲突需要重试,写密集时性能反而差。

第四种方案:Redis 分布式锁

如果这个操作不是单机服务,而是具有多个服务实例:

服务实例 A
服务实例 B
服务实例 C

这时候 JVM 的 synchronized 和 ReentrantLock 只能锁住当前实例,无法解决跨实例并发。

可以使用 Redis 分布式锁:

SET lock:user:1 xxx NX EX 10

拿到锁,执行数据库更新,然后再释放锁。

注意:数据库字段并发更新,优先考虑数据库本身的原子更新、乐观锁、悲观锁,而不是一上来就 Redis 分布式锁。

因为 Redis 锁会增加系统复杂度,而且还涉及:

  • 锁过期
  • 锁误删
  • 业务执行时间超过 TTL
  • Redis 故障
  • 续期
  • 等等一系列问题。。。

面试回答

面试官如果问:“并发操作数据库一个字段,怎么处理?”

你可以直接这么答:

如果只是库存、余额、点赞数、计数器这种简单字段更新,我优先使用数据库原子更新,比如 UPDATE ... SET count = count + 1,避免先查后改导致丢失更新。

如果业务逻辑比较复杂,需要先读取数据再判断再修改,我会考虑悲观锁 SELECT ... FOR UPDATE(并发冲突多,写多读少的场景),或者乐观锁(并发冲突少、读多写少的场景)。

如果是分布式环境下,需要保证某段跨数据库、跨服务的业务逻辑同一时间只有一个实例执行,再考虑 Redis 分布式锁。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐