Redis Cluster Pipeline によって引き起こされるデッドロックの問題を思い出してください

著者: vivo インターネットサーバーチーム-Li Gang


この記事では、Dubbo スレッド プールの枯渇問題のトラブルシューティングのプロセスを紹介します。Dubbo スレッドのステータスを確認し、Jedis 接続プールを分析して接続のソース コードを取得し、デッドロック状態のトラブルシューティングを行うことにより、デッドロックの問題は、タイムアウトを設定せずにクラスター パイプライン モードを使用することによって引き起こされたことが最終的に確認されました。


1. 背景の紹介


Redis Pipeline は、ネットワーク遅延を大幅に削減し、Redis での読み取りおよび書き込み機能を向上させる、効率的なコマンド バッチ処理メカニズムです。Redis Cluster Pipeline は、Redis Cluster をベースとしたパイプラインであり、複数の操作を 1 つの操作にパッケージ化し、Redis Cluster 内の複数のノードに一度に送信することで、通信遅延を削減し、システム全体の読み取りおよび書き込みのスループットとパフォーマンスを向上させます。 Redis Cluster コマンドを効率的に処理する必要があるシナリオに適用されます。


今回パイプラインを使用するシナリオは、Redis Cluster から予約ゲーム情報をバッチクエリすることです。プロジェクトで使用される Redis Cluster Pipeline のプロセスは次のとおりです。JedisClusterPipeline は、Redis Cluster モードでパイプライン機能を提供するために内部的に使用されるツール クラスです。


JedisClusterPipeline使用

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


2. 故障箇所の記録


ある日、Dubbo スレッド プールが枯渇したという警告を受け取りました。ログを確認すると、問題が発生したマシンは 1 台だけで復旧しておらず、完了したタスクの数も増えていません。



リクエスト番号の監視を確認すると、リクエスト番号が 0 に戻っており、マシンがハングアップしていることがわかります。



arthas を使用して Dubbo スレッドを表示すると、400 個のスレッドすべてが待機状態であることがわかります。



3. 故障プロセス分析


Dubbo スレッドが待機状態であることは問題ありません。Dubbo スレッドはタスクを待っているときにも待機状態になりますが、完全なコール スタックを見ると問題があることがわかります。下の写真は問題のあるマシンのスタック、2 番目の写真は通常のマシンのスタックであり、問​​題のあるマシンのスレッドが Redis 接続プールで利用可能な接続を待っていることは明らかです。




jstack を使用してスレッド スナップショットをエクスポートした後、問題のマシンのすべての Dubbo スレッドが Redis 接続プール内の利用可能な接続を待機していることが判明しました。


ここで調査すると、2 つの問題が見つかりました。

  1. スレッドは中断されることなく接続を待ち続けます。

  2. スレッドは接続を取得できません。


3.1 スレッドが中断されずに接続待ちを続ける原因の分析


ジェダイが接続を取得するロジックは次のとおりです。

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 が設定されていないため、スレッドは利用可能な接続を待機しています。この値は、j​​edis プールの maxWaitMillis 属性を構成することで設定できます。


スレッドが待機している理由はここで判明しましたが、スレッドが接続を取得できない理由は引き続き分析する必要があります。


3.2 スレッドが接続を取得できない原因の分析


接続を取得できない状況は 2 つだけです。

  1. Redis に接続できず、接続を作成できません

  2. 接続プール内のすべての接続が占有されており、接続を取得できません。


推測 1: 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. 接続が占有されている場合は問題ありませんが、解放されない場合は問題があります。


推測 2: ビジネス コードが Redis 接続を返さなかったためでしょうか?


接続が解放されていません。最初に思い浮かぶのは、Redis 接続を返すためのコードがビジネス コードのどこかに欠落している可能性があるということです。パイプライン モードでは、最終的に JedisClusterPipeline#close() メソッドを手動で呼び出す必要があります。通常モードでは、finally ブロックで JedisClusterPipeline#close() メソッドを手動で呼び出す必要があります。手動で解放する必要はありません (redis.clients.jedis.JedisClusterCommand#runWithRetries を参照) , 各コマンドが実行されると自動的に解放されます) ビジネスコード内でクラスターパイプラインを使用するコードをすべてグローバルに検索し、JedisClusterPipeline#close()メソッドを手動で呼び出しているため、ビジネスコードの問題ではありません。


推測 3: Jedis には接続リークの問題があるのでしょうか?


業務コードには問題がないので、コネクションを返すコードに問題があり、コネクションリークが発生している可能性はありますか?Jedis のバージョン 2.10.0 では接続リークが実際に発生する可能性があります。詳細については、この問題を参照してください: https://github.com/redis/jedis/issues/1920。ただし、私たちのプロジェクトはバージョン 2.9.0 を使用しているため、接続リークが発生します。は除外されます。


推測 4: 行き詰まりはあるのでしょうか?


上記の可能性を除外すると、デッドロックしか考えられません。よく考えてみると、タイムアウトを設定せずにパイプラインを使用すると、デッドロックが発生する可能性があります。このデッドロックは、接続から接続を取得するときに発生します。プール (LinkedBlockingDeque)。


まず、クラスター パイプライン モードの Redis と通常の Redis の違いを見てみましょう。Jedis は、各 Redis インスタンスの接続プールを維持します。クラスター パイプライン モードでは、最初にクエリ キーを使用して、それが配置されている Redis インスタンスのリストを計算し、次にこれらのインスタンスに対応する接続​​プールから接続を取得し、その後それらを統合します。使用して解放されます。通常モードでは、一度に 1 つの接続プール接続のみが取得され、使用後すぐに解放されます。これは、クラスター パイプライン モードでは、接続を取得するときにデッドロックの「保持して待機」条件が満たされますが、通常モードではこの条件が満たされないことを意味します。


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;    }}



例えば:

ノード 1/2 として記録された 2 つの Redis マスター ノード (クラスター モードのマスター ノードの最小数は 3 です。これは単なる例です) を持つクラスターがあり、次のように記録された 4 つの Dubbo スレッドを持つ Java プログラムがあるとします。スレッド 1/ 2/3/4 では、各 Redis インスタンスにはサイズ 2 の接続プールがあります。


スレッド 1 とスレッド 2 は、まず Redis1 の接続を取得し、次に Redis2 の接続を取得します。スレッド 3 とスレッド 4 は、最初に Redis2 の接続を取得し、次に Redis1 の接続を取得します。この 4 つのスレッドが最初の接続を取得した後、しばらく待機すると、2 番目の接続を取得するときにデッドロックが発生します (待機時間が長いほど、発動の確率が高くなります)。



そのため、パイプラインでデッドロックが発生する可能性がありますが、このデッドロック状態は接続待ち時にタイムアウトを設定するだけで簡単に解消できます。接続プールのサイズを増やすこともでき、リソースが十分であればデッドロックは発生しません。


4. デッドロック防止


上記は単なる推測であり、デッドロックが発生していることを証明するには、次の条件が必要です。

  1. 現在スレッドによって取得されている接続プール接続はどれですか?

  2. スレッドは現在どの接続プールを待機していますか?

  3. 各接続プールにはいくつの接続が残っていますか?


既知の問題マシンの Dubbo スレッド プール サイズは 400、Redis クラスター マスター ノードの数は 12、Jedis によって構成された接続プール サイズは 20 です。


4.1 ステップ 1: スレッドがアイドル接続を待機している接続プールを取得する


ステップ 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按照一定的步骤来寻找线程在等待哪个连接池的连接和持有哪些连接池的连接,再结合死锁检测算法最终确认故障机器发生了死锁。


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



END

猜你喜欢


本文分享自微信公众号 - vivo互联网技术(vivoVMIC)。
如有侵权,请联系 [email protected] 删除。
本文参与“OSC源创计划”,欢迎正在阅读的你也加入,一起分享。

Qt 6.6 正式发布 国美 App 抽奖页面弹窗辱骂其创始人 Ubuntu 23.10 正式发布,不妨趁周五升级一波! RISC-V:不受任何单一公司或国家的控制 Ubuntu 23.10 发版插曲:因包含仇恨言论,ISO 镜像被紧急“召回” 俄罗斯企业基于龙芯处理器生产电脑和服务器 ChromeOS 是使用 Google 桌面环境的 Linux 发行版 23 岁博士生修复 Firefox 中的 22 年“幽灵老 Bug” TiDB 7.4 发版:正式兼容 MySQL 8.0 微软推出 Windows Terminal Canary 版本
{{o.name}}
{{m.name}}

おすすめ

転載: my.oschina.net/vivotech/blog/10117429