作者:vivo 互联网服务器团队- Li Gang

本文介绍了一次排查Dubbo线程池耗尽问题的过程。通过查看Dubbo线程状态、分析Jedis连接池获取连接的源码、排查死锁条件等方面,最终确认是因为使用了cluster pipeline模式且没有设置超时时间导致死锁问题。

一、背景介绍

Redis Pipeline是一种高效的命令批量处理机制,可以在Redis中大幅度降低网络延迟,提高读写能力。Redis Cluster Pipeline是基于Redis Cluster的pipeline,通过将多个操作打包成一组操作,一次性发送到Redis Cluster中的多个节点,减少了通信延迟,提高了整个系统的读写吞吐量和性能,适用于需要高效处理Redis Cluster命令的场景。

本次使用到pipeline的场景是批量从Redis Cluster批量查询预约游戏信息,项目内使用的Redis Cluster Pipeline的流程如下,其中的JedisClusterPipeline是我们内部使用的工具类,提供Redis Cluster模式下的pipeline能力:

JedisClusterPipeline使用:

JedisClusterPipline jedisClusterPipline = redisService.clusterPipelined();
List<Object> response;
try {
for (String key : keys) {
jedisClusterPipline.hmget(key, VALUE1, VALUE2);
}
// 获取结果
response = jedisClusterPipline.syncAndReturnAll();
} finally {
jedisClusterPipline.close();
}

二、故障现场记录

某天,收到了Dubbo线程池耗尽的告警。查看日志发现只有一台机器有问题,并且一直没恢复,已完成任务数也一直没有增加。

查看请求数监控,发现请求数归零,很明显机器已经挂了。

使用arthas查看Dubbo线程,发现400个线程全部处于waiting状态。

三、故障过程分析

Dubbo线程处于waiting状态这一点没有问题,Dubbo线程等待任务的时候也是waiting状态,但是查看完整调用栈发现有问题,下面两张图里的第一张是问题机器的栈,第二张是正常机器的栈,显然问题机器的这个线程在等待Redis连接池里有可用连接。

使用jstack导出线程快照后发现问题机器所有的Dubbo线程都在等待Redis连接池里有可用连接。

调查到这里,能发现两个问题。

  1. 线程一直等待连接而没有被中断。

  2. 线程获取不到连接。

3.1 线程一直等待连接而没有被中断原因分析

Jedis获取连接的逻辑在org.apache.commons.pool2.impl.GenericObjectPool#borrowObject(long)方法下。

public T borrowObject(long borrowMaxWaitMillis) throws Exception {
...
PooledObject<T> p = null; // 获取blockWhenExhausted配置项,该配置默认值为true
boolean blockWhenExhausted = getBlockWhenExhausted(); boolean create;
long waitTime = System.currentTimeMillis(); while (p == null) {
create = false;
if (blockWhenExhausted) {
// 从队列获取空闲的对象,该方法不会阻塞,没有空闲对象会返回null
p = idleObjects.pollFirst();
// 没有空闲对象则创建
if (p == null) {
p = create();
if (p != null) {
create = true;
}
}
if (p == null) {
// borrowMaxWaitMillis默认值为-1
if (borrowMaxWaitMillis < 0) {
// 线程栈快照里所有的dubbo线程都卡在这里,这是个阻塞方法,如果队列里没有新的连接会一直等待下去
p = idleObjects.takeFirst();
} else {
// 等待borrowMaxWaitMillis配置的时间还没有拿到连接的话就返回null
p = idleObjects.pollFirst(borrowMaxWaitMillis,
TimeUnit.MILLISECONDS);
}
}
if (p == null) {
throw new NoSuchElementException(
"Timeout waiting for idle object");
}
if (!p.allocate()) {
p = null;
} } ... } updateStatsBorrow(p, System.currentTimeMillis() - waitTime); return p.getObject();
}

由于业务代码没有设置borrowMaxWaitMillis,导致线程一直在等待可用连接 ,该值可以通过配置jedis pool的maxWaitMillis属性来设置。

到这里已经找到线程一直等待的原因,但线程获取不到连接的原因还需要继续分析。

3.2 线程获取不到连接原因分析

获取不到连接无非两种情况:

  1. 连不上Redis,无法创建连接

  2. 连接池里的所有连接都被占用了,无法获取到连接

猜想一:是不是连不上Redis?

询问运维得知发生问题的时间点确实有一波网络抖动,但是很快就恢复了,排查时问题机器是能正常连上Redis的。那有没有可能是创建Redis连接的流程写的有问题,无法从网络抖动中恢复导致线程卡死?这一点要从源码中寻找答案。

创建连接:

private PooledObject<T> create() throws Exception {
int localMaxTotal = getMaxTotal();
long newCreateCount = createCount.incrementAndGet();
if (localMaxTotal > -1 && newCreateCount > localMaxTotal ||
newCreateCount > Integer.MAX_VALUE) {
createCount.decrementAndGet();
return null;
} final PooledObject<T> p;
try {
// 创建redis连接,如果发生超时会抛出异常
// 默认的connectionTimeout和soTimeout都是2秒
p = factory.makeObject();
} catch (Exception e) {
createCount.decrementAndGet();
// 这里会把异常继续往上抛出
throw e;
} AbandonedConfig ac = this.abandonedConfig;
if (ac != null && ac.getLogAbandoned()) {
p.setLogAbandoned(true);
} createdCount.incrementAndGet();
allObjects.put(new IdentityWrapper<T>(p.getObject()), p);
return p;
}

可以看到,连接Redis超时时会抛出异常,调用create()函数的borrowObject()也不会捕获这个异常,这个异常最终会在业务层被捕获,所以连不上Redis的话是不会一直等待下去的,网络恢复后再次调用create()方法就能重新创建连接。

综上所诉,第一种情况可以排除,继续分析情况2,连接被占用了没问题,但是一直不释放就有问题。

猜想二:是不是业务代码没有归还Redis连接?

连接没有释放,最先想到的是业务代码里可能有地方漏写了归还Redis连接的代码,pipeline模式下需要在finally块中手动调用JedisClusterPipeline#close()方法将连接归还给连接池,而普通模式下不需要手动释放(参考redis.clients.jedis.JedisClusterCommand#runWithRetries,每次执行完命令后都会自动释放),在业务代码里全局搜索所有使用到了cluster pipeline的代码,均手动调用了JedisClusterPipeline#close()方法,所以不是业务代码的问题。

猜想三:是不是Jedis存在连接泄露的问题?

既然业务代码没问题,那有没有可能是归还连接的代码有问题,存在连接泄露?2.10.0版本的Jedis确实可能发生连接泄露,具体可以看这个issue:https://github.com/redis/jedis/issues/1920,不过我们项目内使用的是2.9.0版本,所以排除连接泄露的情况。

猜想四:是不是发生了死锁?

排除以上可能性后,能想到原因的只剩死锁,思考后发现在没有设置超时时间的情况下,使用pipeline确实有概率发生死锁,这个死锁发生在从连接池(LinkedBlockingDeque)获取连接的时候。

先看下cluster pipeline模式下的Redis和普通的Redis有什么区别。Jedis为每个Redis实例都维护了一个连接池,cluster pipeline模式下,先使用查询用的key计算出其所在的Redis实例列表,再从这些实例对应的连接池里获取到连接,使用完后统一释放。而普通模式下一次只会获取一个连接池的连接,用完后立刻释放。这意味着cluster pipeline模式在获取连接时是符合死锁的“占有并等待”条件的,而普通模式不符合这个条件。

JedisClusterPipeline使用:

JedisClusterPipline jedisClusterPipline = redisService.clusterPipelined();
List<Object> response;
try {
for (String key : keys) {
// 申请连接,内部会先调用JedisClusterPipeline.getClient(String key)方法获取连接
jedisClusterPipline.hmget(key, VALUE1, VALUE2);
// 获取到了连接,缓存到poolToJedisMap
}
// 获取结果
response = jedisClusterPipline.syncAndReturnAll();
} finally {
// 归还所有连接
jedisClusterPipline.close();
}

JedisClusterPipeline部分源码:

public class JedisClusterPipline extends PipelineBase implements Closeable {

    private static final Logger log = LoggerFactory.getLogger(JedisClusterPipline.class);

    // 用于记录redis命令的执行顺序
private final Queue<Client> orderedClients = new LinkedList<>();
// redis连接缓存
private final Map<JedisPool, Jedis> poolToJedisMap = new HashMap<>(); private final JedisSlotBasedConnectionHandler connectionHandler;
private final JedisClusterInfoCache clusterInfoCache; public JedisClusterPipline(JedisSlotBasedConnectionHandler connectionHandler, JedisClusterInfoCache clusterInfoCache) {
this.connectionHandler = connectionHandler;
this.clusterInfoCache = clusterInfoCache;
} @Override
protected Client getClient(String key) { return getClient(SafeEncoder.encode(key));
} @Override
protected Client getClient(byte[] key) { Client client;
// 计算key所在的slot
int slot = JedisClusterCRC16.getSlot(key);
// 获取solt对应的连接池
JedisPool pool = clusterInfoCache.getSlotPool(slot);
// 从缓存获取连接
Jedis borrowedJedis = poolToJedisMap.get(pool);
// 缓存中没有连接则从连接池获取并缓存
if (null == borrowedJedis) {
borrowedJedis = pool.getResource();
poolToJedisMap.put(pool, borrowedJedis);
} client = borrowedJedis.getClient(); orderedClients.add(client); return client;
} @Override
public void close() {
for (Jedis jedis : poolToJedisMap.values()) {
// 清除连接内的残留数据,防止连接归还时有数据漏读的现象
try {
jedis.getClient().getAll();
} catch (Throwable throwable) {
log.warn("关闭jedis时遍历异常,遍历的目的是:清除连接内的残留数据,防止连接归还时有数据漏读的现象");
}
try {
jedis.close();
} catch (Throwable throwable) {
log.warn("关闭jedis异常");
}
}
// 归还连接
clean();
orderedClients.clear();
poolToJedisMap.clear();
} /**
* go through all the responses and generate the right response type (warning :
* usually it is a waste of time).
*
* @return A list of all the responses in the order
*/
public List<Object> syncAndReturnAll() {
List<Object> formatted = new ArrayList<>();
List<Throwable> throwableList = new ArrayList<>();
for (Client client : orderedClients) {
try {
Response response = generateResponse(client.getOne());
if(response == null){
continue;
}
formatted.add(response.get());
} catch (Throwable e) {
throwableList.add(e);
}
}
slotCacheRefreshed(throwableList);
return formatted;
}
}

举个例子:

假设有一个集群有两台Redis主节点(集群模式下最小的主节点数量是3,这里只是为了举例),记为节点1/2,有个java程序有4个Dubbo线程,记为线程1/2/3/4,每个Redis实例都有一个大小为2的连接池。

线程1和线程2,先获取Redis1的连接再获取Redis2的连接。线程3和线程4,先获取Redis2的连接再获取Redis1的连接,假设这四个线程在获取到连接第一个连接后都等待了一会,在获取第二个连接的时候就会发生死锁(等待时间越长,触发的概率越大)。

所以pipeline是可能导致死锁的,这个死锁的条件很容易破坏,等待连接的时候设置超时时间即可。还可以增大下连接池的大小,资源够的话也不会发生死锁。

四、死锁证明

以上只是猜想,为了证明确实发生了死锁,需要以下条件:

  1. 线程当前获取到了哪些连接池的连接

  2. 线程当前在等待哪些连接池的连接

  3. 每个连接池还剩多少连接

已知问题机器的Dubbo线程池大小为400,Redis集群主节点数量为12,Jedis配置的连接池大小为20。

4.1 步骤一:获取线程在等待哪个连接池有空闲连接

第一步:先通过jstack和jmap分别导出栈和堆

第二步:通过分析栈可以知道线程在等待的锁的地址,可以看到Dubbo线程383在等待0x6a3305858这个锁对象,这个锁属于某个连接池,需要找到具体是哪个连接池。

第三步:使用mat(Eclipse Memory Analyzer Tool)工具分析堆,通过锁的地址找到对应的连接池。

使用mat的with incoming references功能顺着引用一层层的往上找。

引用关系:ConditionObject->LinkedBlockingDeque

引用关系:LinkedBlockingDeque->GenericObjectPool

引用关系:GenericObjectPool->JedisPool。这里的ox6a578ddc8就是这个锁所属的连接池地址。

这样我们就能知道Dubbo线程383当前在等待0x6a578ddc8这个连接池的连接。

通过这一套流程,我们可以知道每个Dubbo线程分别在等待哪些连接池有可用连接。

4.2 步骤二:获取线程当前持有了哪些连接池的连接

第一步:使用mat在堆中查找所有JedisClusterPipeline类(正好400个,每个Dubbo线程都各有一个),然后查看里面的poolToJedisMap,其中保存了当前JedisClusterPipeline已经持有的连接和其所属的连接池。

下图中,我们可以看到JedisClusterPipeline(0x6ac40c088)对象当前的poolToJedisMap里有三个Node对象(0x6ac40dd40, 0x6ac40dd60, 0x6ac40dd80),代表其持有三个连接池的连接,可以从Node对象中找到JedisPool的地址。

第二步:第一步拿到JedisClusterPipeline持有哪个连接池的连接后,再查找持有此JedisClusterPipeline的Dubbo线程,这样就能得到Dubbo线程当前持有哪些连接池的连接。

4.3 死锁分析

通过流程一可以发现虽然Redis主节点有12个,但是所有的Dubbo线程都只在等待以下5个节点对应的连接池之一:

  • 0x6a578e0c8

  • 0x6a578e048

  • 0x6a578ddc8

  • 0x6a578e538

  • 0x6a578e838

通过流程二我们可以得知这5个连接池的连接当前被哪些线程占用:

已知每个连接池的大小都配置为了20,这5个连接池的所有连接已经被100个Dubbo线程占用完了,而所有的400个Dubbo线程又都在等待这5个连接池的连接,并且其等待的连接当前没被自己占用,通过这些条件,我们可以确定发生了死锁。

五、总结

这篇文章主要展现了一次系统故障的分析过程。在排查过程中,作者使用jmap和jstack保存故障现场,使用arthas分析故障现场,再通过阅读和分析源码,从多个可能的角度一步步的推演故障原因,推测是死锁引起的故障。在验证死锁问题时,作者使用mat按照一定的步骤来寻找线程在等待哪个连接池的连接和持有哪些连接池的连接,再结合死锁检测算法最终确认故障机器发生了死锁。

排查线上问题并非易事,不仅要对业务代码有足够的了解,还要对相关技术知识有系统性的了解,推测出可能导致问题的原因后,再熟练运用好排查工具,最终确认问题原因。

记一次Redis Cluster Pipeline导致的死锁问题的更多相关文章

  1. Redis Cluster 分区实现原理

    Redis Cluster本身提供了自动将数据分散到Redis Cluster不同节点的能力,分区实现的关键点问题包括:如何将数据自动地打散到不同的节点,使得不同节点的存储数据相对均匀:如何保证客户端 ...

  2. JedisCluster使用pipeline操作Redis Cluster最详细从0到1实现过程

    公众号文章链接:https://mp.weixin.qq.com/s/6fMsG009RukLW954UUndbw 前言 2020年4月30日,Redis 6.0.0正式发布,标志着redis从此告别 ...

  3. redis cluster如何支持pipeline

    当我们要操作一批key时,可以通过 redis pipline 再执行完后一次性读取所有结果来较少网络传输的消耗: 很明显,这有个限制条件 => 这批key的执行必须在同一个连接上 当部署的re ...

  4. 全面剖析Redis Cluster原理和应用 (转)

    1.Redis Cluster总览 1.1 设计原则和初衷 在官方文档Cluster Spec中,作者详细介绍了Redis集群为什么要设计成现在的样子.最核心的目标有三个: 性能:这是Redis赖以生 ...

  5. Redis Cluster部署、管理和测试

    背景: Redis 3.0之后支持了Cluster,大大增强了Redis水平扩展的能力.Redis Cluster是Redis官方的集群实现方案,在此之前已经有第三方Redis集群解决方案,如Twen ...

  6. Redis Cluster架构优化

    Redis Cluster架构优化 在<全面剖析Redis Cluster原理和应用>中,我们已经详细剖析了现阶段Redis Cluster的缺点: 无中心化架构 Gossip消息的开销 ...

  7. 全面剖析Redis Cluster原理和应用

    全面剖析Redis Cluster原理和应用 1.Redis Cluster总览 1.1 设计原则和初衷 在官方文档Cluster Spec中,作者详细介绍了Redis集群为什么要设计成现在的样子.最 ...

  8. 高可用Redis(十二):Redis Cluster

    Redis Cluster是Redis官方提供的Redis集群功能 1.为什么要实现Redis Cluster 1.主从复制不能实现高可用 2.随着公司发展,用户数量增多,并发越来越多,业务需要更高的 ...

  9. Redis Cluster集群架构实现(四)--技术流ken

    Redis集群简介 通过前面三篇博客的介绍<Redis基础认识及常用命令使用(一)--技术流ken>,<Redis基础知识补充及持久化.备份介绍(二)--技术流ken>,< ...

  10. centos6下redis cluster集群部署过程

    一般来说,redis主从和mysql主从目的差不多,但redis主从配置很简单,主要在从节点配置文件指定主节点ip和端口,比如:slaveof 192.168.10.10 6379,然后启动主从,主从 ...

随机推荐

  1. requests Python中最好用的网络请求工具 基础速记+最佳实践

    简介 requests 模块是写python脚本使用频率最高的模块之一.很多人写python第一个使用的模块就是requests,因为它可以做网络爬虫.不仅写爬虫方便,在日常的开发中更是少不了requ ...

  2. SpringBoot 如何优雅的进行全局异常处理?

    在SpringBoot的开发中,为了提高程序运行的鲁棒性,我们经常需要对各种程序异常进行处理,但是如果在每个出异常的地方进行单独处理的话,这会引入大量业务不相关的异常处理代码,增加了程序的耦合,同时未 ...

  3. Unity UGUI的所有组件的介绍及使用

    Unity UGUI的所有组件的介绍及使用 本文将介绍Unity UGUI中的各个组件,包括它们的具体介绍.用途 1. Text(文本) 介绍:Text组件用于在UI界面上显示文本内容. 用途:常用于 ...

  4. 普通用户启动 supervisor 报 HTTP 错误(strace)

    公司的开发对生产环境都有普通用户 www 的权限,采用堡垒机登录到生产环境的机器. 默认 supervisor 使用 root 用户启动,开发没有权限直接修改配置和操作 supervisor 管理的进 ...

  5. SpringBoot 启动流程分析(寻找扩展点)

    1.SpringBoot maven 依赖版本 <?xml version="1.0" encoding="UTF-8"?> <project ...

  6. Android 架构模式如何选择

    作者:vivo 互联网客户端团队-Xu Jie Android架构模式飞速演进,目前已经有MVC.MVP.MVVM.MVI.到底哪一个才是自己业务场景最需要的,不深入理解的话是无法进行选择的.这篇文章 ...

  7. 应用层协议之DNS、DHCP

    运输层为应用进程提供了端对端的通信服务,但不同的网络应用的应用进程之间,还需要有不同的通信规则.因此在运输层协议之上,还需要有应用层协议. 应用层中有这些常见的协议 域名系统:DNS 动态主机配置:D ...

  8. Promise的理解与使用(一)

    一.Promise是什么?Promise是JS中进行异步操作的新的解决方案(旧的方案是回调函数的形式,回调函数里嵌套函数)从语法上来说,Promise是一个构造函数.从功能上来说,用Promise的实 ...

  9. SpringBoot3基础用法

    目录 一.背景 二.环境搭建 1.工程结构 2.框架依赖 3.环境配置 三.入门案例 1.测试接口 2.全局异常 3.日志打印 3.1 日志配置 3.2 日志打印 四.打包运行 五.参考源码 技术和工 ...

  10. CodeForces CF1846G 题解

    CodeForces CF1846G 题解 CodeForces题目链接 洛谷题目链接 标准答案是状压之后,转化成Dijkstra算法跑最短路.我这里提供一个不一样的思路. 题意简述 主人公得了病,有 ...