从理论上来说,解决方案有三种:
- 不管有没有数据,先插入一个默认的数据。如果没有数据,那么会插入成功;如果有数据,会出现主键冲突或唯一索引冲突,插入失败。在插入成功的时候,执行以前数据不存在的逻辑,因为此时数据库里有数据,所以不会使用间隙锁,而是使用行锁,从而规避了死锁问题。
- 调整数据库的隔离级别,降低为已提交读就没有间隙锁了。可以进一步把话题引申到MVCC中。
- 放弃悲观锁,使用乐观锁。这也是亮点方案。
可以通过一个案例说明,关键词是临键锁。
早期优化过一个死锁问题,是临键锁引起的,业务逻辑很简单,先用 SELECT FOR UPDATE 查询数据。如果查询到了数据,那么就执行一段业务逻辑,然后更新结果;如果没有查询到,那么就执行另外一段业务逻辑,然后插入计算结果。
那么如果 SELECT FOR UPDATE 查找的数据不存在,那么数据库会使用一个临键锁。此时,如果有两个线程加了临键锁,然后又希望插入计算结果,那么就会造成死锁。
我这个优化也很简单,就是上来先不管三七二十一,直接插入数据。如果插入成功,那么就执行没有数据的逻辑,此时不会再持有临键锁,而是持有了行锁。如果插入不成功,那么就执行有数据的业务逻辑。
此外,还有两个思路。一个是修改数据库的隔离级别为 RC,那么自然不存在临键锁了,但是这个修改影响太大,被 DBA 否决了。另外一个思路就是使用乐观锁,不过代码改起来要更加复杂,所以就没有使用。
后续可能会追问隔离级别的事情,或是问乐观锁的细节。
很多人为了省事会直接使用悲观锁,比如事务里存在SELECT ... FOR UPDATE
的语句,而后面紧跟一个UPDATE语句
// 开启事务
Begin()
// 查询到已有的数据 SELECT * FROM xxx WHERE id = 1 FOR UPDATE
data := SelectForUpdate(id)
newData := calculate(data) // 一大通计算
// 将新数据写回去数据库 UPDATE xxx SET data = newData WHERE id =1
Update(id, newData)
Commit()
考虑这一类代码直接把事务给去掉,纯粹依赖CAS操作
for {
// 查询到已有的数据 SELECT * FROM xxx WHERE id = 1
data := Select(id)
newData := calculate(data) // 一大通计算
// 将新数据写回去数据库
// UPDATE xxx SET data = newData WHERE id =1 AND data=oldData
success := CAS(id, newData, data)
// 确实更新成功,代表在业务执行过程中没有人修改过这个 data。
// 适合读多写少的情况
if success {
break;
}
}
这里是直接用data来比较的,实践中也可能引入version列,或是update_time来确保数据没有发生更改。
可以聊到乐观锁的情况下,用这个案例。