MDB死锁的n种姿势

MDB 死锁的n种姿势

 

为了充分利用多核CPU的性能,高性能的服务端程序,都会采用多进程/多线程并发编程,并发编程最绕不开的话题就是锁了。作为计费产品的核心高性能组件——MDB,锁也是老生常谈的话题。

在展开谈MDB的锁之前,需要先介绍下MDB的版本演进和使用方式。MDB 全称Memory Database,是公司自研的内存数据库。MDB采用多线程模型,从2.X开始支持简单的sql,有表结构,支持事务。随着版本演进,MDB3.x版本,已经可以支持标准SQL,并且兼容mysql协议。

从MDB2.x开始,MDB均采用定制化接口访问的方式,单次的数据访问可以控制在us级别,这里没有采用单表访问的方式,如果按照单表访问的方式,其实并不能发挥内存数据库的优势,MDB主机一般独立部署,业务主机和MDB主机的网络消耗一般都在ms级,哪怕是采用InfiniBand网卡,网络传输的消耗也还是比内存的存取要大得多,所以内存数据库最好采用集中存取,把一部分数据加工的逻辑放在服务端,所以MDB目前对外提供的服务,均是采用定制化接口的方式。MDB采用单进程多线程的方式提供数据访问,2.X版本,服务端线程按连接建立,每建立一个连接,服务端开一个线程处理。3.0采用了线程池,不需要每个连接都新建线程,但是都存在多线程并发的问题,也都需要通过锁,解决线程之间更新和访问同一份数据的问题。

接下来我们来看下MDB2.X锁的实现,2.X版本提供几种类型的锁:

一种是进程锁,也可以成为全局对象锁,加进程锁以后,可以保证服务端所有的线程串行执行。内部通过全局的pthread_mutex_t实现互斥,因为锁的粒度比较粗,如果多个业务线程都执行到这个逻辑,最差的情况下,会阻塞所有服务端线程。在日备的场景中,我们可以加进程锁,这样可以控制其他线程暂时不提交事务,由于日备是单线程异步操作,锁的时间不长,并且一般日备时间在业务低谷,对其他线程的阻塞也不会太长。

MDB提供的第二种锁,细究起来也不算是MDB提供的,只是对pthread_mutex_t进行了封装,是经典的线程互斥的用法。通过定义局部的pthread_mutex_t的,实现部分线程串行执行。对应的业务场景是ABMMDB的信控队列的获取。假如信控队列取数有10个消费者,加锁之后,消费者可以串行获取数据执行。

并发度少的情况下,线程互斥的方案是可行的。当服务端的线程数不断增加,如果几千上万个线程都串行执行,效率会大打折扣,所以MDB提供了一个更细粒度对象锁的设计方案。可以打散锁的分布,提高吞吐量。先来看下MDB设计的锁的数据结构:

CNamedLockMgr是管理类,负责锁的管理,需要定义好锁类型,即CNamedLock,加入到锁管理器,锁类型一般可以理解为表级别的定义。每个类型的锁,会预先分配好2047个pthread_mutex_t,这个数字可以修改,但最好是质数,后续使用的时候,会对这个数字取模,每把锁对应有一个排队的队列。下面我们看下加锁的过程:

对账户编号100000001加acct lock,先定位到账户锁位置。在用账户编号对2047取模,得到锁的位置,加锁,并且把锁信息放到第一个不为空的位置。看到这里,很多同学已经看出来了,这里其实是一个hash表的设计,hash函数是对桶的总数取模,而处理hash冲突的方式采用的是排队写入初始大小为2000的数组中。

这样对于不同类型的锁,不同的数据,会散开到不同的对象锁中,减少锁冲突的概率。

下面我们结合以上原理 和 ABMMDB的业务,来介绍下MDB2.X 版本,可能发生的死锁情况:

ABMMDB中按照业务,定义了以下 10种类型的锁:

m_pLockInfo[0].init((char *)"acct lock",(char *)"acct_id", 2047);// 账户锁,用于锁账户级资源(账本、信用度)

m_pLockInfo[1].init((char *)"serv lock",(char *)"serv_id", 2047);// 用户锁, 用于锁用户级资源(一般信控用)

m_pLockInfo[2].init((char *)"rating serv lock", (char *)"rating serv_id", 2047);// 计费用户锁,用于批价使用锁用户级资源

m_pLockInfo[3].init((char *)"rating acct lock", (char *)"rating acct_id", 2047); // 计费账户锁,用于批价使用锁账户级资源

m_pLockInfo[4].init((char *)"rating group lock",(char *)"rating group_id", 2047); // 计费群组锁,用于批价使用锁群组级资源

m_pLockInfo[5].init((char *)"monitor queue lock", (char *)"monitor acct_id", 2047);

m_pLockInfo[6].init((char *)"budget queue lock",(char *)"budget owner_id", 2047);

m_pLockInfo[7].init((char *)"notification lock",(char *)"notification acct_id", 2047);

m_pLockInfo[8].init((char *)"version lock", (char *)"version serv_id", 2047);// 版本锁,用于数据版本是否一致。

m_pLockInfo[9].init((char *)"slice key lock", (char *)"slice key_id", 2047);

 

我们先来看死锁第一种锁的情况,锁的类型的顺序不一致,导致的死锁。批价接口使用到了acct lock 、 rating 的三个lock、version lock 等,信控接口中,使用到了acct lock和 version lock,假如两个接口中,加锁的顺序不一致,就会导致死锁的问题,如下图所示:

 

在设计接口的过程中,尽量避免多个接口用相同类型的锁。如果没办法避免,使用上需要注意顺序。

第二个需要注意顺序的地方,是同一个类型的锁,数据的先后顺序。如果数据顺序随机,也会产生死锁,如下图所示:

如果接口中同时操作多条数据,对于数据加锁之前,需要进行排序,保证数据有序,否则也会造成死锁。

是不是数据排完序,是不是就不会死锁了呢?答案是否定的,在分布式的MDB系统中,也存在一种死锁的情况,如下图所示:

这个场景中,影响顺序的因素是网络,如果存在这种情况,那需要把用户A的加解锁,和用户B的加解锁串行执行,即:

A加锁-A解锁B加锁-B解锁。

发包需要串行来发,这样效率会有一定的影响,但是总体来说场景比较少,也还在可接受范围。

如果真发生了死锁的情况,有没有什么快速定位锁的办法呢?MDB2.x版本提供了查看锁的工具mdb_check_lock,可以结合日志,分析加锁的业务进程。具体的方法如下:

1、使用mdb_check_lock 或者mdb的日志,查看锁的线程,如下:

2016-06-15 19:13:15.513087 322034 mdb2(76010515) _2299.4057.1 [47791328732928] 0 [error] mf_frame_impl.cpp 858 !!time out: accp query acct lock, thread = 47793339770624, session = 0, pay_acct_id = 2049391848, want = 0, time = 20160615191312, timeout = 3, pos = 48

看可以看出服务端的线程号是 47793339770624

2、通过线程号从日志中找到连接接入的信息 

grep 47793339770624 aps_mdb_2299.4057.1_

aps_mdb_2299.4057.1_:2016-06-15 17:46:33.118291 0 mdb2(76010515) _2299.4057.1 [47793339770624] 0 [error] mf_frame_impl.cpp 430 ==[MDB_FRAME_LIB]==> New client connect from 10.251.16.61:43344, sock=657, tid=47793339770624

以上日志可以看出,是从10.251.16.61 主机的43344端口连接过来的

3、登录到对应的主机,执行 lsof -i:43344 找到对应的进程号

在执行ps -ef|grep 进程号,查看进程信息。通过进程到具体业务接口,结合代码进一步分析是否是以上情况。

 

MDB3.0版本开始,对锁进行了重构,修改成了数据库通用的行锁。对于原来对象锁实现的内容,需要进行改造升级。行锁又称为记录锁,能锁住的前提是存在对应的记录。如果记录不存在,行锁是无法锁住的。在行锁的这个特点下,我们来看下,之前的场景是否还有问题。还是以ABMMDB为例。

根据2.x版本的经验,行锁是和表相关的,所以接口中查询或者更新的时候,是需要按表进行排序的,如果表的顺序不一致,也是会出现死锁的,这点和2.x版本的类型锁类似,不在赘述。数据也需要排序的,这点也类似。

假如顺序都一致,还会有什么场景造成死锁吗?我们直接给出一个例子:

在这个场景中,如果免费资源表中,没有相应用户的记录,就出现了上面这个死锁的情况。那这个问题有办法避免吗?我个人觉得只能尽力避免。这只是两张表的场景,而批价接口中,操作的表有十几张,那两两组合的表之间都存在这种可能性。为了避免这种情况发生,设计的原则是什么呢?个人觉得应该是把最小概率无数据的表,作为第一个操作的表。

有记录的情况,是否会好一些呢?来看这个例子:

这个例子中,线程1有数据,但是由于中间存在数据变化,最终的结果也还是造成了死锁。

这个例子有些极端,但是在高并发,大数据量的情况下,也都是存在的。为了避免这些业务场景,我们可以做一些努力,比如缩短查询和更新之间的数据过程,减小缝隙,也就是把长锁修改为短缩。来减少死锁的概率。

除了数据问题,由于MDB3.0采用了协程,在某些场景下,如果业务进程里也加了锁,也会存在和协程死锁的情况,看下面这个例子:

这个例子中,要求我们的代码中需要避免使用自旋锁、互斥锁的同时,在去等行锁,这样就会因为协程,产生死锁。

既然MDB侧无法解决死锁的问题,业务侧有没有什么手段可以减少死锁的影响呢?

MDB服务端提供了锁超时机制,默认的配置中,锁超时时间配置是50s,也就是说,接口如果等锁时间超过50s,会返回一个超时标识。为了避免直接产生错单,此时业务可以再次尝试。根据死锁的概率,再次尝试也死锁的概率极低了,只是某条话单会处理时间较长,对整体的业务也不会产生较大的影响。

MDB3.0有没有监测锁的工具呢?也是有的,可以通过mdb_lock表进行观察,如下:

select * from mdb_lock; 

这是一个死锁的例子。

死锁问题会严重影响程序性能,所以在MDB服务端的开发过程中,需要多思考业务场景,尽量避免。

 
posted @ 2022-12-07 17:56  我爱编程到完  阅读(280)  评论(0编辑  收藏  举报