赞
踩
假设现在有一张 item 商品表,quantity 字段表示该商品的数量。
这时候有一个用户下了订单,购买一件商品。那么我们可以用以下 SQL 来实现这个逻辑
UPDATE item SET quantity = quantity - 1 WHERE id = 1;
这个实现在一般情况下是没有问题的,但是现在的后端应用都是在多线程或者多进程环境下运行,在高并发情况下就有可能发生问题
假设现在有 A 和 B 两个用户同时下单,后端服务会分配 2 个不同的线程去处理请求,这里分别用线程 A 和 B 来表示。
线程A(用户A下单) | 线程B(用户B下单) |
---|---|
查询商品 id = 1,此时 quantity = 100 | |
查询商品 id = 1,此时 quantity = 100 | |
用户A下单,更新 quantity = 99 | |
用户B下单,更新 quantity = 99 |
在线程 A 还没更新数量之前,B 就去把商品数量查出来了,并发更新导致数据不一致,业务上就体现为超卖。
那么这个问题该如何解决呢?答案就是加锁。锁可以在不同的层面加。如果是单实例应用,直接加本地锁,例如 Java 应用可以使用 synchronized。如果是分布式应用,可以通过 Redis、ZooKeeper、Etcd 加分布式锁
这种情况是数据库并发更新导致的,能不能直接在数据库层面解决呢?答案是可以的,可以利用数据库锁机制来解决并发更新问题。方案有悲观锁和乐观锁,本文对这2种解决方案展开说明
MySQL 的 InnoDB 引擎提供了以下两种行锁机制。在查询记录时,使用以下 SQL,可以给对应行加上共享锁和排他锁。
-- 共享锁(S)
SELECT * FROM table_name WHERE ... LOCK IN SHARE MODE
-- 排他锁(X)
SELECT * FROM table_name WHERE ... FOR UPDATE
其中 SELECT ... FOR UPDATE
是悲观锁的具体实现。并发更新可以通过该机制保证数据一致性
需要注意的是,锁在 autocommit=0
状态下使用才有意义,因为锁会在 commit 之后自动释放。默认情况下 MySQL 单行语句就是一个事务,加锁语句执行完,锁立即就被释放了,也就没意义了
下面给出 SELECT ... FOR UPDATE
解决并发更新的示例
-- A和B开启事务
BEGIN;
-- A查询,加上排他锁
SELECT * FROM item WHERE id = 1 FOR UPDATE;
-- B查询加锁,由于锁被A占用,所以阻塞
SELECT * FROM item WHERE id = 1 FOR UPDATE;
-- A更新
UPDATE item SET quantity = quantity - 1 WHERE id = 1;
-- A提交
COMMIT;
-- B成功查询出记录,继续执行更新
UPDATE item SET quantity = quantity - 1 WHERE id = 1;
-- B提交
COMMIT;
乐观锁本质上不加锁,是一种 CAS 无锁机制。所谓 CAS,就是在更新的时候,检查该实际值是不是和期望值一样,一样就更新成功,不一样就更新失败
下面给出 CAS 解决并发更新的示例
-- A 查出来 quantity = 100
SELECT * FROM item WHERE id = 1;
-- B 查出来 quantity = 100
SELECT * FROM item WHERE id = 1;
-- A 更新 quantity,同时加上 where 条件检查 quantity 是不是期望值。发现是,更新成功
UPDATE item SET quantity = quantity - 1 WHERE id = 1 AND quantity = 100;
-- B 更新 quantity,发现 quantity 不是期望值,更新失败
UPDATE item SET quantity = quantity - 1 WHERE id = 1 AND quantity = 100;
CAS 存在更新失败的情况。如何判断更新是否失败呢?这也很简单,UPDATE 语句返回值代表更新的行数,直接判断返回值是不是 0 即可,0 就是失败。
现在我们可以判断更新失败了,那如何解决呢?这个得具体业务具体解决了。如果业务容许这种错误发现,可以给用户一个错误提示,比如:
// 查询记录
doQuery();
// CAS 更新
if (doCasUpdate() == 0) {
doError("提示系统繁忙,请重试");
}
如果业务不容许失败,这时候可以加一个死循环进行重试
while (true) {
// 查询记录
doQuery();
// CAS 更新
if (doCasUpdate() > 0) {
break;
}
}
我们继续以商品这个场景举例,假设现在有3个操作同时进行,分别是 A、B 用户同时下单,C 用户添加商品数据
线程A(用户A下单) | 线程B(用户B下单) | 线程C(运营C添加库存) |
---|---|---|
查询 quantity = 100 | 查询 quantity = 100 | |
更新 quantity = 99 | ||
更新 quantity = 99+1= 100 | ||
更新 quantity = 99 |
用户 B 下单减库存本来应该失败的,但是在 C 用户的干预下,更新商品数量成功了,因为 quantity 在中间阶段又被更新回预期值 100
这就是 ABA 问题。一个变量一开始是A,被修改为B,又被修改为A,这在程序看来数据是没有变化的。但实际上此A非彼A。
这个情况对业务有没有影响呢?在这个商品数量场景下确实是没有影响的。但是有的业务可能是会有影响的。这时候需要单独引入一个版本号或时间戳字段来解决
SELECT * FROM item WHERE id = 1;
UPDATE item SET quantity = quantity - 1, version = version + 1 WHERE id = 1 AND version = 预期版本号
悲观锁和乐观锁都是抽象概念,而且都是针对并发更新场景提出的,物理上不存在对应的锁。
悲观锁,去查数据的时候都悲观地认为别人会修改,所以每次查数据时直接上锁。排他锁是悲观锁的一种实现方案
乐观锁,相对悲观锁而言,查数据时认为一般不会被修改,所以只在更新数据时检测冲突。CAS 是乐观锁的一种具体实现
写多读少用悲观锁,读多写少用乐观锁
举个例子,假设有10万并发,其中有几个是更新操作,其它都是读操作,这时候就特别适合使用乐观锁。对于更新操作,由于请求数较少,CAS 冲突概率就小,大部分都是成功的。对于读操作,由于没有加锁,就没有性能响应
假设有10万并发,有几个是读操作,其它都是写操作。如果使用乐观锁,CAS 冲突概率极大,大部分都是更新失败。如果还有循环不停地进行 CAS 操作,一个是应用的 CPU 开销过大,一个是给数据库带来过多的并发,严重影响性能。这时候就使用悲观锁,直接上锁。
如果 CAS 业务上存在 ABA 问题,那么就得加版本号或时间戳字段。
如果不存在 ABA 问题的话,直接通过业务字段本身来检测冲突即可,没有必要再引入额外字段
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。