如何处理并发操作同一数据库字段?
这个问题本质上是在问:多个请求同时修改同一条数据,怎么避免并发覆盖?
例如数据库: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 分布式锁。
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐



所有评论(0)