数据库锁的使用
锁的副作用
锁等待
1 | #正在执行的事务 |
死锁
死锁都是由加锁顺序不一致导致的,最常见的update死锁,insert也能造成死锁,有兴趣的可以自行了解
TransactionA | TransactionB |
---|---|
start transaction; | start transaction; |
update t_test_01 set name=“name8” where status=8;#持有status=8的锁 | |
update t_test_01 set name=“name9” where status=9;#持有status=9的锁 | |
update t_test_01 set name=“name9” where status=9;#等待status=9的锁 | |
update t_test_01 set name=“name8” where status=8;#等待status=8的锁 | |
commit | Commit |
1 | start TRANSACTION; |
1 | start transaction; |
减少死锁锁等待
1.小事务
事务加锁范围不宜过大,如果比较大,业务上能分割的尽量分割。
例如:订单定时完成的批量,查出一批需要完成的订单,每个订单单独的事务处理,而不是放到一个大的事务里。
2.统一加锁顺序
3.update对应的查询走索引
悲观锁
一个例子
1 | /** |
有什么问题?
如果是客服给客人退款,不小心点了两次,或者退款比较慢点完又点了一次,如果退款走的是转账……
最简单的解决方案:
1 | /** |
即使同一个订单退款同时出发了两次,由于X锁,第二次请求会阻塞。
这就是悲观锁:假定会发生并发冲突,屏蔽一切可能违反数据完整性的操作。
使用场景
1.存在需要控制并发的场景。
2.加锁的对象并发量不大。例如:对一个订单来说,并发主要来自用户对这个订单的操作,量并不大。
3.加锁的范围不能太大。
两方面:
数据库层面:建议只对主键加锁,例如:如果我对order里的userId加锁,影响范围就比较大了。
业务层面:如果订单系统由用户表(user),对用户表里的主键加锁对业务的影响。
乐观锁
例子
下单减库存
t_stock
条数 400W
列名 | 类型 | 说明 |
---|---|---|
id | bigint | 产品id主键 primary key |
amount | int | 库存数 |
version | integer | 版本 |
1 |
|
思想是CAS(Compare and Swap),JUC下面的Atomic包利用的就是CPU的CAS操作。
我就在不同隔离级别,不同的索引类型下做了试验:
Jmeter 1秒内1000并发,记录i值。i表示经过了几次CAS操作,0表示1次,1表示2次,以此类推,6表示超过定义的最大循环次数更新失败退出了。
注意:全局修改隔离级别后需要重启应用,否则连接池里的连接还是用的修改前的隔离级别,或者直接在连接参数里修改隔离级别。
RC级别下高并发结果
Jmeter 1S 1000个的并发
- id主键索引
{0=1, 1=999, 2=0, 3=0, 4=0, 5=0, 6=0}
- idx_id_version普通索引
{0=1, 1=999, 2=0, 3=0, 4=0, 5=0, 6=0}
- primary_id_version主键索引
{0=64, 1=61, 2=57, 3=63, 4=41, 5=47, 6=667}
- id唯一索引
{0=1, 1=999, 2=0, 3=0, 4=0, 5=0, 6=0}
- id普通非唯一索引
{0=31, 1=40, 2=47, 3=23, 4=28, 5=21, 6=810}
结果
期望的结果更新次数在1~6之间都有分布的,RC级别下只有id和version是主键索引,或者id是非唯一的普通索引的时候才符合预期。其他情况除了一条是1次更新成功,其他都是第二次更新成功。
分析
id主键索引/id version 联合索引 /id唯一索引的情况下,第一次循环里的update t_stock set amount={stock.amount} where id={stock.id} and version = {stock.version};
,即使version不对也会对记录加锁,1000个请求过来,只有一个请求获取了锁并更新成功,其他锁加入等待队列;等到第一个请求更新成功,后面某个获取到锁的请求必然在第一个循环里更新失败,但并不会释放锁,第二次循环会更新成功。
primary_id_version是联合主键的时候update t_stock set amount={stock.amount} where id={stock.id} and version = {stock.version};
锁主键的时候如果version不对主键并不存在,所以不会锁记录。
id是普通非唯一索引的时候update t_stock set amount={stock.amount} where id={stock.id} and version = {stock.version};
会锁stock.id对应的记录,单发现不符合where条件里的version会立即释放锁,参考MySQL事务与锁-2.4.2 RC级别下update … where 加锁后释放锁里的场景。
RR级别下高并发结果
- id主键索引
{0=11, 1=0, 2=0, 3=0, 4=0, 5=0, 6=989}
- id主键索引+idx_id_version普通索引
{0=13, 1=0, 2=0, 3=0, 4=0, 5=0, 6=987}
- primary_id_version主键索引
{0=304, 1=0, 2=0, 3=0, 4=0, 5=0, 6=696}
- id 唯一索引
{0=13, 1=0, 2=0, 3=0, 4=0, 5=0, 6=987}
结果
如果第一次没有更新成功,后面就不会更新成功
分析
RR级别下MVCC的一致读导致第一次循环如果没有更新成功,即使加了锁,第二次的快照读的结果和第一次还是一样,这样获取的version还是第一次的version,后面的更新都不会更新上。
乐观锁的变体
1 |
|
利用amount替换version,没有仿CAS的操作,其实也不算乐观锁了。
总结
MySQL底层用锁实现的,想实现无锁化的乐观锁并不现实,使用起来也有坑,并不推荐使用。
分布式锁
Redis实现的分布式锁
redis中有个命令setNX
,是一种CAS操作,不同于一般的set
命令直接覆盖原值,setNx
在更新的时候会判断当前key
是否存在,如果存在返回false
,如果不存在设置value
并返回true
。
下面的代码利用这个CAS操作写简单的乐观锁:
1 | /** |
上面只是简单演示基本原理,实际使用中需要考虑很多问题。如redis失效和expire失败导致锁不释放(redis 2.8版本支持setnx命令支持设置失效时长;reids是单线程,也可以用eval执行lua脚本的方式实现)。
redis方案的分布式锁推荐RedissonLock,实现java.util.concurrent.Lock接口,用起来很方便;内部用redis的eval命令执行lua脚本,可以参看https://github.com/angryz/my-blog/issues/4。
使用分布式锁
扣减库存的例子
1 |
|
存在的问题:强依赖redis,如果redis挂了怎么办。
修改:
1 |
|
Redis锁+数据库锁双重保障的方式
相比只有数据库加锁的优点
1.redis锁生效的时候,数据库没有锁等待。
2.redis失效的时候
可以考虑服务降级:例如上面的乐观锁,去掉循环之后,更新一次如果失败就返回失败;
或者服务不降级:用数据库锁扛着。
相比只用redis加锁的优点
不强依赖redis
使用场景
只想在特定的操作加锁
例如:同一用户每次只允许下一单,如果用数据库锁,可能会锁住用户相关的所有操作;这时候用分布式锁没有问题,因为锁对象(redis里的key值)定义很自由。用户退款可以定义为:lock:user:refund:{userId}
,用户下单可以定义为:lock:user:order:{userid}
锁对象的并发量很大
高并发的时候如果使用数据库锁,会有很长的锁等待队列,数据库连接也被占;虽然锁等待超时会抛异常,放弃等待,等待时间也很难控制。
经典场景:秒杀,对单个产品对象的并发。