解决mongo的tickets被耗尽导致卡顿问题
作者:泰勒今天不想展开
近一年来,项目线上环境的mongo数据库出现多次tickets被耗尽,导致数据库卡顿,并且都是突然出现,等待一段时间后又能自动恢复。
为了解决这个问题,我们进行了长期的探索和研究,先后从多个角度进行优化,于此记录和分享一下这一路的历程。
tickets是什么
为了解决这个问题,我们首先要明白ticktes是什么,其实网上基本都说的一知半解,没有一个能说明白的,但是有一个查询tieckts消耗情况的mongo命令:
db.serverStatus().wiredTiger.concurrentTransactions
查询结果:
{ "write" : { "out" : 0, "available" : 128, "totalTickets" : 128 }, "read" : { "out" : 1, "available" : 127, "totalTickets" : 128 } }
可以看到tickets分为读写两种,那ticktets到底是什么呢,我们根据这个查询命令,其实大致可以猜测认为是当前同时存在的事务数量。
也就是mongo限制了同时进行的事务数。
早期因为不知道tickets到底是什么意思,尝试过很多思路错误的优化,所以解决问题,最好还是能弄明白问题本身,才能对症下药。
思考历程
在众多数据库卡顿的经历中,曾有一次因为rabbitmq导致的数据库卡顿,原因是一小伙伴在请求的过滤层加了一个发送mq的逻辑,但是没有进行限制,导致每次只有有接口被调,都会去发布一个mq消息,由于过高的并发导致rabbitmq不堪重负,倒是让人想不明到的是mq卡的同时,数据库也卡住了。
一开始以为是因为消息过多,导致消费者疯狂消费,压垮了数据库,其实不存在这个问题,因为我们的mq配置单个消费者机器是串行的,也就是同一台机器同一时间只会消费同一个消息队列的一条消息,所以并不会因为消息的多给数据库带来压力,只会堆积在mq集群里。所以这次其实没有找到mq卡顿导致mongo卡顿的原因。
我们接入的几家第三方服务,比如给我们提供IM消息服务的融云,每次他们出现问题的时候,我们也会出现数据库卡顿,并且每次时间出奇的一直,但也始终找不到原因。
起初经过对他们调用我们接口情况进行分析,发现每次他们出问题时,我们收到的请求会倍增,以为是这个原因导致的数据库压力过大,并且我们基于redis和他们回调的流水号进行了拦截,拦截方式如下:
- 当请求过来时从redis中查询该笔流水号状态,如果状态为已完结,则直接成功返回
- 如果查询到状态是进行中,则抛异常给第三方,从而让他继续重试
- 如果查询不到状态,则尝试设置状态为进行中并设置10秒左右的过期时间,如果设置成功,则放到数据库层面进行数据处理;如果设置失败,也抛异常给第三方,等待下次重试
- 等数据库曾处理完成后,将redis中的流水号状态改为已完结。
避免重复请求给我们带来的数据库的压力。这其实也算是一部分原因但还是不算主要原因。
引起mongo卡顿的还有发布版本,有一段时间隔三差五发布版本,就会出现卡顿,但是查看更新的代码也都是一些无关痛痒理论上不会引起问题的内容。
后来发现是发布版本时每次同时关闭和启动的机器从原来的一台改成了两台(一台一台发布太慢,所以运维改成了两台两台一起发),感觉原因应该就在这里,后来想到会不会和优雅关闭有关,当机器关闭时仍然有mq消费者以及内置循环脚本在执行,当进程杀死时,会产生大量需要立马回滚的事务,从而导致mongo卡顿。
后来经过和运维小伙伴的沟通发现,在优雅关闭方面确实存在问题,他们关闭容器时会小容器内的主进程发一个容器即将关闭的信号,然后等待几十秒后,如果主进程没有自己关闭,则会直接杀死进程。
为此我们需要在程序中实现对关闭信号的监听,并实现优雅关闭的逻辑,在spring中,我们可以通过spring的时间拿到外部即将关闭的信号:
@Volatile private var consumeSwitch = true /** * 销毁逻辑 */ @EventListener fun close(event: ContextClosedEvent){ consumeSwitch = false logger.info("----------------------rabbitmq停止消费----------------------") }
可以通过如上方式,对系统中的mq消费者或者其他内置程序进行优雅关停控制,对优雅关闭问题优化后,服务器关闭重启导致的数据库卡顿确实得到了有效解决。
上面的融云问题优化过后,后来融云再次卡顿的时候,还是会出现mongo卡顿,由此可见,肯定和第三方有关,但上面说的问题肯定不是主要原因。
后来我看到我们调用第三方的逻辑很多都在@Transactional代码块中间,后来去看了第三方sdk里的逻辑,其实就是封装了一个http请求,但是http请求的请求超时时间长达60秒,那就会有一个问题,如果这个时候第三方服务器卡顿了,这个请求就会不断地等,知道60s超时,而由于这个操作是在事务块中,意味着这个事务也不会commit掉,那等于这个事务所占用的tickets也一直不会放掉,至此根本原因似乎找到了,是因为事务本身被卡住了,导致tickets耗尽,从而后面新的事务全部都在等待状态,全部都卡住了。
其实这次找的原因,同样也可以解释前面mq卡顿导致的数据库卡顿,因为同样有大量的发送mq的操作在事务块中,因为短时间疯狂发mq,导致mq服务端卡顿,从而导致发mq的操作出现卡顿,这就会出现整个事务被卡住,接着tickets被消耗殆尽,整个数据库卡顿。
找到确定问题后就好对症下药了,第三方的问题由于我们不能保证第三方的稳定性,所以当第三方出现问题时的思路应该是进行服务降级,允许部分功能不可用,确定核心业务不受影响,我们基于java线程池进行了同步改异步处理,并且由于第三方的工作是给用户推送im消息,所以配置的舍弃策略是当阻塞队列堆积满之后,将最老的进行丢弃。
而如果是mq导致的这种情况,我们这边没有进行额外的处理,因为这种情况是有自身的bug导致的,这需要做好整理分享工作,避免再次出现这样的bug。
//自己实现的runnable abstract class RongCloudRunnable( private val taskDesc: String, private val params: Map<String, Any?> ) : Runnable { override fun toString(): String { return "任务名称:${taskDesc};任务参数:${params}" } } //构建线程池 private val rongCloudThreadPool = ThreadPoolExecutor( externalProps.rongCloud.threadPoolCoreCnt, externalProps.rongCloud.threadPoolMaxCnt, 5, TimeUnit.MINUTES, LinkedBlockingQueue<Runnable>(externalProps.rongCloud.threadPoolQueueLength), RejectedExecutionHandler { r, executor -> if (!executor.isShutdown) { val item = executor.queue.poll() logger.warn("当前融云阻塞任务过多,舍弃最老的任务:${item}") executor.execute(r) } } ) //封装线程池任务处理方法 fun taskExecute(taskDesc: String, params: Map<String,Any?>, handle: ()-> Unit){ rongCloudThreadPool.execute(object :RongCloudRunnable(taskDesc, params){ override fun run() { handle() } }) } //具体使用 taskExecute("发送消息", mapOf( "from_id" to fromId, "target_ids" to targetIds, "data" to data, "is_include_sender" to isIncludeSender )){ sendMessage(BatchSendData(fromId, targetIds, data, isIncludeSender)) }
总结
以上为个人经验,希望能给大家一个参考,也希望大家多多支持脚本之家。