顾名思义,行锁就是针对数据表中行记录的锁。这很好理解,比如事务 A 更新了一行,而这时候事务 B 也要更新同一行,则必须等事务 A 的操作完成后才能进行更新。
在实际情况下,SQL是千变万化、条数不定的,数据库很难在事务中判定什么是加锁阶段,什么是解锁阶段。于是引入了S2PL(Strict-2PL),即: 在事务中只有提交(commit)或者回滚(rollback)时才是解锁阶段,其余时间为加锁阶段。
举个例子。在下面的操作序列中,事务 B 的 update 语句执行时会是什么现象呢?假设字段 id 是表 t 的主键。
实际上事务 B 的 update 语句会被阻塞,直到事务 A 执行 commit 之后,事务 B 才能继续执行。事务 A 持有的两个记录的行锁,都是在 commit 的时候才释放的。在 InnoDB 事务中,行锁是在需要的时候才加上的,但并不是不需要了就立刻释放,而是要等到事务结束时才释放。这个就是两阶段锁协议。
两阶段加锁对性能的影响,下面两种不同的扣减库存的方案:
方案1:begin;// 扣减库存update t_inventory set count=count-5 where id= ${id} and count>=5;// 锁住用户账户表select * from t_user_account where user_id=123 for update;// 插入订单记录insert into t_trans; commit;
方案2:begin;// 锁住用户账户表select * from t_user_account where user_id=123 for update;// 插入订单记录insert into t_trans;// 扣减库存update t_inventory set count=count-5 where id=${id} and count>=5;commit;
两者方案的时序如下图所示:
由于库存往往是最重要的热点,是整个系统的瓶颈。那么如果采用第二种方案的话,tps应该理论上能够提升3rt/rt=3倍。这还仅仅是业务就只有三条SQL的情况下,多一条sql就多一次rt,就多一倍的时间。
根据两阶段锁协议,不论你怎样安排语句顺序,所有的操作需要的行锁都是在事务提交的时候才释放的。所以,如果你把更新库存 安排在最后,那么库存这一行的锁时间就最少。这就最大程度地减少了事务之间的锁等待,提升了并发度。
值得注意的是:
在更新到数据库的那个时间点才算锁成功,提交到数据库的时候才算解锁成功,这两个round_trip的前半段是不会计算在内的:
从上面的例子中,可以看出,需要把最热点的记录,放到事务最后,这样可以显著的提高吞吐量。更进一步:越热点记录离事务的终点越近(无论是commit还是rollback)
避免死锁
这也是任何SQL加锁不可避免的。上文提到了按照记录Key的热度在事务中倒序排列。 那么写代码的时候任何可能并发的SQL都必须按照这种顺序来处理,不然会造成死锁。如下图所示:
但是当业务场景复杂,依然会有死锁的可能,当出现死锁以后,有两种策略:
一种策略是,直接进入等待,直到超时。这个超时时间可以通过参数 innodb_lock_wait_timeout 来设置。
另一种策略是,发起死锁检测,发现死锁后,主动回滚死锁链条中的某一个事务,让其他事务得以继续执行。将参数 innodb_deadlock_detect 设置为 on,表示开启这个逻辑。
在 InnoDB 中,innodb_lock_wait_timeout 的默认值是 50s,意味着如果采用第一个策略,当出现死锁以后,第一个被锁住的线程要过 50s 才会超时退出,然后其他线程才有可能继续执行。对于在线服务来说,这个等待时间往往是无法接受的。但是,我们又不可能直接把这个时间设置成一个很小的值,比如 1s。这样当出现死锁的时候,确实很快就可以解开,但如果不是死锁,而是简单的锁等待呢?所以,超时时间设置太短的话,会出现很多误伤。
所以,正常情况下我们还是要采用第二种策略,即:主动死锁检测,而且 innodb_deadlock_detect 的默认值本身就是 on。主动死锁检测在发生死锁的时候,是能够快速发现并进行处理的,但是它也是有额外负担的。
你可以想象一下这个过程:每当一个事务被锁的时候,就要看看它所依赖的线程有没有被别人锁住,如此循环,最后判断是否出现了循环等待,也就是死锁。
当有热点数据被并发更新的话,每个新来的被堵住的线程,都要判断会不会由于自己的加入导致了死锁,虽然最终检测的结果是没有死锁,但是这期间要消耗大量的 CPU 资源。因此,你就会看到 CPU 利用率很高,但是每秒却执行不了几个事务。
热点行更新的解决策略: 降低并发度 :
1. 拆行,一行拆多行,减少锁的冲突,以账户为例,可以考虑放在多条记录上,比如 10 个记录,账户总额等于这 10 个记录的值的总和。这样每次要给账户加金额的时候,随机选其中一条记录来加。这样每次冲突概率变成原来的 1/10,可以减少锁等待个数,也就减少了死锁检测的 CPU 消耗。但其实这类方案需要根据业务逻辑做详细设计。如果账户余额可能会减少,比如退款逻辑,那么这时候就需要考虑当一部分行记录变成 0 的时候,代码要有特殊处理。
2. Server 层限流,限制同一时间进入更新的线程数 ,比如在应用网关层限流,或是使用sentinel之类的组件
3. 关闭死锁监测(关闭的弊端是可能超时较多)
网友评论