MySQL排他锁使用案例

背景

在开发业务需求时,发现一个线上存在的并发问题。具体场景如下:

数据表t1: 字段简化为id, aid, tag_id, remark, 唯一索引ux_aid_tag_id, 其中,

  • aid表示一个稿件信息
  • tag_id表示该稿件信息关联的标签id,一个稿件可以对应多个标签
  • remark则表示该稿件和标签关联的备注信息

数据更新场景: 一次接口请求,payload提供一个aid和其绑定的标签tag_remark_list(记为A),形如[{tag_id: 1, remark: "xx"}, {tag_id: 2, remark: "xx"}],表示该稿件需要和这些标签关联,并需要记录绑定时的备注信息

后台处理逻辑: 拿到请求后,先从数据库查找目前该稿件已绑定的tag_remark信息列表(记为B),然后请求参数中提交的A作对比,整体逻辑:

  1. 步骤1:从数据库中查询该aid已绑定的tag_list B
  2. 步骤2:tag_listB与payload中提交的tag_list做对比,具体逻辑如下:
    1. A和B共有的,如果remark没变,忽略
    2. A和B共有的,如果remark变化,做update操作
    3. A中独有的,做insert操作
    4. B中独有的,做delete操作

补充一下调用逻辑: 调用从前端发起,用户页面会做一次查询,加载出当前aid可以绑定的所有tag信息,以及该aid已经绑定的tag信息,即B,然后用户可以通过前端操作,或新增、或取消、或更新备注等

存在的问题

  1. 若有两个用户同时编辑(此时前端页面都加载了同样的信息),用户U1新增了tag1,并提交完成;此时用户U2没有刷新页面,直接新增tag2后提交,那么,由于新增的payload A中没有tag1,但后台查询的tag列表有tag1,根据上述规则4,此时tag1会被删除,U1的新增被覆盖(删除)
  2. 若有两个用户同时提交的请求,且都新增了tag1,但是remark信息不同。在步骤2的第3种情形下insert,会出现duplicate key error,唯一键冲突

问题分析

向前辈了解了接口如此设计的原因,主要是为了减少运营人员的操作成本,由于任务是分发式的,每一个aid的处理只会分发到一个人,所以基本不会出现上述的并发问题。

抛开这里的业务场景,作为一个系统设计人员,应该要考虑如何在保证主体功能的前提下,提高系统的可用性(这里主要考虑并发情况下的系统稳定性和可靠性)

针对问题1,其根因是用户同时操作时,某个用户的操作,改变了数据表的现状,且结果对另一个用户不可见(假设该用户没有刷新页面,实际上用户确实很少愿意刷新页面,尤其当这个页面信息量很大,他已经做了某些修改的情况下)。

针对问题2,其根因是select + update不是原子操作,这两个操作的间隙会产生很多变化,且两个并行的操作互相不可见

解决方案探讨

针对问题1

解决方案:版本号管理(乐观锁原理)

在每一个aid上维护版本号(使用redis维护,单实例可以考虑丢内存),用户查询时返回当前查询记录的版本号,服务端数据有变更时更新版本号,如果提交数据的版本号和当前数据版本号不一致,则拒绝请求,返回错误,具体步骤:

  1. 每一个aid维护当前版本号(随机值),之所以不用递增递减数据,是因为
    • 一是不需要,场景下只用比对提交数据的版本号和当前最新版本是否一致即可,没有版本回溯的功能
    • 二是防止伪造,如果使用递增递减数据,调用端可能随机伪造更大的版本号,来强行请求数据
  2. 用户加载页面时,查询aid信息,并返回当前aid对应版本号
  3. 用户提交信息,接口需要知道前端查询时的版本号,用于和当前aid版本号比对
    • 如果版本号一致,说明此期间该aid没有相关操作, 执行逻辑,完成后更新版本号
    • 如果版本号不一致,则拒绝请求

解决了读写覆盖的问题,其实这里仍然会有并发问题,如果两个请求同时进来,并通过了版本号校验,后面的并发操作仍然可能读写覆盖。

此时,需要和下面的锁机制同时使用

针对问题2

方案一: 在步骤一处使用全局锁

可以使用golang的mutex锁全局锁住此处的增删改操作,可以保证并发的请求不会因为同时insert造成error

  • 优势
    • 简单,易开发
  • 劣势
    • 锁的粒度较大,不同aid的操作理论上不会相互影响,但是也会被锁住
    • 使用本地锁,在多实例的情况下不起作用,需要使用分布式锁,开发量又上去了
方案二: 在步骤一处使用MySQL排他锁

方案一的劣势在于锁粒度比较大,不同aid下的操作不会相互影响,也会被锁住,影响性能,更没有必要。这时候使用MySQL排它锁,可以将锁粒度减小到同一个aid的操作

在步骤1的请求中,使用select xxx for update,加上排它锁后,对于同一个aid的并发操作,将阻塞在步骤1,直到步骤2完成并提交事务。(这里涉及到两阶段锁协议,即在一个事务中,锁在使用时加上,事务提交时释放)。所以,每一个aid进入操作时,可以保证此时查询到的数据是最新的,且无其他进程修改数据。

  • 优势:
    • 基本没有开发工作量
    • 锁粒度比较小,不影响不同aid的并发操作
  • 劣势
    • 虽然不会报错,但是并发操作仍然会出现remark字段相互覆盖的情况,影响用户体验
      • 要避免这种情况,可以考虑在遇锁时直接返回错误信息,需要用户重新加载后更新,影响用户体验,而且对于并发多的情景不适用
    • 在无数据时并发写,锁不起作用
      • MySQL行锁是加在索引上的,如果当前aid下没有绑定tag,那么select ... for update没有锁住的具体索引项,索引不生效

总结

以上解决方案,仍然没有跳出“批量操作”的大圈,“批量操作”隐含了增删改的行为,给用户增加了便捷性(在没有并发的情况下),但是也增加了逻辑处理的复杂度(有并发),且并发发生时,用户需要重新提交数据,体验上可能更不优雅

因此,如果场景存在并发,这种“批量操作”需要避免。拆分“批量操作”,独立增删改接口可能会更和谐。这就需要具体问题具体解决,本文暂时不深究了

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值