ClickHouse Drop Table On Cluster 故障分析和原理解析

文章目录

摘要

我们在ClickHouse中建立的On Cluster的Kafka表,即基于Kafka Table -> MV Table -> Local Table的方式将Kafka的数据存入ClickHouse。

在一次上线过程中,我们需要修改Kafka表的Broker Endpoint,于是很自然的通过DETACH TABLE .. ON CLUSTER的方式先让ClickHouse停止消费,但是在线上操作的时候,该操作导致ClickHouse的一台Kafka机器进入了假死状态,该状态无法通过kill query的方式改出,只好重启ClickHouse。我们在重启对应ClickHouse Server以前打印了堆栈,事后对堆栈进行了分析。

本文讲解了该线上事故发生的具体原因,并且以此为出发点,讲解了ClickHouse 进行Detach操作的具体过程。

堆栈分析

被阻塞的DROP TABLE请求对应的线程堆栈

由于在问题发生的时候,我们的DROP TABLE操作被阻塞, 因此我们首先找到对应的被阻塞的线程堆栈,分析一下阻塞的位置和表面原因。

由于我们在执行DROP TABLE ON CLUSTER的时候发现一台机器总是执行不完,因此,我们登录到这台机器,尝试对这台机器的Kafka表进行本地的DETACH/Drop操作,发现依然Block住,之前的DROP也同样持续处于Blocked状态。

于是我们打印堆栈,看到如下堆栈状态:

cpp 复制代码
Thread 2583 (Thread 0x7fdd5331e700 (LWP 3287062)):
#0  __lll_lock_wait (futex=futex@entry=0x7fdac4a634f0, private=0) at lowlevellock.c:52
#1  0x00007fe3888d60a3 in __GI___pthread_mutex_lock (mutex=0x7fdac4a634f0) at ../nptl/pthread_mutex_lock.c:80
#2  0x000000001810b491 in std::__1::mutex::lock() ()
#3  0x000000001168ee2d in DB::DDLGuard::DDLGuard(std::__1::map<std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char> >, DB::DDLGuard::Entry, std::__1::less<std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char> > >, std::__1::allocator<std::__1::pair<std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char> > const, DB::DDLGuard::Entry> > >&, DB::SharedMutex&, std::__1::unique_lock<std::__1::mutex>, std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char> > const&, std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char> > const&) ()
#4  0x0000000011683286 in DB::DatabaseCatalog::getDDLGuard(std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char> > const&, std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char> > const&) ()
#5  0x00000000119fb94f in DB::InterpreterDropQuery::executeToTableImpl(std::__1::shared_ptr<DB::Context const> const&, DB::ASTDropQuery&, std::__1::shared_ptr<DB::IDatabase>&, StrongTypedef<wide::integer<128ul, unsigned int>, DB::UUIDTag>&) ()
#6  0x00000000119f7a83 in DB::InterpreterDropQuery::execute() ()
#7  0x0000000011e27dd9 in DB::executeQueryImpl(char const*, char const*, std::__1::shared_ptr<DB::Context>, DB::QueryFlags, DB::QueryProcessingStage::Enum, DB::ReadBuffer*) ()
#8  0x0000000011e23e1a in DB::executeQuery(std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char> > const&, std::__1::shared_ptr<DB::Context>, DB::QueryFlags, DB::QueryProcessingStage::Enum) ()
#9  0x0000000012fb6504 in DB::TCPHandler::runImpl() ()
#10 0x0000000012fd1258 in DB::TCPHandler::run() ()
#11 0x0000000015dbd6a7 in Poco::Net::TCPServerConnection::start() ()
#12 0x0000000015dbdb39 in Poco::Net::TCPServerDispatcher::run() ()
#13 0x0000000015d8aae1 in Poco::PooledThread::run() ()
#14 0x0000000015d8909d in Poco::ThreadImpl::runnableEntry(void*) ()
#15 0x00007fe3888d3609 in start_thread (arg=<optimized out>) at pthread_create.c:477
#16 0x00007fe3887f8353 in clone () at ../sysdeps/unix/sysv/linux/x86_64/clone.S:95

这个堆栈其实显式了我们在Kafka表的drop发生异常以后,我们发起的后续的Drop query被block住的堆栈信息:

  1. 可以看到,ClickHouse Server启动以后,启动了对对应的TCP端口的监听,这是由TCPServerConnection和TCPServerDispatcher这些独立的线程池来负责的。具体的执行和监听细节,本文不做赘述。具体的TCP请求交付给了TCPHandler来进行处理:

    cpp 复制代码
        #9  0x0000000012fb6504 in DB::TCPHandler::runImpl() ()
        #10 0x0000000012fd1258 in DB::TCPHandler::run() ()
        #11 0x0000000015dbd6a7 in Poco::Net::TCPServerConnection::start() ()
        #12 0x0000000015dbdb39 in Poco::Net::TCPServerDispatcher::run() ()
        #13 0x0000000015d8aae1 in Poco::PooledThread::run() ()
  2. 由于是DROP Query,因此根据对应的Query语句构造了InterpreterDropQuery, 并执行对应的表的DROP操作:

    cpp 复制代码
    #5  0x00000000119fb94f in DB::InterpreterDropQuery::executeToTableImpl(std::__1::shared_ptr<DB::Context const> const&, DB::ASTDropQuery&, std::__1::shared_ptr<DB::IDatabase>&, StrongTypedef<wide::integer<128ul, unsigned int>, DB::UUIDTag>&) ()
    #6  0x00000000119f7a83 in DB::InterpreterDropQuery::execute() ()
  3. 在执行Drop table的过程中,尝试获取对应Table的DDLGuard对象:

    cpp 复制代码
    #3  0x000000001168ee2d in DB::DDLGuard::DDLGuard(std::__1::map<std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char> >, DB::DDLGuard::Entry, std::__1::less<std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char> > >, std::__1::allocator<std::__1::pair<std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char> > const, DB::DDLGuard::Entry> > >&, DB::SharedMutex&, std::__1::unique_lock<std::__1::mutex>, std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char> > const&, std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char> > const&) ()
    #4  0x0000000011683286 in DB::DatabaseCatalog::getDDLGuard(std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char> > const&, std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char> > const&) ()
  4. 在构造DDLGuard的过程中,始终在一个条件变量上等待:

    cpp 复制代码
        #0  __lll_lock_wait (futex=futex@entry=0x7fdac4a634f0, private=0) at lowlevellock.c:52
        #1  0x00007fe3888d60a3 in __GI___pthread_mutex_lock (mutex=0x7fdac4a634f0) at ../nptl/pthread_mutex_lock.c:80
        #2  0x000000001810b491 in std::__1::mutex::lock() ()

其中,DatabaseCatalog其实是一个单例类,负责ClickHouse Server的所有Database 目录的管理。

在ClickHouse启动并构造全局的Context时,会进行唯一一次的DatabaseCatalog的初始化:

cpp 复制代码
void Context::initGlobal()
{
    assert(!global_context_instance);
    global_context_instance = shared_from_this();
    DatabaseCatalog::init(shared_from_this()); // 初始化并构造唯一的DatabaseCatalog
    EventNotifier::init();
}
cpp 复制代码
DatabaseCatalog & DatabaseCatalog::init(ContextMutablePtr global_context_)
{
    if (database_catalog)
    {
        throw Exception(ErrorCodes::LOGICAL_ERROR, "Database catalog is initialized twice. This is a bug.");
    }

    database_catalog.reset(new DatabaseCatalog(global_context_));

    return *database_catalog;
}

DatabaseCatalog & DatabaseCatalog::instance()
{
    .....
    return *database_catalog;
}

我们根据上面的堆栈,看一下重要方法DatabaseCatalog::getDDLGuard(...)的实现细节:

cpp 复制代码
    DDLGuardPtr DatabaseCatalog::getDDLGuard(const String & database, const String & table)
    {
        std::unique_lock lock(ddl_guards_mutex);
        /// TSA does not support unique_lock
        auto db_guard_iter = TSA_SUPPRESS_WARNING_FOR_WRITE(ddl_guards).try_emplace(database).first;
        DatabaseGuard & db_guard = db_guard_iter->second;
        return std::make_unique<DDLGuard>(db_guard.table_guards, db_guard.database_ddl_mutex, std::move(lock), table, database);
    }

可以看到, getDDLGuard()的作用是为特定数据库或者表创建一个 DDLGuard 对象,以防止并发执行冲突的 DDL 操作(如 ALTER、DROP 等) ,该方法返回 DDLGuardPtrstd::unique_ptr<DDLGuard> 类型), 接收两个参数:database 和 table,表示你要操作的数据库和表名(表名可能为空):

  1. ddl_guards_mutex 这个原语加锁,防止多个线程同时修改 ddl_guards,即DatabaseCatalog::getDDLGuard(...)是一个实例的同步方法,由于DatabaseCatalog是一个单例类,因此实际上在ClickHouse Server中同时只有一个线程可以调用DatabaseCatalog::getDDLGuard方法。使用 std::unique_lock 是为了能够后续将 lock 所拥有的锁"移动"(std::move(lock))给 DDLGuard 使用。

    cpp 复制代码
    std::unique_lock lock(ddl_guards_mutex);
  2. DDLGuards ddl_guards的这个map中找到以 database 为 key 的DatabaseGuard对象,代表这个数据库的锁控制对象,所以,可以看到,一个ClickHouse Server会有一个全局的DDLGuards,存放了所有的database -> DatabaseGuard对象的映射关系, 每一个DatabaseGuard对象用来对这个数据库下面的每一张表进行DDL的同步控制:

    cpp 复制代码
        // 声明了一个从Database名字到DatabaseGuard进行映射的map
        using DDLGuards = std::map<String, DatabaseGuard>;
        DDLGuards ddl_guards TSA_GUARDED_BY(ddl_guards_mutex);
    cpp 复制代码
        // try_emplace的意思是,如果 key 不存在,则插入默认值;如果存在,则什么都不做。
        auto db_guard_iter = TSA_SUPPRESS_WARNING_FOR_WRITE(ddl_guards).try_emplace(database).first;
        // 取出这个database对应的DatabaseGuard引用
        DatabaseGuard & db_guard = db_guard_iter->second;

    其中,DatabaseGuard的主要目的是在执行 DDL(如 CREATE、DROP、ALTER 等)时,确保同一个表或数据库不会被多个线程同时操作,防止并发冲突,所以,DatabaseGuard内部也维护了一个map,即从表名到对应表的锁关系的map关系:

    cpp 复制代码
        struct Entry
        {
            std::unique_ptr<std::mutex> mutex; // 对应的表的mutex,对表进行DDL操作的时候需要对这个mutex上锁
            UInt32 counter; // 这个表上的DDL的计数器锁
        };
        using Map = std::map<String, Entry>;
        struct DatabaseGuard
        {
            SharedMutex database_ddl_mutex;
            SharedMutex restart_replica_mutex;
    
            DDLGuard::Map table_guards; // 表的锁关系
        };
        
    • 可以看到,一个Entry对象代表了一张表的锁控制信息,mutex代表了这张表的DDL的锁控制变量,而对应的counter代表当前有多少的DDL正在等待。

    • SharedMutex database_ddl_mutex 控制整个数据库范围的 DDL 操作互斥, 比如执行 DROP DATABASE 时会用这个锁,确保期间不允许并发的表级操作。

    • SharedMutex restart_replica_mutex 用于控制 RESTART REPLICA 操作,防止副本重启和 DDL 同时进行,避免状态不一致。

    • DDLGuard::Map table_guards;这是一个 std::map<String, Entry> 类型的别名(你可以去 DDLGuard 类中确认), key是表名,value是一个 mutex 和 counter 的组合,其中, mutex是防止多个线程同时修改这张表, 而counter则记录当前有多少线程正在试图操作该表,显然,任何时候只能有一个线程能够获取对应的表锁。

  3. 基于获取到的这个Database对应的DatabaseGuard对象,尝试读取这个Database已经存在的DDLGuard实例,如果目前不存在,再构造对应表的DDLGuard实例:

    cpp 复制代码
        DDLGuard::DDLGuard(Map & map_, SharedMutex & db_mutex_, std::unique_lock<std::mutex> guards_lock_, const String & elem, const String & database_name)
                : map(map_), db_mutex(db_mutex_), guards_lock(std::move(guards_lock_))
        {
            it = map.emplace(elem, Entry{std::make_unique<std::mutex>(), 0}).first;
            ++it->second.counter;
            guards_lock.unlock();
            table_lock = std::unique_lock(*it->second.mutex); // 表级别上锁
            is_database_guard = elem.empty();// 如果没有table,那么就是需要在database层面加guard
            if (!is_database_guard)
            {
                // 如果是表级别,那么需要在数据库级别上共享锁
                bool locked_database_for_read = db_mutex.try_lock_shared();
                if (!locked_database_for_read)
                {
                    releaseTableLock();
                    throw Exception(ErrorCodes::UNKNOWN_DATABASE, "Database {} is currently dropped or renamed", database_name);
                }
            }
        }

    每一个DDLGuard对象,是为了确保同一时间只有一个线程能对目标数据库或者目标表执行 DDL 操作,防止并发冲突或数据库状态不一致。其中

    • 试图在DatabaseGuard的map中插入一个key-value pair, 其中key为对应的elem(其实就是对应的表名)、值为对应的Entry,显然,如果对应的key-value pair已经存在,则取出对应的Entry,如果不存在,则构造一个新的Entry::
    cpp 复制代码
            it = map.emplace(elem, Entry{std::make_unique<std::mutex>(), 0}).first;
    • 对该表的 DDL 线程计数加一,代表有多少个线程试图调用这张表的DDL。这个计数器用于在析构时知道是否还有其他线程持有这个表的 DDL guard:

      cpp 复制代码
      ++it->second.counter;
      cpp 复制代码
      void DDLGuard::releaseTableLock() noexcept
      {
          if (table_lock_removed)
              return;
      
          table_lock_removed = true;
          guards_lock.lock();
          UInt32 counter = --it->second.counter;
          table_lock.unlock();
          if (counter == 0)
              map.erase(it);
          guards_lock.unlock();
      }

      我们从释放表锁的过程可以看到,当锁计数器为0以后,代表已经没有任何一个等待中的DDL在这张表上执行了,因此此时就将对应的Entry从DatabaseGuard中删除。

    • 对这个表的mutex上锁:

      cpp 复制代码
      table_lock = std::unique_lock(*it->second.mutex); // 表级别上锁
    • 判断是否传入了对应的表名,如果的确传入了表名,则代表是表级别操作,否则是数据库级别操作

    • 如果是表级操作

      • 还需要对整个数据库加 共享锁(读锁),防止在我们操作表的过程中,其他线程把整个数据库删掉(DROP DATABASE);
      • try_lock_shared() 可能失败,比如另一个线程正在加数据库的写锁(DROP DATABASE)。
      • 如果无法加读锁,说明数据库正在被删除,不能安全执行表级操作, 因此释放刚才加的表锁并抛出异常。

我们回到刚才的堆栈,可以看到,DDLGuard构造中,正在锁上等待,即在下面的代码上等待表级别的排他锁:

cpp 复制代码
DDLGuard::DDLGuard(Map & map_, SharedMutex & db_mutex_, std::unique_lock<std::mutex> guards_lock_, const String & elem, const String & database_name)
        : map(map_), db_mutex(db_mutex_), guards_lock(std::move(guards_lock_))
{
    ......
    // 表级别上锁
    table_lock = std::unique_lock(*it->second.mutex);
    is_database_guard = elem.empty();
    ......
}

对应堆栈如下:

cpp 复制代码
#0  __lll_lock_wait (futex=futex@entry=0x7fdac4a634f0, private=0) at lowlevellock.c:52
#1  0x00007fe3888d60a3 in __GI___pthread_mutex_lock (mutex=0x7fdac4a634f0) at ../nptl/pthread_mutex_lock.c:80
#2  0x000000001810b491 in std::__1::mutex::lock() ()
#3  0x000000001168ee2d in DB::DDLGuard::DDLGuard(std::__1::map<std::__1::basic_string<char, std::__1::char_traits<char>, ......

因此,我们需要继续观察整个ClickHouse Server的堆栈信息,看看是哪个线程已经获取了对应的锁。

StorageKafka::shutdown()持有并等待锁

在确定了表级别锁是block住我们的query的原因,我们需要查找当前是哪个线程已经获取了表级别的排它锁。

我们必须清楚,一个已经获取了锁并且继续执行的线程,这个获取成功的锁是不会体现在堆栈中的 。因此,我们搜索锁ID只能搜索到所有当前正在该锁上等待的线程,无法搜索到已经成功获取到该锁的线程

为了搜索到已经获取了该锁的线程,我们只能从代码层面看一下已经 DatabaseCatalog::getDDLGuard(...)方法的调用者是否存在于其他堆栈中。显然,除了DROP, 还有其他一些Database 或 Table级别的操作需要调用该方法,通过搜索DatabaseCatalog::instance().getDDLGuard可以知道。

但是由于我们当前被Block住的DDL Query是由于第一次Drop Kafka表无法完成导致的,因此我们怀疑已经获取了Table 锁也是从InterpreterDropQuery中进入的,因此我们搜索InterpreterDropQuery, 得到了如下可疑堆栈:

cpp 复制代码
    Thread 674 (Thread 0x7fe1cacf6700 (LWP 1050695)):
    #0  futex_wait_cancelable (private=<optimized out>, expected=0, futex_word=0x7fdcc98eeef8) at ../sysdeps/nptl/futex-internal.h:183
    #1  __pthread_cond_wait_common (abstime=0x0, clockid=0, mutex=0x7fdcc98eeea8, cond=0x7fdcc98eeed0) at pthread_cond_wait.c:508
    #2  __pthread_cond_wait (cond=0x7fdcc98eeed0, mutex=0x7fdcc98eeea8) at pthread_cond_wait.c:647
    #3  0x0000000015d536e2 in Poco::EventImpl::waitImpl() ()
    #4  0x00000000108ab386 in DB::StorageKafka::shutdown(bool) ()
    #5  0x00000000119fd3a7 in DB::InterpreterDropQuery::executeToTableImpl(std::__1::shared_ptr<DB::Context const> const&, DB::ASTDropQuery&, std::__1::shared_ptr<DB::IDatabase>&, StrongTypedef<wide::integer<128ul, unsigned int>, DB::UUIDTag>&) ()
    #6  0x00000000119f7a83 in DB::InterpreterDropQuery::execute() ()
    #7  0x0000000011e27dd9 in DB::executeQueryImpl(char const*, char const*, std::__1::shared_ptr<DB::Context>, DB::QueryFlags, DB::QueryProcessingStage::Enum, DB::ReadBuffer*) ()
    #8  0x0000000011e2cbde in DB::executeQuery(DB::ReadBuffer&, DB::WriteBuffer&, bool, std::__1::shared_ptr<DB::Context>, std::__1::function<void (DB::QueryResultDetails const&)>, DB::QueryFlags, std::__1::optional<DB::FormatSettings> const&, std::__1::function<void (DB::IOutputFormat&, std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char> > const&, std::__1::shared_ptr<DB::Context const> const&, std::__1::optional<DB::FormatSettings> const&)>) ()
    #9  0x0000000011667a75 in DB::DDLWorker::tryExecuteQuery(DB::DDLTaskBase&, std::__1::shared_ptr<zkutil::ZooKeeper> const&) ()
    #10 0x0000000011665da8 in DB::DDLWorker::processTask(DB::DDLTaskBase&, std::__1::shared_ptr<zkutil::ZooKeeper> const&) ()
    #11 0x0000000011662c70 in DB::DDLWorker::scheduleTasks(bool) ()
    #12 0x000000001165b74e in DB::DDLWorker::runMainThread() ()
    #13 0x0000000011674f42 in void std::__1::__function::__policy_invoker<void ()>::__call_impl<std::__1::__function::__default_alloc_func<ThreadFromGlobalPoolImpl<true, true>::ThreadFromGlobalPoolImpl<void (DB::DDLWorker::*)(), DB::DDLWorker*>(void (DB::DDLWorker::*&&)(), DB::DDLWorker*&&)::{lambda()#1}, void ()> >(std::__1::__function::__policy_storage const*) ()
    #14 0x000000000dbd7509 in void* std::__1::__thread_proxy[abi:v15007]<std::__1::tuple<std::__1::unique_ptr<std::__1::__thread_struct, std::__1::default_delete<std::__1::__thread_struct> >, ThreadPoolImpl<std::__1::thread>::scheduleImpl<void>(std::__1::function<void ()>, Priority, std::__1::optional<unsigned long>, bool)::{lambda()#2}> >(std::__1::tuple<std::__1::unique_ptr<std::__1::__thread_struct, std::__1::default_delete<std::__1::__thread_struct> >, ThreadPoolImpl<std::__1::thread>::scheduleImpl<void>(std::__1::function<void ()>, Priority, std::__1::optional<unsigned long>, bool)::{lambda()#2}>) ()
    #15 0x00007fe3888d3609 in start_thread (arg=<optimized out>) at pthread_create.c:477
    #16 0x00007fe3887f8353 in clone () at ../sysdeps/unix/sysv/linux/x86_64/clone.S:95

我们先确认一下该堆栈是否已经获取了Table锁。从堆栈可以看到:

  1. 这个堆栈的发起者是来自于一个任务的调度,即**DDLWorker所调度的任务,而不是直接来自DB::TCPHandler::run(),说明这是一个 异步调度任务**,我们猜想这是一个通过Keeper来分发的分布式的DDL Query的独立的任务调度器。

    在ClickHouse Server启动的时候,会创建对应的DDLWorker对象并放入到Context中:

    cpp 复制代码
        if (has_zookeeper && config().has("distributed_ddl"))
        {
            /// DDL worker should be started after all tables were loaded
            String ddl_queue_path = config().getString("distributed_ddl.path", "/clickhouse/task_queue/ddl/");
            String ddl_replicas_path = config().getString("distributed_ddl.replicas_path", "/clickhouse/task_queue/replicas/");
            int pool_size = config().getInt("distributed_ddl.pool_size", 1);
            global_context->setDDLWorker(
                std::make_unique<DDLWorker>(
                    pool_size,
                    ddl_queue_path,
                    ddl_replicas_path,
                    global_context,
                    &config(),
                    "distributed_ddl",
                    "DDLWorker",
                    &CurrentMetrics::MaxDDLEntryID,
                    &CurrentMetrics::MaxPushedDDLEntryID),
                joinTasks(load_system_metadata_tasks, load_metadata_tasks));
        }

    在我们的另外一篇文章会详细分析DDLWorker的基本功能和实现原理,这里不做赘述。

  2. 我们从堆栈可以看到,DDLWorker会通过调用InterpreterDropQuery::executeToTableImpl()来尝试在本地运行这个DROP Table的DDL操作。我们下文会详细详解InterpreterDropQuery::executeToTableImpl()的实现细节,并且看到,InterpreterDropQuery::executeToTableImpl()的确会通过方法DatabaseCatalog::getDDLGuard(...)来获取对应的DDLGuard锁。所以,我们确认了,这个堆栈就是导致这台机器上的DROP Table被阻塞的原因,因为该堆栈持有并保持 了对应的DDLGuard中的锁:

    InterpreterDropQuery::executeToTableImpl方法在执行的时候,会通过方法getDDLGuard()把对应的Database & Table的DDLBGuard对象取出来或者创建出来。由于ddl_guard 是局部变量,因此只要 executeToTableImpl() 没返回,它就不会析构、不会释放锁。

    cpp 复制代码
        BlockIO InterpreterDropQuery::executeToTableImpl(const ContextPtr & context_, ASTDropQuery & query, DatabasePtr & db, UUID & uuid_to_wait)
        {
            /// NOTE: it does not contain UUID, we will resolve it with locked DDLGuard
            auto table_id = StorageID(query);
            // ...
            auto ddl_guard = (!query.no_ddl_lock ? DatabaseCatalog::instance().getDDLGuard(table_id.database_name, table_id.table_name) : nullptr);
        
            /// If table was already dropped by anyone, an exception will be thrown
            auto [database, table] = query.if_exists ? DatabaseCatalog::instance().tryGetDatabaseAndTable(table_id, context_)
                                                     : DatabaseCatalog::instance().getDatabaseAndTable(table_id, context_);
            // ...
        }
    cpp 复制代码
        DDLGuardPtr DatabaseCatalog::getDDLGuard(const String & database, const String & table)
    {
        std::unique_lock lock(ddl_guards_mutex);
        /// TSA does not support unique_lock
        auto db_guard_iter = TSA_SUPPRESS_WARNING_FOR_WRITE(ddl_guards).try_emplace(database).first;
        DatabaseGuard & db_guard = db_guard_iter->second;
        return std::make_unique<DDLGuard>(db_guard.table_guards, db_guard.database_ddl_mutex, std::move(lock), table, database);
    }
  3. 找到了锁的持有者,我们的问题就变成了:为什么DDLTask始终不进行锁的释放呢?

    从堆栈可以看到,DDLTask在获取了锁并执行InterpreterDropQuery::executeToTableImpl()的过程中,在尝试等待一个条件变量:

    cpp 复制代码
    #3  0x0000000015d536e2 in Poco::EventImpl::waitImpl() ()
    #4  0x00000000108ab386 in DB::StorageKafka::shutdown(bool) ()
    #5  0x00000000119fd3a7 in DB::InterpreterDropQuery::executeToTableImpl(std::__1::shared_ptr<DB::Context const> const&, DB::ASTDropQuery&, std::__1::shared_ptr<DB::IDatabase>&, StrongTypedef<wide::integer<128ul, unsigned int>, DB::UUIDTag>&) ()
    #6  0x00000000119f7a83 in DB::InterpreterDropQuery::execute() ()

    可以看到,在通过调用StorageKafka::shutdown()的时候block住了,block的位置在这里:

    cpp 复制代码
      void StorageKafka::shutdown(bool)
     {
         shutdown_called = true;
         cleanup_cv.notify_one(); // 在这里通知cleanup_thread的执行
         {
             ...
             if (cleanup_thread)
             {
     
                 cleanup_thread->join(); // 在这里会造成 moveConsumer 的调用和 Consumer 的析构,但是析构的过程被永远阻塞了
                 cleanup_thread.reset();
             }
             LOG_TRACE(log, "Consumers cleanup thread finished in {} ms.", watch.elapsedMilliseconds());
         }

    下文会讲到,这里等待的Event是由CleanupThread发出来的,但是CleanupThread现在已经处于一个不可恢复的Blocking状态,因此迟迟无法发出对应的Event Sinal。

    下文中,我们会详细讲解包括CleanupThread退出在内的StorageKafka的关闭流程以及由此引起的CleanupThread的Blocking状态。

CleanupThread中Consumer的取消订阅阻塞了StorageKakfa::shutdown()的完成

上文讲过,由于CleanupThread始终无法结束,导致StorageKafka::shutdown()方法的join()始终无法完成因此始终无法正常退出。

我们刚好在ClickHouse的堆栈中找到了CleanupThread的堆栈,试图探寻CleanupThread无法正常退出的原因:

cpp 复制代码
    Thread 1207 (Thread 0x7f607c9ee700 (LWP 3826922)):
    #0  futex_wait_cancelable (private=<optimized out>, expected=0, futex_word=0x7fe090df05d0) at ../sysdeps/nptl/futex-internal.h:183
    #1  __pthread_cond_wait_common (abstime=0x0, clockid=0, mutex=0x7fe090df0580, cond=0x7fe090df05a8) at pthread_cond_wait.c:508
    #2  __pthread_cond_wait (cond=0x7fe090df05a8, mutex=0x7fe090df0580) at pthread_cond_wait.c:647
    #3  0x0000000015bd7b37 in rd_kafka_q_pop_serve ()
    #4  0x0000000015bc8cab in rd_kafka_op_req ()
    #5  0x0000000015c0a373 in rd_kafka_assign ()
    #6  0x0000000015b1dcb1 in cppkafka::Consumer::rebalance_proxy(rd_kafka_s*, rd_kafka_resp_err_t, rd_kafka_topic_partition_list_s*, void*) ()
    #7  0x0000000015b49522 in rd_kafka_poll_cb ()
    #8  0x0000000015b4b5d8 in rd_kafka_consumer_close ()
    #9  0x0000000015b1eb52 in cppkafka::Consumer::~Consumer() ()
    #10 0x00000000108b548e in void std::__1::__function::__policy_invoker<void ()>::__call_impl<std::__1::__function::__default_alloc_func<ThreadFromGlobalPoolImpl<true, true>::ThreadFromGlobalPoolImpl<DB::StorageKafka::StorageKafka(DB::StorageID const&, std::__1::shared_ptr<DB::Context const>, DB::ColumnsDescription const&, std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char> > const&, std::__1::unique_ptr<DB::KafkaSettings, std::__1::default_delete<DB::KafkaSettings> >, std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char> > const&)::$_1>(DB::StorageKafka::StorageKafka(DB::StorageID const&, std::__1::shared_ptr<DB::Context const>, DB::ColumnsDescription const&, std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char> > const&, std::__1::unique_ptr<DB::KafkaSettings, std::__1::default_delete<DB::KafkaSettings> >, std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char> > const&)::$_1&&)::{lambda()#1}, void ()> >(std::__1::__function::__policy_storage const*) ()
    #11 0x000000000dbd7509 in void* std::__1::__thread_proxy[abi:v15007]<std::__1::tuple<std::__1::unique_ptr<std::__1::__thread_struct, std::__1::default_delete<std::__1::__thread_struct> >, ThreadPoolImpl<std::__1::thread>::scheduleImpl<void>(std::__1::function<void ()>, Priority, std::__1::optional<unsigned long>, bool)::{lambda()#2}> >(std::__1::tuple<std::__1::unique_ptr<std::__1::__thread_struct, std::__1::default_delete<std::__1::__thread_struct> >, ThreadPoolImpl<std::__1::thread>::scheduleImpl<void>(std::__1::function<void ()>, Priority, std::__1::optional<unsigned long>, bool)::{lambda()#2}>) ()
    #12 0x00007fe3888d3609 in start_thread (arg=<optimized out>) at pthread_create.c:477
    #13 0x00007fe3887f8353 in clone () at ../sysdeps/unix/sysv/linux/x86_64/clone.S:95

可以看到,CleanupThread的执行导致了cppkafka::Consumer的析构,但是在cppkafka::Consumer的析构过程中,貌似触发了

代码和原理解析

StorageKafka的Shutdown流程

StorageKafka是我们在创建一张Kafka表的时候所构造的,即每一个Kafka表都有一个StorageKafka对象与之相对应,这是一个IStorage实现,这和Distributed表的StorageDistributed, MergeTree表的StorageReplicatedMergeTree类似。

在新版本的ClickHouse中,除了StorageKafka,还有StorageKafka2,用来支持将Kafka的Offset保存在Keeper中,而不是Kafka的broker中。我们可以看下Kafka的IStorage的注册过程。

StorageKafka和StorageKafka2

和MergeTreeData一样,StorageKafkaStorageKafka2都是IStorage的具体实现。在ClickHouse Server启动的时候,会通过registerStorages()()方法注册不同的IStorage实现,其中包括注册MergeTreeDataStorageKafka以及StorageKafka2:

cpp 复制代码
void registerStorages()
{
    auto & factory = StorageFactory::instance();

    ...
    registerStorageMergeTree(factory);
    registerStorageNull(factory);
    registerStorageMerge(factory);
    registerStorageBuffer(factory);
    registerStorageDistributed(factory);
    registerStorageMemory(factory);
    ...
    #if USE_HDFS
    #if USE_HIVE
    registerStorageHive(factory);
    #endif
    #endif
    ...
    registerStorageKafka(factory);
    #endif

可以看到,ClickHouse Server启动的时候,会有一个全局唯一的单例类StorageFactory,然后通过这个全局唯一的工厂类,对不同的IStorage进行注册,其中,对Kafka的注册(即用来处理Kafka表)是通过方法registerStorageKafka()来进行的,注册过程如下:

cpp 复制代码
void registerStorageKafka(StorageFactory & factory)
{
    auto creator_fn = [](const StorageFactory::Arguments & args) -> std::shared_ptr<IStorage>
    {
        .......

        auto num_consumers = (*kafka_settings)[KafkaSetting::kafka_num_consumers].value;
        auto max_consumers = std::max<uint32_t>(getNumberOfCPUCoresToUse(), 16);
        ...
        NamesAndTypesList supported_columns;
        for (const auto & column : args.columns)
        {
            if (column.default_desc.kind == ColumnDefaultKind::Alias)
                supported_columns.emplace_back(column.name, column.type);
            if (column.default_desc.kind == ColumnDefaultKind::Default && !column.default_desc.expression)
                supported_columns.emplace_back(column.name, column.type);
        }
        ....

        const auto has_keeper_path = (*kafka_settings)[KafkaSetting::kafka_keeper_path].changed && !(*kafka_settings)[KafkaSetting::kafka_keeper_path].value.empty();
        const auto has_replica_name = (*kafka_settings)[KafkaSetting::kafka_replica_name].changed && !(*kafka_settings)[KafkaSetting::kafka_replica_name].value.empty();
        // 使用 StorageKafka
        if (!has_keeper_path && !has_replica_name)
            return std::make_shared<StorageKafka>(
                args.table_id, args.getContext(), args.columns, args.comment, std::move(kafka_settings), collection_name);
        .....

        if (!has_keeper_path || !has_replica_name)
            throw Exception(
        ErrorCodes::BAD_ARGUMENTS, "Either specify both zookeeper path and replica name or none of them");

        const auto is_on_cluster = args.getLocalContext()->getClientInfo().query_kind == ClientInfo::QueryKind::SECONDARY_QUERY;
        const auto is_replicated_database = args.getLocalContext()->getClientInfo().query_kind == ClientInfo::QueryKind::SECONDARY_QUERY
            && DatabaseCatalog::instance().getDatabase(args.table_id.database_name)->getEngineName() == "Replicated";

        // UUID macro is only allowed:
        // - with Atomic database only with ON CLUSTER queries, otherwise it is easy to misuse: each replica would have separate uuid generated.
        // - with Replicated database
        // - with attach queries, as those are used on server startup
        const auto allow_uuid_macro = is_on_cluster || is_replicated_database || args.query.attach;

        auto context = args.getContext();
        // Unfold {database} and {table} macro on table creation, so table can be renamed.
        if (args.mode < LoadingStrictnessLevel::ATTACH)
        {
            Macros::MacroExpansionInfo info;
            /// NOTE: it's not recursive
            info.expand_special_macros_only = true;
            info.table_id = args.table_id;
            // We could probably unfold UUID here too, but let's keep it similar to ReplicatedMergeTree, which doesn't do the unfolding.
            info.table_id.uuid = UUIDHelpers::Nil;
            (*kafka_settings)[KafkaSetting::kafka_keeper_path].value = context->getMacros()->expand((*kafka_settings)[KafkaSetting::kafka_keeper_path].value, info);

            info.level = 0;
            (*kafka_settings)[KafkaSetting::kafka_replica_name].value = context->getMacros()->expand((*kafka_settings)[KafkaSetting::kafka_replica_name].value, info);
        }


        auto * settings_query = args.storage_def->settings;
        chassert(has_settings && "Unexpected settings query in StorageKafka");

        settings_query->changes.setSetting("kafka_keeper_path", (*kafka_settings)[KafkaSetting::kafka_keeper_path].value);
        settings_query->changes.setSetting("kafka_replica_name", (*kafka_settings)[KafkaSetting::kafka_replica_name].value);

        // Expand other macros (such as {replica}). We do not expand them on previous step to make possible copying metadata files between replicas.
        // Disable expanding {shard} macro, because it can lead to incorrect behavior and it doesn't make sense to shard Kafka tables.
        Macros::MacroExpansionInfo info;
        info.table_id = args.table_id;
        if (is_replicated_database)
        {
            auto database = DatabaseCatalog::instance().getDatabase(args.table_id.database_name);
            info.shard.reset();
            info.replica = getReplicatedDatabaseReplicaName(database);
        }
        if (!allow_uuid_macro)
            info.table_id.uuid = UUIDHelpers::Nil;
        (*kafka_settings)[KafkaSetting::kafka_keeper_path].value = context->getMacros()->expand((*kafka_settings)[KafkaSetting::kafka_keeper_path].value, info);

        info.level = 0;
        info.table_id.uuid = UUIDHelpers::Nil;
        (*kafka_settings)[KafkaSetting::kafka_replica_name].value = context->getMacros()->expand((*kafka_settings)[KafkaSetting::kafka_replica_name].value, info);

        return std::make_shared<StorageKafka2>(
            args.table_id, args.getContext(), args.columns, args.comment, std::move(kafka_settings), collection_name);
    };
    // 将对应的function注册到全局唯一的StorageFactory中进行保存
    factory.registerStorage(
        "Kafka",
        creator_fn,
        StorageFactory::StorageFeatures{
            .supports_settings = true,
            .source_access_type = AccessType::KAFKA,
            .has_builtin_setting_fn = KafkaSettings::hasBuiltin,
        });
} 

从上面的代码可以看到:

  • 该方法首先定义了一个function,这个function接收对应的Kafka的参数,返回一个IStorage实例比如StorageKafkaStorageKafka2:

    cpp 复制代码
    auto creator_fn = [](const StorageFactory::Arguments & args) -> std::shared_ptr<IStorage>
        {
        
        }
            // 将对应的function注册到全局唯一的StorageFactory中进行保存
        factory.registerStorage(
            "Kafka",
            creator_fn,
            StorageFactory::StorageFeatures{
                .supports_settings = true,
                .source_access_type = AccessType::KAFKA,
                .has_builtin_setting_fn = KafkaSettings::hasBuiltin,
            });

    显然,这个function的输入其实就是我们在创建Kafka表的时候的一些参数,比如kafka_topic_list,kafka_broker_list, kafka_group_name等等,输出的,就是对应于这张Kafka表的IStorage实现,StorageKafka或者StorageKafka2

  • 在定义完成了对应的function以后,调用StorageFactory的实例方法StorageFactory::registerStorage()方法,进行该IStorage的注册,可以看到,这个注册其实是将创建对应的IStorage的lambda function,保存到这个StorageFactory的storages中:

    cpp 复制代码
    void StorageFactory::registerStorage(const std::string & name, CreatorFn creator_fn, StorageFeatures features)
    {
        if (!storages.emplace(name, Creator{std::move(creator_fn), features}).second)
            throw Exception(ErrorCodes::LOGICAL_ERROR, "TableFunctionFactory: the table function name '{}' is not unique", name);
    }

    这里的Storages其实是一个map,这个map的key是对应的Storage实现的名字字符串,map的value则是一个叫做Creator的struct,这个struct包含了创建对应的IStorage实现的注册用的function,和对应的这个IStorage实现的StorageFeatures:

    • 从上面的代码可以看到,对于Kafka表,这里的map key就是"kafka":

      cpp 复制代码
      using CreatorFn = std::function<StoragePtr(const Arguments & arguments)>;
      struct Creator
      {
          CreatorFn creator_fn;
          StorageFeatures features;
      };
      
      using Storages = std::unordered_map<std::string, Creator>;
    • 而在Creator中,StorageFeatures是一个struct结构,代表各个IStorage实现对一些公共特性的支持情况:

      cpp 复制代码
          /// Analog of the IStorage::supports*() helpers
      /// (But the former cannot be replaced with StorageFeatures due to nesting)
      struct StorageFeatures
      {
          bool supports_settings = false;
          bool supports_skipping_indices = false;
          bool supports_projections = false;
          bool supports_sort_order = false;
          /// See also IStorage::supportsTTL()
          bool supports_ttl = false;
          /// See also IStorage::supportsReplication()
          bool supports_replication = false;
          /// See also IStorage::supportsDeduplication()
          bool supports_deduplication = false;
          /// See also IStorage::supportsParallelInsert()
          bool supports_parallel_insert = false;
          bool supports_schema_inference = false;
          AccessType source_access_type = AccessType::NONE;
      };

      该结构体是对存储引擎能力的统一描述。ClickHouse 中每种 Storage(表引擎)可以具备不同的功能特性,这个结构体被用来表示每个引擎是否支持某项功能。

      • bool supports_settings = false; 表示该引擎是否支持 SETTINGS 子句,例如在建表语句中使用 SETTINGS index_granularity = 8192。若为 false,则用户不能通过建表语句配置该表引擎的参数。

      • bool supports_skipping_indices = false; 表示该引擎是否支持「跳过索引」(skipping indices)。这是 ClickHouse 的一种加速过滤的机制,例如 minmax、bloom_filter 等。某些轻量引擎(如 Log)是不支持索引的。

      • bool supports_projections = false;

        表示该引擎是否支持「投影」(projection),投影是一种类似物化视图的功能,用于加速部分查询。只有支持这个特性的表引擎才能使用 PROJECTION 语法。

      • bool supports_sort_order = false;

        表示是否支持排序相关的语句,如 ORDER BY、PRIMARY KEY、PARTITION BY、SAMPLE BY。这些结构用于优化查询性能,比如跳过数据块读取。

      • bool supports_ttl = false;

        表示是否支持 TTL(Time-To-Live)机制,允许用户配置列或整表的自动过期清理逻辑。适用于日志清理、自动归档等。

      • bool supports_replication = false;表示是否支持数据副本机制(如 ReplicatedMergeTree),用于实现高可用的数据冗余、容错与数据一致性。

      • bool supports_deduplication = false; 表示是否支持去重逻辑(deduplication),主要用于 Replicated 引擎中的写入数据去重。依赖于 insert_id 等机制判断是否重复。

      • bool supports_parallel_insert = false

        表示是否支持并行写入。在并发场景下,有的表引擎能接受多个线程/请求同时写入,有的则只允许单线程写入以保持一致性。

      • bool supports_schema_inference = false;

        表示该引擎是否支持自动推断表结构(schema inference),比如 File、URL、S3 引擎可以从 CSV、Parquet 等文件自动识别列名和类型。

      • AccessType source_access_type = AccessType::NONE;

        标记该表引擎的底层数据访问方式(source type),如是从本地文件、本地表,还是远程数据源等。这与权限管理(如 ACCESS 权限)有关。

  • 通过查看 lambda function 的具体代码可以看到,对于 Kafka 表的 IStorage 实现有两种,StorageKafkaStorageKafka2。在默认情况下,ClickHouse 使用的是 StorageKafka,而 StorageKafka2 其实是一个 experimental feature。只有在以下条件满足的时候,才会启用 StorageKafka2:

    • 当且仅当 keeper_pathreplica_name 都设置了,才有可能使用StorageKafka2

    • 同时,在如果我们设置了Keeper_path和replica_name,还必须显式enable allow_experimental_kafka_offsets_storage_in_keeper:

    • 并且,keeper_path和replica_name都必须全部设置,不可以只设置一个

      cpp 复制代码
      const auto has_keeper_path = kafka_settings->kafka_keeper_path.changed && !kafka_settings->kafka_keeper_path.value.empty();
      const auto has_replica_name = kafka_settings->kafka_replica_name.changed && !kafka_settings->kafka_replica_name.value.empty();
      
      if (!has_keeper_path && !has_replica_name)
          return std::make_shared<StorageKafka>(
              args.table_id, args.getContext(), args.columns, args.comment, std::move(kafka_settings), collection_name);
      
      if (!args.getLocalContext()->getSettingsRef().allow_experimental_kafka_offsets_storage_in_keeper && !args.query.attach)
          throw Exception(
              ErrorCodes::SUPPORT_IS_DISABLED,
              "Storing the Kafka offsets in Keeper is experimental. Set `allow_experimental_kafka_offsets_storage_in_keeper` setting "
              "to enable it");
      
      if (!has_keeper_path || !has_replica_name)
          throw Exception(
      ErrorCodes::BAD_ARGUMENTS, "Either specify both zookeeper path and replica name or none of them");

建表时构造StorageKafka

我们上面讲过,ClickHouse Server启动的时候会通过StorageFactory注册好每一种IStorage的function,这个function接收相应的参数,返回一个IStorage实现类对象,来负责对这张表进行管理。

这样,在ClickHouse Server运行过程中,任何时候我们创建一张Kafka表,就可以通过我们建表的AST判断出对应的IStorage类型,然后通过StorageFactory注册的function创建StorageKafka或者StorageKafka2

cpp 复制代码
StoragePtr StorageFactory::get(
    const ASTCreateQuery & query,
    const String & relative_data_path,
    ContextMutablePtr local_context,
    ContextMutablePtr context,
    const ColumnsDescription & columns,
    const ConstraintsDescription & constraints,
    LoadingStrictnessLevel mode) const
{
    String name, comment;

    ASTStorage * storage_def = query.storage;

    bool has_engine_args = false;

    ....
    if (query.comment)
        comment = query.comment->as<ASTLiteral &>().value.safeGet<String>();

    ASTs empty_engine_args;
    Arguments arguments{
        .engine_name = name,
        ....
        .comment = comment};


    auto res = storages.at(name).creator_fn(arguments);
    ....
    
    return res;
}

可以看到,我们在建表的时候,触发StorageFactory::get()方法,其基本流程为:

  • 初始化了一些变量,包括用于存储引擎名称的 name、注释信息 comment,并从 query 中提取 storage 节点指针。has_engine_args 用于记录是否传入了 ENGINE 的参数。

    cpp 复制代码
    {
        String name, comment;
    
        ASTStorage * storage_def = query.storage;
    
        bool has_engine_args = false;
  • 针对几种特殊视图类型的建表语句进行检查,它们不允许带 ENGINE 子句;一旦带有就抛出异常。

    cpp 复制代码
    if (query.is_ordinary_view)
    {
        if (query.storage)
            throw Exception(ErrorCodes::INCORRECT_QUERY, "Specifying ENGINE is not allowed for a View");
    
        name = "View";
    }
    else if (query.is_live_view)
    {
        if (query.storage)
            throw Exception(ErrorCodes::INCORRECT_QUERY, "Specifying ENGINE is not allowed for a LiveView");
    
        name = "LiveView";
    }
    else if (query.is_dictionary)
    {
        if (query.storage)
            throw Exception(ErrorCodes::INCORRECT_QUERY, "Specifying ENGINE is not allowed for a Dictionary");
    
        name = "Dictionary";
    }
  • 对常规表而言,必须指定storage 和 ENGINE 字段;提取 ENGINE 定义结构,并判断其合法性(是否带有参数、名称是否合法),同时标记是否有参数。

    cpp 复制代码
    else
    {
        if (!query.storage)
            throw Exception(ErrorCodes::INCORRECT_QUERY, "Incorrect CREATE query: storage required");
        
        if (!storage_def->engine)
            throw Exception(ErrorCodes::ENGINE_REQUIRED, "Incorrect CREATE query: ENGINE required");
        
        const ASTFunction & engine_def = *storage_def->engine;
        
        if (engine_def.parameters)
            throw Exception(ErrorCodes::FUNCTION_CANNOT_HAVE_PARAMETERS, "Engine definition cannot take the form of a parametric function");
        
        if (engine_def.arguments)
            has_engine_args = true;
        
        name = engine_def.name;
  • 禁止用户显式用 ENGINE=View 等来建特殊表(应使用专用语法 CREATE VIEW 等),否则抛错

    cpp 复制代码
    if (name == "View")
    {
        throw Exception(ErrorCodes::INCORRECT_QUERY, "Direct creation of tables with ENGINE View is not supported, use CREATE VIEW statement");
    }
    else if (name == "MaterializedView")
    {
        throw Exception(ErrorCodes::INCORRECT_QUERY,
                        "Direct creation of tables with ENGINE MaterializedView "
                        "is not supported, use CREATE MATERIALIZED VIEW statement");
    }
    else if (name == "LiveView")
    {
        throw Exception(ErrorCodes::INCORRECT_QUERY,
                        "Direct creation of tables with ENGINE LiveView "
                        "is not supported, use CREATE LIVE VIEW statement");
    }
    else if (name == "WindowView")
    {
        throw Exception(ErrorCodes::INCORRECT_QUERY,
                        "Direct creation of tables with ENGINE WindowView "
                        "is not supported, use CREATE WINDOW VIEW statement");
    }
  • 对普通表和物化视图、窗口视图等进行处理:首先检查列类型是否合法,然后根据语义设置存储引擎名称

    cpp 复制代码
    else
    {
        checkAllTypesAreAllowedInTable(columns.getAll());
        
        if (query.is_materialized_view)
        {
            name = "MaterializedView";
        }
        else if (query.is_window_view)
        {
            name = "WindowView";
        }
  • 根据引擎名查找注册的构造器(creator_fn);如果不存在则报错,并尝试给出拼写建议(hints)

    cpp 复制代码
        auto it = storages.find(name);
        if (it == storages.end())
        {
            auto hints = getHints(name);
            if (!hints.empty())
                throw Exception(ErrorCodes::UNKNOWN_STORAGE, "Unknown table engine {}. Maybe you meant: {}", name, toString(hints));
            else
                throw Exception(ErrorCodes::UNKNOWN_STORAGE, "Unknown table engine {}", name);
        }
  • 定义一个 lambda 函数用于校验引擎功能是否支持某些语法元素,并列出支持的引擎供用户参考。

    cpp 复制代码
        auto check_feature = [&](String feature_description, FeatureMatcherFn feature_matcher_fn)
        {
            if (!feature_matcher_fn(it->second.features))
            {
                String msg;
                auto supporting_engines = getAllRegisteredNamesByFeatureMatcherFn(feature_matcher_fn);
                for (size_t index = 0; index < supporting_engines.size(); ++index)
                {
                    if (index)
                        msg += ", ";
                    msg += supporting_engines[index];
                }
                throw Exception(ErrorCodes::BAD_ARGUMENTS, "Engine {} doesn't support {}. "
                                "Currently only the following engines have support for the feature: [{}]",
                                name, feature_description, msg);
            }
        };
  • 基于定义的labmda函数,对各种子句支持能力进行验证,如 SETTINGS、PARTITION BY、TTL、索引、projection 等

    cpp 复制代码
        if (storage_def->settings)
            check_feature(
                "SETTINGS clause",
                [](StorageFeatures features) { return features.supports_settings; });
    
        if (storage_def->partition_by || storage_def->primary_key || storage_def->order_by || storage_def->sample_by)
            check_feature(
                "PARTITION_BY, PRIMARY_KEY, ORDER_BY or SAMPLE_BY clauses",
                [](StorageFeatures features) { return features.supports_sort_order; });
    
        if (storage_def->ttl_table || !columns.getColumnTTLs().empty())
            check_feature(
                "TTL clause",
                [](StorageFeatures features) { return features.supports_ttl; });
    
        if (query.columns_list && query.columns_list->indices && !query.columns_list->indices->children.empty())
            check_feature(
                "skipping indices",
                [](StorageFeatures features) { return features.supports_skipping_indices; });
    
        if (query.columns_list && query.columns_list->projections && !query.columns_list->projections->children.empty())
            check_feature(
                "projections",
                [](StorageFeatures features) { return features.supports_projections; });
  • 构造 Arguments 参数结构体,供具体引擎的 creator_fn 使用,其中包括表路径、引擎名、上下文、列信息等。

    cpp 复制代码
        ASTs empty_engine_args;
        Arguments arguments{
            .engine_name = name,
            .engine_args = has_engine_args ? storage_def->engine->arguments->children : empty_engine_args,
            .storage_def = storage_def,
            .query = query,
            .relative_data_path = relative_data_path,
            .table_id = StorageID(query.getDatabase(), query.getTable(), query.uuid),
            .local_context = local_context,
            .context = context,
            .columns = columns,
            .constraints = constraints,
            .mode = mode,
            .comment = comment};
  • 调用注册的 storage 构造器创建存储对象并返回:

    cpp 复制代码
        auto res = storages.at(name).creator_fn(arguments);
        return res;

StorageKafka的shutdown过程

我们看一下StorageKafka::shutdown()的具体流程。具体代码如下所示。总之,StorageKafka的关闭过程其实就是对其内部所管理的一个或者多个KafkaConsumer(一个StorageKafka对应的多个cppkafka::Consumer保存在std::vector<KafkaConsumerPtr> consumers成员中)的析构,而每一个KafkaConsumer的析构其实就是对其内部对应的cppkafka::Consumer的析构。

cpp 复制代码
/**
 * 在InterpreterDropQuery.executeToTableImpl()中调用
 * 参数存在是为了匹配某个接口,但实现中不使用它, 避免编译器警告未使用变量, 接口兼容性,如实现某个虚函数:
 */
void StorageKafka::shutdown(bool)
{
    shutdown_called = true;
    cleanup_cv.notify_one(); // 在这里通知cleanup_thread的执行
    cleanup_thread->join(); // 在这里会造成 moveConsumer 的调用和 Consumer 的析构已经被调用
    cleanup_thread.reset();
    for (auto & task : tasks) // 每一个task执行的是 void StorageKafka::threadFunc(size_t idx)
        {
            // Interrupt streaming thread
            task->stream_cancelled = true;  // 在这里才会阻止CPPKafka进行消息的消费和commit
    
            LOG_TEST(log, "Waiting for cleanup of a task");
            task->holder->deactivate();
    }
    consumers.clear(); // 在这里进行KafkaConsumer的析构
    rd_kafka_wait_destroyed(KAFKA_CLEANUP_TIMEOUT_MS);
}

我们通过ClickHouse CLI执行Kafka表的Drop操作时,会调用 InterpreterDropQuery::executeToTableImpl() ,进而调用StorageKafka::shutdown()StorageKafka::shutdown()的作用是清理Consumer线程、消费任务和底层资源。虽然有一个 bool 参数,但并未使用,仅为了接口兼容性。

其基本流程如下所示:

  • 设置关闭标志,唤醒 cleanup 线程。首先将标志位 shutdown_called 设为 true,并唤醒等待中的 cleanup 线程,以便后续执行清理工作。后面我们会讲到CleanupThread等待唤醒标志并执行清理的过程:

    cpp 复制代码
    shutdown_called = true;
    cleanup_cv.notify_one(); // 在这里通知cleanup_thread的执行
  • 等待 cleanup_thread 执行完毕,即如果存在 cleanup 线程,则通过 join() 等待其执行完成,并释放资源。这是为了确保没有并发清理操作。

    cpp 复制代码
            cleanup_thread->join(); // 在这里会造成 moveConsumer 的调用和 Consumer 的析构已经被调用
            cleanup_thread.reset();

    cleanup_thread->join() 会阻塞主线程,直到 CleanupThread完成清理。清理线程内部可能触发对 moveConsumer() 的调用及消费者的析构操作。

  • 在CleanupThread完成以后,就开始Stop所有的streaming 任务(即消费线程)。对每一个 StorageKafka::threadFunc(size_t idx) 启动的消费任务,设置 stream_cancelled = true,让线程退出。接着调用 task->holder->deactivate() 清理任务资源。

    cpp 复制代码
    for (auto & task : tasks) // 每一个task执行的是 void StorageKafka::threadFunc(size_t idx)
        {
            // Interrupt streaming thread
            task->stream_cancelled= = true;  // 在这里才会阻止CPPKafka进行消息的消费和commit
    
            LOG_TEST(log, "Waiting for cleanup of a task");
            task->holder->deactivate();
        }

    这里是让任务优雅退出的核心逻辑。在任务函数中定期检查 stream_cancelled 变量,因此主线程在设置了stream_cancelled=true以后会主动退出。

  • 清理 Kafka 消费者(cppkafka 对象)。加锁保护下清空消费者列表 consumers.clear()。这会导致所有 cppkafka::Consumer 析构,自动关闭连接:

    cpp 复制代码
    consumers.clear();

    这里是释放 cppkafka 中的实际连接资源(包括 RdKafka 客户端、底层 socket 等)的阶段。

  • 等待底层 librdkafka 的全局清理完成。调用 rd_kafka_wait_destroyed() 等待 librdkafka 全部内部对象被销毁,避免内存泄露或未释放 socket。该函数是阻塞的,有超时机制。

    cpp 复制代码
    rd_kafka_wait_destroyed(KAFKA_CLEANUP_TIMEOUT_MS);

    这是 Kafka 库层面最后的资源回收步骤。在调用 rd_kafka_destroy() 后,必须等待内部所有对象(如 producer/consumer 的 IO 线程)都安全退出。

CleanupThread运行和退出过程解析

在StorageKafka构造的时候,会同时创建对应的CleanupThread。我们这里介绍Cleanup Thread,是因为它和我们整个事故的发生有关:

cpp 复制代码
StorageKafka::StorageKafka(
    const StorageID & table_id_,
    ContextPtr context_,
    const ColumnsDescription & columns_,
    const String & comment,
    std::unique_ptr<KafkaSettings> kafka_settings_,
    const String & collection_name_)
    : IStorage(table_id_)
    ......
    auto task_count = thread_per_consumer ? num_consumers : 1;
    for (size_t i = 0; i < task_count; ++i)
    {
        auto task = getContext()->getMessageBrokerSchedulePool().createTask(log->name(), [this, i]{ threadFunc(i); });
        task->deactivate();
        tasks.emplace_back(std::make_shared<TaskContext>(std::move(task)));
    }

    consumers.resize(num_consumers);
    for (size_t i = 0; i < num_consumers; ++i)
        consumers[i] = createKafkaConsumer(i);
    // 创建一个ThreadFromGlobalPool对象,传入的参数是一个callback,
    cleanup_thread = std::make_unique<ThreadFromGlobalPool>([this]()
    {
        const auto & table = getStorageID().getTableName();
        const auto & thread_name = std::string("KfkCln:") + table;
        setThreadName(thread_name.c_str(), /*truncate=*/ true);
        cleanConsumers(); // 关键方法,执行cleanup
    });
}

从下面的代码可以看到,CleanupThread实际上是在执行StorageKafka::cleanConsumers()这个function,这个function内部就会有一个while循环不断执行,直到某种信号到来:

cpp 复制代码
void StorageKafka::cleanConsumers()
{
    // 在 cleanup_cv的条件变量上,基于lock进行等待. 在StorageKafka.shutdown()中会对这个cv置位,从而收到通知
    while (!cleanup_cv.wait_for(lock, timeout, [this]() { return shutdown_called == true; }))
    {
        //收到cleanup通知,开始执行cleanup
        /// Copy consumers for closing to a new vector to close them without a lock
        std::vector<ConsumerPtr> consumers_to_close;

        UInt64 now_usec = std::chrono::duration_cast<std::chrono::microseconds>(std::chrono::system_clock::now().time_since_epoch()).count();
        {
            for (size_t i = 0; i < consumers.size(); ++i)
            {
                auto & consumer_ptr = consumers[i];

                UInt64 consumer_last_used_usec = consumer_ptr->getLastUsedUsec();
                chassert(consumer_last_used_usec <= now_usec);
                // 当前的KafkaConsumer是否含有封装的 cppkafka::Consumer对象
                if (!consumer_ptr->hasConsumer())
                    continue;
                // 搜索 void notInUse(),当前KafkaConsumer是否正在使用中
                if (consumer_ptr->isInUse()) // 如果当前kafka正在消费,不应该close
                    continue;

                if (now_usec - consumer_last_used_usec > ttl_usec)
                {
                    consumers_to_close.push_back(consumer_ptr->moveConsumer()); // 这里会unsubscribe并清空消息
                }
            }
        }

        if (!consumers_to_close.empty())
        {
            size_t closed = consumers_to_close.size();
            consumers_to_close.clear(); // 在这里会触发Consumer::~Consumer的析构,因此,析构调用以前,已经通过调用moveConsumer 来消费完了所有的消息,并且调用了unsubscribe
            ....
        }
    }
  1. 等待cleanup_cv条件变量的唤醒:

    cpp 复制代码
    while (!cleanup_cv.wait_for(lock, timeout, [this]() { return shutdown_called == true; }))
    {
        // 不是一次shutdown,只是一次regular的cleanup执行,用来定期进行一次idle consumer等等的关闭
    }

    cleanup_cv.wait_for(...)是一个带超时的条件等待,完整语法是:

    复制代码
    condition_variable::wait_for(lock, timeout_duration, predicate)

    它的作用是:

    • 如果在Condition cleanup_cv上没有被通知,那么就最多等待timeout(s)被唤醒,而如果在cleanup_cv上收到通知,则直接被唤醒;
    • 上面两种情况的任何一种情况发生导致唤醒,都会检查predicate的值,并且将predicate的值作为wait_for方法的返回值:

    所以,对于方法StorageKafka::cleanConsumers():

    • 如果shutdown_called == false(正常的、regular的清理操作),那么cleanup_cv被唤醒以后会执行内部的while循环
    • 如果shutdown_called == true(由于StorageKafka::shutdown()导致的清理),那么cleanup_cv被唤醒以后不会执行内部的while循环进行清理,而知直接跳出了while循环

    我们上文讲过,StorageKafka::shutdown()调用的时候,会设置shutdown_called=true,然后唤醒cleanup_cv:

    cpp 复制代码
        void StorageKafka::shutdown(bool)
        {
            // Interrupt streaming, inform consumers to stop
            for (auto & task : tasks)
                task->stream_cancelled = true;
        
            shutdown_called = true;
            cleanup_cv.notify_one();

    必须注意,只有当cleanup_cv的确处于等待(wait)状态,唤醒才有效。如果cleanup_cv不是处于等待状态,而是处于已经被唤醒的状态(比如,while循环内部正在执行,即regular cleanup正在执行比如consumers_to_close.clear()操作),那么外部的唤醒是没有用的,只有当regular cleanup执行完以后再次进行cleanup_cv的检查才会发现shutdown_called=true,然后跳出循环,结束方法StorageKafka::cleanConsumers()

    所以,上面的StorageKafka::shutdown(bool)方法在对CleanupThread执行唤醒的时候,有两种情况:

    1. 如果此时CleanupThread正处于cleanup_cv的等待状态,那么此时由于设置了shutdown_called,此时cleanup_cv会立刻被唤醒,并且循环退出。对,循环内部不会再执行;
    2. 如果此时CleanupThread不是处于等待cleanup_cv的状态,即while循环内部正在运行,那么cleanup_cv的唤醒此时无效,所以本轮循环结束,执行下一轮循环前进行条件检查的时候,发现shutdown_called == true,会立刻退出循环,而不会进入下一轮条件等待。

    所以,我们可以看到,void StorageKafka::shutdown(bool)不会触发一次StorageKafka::cleanConsumers()内部清理的执行。我们之所以在堆栈中看到CleanupThread的内部while循环正在执行Consumer的析构,并不是cleanup_cv.notify_one()触发的,而是CleanupThread的regular调度,这个清理卡住了,但是void StorageKafka::shutdown(bool)必须要等待CleanupThread执行完成,于是就发生了无限等待,尽管已经发出了cleanup_cv信号并设置shutdown_called=true

  2. 进入循环。这意味着不是一次shutdown,只是一次普通的cleanup check。

    • 因此首先进行需要关闭的cppkafka::Consumer的收集。这是通过遍历 consumers 向量,对每个 cppkafka::Consumer 检查其是否空闲、可清理,并收集到 consumers_to_close 数组中。

      cpp 复制代码
      std::vector<ConsumerPtr> consumers_to_close; // 这里的每一个元素是一个 std::shared_ptr<cppkafka::Consumer>
      
      UInt64 now_usec = timeInMicroseconds(std::chrono::system_clock::now());
      {
          for (size_t i = 0; i < consumers.size(); ++i)
          {
              auto & consumer_ptr = consumers[i];
              // 当前的KafkaConsumer是否含有封装的 cppkafka::Consumer对象
              if (!consumer_ptr->hasConsumer())
                  continue;
              // 搜索 void notInUse(),当前KafkaConsumer是否正在使用中
              if (consumer_ptr->isInUse()) // 如果当前kafka正在消费,不应该close
                  continue;
      
              if (now_usec - consumer_last_used_usec > ttl_usec)
              {
                  consumers_to_close.push_back(consumer_ptr->moveConsumer()); // 这里会unsubscribe并清空消息
              }
          }
      }

      可以看到:

      • 正常清理的时候,会跳过无 consumer(hasConsumer() 为 false)或正在使用的(isInUse() 为 true)。
      • 同时,如果一个 consumer 最后使用的时间距离当前时间超过 ttl_usec,就认为它该被清理。
      • 在将对应的cppkafka::Consumer放到consumers_to_close中的时候,会调用KafkaConsumer::moveConsumer()这个方法内部会进行cppkafka::Consumer的unsubscribe(),然后返回已经unsubscribe的cppkafka::Consumer进行最后的析构
    • 收集完需要关闭的cppkafka::Consumer以后,就开始释放这些 cppkafka::Consumer(脱锁),并记录日志, 因为释放cppkafka::Consumer本身可能会阻塞一小段时间,为了不长时间持有锁,先 unlock,再清理,再重新加锁:

      cpp 复制代码
      if (!consumers_to_close.empty())
      {
          lock.unlock();
      
          Stopwatch watch;
          size_t closed = consumers_to_close.size();
          consumers_to_close.clear(); // 在这里会触发Consumer::~Consumer的析构,因此,析构调用以前,已经通过调用moveConsumer 来消费完了所有的消息,并且调用了unsubscribe
          LOG_TRACE(log, "{} consumers had been closed (due to {} usec timeout). Took {} ms.",
              closed, ttl_usec, watch.elapsedMilliseconds());
      
          lock.lock();
      }

    consumers_to_close.clear() 其实在释放move出来的cppkafka::Consumer(析构),所以,这里会触发cppkafka::Consumer对象的析构。我们下文会讲到,cppkafka::Consumer的析构超时就是导致CleanupThread无法正常退出和结束的原因, 而cppKafka::Consumer的析构超时又是底层的librdkafka的异步队列处理问题导致的

Consumer的取消订阅(Unsubscribe)和销毁(Close)逻辑解析

上文讲过,由于CleanupThread始终无法结束,导致StorageKafka::shutdown()方法的join()始终无法完成因此始终无法正常退出。

我们在ClickHouse的堆栈中找到了CleanupThread的堆栈,试图探寻CleanupThread无法正常退出的原因:

cpp 复制代码
Thread 1207 (Thread 0x7f607c9ee700 (LWP 3826922)):
#0  futex_wait_cancelable (private=<optimized out>, expected=0, futex_word=0x7fe090df05d0) at ../sysdeps/nptl/futex-internal.h:183
#1  __pthread_cond_wait_common (abstime=0x0, clockid=0, mutex=0x7fe090df0580, cond=0x7fe090df05a8) at pthread_cond_wait.c:508
#2  __pthread_cond_wait (cond=0x7fe090df05a8, mutex=0x7fe090df0580) at pthread_cond_wait.c:647
#3  0x0000000015bd7b37 in rd_kafka_q_pop_serve ()
#4  0x0000000015bc8cab in rd_kafka_op_req ()
#5  0x0000000015c0a373 in rd_kafka_assign ()
#6  0x0000000015b1dcb1 in cppkafka::Consumer::rebalance_proxy(rd_kafka_s*, rd_kafka_resp_err_t, rd_kafka_topic_partition_list_s*, void*) ()
#7  0x0000000015b49522 in rd_kafka_poll_cb ()
#8  0x0000000015b4b5d8 in rd_kafka_consumer_close ()
#9  0x0000000015b1eb52 in cppkafka::Consumer::~Consumer() ()
#10 0x00000000108b548e in void std::__1::__function::__policy_invoker<void ()>::__call_impl<std::__1::__function::__default_alloc_func<ThreadFromGlobalPoolImpl<true, true>::ThreadFromGlobalPoolImpl<DB::StorageKafka::StorageKafka(DB::StorageID const&, std::__1::shared_ptr<DB::Context const>, DB::ColumnsDescription const&, std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char> > const&, std::__1::unique_ptr<DB::KafkaSettings, std::__1::default_delete<DB::KafkaSettings> >, std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char> > const&)::$_1>(DB::StorageKafka::StorageKafka(DB::StorageID const&, std::__1::shared_ptr<DB::Context const>, DB::ColumnsDescription const&, std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char> > const&, std::__1::unique_ptr<DB::KafkaSettings, std::__1::default_delete<DB::KafkaSettings> >, std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char> > const&)::$_1&&)::{lambda()#1}, void ()> >(std::__1::__function::__policy_storage const*) ()
#11 0x000000000dbd7509 in void* std::__1::__thread_proxy[abi:v15007]<std::__1::tuple<std::__1::unique_ptr<std::__1::__thread_struct, std::__1::default_delete<std::__1::__thread_struct> >, ThreadPoolImpl<std::__1::thread>::scheduleImpl<void>(std::__1::function<void ()>, Priority, std::__1::optional<unsigned long>, bool)::{lambda()#2}> >(std::__1::tuple<std::__1::unique_ptr<std::__1::__thread_struct, std::__1::default_delete<std::__1::__thread_struct> >, ThreadPoolImpl<std::__1::thread>::scheduleImpl<void>(std::__1::function<void ()>, Priority, std::__1::optional<unsigned long>, bool)::{lambda()#2}>) ()
#12 0x00007fe3888d3609 in start_thread (arg=<optimized out>) at pthread_create.c:477
#13 0x00007fe3887f8353 in clone () at ../sysdeps/unix/sysv/linux/x86_64/clone.S:95

ℹ️ ClickHouse中三个层面的Consumer

在ClickHouse中,Consumer其实包含三个层面(自底向上)

  • librdkafka
  • cppkafka::Consumer
  • KafkaConsumer
    • 这是真正的Kafka应用层,对cppKafka::Consumer进行了封装, 是ClickHouse的kafka表所直接接触的Kafka客户端

可以看到,实际上,CleanupThread的触发了cppkafka::Consumer的析构。

从上面的方法可以看到,ClickHouse中CleanupThread在尝试销毁一个长期idle的KafkaConsumer的时候,主要会做两件事情,解除订阅(unsubscribe)和析构cppkafka::Consumer:

  1. 通过moveConsumer(),对这个KafkaConsumer内部封装的cppkafka进行unsubscribe操作。
    所以,这里的StorageKafka::moveConsumer()方法是StorageKafka的成员方法,但是,StorageKafka::moveConsumer()内部是对内部对应的cppkafka::Consumer进行unsubscribe调用, 然后返回内部封装的cppkafka::Consumer对象,而不是返回自己:

    cpp 复制代码
    using ConsumerPtr = std::shared_ptr<cppkafka::Consumer>;
    
    ConsumerPtr && KafkaConsumer::moveConsumer()
    {
        cleanUnprocessed();
        if (!consumer->get_subscription().empty()) // 如果有订阅
        {
            try
            {
                // unsubscribe只能在cppkafka::Consumer级别进行, 这是 Kafka 消费者(rd_kafka_t)提供的方法,它用于取消对已订阅主题的订阅。
                // 当调用cppkafka::Consumer::unsubscribe() 时,消费者将不再从当前订阅的主题中拉取消息,但并不会改变消费者的分区分配策略,只是停止从所有订阅的主题中拉取消息。
                // 当cppkafka::Consumer::unsubscribe() 方法返回,说明已经unsubscribe成功了
                consumer->unsubscribe(); // 调用 cppkafka::Consumer::unsubscribe()
            }
            catch (const cppkafka::HandleException & e)
            {
                LOG_ERROR(log, "Error during unsubscribe: {}", e.what());
            }
            drain(); // 等待,一直到Kafka收不到消息了
        }
        return std::move(consumer);
    }

    在完成了取消订阅以后,会通过drain()方法清空消息队列中的所有消息。然后,当通过std::move(consumer)把所有的cppkafka::Consumer转移到一个Consumer数组并清空这个数组的时候,实际上就是析构cppkafka::Consumer对象了。

  2. 析构对应的cppkafka::Consumer对象。
    在通过方法KafkaConsumer::moveConsumer()完成了unsubscribe并且没有收到任何消息以后(drain()以后),StorageKafka::cleanConsumers()会析构这个cppkafka::Consumer对象。
    注意,这里依然只是析构 cppkafka::Consumer对象,而不是析构这个 cppkafka::Consumer的上层管理者KafkaConsumer。KafkaConsumer的析构是在底层的cppkafka::Consumer析构完成以后才发生。

    cpp 复制代码
    /**
     * 在析构以前,会调用 moveConsumer() 进行订阅取消的操作,
     * 由于consumer->unsubscribe()是阻塞的,因此,执行到Consumer::~Consumer(),说明unsubscribe已经成功并且结束了
     * 所以在调用moveConsumer()进行unsubscribe以前还没有清空assignment_callback, revocation_callback和rebalance_error_callback
     */
    Consumer::~Consumer() {
                // make sure to destroy the function closures. in case they hold kafka
            // objects, they will need to be destroyed before we destroy the handle
            assignment_callback_ = nullptr; // 不再处理 assignment_callback
            revocation_callback_ = nullptr; // 不再处理 assignment_callback
            rebalance_error_callback_ = nullptr; // 不再处理 rebalance_error_callback
            close(); // 在这里发生阻塞,调用 cppkafka::Consumer::close()方法
        }

    这里可以看到,在析构cppkafka:;Consumer的时候,会将一些callback设置为nullptr。我们下文会讲,这些callback只是用来进行一些日志记录、metrics收集的辅助函数,他们并不是librdkakfa的协议的一部分,他们不负责对应的assign/unassign等操作,真正属于librdkafka协议的是rebalance_cb,在实际代码中,它是Consumer::rebalance_proxy,下文会讲。
    同时,我们堆栈的阻塞位置就发生在cppkafka::Consumer::close()方法中,cppkafka::Consumer::close()由于assign的发生而被hang住。

    shell 复制代码
    Thread 1207 (Thread 0x7f607c9ee700 (LWP 3826922)):
    #0  futex_wait_cancelable (private=<optimized out>, expected=0, futex_word=0x7fe090df05d0) at ../sysdeps/nptl/futex-internal.h:183
    #1  __pthread_cond_wait_common (abstime=0x0, clockid=0, mutex=0x7fe090df0580, cond=0x7fe090df05a8) at pthread_cond_wait.c:508
    #2  __pthread_cond_wait (cond=0x7fe090df05a8, mutex=0x7fe090df0580) at pthread_cond_wait.c:647
    #3  0x0000000015bd7b37 in rd_kafka_q_pop_serve ()
    #4  0x0000000015bc8cab in rd_kafka_op_req ()
    #5  0x0000000015c0a373 in rd_kafka_assign ()
    #6  0x0000000015b1dcb1 in cppkafka::Consumer::rebalance_proxy(rd_kafka_s*, rd_kafka_resp_err_t, rd_kafka_topic_partition_list_s*, void*) ()
    #7  0x0000000015b49522 in rd_kafka_poll_cb ()
    #8  0x0000000015b4b5d8 in rd_kafka_consumer_close ()
    #9  0x0000000015b1eb52 in cppkafka::Consumer::~Consumer() ()
  3. cppkafka::Consumer的析构是通过管理者KafkaConsumer`的销毁来完成的相似,KafkaConsumer的析构也是在上层的管理者StorageKafka的析构中完成的。我们知道,StorageKafka的声明周期就是一个Kafka表的生命周期,StorageKafka在 DETACH TABLEDROP TABLE,或服务器退出时被销毁:

    cpp 复制代码
    /**
     * 在InterpreterDropQuery.executeToTableImpl()中调用
     * 参数存在是为了匹配某个接口,但实现中不使用它, 避免编译器警告未使用变量, 接口兼容性,如实现某个虚函数:
     */
    void StorageKafka::shutdown(bool)
    {
        shutdown_called = true;
        cleanup_cv.notify_one(); // 在这里通知cleanup_thread的执行
    
        {
            cleanup_thread->join(); // 在这里会造成 moveConsumer 的调用和 Consumer 的析构已经被调用
            consumers.clear(); // 在这里进行KafkaConsumer的析构
    }

    KafkaConsumer的析构如下所示。但是请注意,我们发生问题时候的堆栈是对应cppkafak::Consumer的析构,还没有到KafkaConsumer的析构阶段:

    cpp 复制代码
        KafkaConsumer::~KafkaConsumer()
        {
            cleanUnprocessed();
            if (!consumer->get_subscription().empty())
                {
                    consumer->unsubscribe();
                    drain();
                }
        }

    可以看到,在KafkaConsumer::moveConsumer()KafkaConsumer::~KafkaConsumer()中,都尝试对对应的cppkafka::Consumer进行unsubscribe操作。

cppkafka::Consumer和librdkafka的unsubscribe的基本过程

我们首先需要理解一下Kafka中关于subscribe, assign, rebalance和leave group的逻辑含义,比如

  • subscribe/unsubscribe是否会导致Consumer 离开Group
  • unsubscribe和leave group的区别
  • rebalance什么时候被触发
  • assign和subscribe在Group层面的区别

subscribe, assign, rebalance和leave group的逻辑理解

在这里,我们会从cppkafka::Consumer到librdkafka两个层面介绍整个unsubscribe请求的发送、响应的接收整个过程。

我们将会看到,librdkafka基于生产者-消费者模式以及通知机制实现的消息传递的基本逻辑。

看一下方法是cppkafka::Consumer上的unsubscribe()方法,这里暗含的意思是,取消订阅是某一个Consumer的主体行为,而不是一个Consumer Group或者其他实体的行为

并且,必须区分unsubscribe和leave group:

客户端动作 / API Coordinator 看到的"存活成员集合"是否改变? 会不会触发 rebalance? 被要求重新 SyncGroup 的其它成员数 本客户端得到的结果
subscribe(topics)(第一次订阅,或者后续改变了订阅集) 增加 / 订阅集改变 N(可能为 0) 获得 / 更新 assignment
自动重新 Join(因 session 超时、网络抖动等) 成员重新出现 当前在线成员数 重新拿 assignment
unsubscribe()(订阅设为空) 成员仍在,但订阅变为 Ø 当前在线成员数 - 1 assignment 变为空
close() / destroy()LeaveGroup 或断连) 成员离组 当前在线成员数 - 1 与组脱离
assign(tp-list)(显式分区模式) 不使用组协议 → 集合不变 0 直接消费指定分区

所以,我们可以看到,unsubscribe和leave group在Group Member和Rebalance上的本质区别是:

✅ unsubscribe的作用是:

  • 只是取消对 topic 的订阅;
  • 触发一次 rebalance;
  • 并不会让 consumer 离开 group(还在 group 内);
  • consumer 实际上还可以后续调用 subscribe()assign() 重新加入新的订阅流。

✅ unsubscribe 对 group 的影响

  • Kafka 会触发 rebalance(因为成员的订阅发生了变化)
  • 但该 consumer 仍是 group 的一员,只是暂时没有订阅任何 topic
  • 在下一次 heartbeat 时,还是会发给 group coordinator

但是,leave group就是显式地将消费者从group中移除。

虽然 Kafka API 没有暴露直接的 leaveGroup() 函数,但它在以下几种场景中会发生:

✅ 触发条件

  • 调用 rd_kafka_consumer_close()(阻塞关闭,主动 leave group);
  • client 被强制关闭或进程崩溃;
  • heartbeat 超时(被 coordinator 认为已经离线);
  • 收到 LeaveGroupRequest(内部调用)。

✅ 作用

  • 从 group metadata 中彻底移除该 consumer;
  • 其所拥有的 partition 会被分配给别的 consumer;
  • 一定会触发 rebalance;
  • 再想加入 group,就得重新发 JoinGroupRequest

librdkafka内部消息体rd_kafka_op_t介绍

librdkfka把各种不同的操作类型定义成了一个名为rd_kafka_op_type_t的enum,具体代码如下所示。

cpp 复制代码
typedef enum {
        .......
        RD_KAFKA_OP_REBALANCE,       /* broker thread -> app:
                                      * group rebalance */
        RD_KAFKA_OP_TERMINATE,       /* For generic use */
        RD_KAFKA_OP_COORD_QUERY,     /* Query for coordinator */
        RD_KAFKA_OP_SUBSCRIBE,       /* New subscription */
        RD_KAFKA_OP_ASSIGN,          /* New assignment */
        ....
} rd_kafka_op_type_t;

在通过rd_kafka_op_type_t 来构造请求结构体rd_kafka_op_t的时候,仅仅有rd_kafka_op_type_t 还不够,根据不同的rd_kafka_op_type_t 类型,还需要额外的信息,比如

  • 对于RD_KAFKA_OP_ASSIGN ,如果是一个Assign而不是Unassign操作,那么还需要提供partition的list
  • 对于RD_KAFKA_OP_SUBSCRIBE,如果是Subscribe而不是Unsubscribe操作,还需要提供对应的Topic List
cpp 复制代码
typedef struct rd_kafka_op_s rd_kafka_op_t;


struct rd_kafka_op_s {
	TAILQ_ENTRY(rd_kafka_op_s) rko_link;

	rd_kafka_op_type_t    rko_type;   /* 具体业务类型,如 FETCH、ASSIGN  */
	rd_kafka_event_type_t rko_evtype;
	int                   rko_flags;  /* See RD_KAFKA_OP_F_... above */
	int32_t               rko_version;
	rd_kafka_resp_err_t   rko_err;
        rd_kafka_error_t     *rko_error;
	int32_t               rko_len;    /* Depends on type, typically the
					   * message length. */
        rd_kafka_prio_t       rko_prio;   /**< In-queue priority.
                                           *   Higher value means higher prio*/

	rd_kafka_toppar_t    *rko_rktp;

        /*
	 * Generic fields
	 */

	/* Indicates request: enqueue reply on rko_replyq.q with .version.
	 * .q is refcounted. */
	rd_kafka_replyq_t rko_replyq;

        /* Original queue's op serve callback and opaque, if any.
         * Mainly used for forwarded queues to use the original queue's
         * serve function from the forwarded position. */
        rd_kafka_q_serve_cb_t *rko_serve;
        void *rko_serve_opaque;

	rd_kafka_t     *rko_rk;

#if ENABLE_DEVEL
        const char *rko_source;  /**< Where op was created */
#endif

        /* RD_KAFKA_OP_CB */
        rd_kafka_op_cb_t *rko_op_cb;

	union {
        .....
		 struct {
			rd_kafka_topic_partition_list_t *topics;
		 } subscribe; /* also used for GET_SUBSCRIPTION */

		 struct {
			rd_kafka_topic_partition_list_t *partitions;
                        rd_kafka_assign_method_t method;
		 } assign; /* also used for GET_ASSIGNMENT */

        struct {
                rd_kafka_topic_partition_list_t *partitions;
        } rebalance;
     } rko_u;
};

从上面的代码可以看到:

  • typedef struct rd_kafka_op_s rd_kafka_op_t封装了请求的结构体,即rd_kafka_op_s 是 librdkafka 内部在 各个线程之间传递一切事件、命令、数据 的"信封"。

  • 无论是 FETCH 消息、DeliveryReportRebalance 事件,还是各类请求/应答(ASSIGN、TERMINATE ...),最终都被打包成一条 rd_kafka_op_t 放进某个 rd_kafka_q_t 队列,在不同线程间转移。下文会详细讲解rd_kafka_q_t 队列;

  • rd_kafka_op_s中,通过一个Union结构体rko_u来设置对应的rd_kafka_op_type_t对应的请求信息体(即对应的载荷),因为不同的rd_kafka_op_type_t的请求信息是不同的:

  • 同时,每一个op自身都携带了一个serve方法,代表对这个op的处理方式。下文会讲到,

  • 每一个请求结构体都反向引用了这个请求结构体对应的owner,表示这个请求结构体属于哪一个客户端:

    cpp 复制代码
        rd_kafka_t     *rko_rk;

    下文会讲到,rd_kafka_q_t 队列也有类似的owner指针,指向这个队列所属的owner。

其中rd_kafka_op_t中的rko_u的定义如下:

cpp 复制代码
    struct rd_kafka_op_s {
        ....
        union {
                struct {
                        rd_kafka_buf_t *rkbuf;
                        rd_kafka_msg_t rkm;
                        int evidx;
                } fetch;
        
                ....
                struct {
                        rd_kafka_topic_partition_list_t *partitions;
                        void (*cb)(rd_kafka_t *rk,
                                   rd_kafka_resp_err_t err,
                                   rd_kafka_topic_partition_list_t *offsets,
                                   void *opaque);
                        void *opaque;
                        int silent_empty; /**< Fail silently if there are no
                                           *   offsets to commit. */
                        rd_ts_t ts_timeout;
                        char *reason;
                } offset_commit;
        
                struct {
                        rd_kafka_topic_partition_list_t *topics;
                } subscribe; /* also used for GET_SUBSCRIPTION */
        
                struct {
                        rd_kafka_topic_partition_list_t *partitions;
                        rd_kafka_assign_method_t method;
                } assign; /* also used for GET_ASSIGNMENT */
        
                struct {
                        rd_kafka_topic_partition_list_t *partitions;
                } rebalance;
                ....
        
                struct {
                        int pause;
                        int flag;
                } pause;
                ....

    } rko_u;

我们可以简单看一下我们关心的几种典型的rd_kafka_op_type_t type,以及这些type对应的rko_u载荷:

Op 类型 消息方向 应用层可见 (rd_kafka_poll() 能否拿到) 主要载荷字段(rd_kafka_op_s::rko_u.*) 作用与说明
RD_KAFKA_OP_FETCH Fetcher 线程 → App ✅ 是 fetch.rkbuf(原始消息 batch buffer) fetch.rkm(首条消息解析结果) 携带一条或多条真正的 Kafka 消息;被放入应用队列后由 rd_kafka_consumer_poll() 返回给用户。
RD_KAFKA_OP_REBALANCE Broker 线程 → App ✅ 是 rebalance.partitions(新的 assignment / 被撤销分区列表) 协调者完成或启动 rebalance 时通知客户端;触发用户注册的 rebalance 回调。
RD_KAFKA_OP_SUBSCRIBE App 线程 → Consumer-Group 处理线程(cgrp) ❌ 否 subscribe.topics(topic 列表,partition 皆 UA 或空) 用户调用 rd_kafka_subscribe() / rd_kafka_unsubscribe() 产生;cgrp 线程收到后向 Coordinator 发送 JoinGroup 并缓存订阅集。
RD_KAFKA_OP_ASSIGN App 线程 → Consumer-Group 处理线程 ❌ 否 assign.partitions(显式分区集合) assign.method(ABSOLUTE / INCR_ASSIGN / INCR_UNASSIGN rd_kafka_assign() 执行时产生;cgrp 线程切换到"手动分区模式",不走组协议。
RD_KAFKA_OP_SEEK App 线程 → Topic-Partition handler ❌ 否 fetch_start.offset(目标 offset) fetch_start.rkcg(所属 cgrp,用于版本对齐) rd_kafka_seek() 请求;目标分区的 fetch 处理器在下一轮开始从新 offset 抓取。
RD_KAFKA_OP_COORD_QUERY 任意线程 → Broker-handler 线程 ❌ 否 无专属 union 字段(仅公共头) 触发一次查找 / 刷新当前 group.id 或事务 coordinator;Broker-handler 线程收到后发送 FindCoordinatorRequest
RD_KAFKA_OP_TERMINATE 任意线程 → 任意线程 ❌ 否 仅公共头 + rko_replyq(若是同步关闭) 通用"请终止"信号:如 consumer_close() 给 cgrp、broker_destroy() 给 broker 线程;目标线程检测到后做清理,并可通过 rko_replyq 回复。

下文在讲解比如Rebalance和Assign的时候会看到发送Assign请求的时候设置rko_u的过程,这里不详细描述。

同时,我们看到, rd_kafka_op_t中的另外一个重要变量

cpp 复制代码
struct rd_kafka_op_s {
	// 通过设置这个变量,请求者指示请求的处理者:将响应消息放入到rko_replyq.q中,并设置好版本号rko_replyq.version
	rd_kafka_replyq_t rko_replyq;
	
}

我们可以看到,rd_kafka_replyq_t是一个类型别名,其实是一个结构体rd_kafka_replyq_s,如下所示:

c 复制代码
/* One-off reply queue + reply version.
 * All APIs that take a rd_kafka_replyq_t makes a copy of the
 * struct as-is and grabs hold of the existing .q refcount.
 * Think of replyq as a (Q,VERSION) tuple. */
typedef struct rd_kafka_replyq_s {
	rd_kafka_q_t *q;       // 响应消息队列
	int32_t       version; // 版本号
#if ENABLE_DEVEL
	char *_id; /* Devel id used for debugging reference leaks.
		    * Is a strdup() of the caller's function name,
		    * which makes for easy debugging with valgrind. */
#endif
} rd_kafka_replyq_t;

可以看到,结构体rd_kafka_replyq_s最重要的是封装了一个响应队列,和一个版本号信息。

这里,一个最平常的调用过程是:

  • 调用方临时创建一条队列rd_kafka_q_t recvq,即一个rd_kafka_q_t对象
  • 将这个请求队列rd_kafka_q_t recvq 与当前 期望版本号 打包进 对应的rd_kafka_replyq_t对象中;
  • 将打包完成的rd_kafka_replyq_t对象塞进请求rd_kafka_q_t oprko_replyq 字段中,;
  • 目标线程处理完请求以后拿到响应,会把响应消息体 reply-op 放到 rd_kafka_q_t recvq中;
  • 调用线程在 rd_kafka_q_t recvq 上进行阻塞式 pop(),拿到结果。

**ℹ️ Subscribe 和 Unsubscribe **

在librdkafka中, subscribe和unsubscribe都被归类为subscribe,即,unsubscribe的语义其实就是Subscribe Nothing...

在对于assign和unassign, 则需要根据是全量的assign还是增量的assign来区分开

  • 对于全量,那么unassign也可以用assign去表示,即,unassign的语义等同于 Assign Nothing,因此他们的操作类型都是RD_KAFKA_ASSIGN_METHOD_ASSIGN

  • 对于增量,那么增量的Unassign和增量的Assign是完全不同的,他们需要用不同的assign类型表达。librdkafka把这三种assign的情况定义在了一个enum中:

    c 复制代码
       /**
        * @brief Enumerates the assign op sub-types.
        */
       typedef enum {
               `RD_KAFKA_ASSIGN_METHOD_ASSIGN`,       /**< Absolute assign/unassign */
               `RD_KAFKA_ASSIGN_METHOD_INCR_ASSIGN`,  /**< Incremental assign */
               `RD_KAFKA_ASSIGN_METHOD_INCR_UNASSIGN` /**< Incremental unassign */
       } `rd_kafka_assign_method_t`;

librdkafka内部消息队列rd_kafka_q_s介绍

上文讲到了一个请求消息会封装到一个rd_kafka_op_t对象。我们知道,librdkafka都是基于队列的,在很多情况下,基于队列的消息传递模式是这样的:

  1. 请求者(比如一个Consumer)根据请求的类型创建对应的请求消息结构体,一个rd_kafka_ops_t对象
  2. 根据请求类型的不同,这个请求消息体会被放入到对应的请求队列。比如,如果是unsubscribe()请求,这个消息会被放入到这个Consumer对应的Consumer Group的请求消息队列中。这个队列其实是一个rd_kafka_q_s对象。
  3. 同时,调用者会自行创建一个响应队列,也是一个rd_kafka_q_s对象,在发送请求的时候,会告知Consumer Group: 请把对应的响应放到这个我创建的响应队列中。同时,请求者会在这个响应队列上阻塞等待;
  4. 随后,Consumer的处理线程(即这个队列的Owner)会从这个请求队列中取出请求消息进行处理,并将处理结果(响应)放回到响应队列并在队列上执行通知;
  5. 消息的发送者收到响应队列有新元素进入的通知,就解除阻塞,从队列中拿出新响应进行处理

所以,librdkafka中的消息队列是一个比较重要的结构,这是定义在rd_kafka_q_s中的:

c 复制代码
struct rd_kafka_q_s {
    /* 线程同步 */
    mtx_t rkq_lock;             // 队列互斥锁,保护所有成员
    cnd_t rkq_cond;             // cond-var,用于 pop/wait

    /* Forward 机制,将消息转发给另外一个rd_kafka_q_s */
    struct rd_kafka_q_s *rkq_fwdq;
        // 若非 NULL,本队列被"软连接"到 rkq_fwdq;
        // push/pop 操作都会跳转到目标队列,相当于管道拼接。
        // 应用队列 rk_rep = forward(rkcg_q) 就用到此特性。

    /* 核心链表结构体 */
    struct rd_kafka_op_tailq rkq_q;   // TAILQ_HEAD → 挂 rd_kafka_op_t
    int      rkq_qlen;                // 当前元素个数
    int64_t  rkq_qsize;               // 按 op->rko_len 统计的总 payload 字节数

    /* 生命周期管理 */
    int rkq_refcnt;                   // 引用计数
    int rkq_flags;                    // 状态&属性位
#define RD_KAFKA_Q_F_ALLOCATED 0x1    // 由 rd_kafka_q_new() malloc,销毁时 free
#define RD_KAFKA_Q_F_READY     0x2    // 已初始化,可读写;destroy 时清除此位
#define RD_KAFKA_Q_F_FWD_APP   0x4    // 正在被 rd_kafka_queue_forward() 转发到应用
#define RD_KAFKA_Q_F_YIELD     0x8    // 让等待线程提前返回(wake-up without op)

    /* 归属与唤醒 */
    rd_kafka_t *rkq_rk;               // 回指自己所属的 rd_kafka_t,用于日志/统计,以及定向销毁某一个rkq_rk的所有队列
    struct rd_kafka_q_io *rkq_qio;    // FD 或 callback 方式的应用级唤醒结构

    /* 服务回调 */
    rd_kafka_q_serve_cb_t *rkq_serve; // 可覆写的 per-op 处理器
    void                  *rkq_opaque;// serve 回调私参
    .....
};

所以,一个典型的调用过程示例如下(同步 RPC)

  1. 调用者创建一个响应队列rd_kafka_q_s recvq = rd_kafka_q_new(rk), 并将这个队列的归属(rd_kafka_t *rkq_rk)设置为传入的rk
  2. 构造请求结构体rk_kafka_op_t,将这个请求结构体中的响应对象rko_replyq中的响应队列设置为刚刚创建的rd_kafka_q_s recvq: rko_replyq.q = recvq
  3. 调用线程在自己设置的响应队列上执行阻塞式等待: rd_kafka_q_wait_result(recvq)
  4. 目标线程处理完请求后, 将响应消息放入到响应队列中: rd_kafka_q_enq(recvq, REPLY op)
  5. 调用者线程从响应队列中pop出来响应消息,随后销毁这个响应队列并释放内存rd_kafka_q_destroy_owner(recvq) (refcnt-- 到 0)

可以看到,队列rd_kafka_q_s只关心搬运和同步,不解释 op 的业务含义。

需要注意到,每一个rd_kafka_q_s队列都有一个owner,代表这个队列的归属信息,即这个队列属于哪个客户端:

cpp 复制代码
    rd_kafka_t *rkq_rk;
  • 一个 rd_kafka_t* 代表一个 Kafka Producer 或 Consumer 句柄,该Consumer或者Producer句柄在初始化时会创建若干内部队列(network poll 队列、main event 队列 ...),这样,任何后续通过 rd_kafka_q_new(rk) 动态创建的队列,都会把当前的客户端 rk 记录进自己的 rkq_rk 字段,同时,在请求过程中,也会创建临时响应队列,这个创建的临时响应队列也会通过rkq_rk来标记队列的owner。比如,我们下文会讲到的消息发送的方法,会创建一个临时响应队列rd_kafka_op_t *reply:

    cpp 复制代码
    /**
       发送一个请求到指定的队列 destq,然后同步等待其返回结果。
       调用者是 rd_kafka_assign0,这里的Timeout是-1,也就是永久
       在 rd_kafka_assign0 中调用,destq是对应的Consumer Group的
     */
    rd_kafka_op_t *
    rd_kafka_op_req(rd_kafka_q_t *destq, // 一般是某个模块的操作处理队列,比如主线程队列,这里是ConsumerGroup的队列
                    rd_kafka_op_t *rko,  // 要发送的请求操作(一个 rd_kafka_op_t 实例,表示要执行的操作)。
                    int timeout_ms) {
            rd_kafka_q_t *recvq; // 存放回复消息的队列,我们为这个请求专门建一个 queue 来等response package。
            rd_kafka_op_t *reply; // 存放回复
            // 创建一个回复队列,即一个rd_kafka
            recvq = rd_kafka_q_new(destq->rkq_rk);
            /**
             * 将请求 rko 发送到目标队列 destq(这里是consumer group的manager queue)。
             * 将 recvq 设为这个请求的 reply-to queue,即响应要回送到这个队列。
             * 阻塞等待 recvq 中出现响应(带 timeout)
             * 返回对应的响应信息 rd_kafka_op_t *
             */
            reply = rd_kafka_op_req0(destq, recvq, rko, timeout_ms);
        
            rd_kafka_q_destroy_owner(recvq); // 销毁响应队列
        
            return reply;
    }

    这里,方法rd_kafka_q_new()会在内部调用rd_kafka_q_init0()方法,从而将destq->rkq_rk句柄设置到新创建的rd_kafka_q_t *recvq中。

  • 为什么要带这个回指?

    • 资源回收:当我们需要销毁一个Kafka客户端句柄rd_kafka_t 时,它递归销毁"隶属"自己的所有队列(检查 rkq_rk==self)。
    • 统计与日志:队列上的指标计入同一 client,日志打印可携带实例名。
    • 线程协作:后台线程拿到队列后能知道该队列该归哪个 client 的锁/计数体系管理。

我们看一下,创建一个rd_kafka_q_s队列的基本过程。

可以看到,在比如方法 rd_kafka_op_req()中,是通过调用rd_kafka_q_new()来创建对应的响应队列结构体rd_kafka_q_s的,其实,rd_kafka_q_new是一个宏定义,背后是调用rd_kafka_q_new0()来完成的:

cpp 复制代码
rd_kafka_q_t *rd_kafka_q_new0 (rd_kafka_t *rk, const char *func, int line);
#define rd_kafka_q_new(rk) rd_kafka_q_new0(rk,__FUNCTION__,__LINE__)
cpp 复制代码
    rd_kafka_q_t *rd_kafka_q_new0 (rd_kafka_t *rk,
                                   const char *func, int line) {
        rd_kafka_q_t *rkq = rd_malloc(sizeof(*rkq));  // ① 分配内存
        rd_kafka_q_init(rkq, rk);                    // ② 结构体字段初始化
        rkq->rkq_flags |= RD_KAFKA_Q_F_ALLOCATED;    // ③ 标明堆分配,未来需要 free
        ......
        return rkq;                                  // ⑤ 返回新建队列指针
    }

可以看到,rd_kafka_q_new0()的基本流程是:

  • 申请 sizeof(rd_kafka_q_t) 的裸内存,即为这个队列申请内存

  • 调用方法rd_kafka_q_init()来对这个队列rd_kafka_q_t结构体中的各种字段进行初始化。其中,rd_kafka_q_init也是一个宏定义,背后调用的是rd_kafka_q_init0(...)方法,我们随后讲解。

    cpp 复制代码
      void rd_kafka_q_init0 (rd_kafka_q_t *rkq, rd_kafka_t *rk,
                             const char *func, int line);
      #define rd_kafka_q_init(rkq,rk) rd_kafka_q_init0(rkq,rk,__FUNCTION__,__LINE__)
  • 返回队列结构指针(rd_kafka_q_t *)给调用者(典型调用点:rd_kafka_op_req() 中创建 recvq;)

其中,rd_kafka_q_init0()的初始化过程不进行赘述,最重要的,是将对应的Kafka客户端设置到rkq_rk中:

cpp 复制代码
void rd_kafka_q_init0 (rd_kafka_q_t *rkq, rd_kafka_t *rk,
                       const char *func, int line) {
        rd_kafka_q_reset(rkq);          // ① 清空链表、计数
        rkq->rkq_fwdq   = NULL;         // ② 转发队列,目前不做 转发
        rkq->rkq_refcnt = 1;            // ③ 初始引用计数
        rkq->rkq_flags  = RD_KAFKA_Q_F_READY;   // ④ 标记可用
        rkq->rkq_rk     = rk;           // ⑤ 回指所属 rd_kafka_t
        rkq->rkq_qio    = NULL;         // ⑥ 还未绑定 fd/callback 唤醒
        ......
}

librdkafka层面unsubscribe()的具体过程

我们从上面基本的堆栈分析可以看出来,KafkaConsumer::moveConsumer()的时候会进行unsubscribe操作,这个unsubscribe操作势必会加重rebalance的storm。当然,即使在close()以前不进行显式的unsubscribe(),整个集群的Consumer执行close,同样会造成整个集群的rebalance发生:

cpp 复制代码
    ConsumerPtr && KafkaConsumer::moveConsumer()
    {
        ....
        consumer->unsubscribe(); // 执行unsubscribe操作
        drain(); // 等待,一直到Kafka收不到消息了
        return std::move(consumer);
    }

我们以cppkafka::Consumer::unsubscribe()为入口,一直深入到librdkafka层面,看一下unsubscribe()的基本过程:

cpp 复制代码
/**
 * unsubscribe是阻塞的,即,如果方法返回,说明unsubscribe成功了,但是很明显,下层的实现机制全部是异步的
 */
void Consumer::unsubscribe() {
    rd_kafka_resp_err_t error = rd_kafka_unsubscribe(get_handle());
    check_error(error);
}

HandlePtr handle_;
using HandlePtr = std::unique_ptr<rd_kafka_t, HandleDeleter>;
rd_kafka_t* KafkaHandleBase::get_handle() const {
    return handle_.get();
}

可以看到,cppkafka::Consumer会直接调用rd_kafka_unsubscribe()方法。get_handle()参数返回的是一个rd_kafka_t结构体,这个结构体就是librdkafka层面的Consumer,这里不具体看librdkafka层面的rd_kafka_t,下文会详细解释,我们直接看一下rd_kafka_unsubscribe(...)方法:

cpp 复制代码
/**
 * RD_KAFKA_OP_SUBSCRIBE 这个请求 不是发送给 Broker 的,而是 发送到本地内部线程的本地队列 (rkcg_ops) 上去处理的。
 * 它本质上是 告诉 librdkafka 自己内部:"我要 unsubscribe,请本地自己做必要的状态清理动作。"  并不是发 Kafka 网络协议到 Broker!
 *
 * 注意: unsubscribe() 本质是本地操作(取消订阅,清理状态)
 * 真正的 LeaveGroup 请求是异步发给 Broker 的,而且后续 rebalance 是 Broker 主导,不是你主动控制。
 * librdkafka 设计成了:应用线程 -> 提交请求到本地队列 -> 后台线程处理 -> 需要时再发网络请求。
 * @param rk
 * @return
 */

rd_kafka_resp_err_t rd_kafka_unsubscribe (rd_kafka_t *rk) {
        rd_kafka_cgrp_t *rkcg;

        if (!(rkcg = rd_kafka_cgrp_get(rk)))
                return RD_KAFKA_RESP_ERR__UNKNOWN_GROUP;
        return rd_kafka_op_err_destroy(rd_kafka_op_req2(rkcg->rkcg_ops,                       RD_KAFKA_OP_SUBSCRIBE));
}

可以看到:

  1. 先判断这个rd_kafka_t *rk中是否含有对应的consumer group,如果没有,则无需进行unsubscribe操作,如果有,则取出来。

    c 复制代码
           if (!(rkcg = rd_kafka_cgrp_get(rk)))
                    return RD_KAFKA_RESP_ERR__UNKNOWN_GROUP;

    librdkafka的 Consumer Group是使用 rd_kafka_cgrp_t这个struct来封装的,这里不再赘述。

  2. 然后,开始发送unsubscribe请求。我们会看到,librdkafka的一切 API 调用最终都会变成一条rd_kafka_op_t在不同的内部队列间流转:

    c 复制代码
        rd_kafka_op_req2(rkcg->rkcg_ops, RD_KAFKA_OP_SUBSCRIBE)

    方法rd_kafka_op_req2()其实是一个向某个队列插入operation的通用方法 ,上层调用者通过提供某个特殊队列和特殊的op,来实现基于生产者-消费者模型的消息异步传递。在这里:

    • 消息会被放入的队列是rkcg->rkcg_ops,即这个Consumer所对应的Consumer Group中的消息队列。我们下文会详细讲到rkcg->rkcg_ops的消息会被转发到rk_ops队列然后有一个独立的main thread通过方法rd_kafka_thread_main()进行统一处理。每一个consumer都有一个独立的main thread。

    • RD_KAFKA_OP_SUBSCRIBE是对应的操作类型,根据该类型进行相应的处理行为。
      注意,我们这里需要进行的是取消订阅unsubscribe,但是却使用的是RD_KAFKA_OP_SUBSCRIBE, 原因是,rd_kafka_op_type_t中没有一个专门针对取消订阅所定义的枚举的,因为根据librdkafka的设计,取消订阅其实是一种特殊的订阅,即Subsribe Nothing,所以,这里就传入了一个op,而没有提供任何topic。

      c 复制代码
          /**
           * Send simple type-only request to queue, wait for response.
           */
          rd_kafka_op_t *rd_kafka_op_req2 (rd_kafka_q_t *destq, rd_kafka_op_type_t type) {
                  rd_kafka_op_t *rko;
                  // 这里根据rd_kafka_op_type_t type 创建对应的rd_kafka_op_t
                  rko = rd_kafka_op_new(type);
                  // 调用rd_kafka_op_req进行消息传递并阻塞等待结果
                  return rd_kafka_op_req(destq, rko, RD_POLL_INFINITE);
          }

      这里,rd_kafka_op_req2()方法只是负责根据rd_kafka_op_type_t构造了对应的rk_kafka_op_t,然后,内部交给rd_kafka_op_req()方法进行执行。下文会详细讲解 rd_kafka_op_req()的具体实现,需要注意, 从调用者视角,rd_kafka_op_req()似乎是一个同步方法,因为它会阻塞等待结果返回。但是,在方法内部,其实质上是一个"伪同步、内部异步"的方式,即基于librdkafka的消息队列进行消息传递。下文我们会详细介绍同步、异步、阻塞、非阻塞的不同分类。

  3. subscribe(...)进行对照,我们可以反观对应的用来进行subscribe的rd_kafka_subscribe(...)的方法rd_kafka_resp_err_t rd_kafka_subscribe(...)rd_kafka_subscribe()方法提供了需要进行订阅的topic list,这里不再赘述:

    cpp 复制代码
           rd_kafka_resp_err_t
                 rd_kafka_subscribe (rd_kafka_t *rk,
                                     const rd_kafka_topic_partition_list_t *topics) {
                 
                         rd_kafka_op_t *rko;
                         rd_kafka_cgrp_t *rkcg;
                         rd_kafka_topic_partition_list_t *topics_cpy;
                         .....
                 
                         topics_cpy = rd_kafka_topic_partition_list_copy(topics);
                         if (rd_kafka_topic_partition_list_has_duplicates(topics_cpy,
                             rd_true/*ignore partition field*/)) {
                                 rd_kafka_topic_partition_list_destroy(topics_cpy);
                                 return RD_KAFKA_RESP_ERR__INVALID_ARG;
                         }
                         // 创建对应的OP 并将对应需要订阅的Topics放到op里面
                         rko = rd_kafka_op_new(RD_KAFKA_OP_SUBSCRIBE);
                 	rko->rko_u.subscribe.topics = topics_cpy;
                 
                         return rd_kafka_op_err_destroy(
                                 rd_kafka_op_req(rkcg->rkcg_ops, rko, RD_POLL_INFINITE));
             }
         ```

ℹ️同步、异步、阻塞、非阻塞

类别 调用线程是否阻塞 结果如何送回 典型 API 内部实现要点
① 纯异步、无回执(fire-and-forget) 无需回传 rd_kafka_poll() 中产生的应用回调;rd_kafka_yield(); producer 默认的 rd_kafka_produce()(未要求 delivery_cb 只把数据封进 RD_KAFKA_OP_XMIT_BUF > / RD_KAFKA_OP_FETCH 并 enqueue 给 broker / app 队列;请求结构体中不设置(为null) rko_replyq
② 异步、事件 / 回调通知 应用线程稍后在 rd_kafka_poll() 中收到 event 或执行回调 delivery report(DR),stats_cbrebalance_cblog_cbthrottle_cb,consumer 普通消息(rd_kafka_consumer_poll()) > 生产侧或内部线程 enqueue RD_KAFKA_OP_*rk_rep;应用线程 poll 时非阻塞地获取。
③ "同步 API"------伪同步、内部异步 是(等待完成) 通过临时回复队列收到 RD_KAFKA_OP_REPLY rd_kafka_commit()rd_kafka_metadata()rd_kafka_consumer_close()rd_kafka_flush() 1. > 调用方创建 recvq → 填 rko_replyq; 2. enqueue 请求到目标线程(broker、cgrp 等); 3. 调用线程在 recvqq_wait_result(); 4. 目标线程处理后把 reply op 放入 rko_replyq.q
④ 内部完全同步 / 阻塞 是(仅内部) 同一线程内直接返回 内部辅助函数(不对外暴露) 不经过 op / queue 机制;直接在当前线程完成逻辑并返回结果。

所以,可以看到, rd_kafka_op_req2()方法内部构造完成了rd_kafka_op_t结构体,就开始调用rd_kafka_op_req()方法发送该请求。

我们会看到,rd_kafka_op_req()是一个阻塞方法,它不仅仅是发送请求,还会阻塞等待响应的到来:

cpp 复制代码
/**
   发送一个请求到指定的队列 destq,然后同步等待其返回结果。
   调用者是 rd_kafka_assign0,这里的Timeout是-1,也就是永久
   在 rd_kafka_assign0 中调用,destq是对应的Consumer Group的
 */
rd_kafka_op_t *
rd_kafka_op_req(rd_kafka_q_t *destq, // 一般是某个模块的操作处理队列,比如主线程队列,这里是ConsumerGroup的队列
                rd_kafka_op_t *rko,  // 要发送的请求操作(一个 rd_kafka_op_t 实例,表示要执行的操作)。
                int timeout_ms) {
        rd_kafka_q_t *recvq; // 存放回复消息的队列,我们为这个请求专门建一个 queue 来等response package。
        rd_kafka_op_t *reply; // 存放回复
        // 创建一个回复队列,即一个rd_kafka
        recvq = rd_kafka_q_new(destq->rkq_rk);
        /**
         * 将请求 rko 发送到目标队列 destq(这里是consumer group的manager queue)。
         * 将 recvq 设为这个请求的 reply-to queue,即响应要回送到这个队列。
         * 阻塞等待 recvq 中出现响应(带 timeout)
         * 返回对应的响应信息 rd_kafka_op_t *
         */
        reply = rd_kafka_op_req0(destq, recvq, rko, timeout_ms);
    
        rd_kafka_q_destroy_owner(recvq); // 销毁相应队列
    
        return reply;
}

可以看到:

  • rd_kafka_op_req()方法的作用是向某个目标队列发送一个请求操作rd_kafka_op_t,并等待该请求的回应,所以,该方法包含了请求发送等待响应 的两个步骤,是一个阻塞式的方法。它会新建一个临时的 recvq(接收队列)用来接收该请求的响应,然后调用内部的 rd_kafka_op_req0() 来执行操作请求,最终返回收到的响应。

  • 上文讲过,对于我们刚刚rd_kafka_subscribe()的调用,这个目标队列是Consumer Group的管理队列rkcg->rkcg_ops。对于后面要讲到的assign请求,也同样是rkcg->rkcg_ops

  • 这个响应队列是在方法rd_kafka_op_req()内部临时新建的,用来存放响应,这个队列在rd_kafka_op_req()返回的时候就会销毁,因为我们只关心结果。下文会详细讲解方法rd_kafka_q_new()创建一个队列的基本过程。

    我们可以看到,整个librdkafka全部是基于生产者-消费者模式,即使一个简单的请求,这个请求也会被放入到对应的队列进行处理,同时,调用者还会亲自创建一个响应队列,并要求请求的处理者在收到响应以后将响应放到这个响应队列中:

    cpp 复制代码
      rd_kafka_q_t *recvq; // 存放响应消息的队列,我们为这个请求专门建一个 队列来等response package
      rd_kafka_op_t *reply; // 存放响应消息的结构体
      
      recvq = rd_kafka_q_new(destq->rkq_rk);  // 创建响应队列

    在这里,创建响应队列rd_kafka_q_s recvq的时候,传入了目标参数destq->rkq_rk,这里的destq队列是这个Consumer Group的内部消息队列,即unsubscribe时候的请求消息队列,所以,destq->rkq_rk对应于这个请求消息队列所属的Kafka实例;

    在 librdkafka 内部,每一个队列对象 rd_kafka_q_t 结构体里都有一个字段rkq_rk,代表这条队列归属于哪一个 rd_kafka_t(Kafka client 实例)

    cpp 复制代码
      struct rd_kafka_q_s {
          rd_kafka_t *rkq_rk;   /* back-pointer to the client handle */
          ...
      };

    所以,destq->rkq_rk代表这个队列(Consumer Group的消息队列)队列所属的rd_kafka_t,其实也是这个rkcg(Consumer Group)所属的Kafka Client。

    我们上文已经具体讲解了rd_kafka_q_new(...)方法,这里不再赘述。

  • rd_kafka_op_t *rko操作请求发送给目标队列destq(上面说过,这里的destq是 Consumer Group 的 manager queue)。同时指定 recvq 作为响应要回送的队列,然后等待响应或超时。

    cpp 复制代码
        reply = rd_kafka_op_req0(destq, recvq, rko, timeout_ms);
  • 在销毁了对应的响应队列以后,将响应返回。

cpp 复制代码
       rd_kafka_q_destroy_owner(recvq); // 销毁响应队列
       return reply;

可以看到,rd_kafka_op_req(...)方法只是负责构造好了对应的响应队列,内部其实是调用rd_kafka_op_req0()来具体发送请求并接收响应的。

rd_kafka_op_req0()方法是一个阻塞方法,虽然内部实现是基于生产者-消费者模式。它的作用是将请求操作rd_kafka_op_t *rko发送到目标请求队列 destq中,然后阻塞等待回复队列(回复队列是调用者自己创建的)recvq中出现对应的响应。所以,这就导致上面说过的rd_kafka_op_req(...)其实是一个阻塞方法。

所以,rd_kafka_op_req0()这个函数是Kafka内部用于实现同步通信机制的基础工具:

cpp 复制代码
rd_kafka_op_t *rd_kafka_op_req0(rd_kafka_q_t *destq, // 目标队列,存放请求, 这里是 这个consumer group的queue
                                rd_kafka_q_t *recvq, // 接收队列,存放响应,这个是在调用者rd_kafka_op_req()中临时创建的队列
                                rd_kafka_op_t *rko,  // 对应的请求结构体,封装了RD_KAFKA_OP_SUBSCRIBE这个operation
                                int timeout_ms) { // 超时时间
        rd_kafka_op_t *reply;
    
        /* Indicate to destination where to send reply. */
        rd_kafka_op_set_replyq(rko, recvq, NULL); // 设置接收队列,这样Kafka把回复的消息放到该队列中
    
        /* Enqueue op */
        if (!rd_kafka_q_enq(destq, rko)) // 发送消息, 把rko发送到目标请求队列destq中
                return NULL;
        // 消息发送成功,开始从返回队列中获取消息
        /* Wait for reply */
        // 这里是堆栈的一部分,Block在了等待reply的阶段
        reply = rd_kafka_q_pop(recvq, rd_timeout_us(timeout_ms), 0);
    
        /* May be NULL for timeout */
        return reply;
}
  • 将创建的回复队列设置到rko->rko_replyq,让对方(即监控请求队列、取出请求、处理请求并收到响应然后把响应交付给调用者的一方)知道把响应投递到这个队列里。这是通过调用 rd_kafka_op_set_replyq(),将rd_kafka_op_t *rko的响应队列rko_replyq设置为 rd_kafka_q_t *recvq

    这一步非常关键,Kafka 的内部机制会在处理请求结构体rd_kafka_op_t *rko时查看请求结构体中设置的这个响应队列rko->rko_replyq,并将处理结果(通常是另一个 rd_kafka_op_t)发送到这个队列中。

    cpp 复制代码
    rd_kafka_op_set_replyq(rko, recvq, NULL); // 设置接收队列,这样Kafka把回复的消息放到该队列中
    复制代码
        static RD_INLINE void
        rd_kafka_op_set_replyq (rd_kafka_op_t *rko, // 需要设置reply队列的rd_kafka_op_t
                                rd_kafka_q_t  *rkq, // 临时创建的响应队列
                                rd_atomic32_t *versionptr) {
                rd_kafka_set_replyq(&rko->rko_replyq,        /* 将响应队列写入到rko->rko_replyq中 */
                                    rkq,
                                    versionptr ? rd_atomic32_get(versionptr) : 0);
        }
        
        static RD_INLINE void
        rd_kafka_set_replyq (rd_kafka_replyq_t *replyq, // rko中的rko_replyq,指向这个请求对应的响应队列
                             rd_kafka_q_t *rkq, int32_t version) {
                replyq->q       = rkq ? rd_kafka_q_keep(rkq) : NULL; /* 引用 +1 */
                replyq->version = version;                           /* 期望版本 */
                ....
        }
  • 调用 rd_kafka_q_enq() 将该操作请求rd_kafka_op_t *rko放入目标发送队列 rd_kafka_q_t *destq中。这实际上是"发请求"这一步。如果队列已关闭或禁用,返回 false,此时函数返回 NULL:

    cpp 复制代码
    if (!rd_kafka_q_enq(destq, rko)) // 发送消息, 把rko发送到目标请求队列destq中
             return NULL;
  • 如果rd_kafka_op_t *rko成功入队,接下来就是"同步等待回应"的步骤。

    由于前面已经通过rd_kafka_op_set_replyq()将响应队列放到了请求结构体rd_kafka_op_t的rko_replyq中,因此,请求的处理者在处理完消息并返回响应的时候,是可以通过rko_replyq知道应该将响应结构体放入到哪个响应队列中的。

    请求者调用以后,就使用 rd_kafka_q_pop()rd_kafka_q_t *recvq中等待拉取响应对象,阻塞最长时间为 timeout_ms 毫秒(单位转换为微秒)。 这个rd_kafka_q_t *recvq就是前面设置到rko->rko_replyq中的回复通道,处理线程(通常在 broker manager thread 中)会处理完请求后将响应放进rko->rko_replyq中来:

    cpp 复制代码
      reply = rd_kafka_q_pop(recvq, rd_timeout_us(timeout_ms), 0);
  • 如果有reply, 则返回。有可能超时无法拿到结果。

所以,这就是一个消息发送的基本过程。

Kafka 的"Group"是 逻辑概念:同一个 group.id 下可以有任意多台主机、任意多进程、任意多客户端成员。而在 librdkafka 的代码层次,每通过rd_kafka_new(RD_KAFKA_CONSUMER, ...)创建一个Kafka实例,就得到一个独立的 rd_kafka_t > *------我们通常称它为"一个 client 实例"或"一个 Consumer 实例"。它内部会:

cpp 复制代码
      `rd_kafka_t` (client对象)         ← 只属于当前进程这一条实例
      └─ `rd_kafka_cgrp_t`             ← 如果配置了 `group.id`,则为该 client 创建
         ├─ `rkcg_ops`   (管理事件请求队列)
         └─ `rkcg_q`     (管理事件输出队列)

所以,虽然逻辑上一个Consumer Group是很多Client的集合,但是在librdkafka的实现层面,一个 client 都有一个Consumer Group对象的私有副本, 这个ConsumerGroup的私有副本的rckg_opsrkcg_q,以及我们说的ConsumerGroup内部的处理线程(处理队列中的请求并形成响应), 也都是这个Consumer Group和Consumer的私有副本,因此,实际上一个Client都有一套独立的cgrp队列和处理线程。

所以请求队列rkcg_opsrkq_rk字段,和响应队列rkcg_qrkq_rk字段,都指回自己所在的 rd_kafka_t *,即kakfa consumer实体;当我们通过rd_kafka_destroy(rk),库会递归销毁"所有的队列中的rkq_rk == rk的队列"。这与同组的其他进程、其他client 实例互不干扰。

可以看到,rd_kafka_op_req0()会通过方法rd_kafka_q_enq1()来发送请求,所以,rd_kafka_q_enq1()是整个基于消息队列的请求发送的关键步骤,其中包含了消息转发的关键逻辑:

cpp 复制代码
/**
Kafka 的队列 (rd_kafka_q_t) 支持转发(forwarding)机制,例如:
        ```
         `rd_kafka_q_fwd_set(group_q, app_q)`;
        ```
意思是:group queue 不再自己处理消息,而是把所有消息都转发到 app queue。
这样带来两个问题:
  - 消息最终入队的队列是 app_q,但逻辑上它原本属于 group_q。
  - 某些信息(比如 serve 回调)应该保留 group_q 上绑定的处理逻辑,即即使在转发的时候,之前最初的处理逻辑应该随之保留并转移
 * @locality any thread.
 */
static RD_INLINE RD_UNUSED int rd_kafka_q_enq1(rd_kafka_q_t *rkq, // 要入队的目标队列
                                               rd_kafka_op_t *rko, // 要入队的操作对象的结构体,结构体中封装了响应队列和请求类型
                                               rd_kafka_q_t *orig_destq, // 最原始用户指定的队列(可能已经被转发),也就是调用者一开始想要把 rko(Kafka 操作事件)放入的队列。它在队列重定向(forwarding)机制下非常重要。


                                               int at_head, // 是否放在队列头部(否则放尾部)
                                               int do_lock) {
        rd_kafka_q_t *fwdq;
        if (unlikely(!(rkq->rkq_flags & RD_KAFKA_Q_F_READY))) { // 如果这个队列已经被disable了,那么就再也无法往这个队列中插入op了
                /* Queue has been disabled, reply to and fail the rko. */
                if (do_lock)
                        rdk_thread_mutex_unlock(&rkq->rkq_lock);

                return rd_kafka_op_reply(rko, RD_KAFKA_RESP_ERR__DESTROY); // 回复一个RD_KAFKA_RESP_ERR__DESTROY消息 
        }
        ......
        if (!(fwdq = rd_kafka_q_fwd_get(rkq, 0))) { // 当前的队列没有转发队列
                .....
                // 将请求rko入队列rkq。 这里的enq0是最底层的入队列操作,仅仅进行入队列,什么其他的都不做
                rd_kafka_q_enq0(rkq, rko, at_head);
                cnd_signal(&rkq->rkq_cond); // 发送信号,这样,在这个队列上等消息的就会收到通知,被unblock并取出消息
                ....
        } else {
                /**
                 * 但如果存在转发队列 fwdq,就不会把事件放入当前队列,而是把事件递归地放入转发队列 fwdq
                 */
                if (do_lock)
                        mtx_unlock(&rkq->rkq_lock);
                // 继续递归, 转发到 目标请求队列 的转发队列中
                rd_kafka_q_enq1(fwdq, rko, orig_destq, at_head, 1 /*do lock*/);
                rd_kafka_q_destroy(fwdq);
        }

        return 1;
}

可以看到,这里的关键逻辑是判断当前请求队列是否有转发队列:

  1. 如果当前这个请求队列rd_kafka_q_t *rkq没有转发队列,那就正常地将请求结构体rd_kafka_op_t *rko放入到目标请求队列rd_kafka_q_t *rkq中,同时,基于通知机制,向目标请求队列rd_kafka_q_t *rkq发送信号,这样,目标请求队列在收到信号以后就会从阻塞状态中唤醒,开始处理这个新放进来的请求rd_kafka_q_t *rkq

    请求异步处理以后生成的respopnse会放入到rd_kafka_op_t *rko中的响应队列中,但是获取响应并不是方法rd_kafka_q_enq1()负责的,而是后续方法 rd_kafka_q_pop()负责的,后面会讲:

    cpp 复制代码
    // 入队列。 这里的enq0是最底层的入队列操作,仅仅进行入队列,什么其他的都不做
    rd_kafka_q_enq0(rkq, rko, at_head);
    cnd_signal(&rkq->rkq_cond); // 发送信号,这样,在这个队列上等消息的就会收到通知,被unblock并取出消息
  2. 如果当前这个目标请求队列rd_kafka_q_t *rkq有对应的转发队列,那么,就不会将请求rd_kafka_op_t *rko放入当前目标请求队列,而是直接放入到目标请求队列rd_kafka_q_t *rkq的转发队列。很显然,这里是一个递归调用 。如果它的转发队列还有转发队列,那么这个请求rko会被放到转发链条的最后一个队列中:

    cpp 复制代码
    rd_kafka_q_enq1(fwdq, rko, orig_destq, at_head, 1 /*do lock*/);
    rd_kafka_q_destroy(fwdq);

所以,rd_kafka_q_enq1()方法已经将请求发送到了指定的目标请求队列,随后就通过方法rd_kafka_q_pop()阻塞等待响应信息。我们看一下 rd_kafka_q_pop()的具体实现:

cpp 复制代码
/**
 * 在rd_kafka_resp_err_t rd_kafka_consumer_close中被调用
 */
rd_kafka_op_t *
rd_kafka_q_pop(rd_kafka_q_t *rkq, rd_ts_t timeout_us, int32_t version) {
        // rkq是用来接收消息的队列
        return rd_kafka_q_pop_serve(rkq, timeout_us, version,
                                    RD_KAFKA_Q_CB_RETURN,  // 这里的意思是,读到的消息用来直接触发callback
                                    NULL, NULL);
}

/**
 * Serve q like rd_kafka_q_serve() until an op is found that can be returned
 * as an event to the application.
 *
 * @returns the first event:able op, or NULL on timeout.
 *
 * Locality: any thread
 * rd_kafka_q_pop(rd_kafka_q_t *rkq, rd_ts_t timeout_us, int32_t version) 中调用了该方法
 * 这里的意图是,在调用该方法以前,已经将rd_kafka_q_t *rkq设置为某一个转发队列,然后通过该方法等待这个队列中出现新的消息
 */
rd_kafka_op_t *rd_kafka_q_pop_serve(rd_kafka_q_t *rkq, // 用来存放response的队列
                                    rd_ts_t timeout_us, // 超时时间
                                    int32_t version,
                                    rd_kafka_q_cb_type_t cb_type,
                                    rd_kafka_q_serve_cb_t *callback,
                                    void *opaque) {
        rd_kafka_op_t *rko; // op
        rd_kafka_q_t *fwdq; // 队列

        rd_dassert(cb_type);

        mtx_lock(&rkq->rkq_lock); // 锁定队列

        rd_kafka_yield_thread = 0;
        if (!(fwdq = rd_kafka_q_fwd_get(rkq, 0))) {
                // 这个检查尝试获取队列的转发队列(如果有的话)。如果返回 NULL,表示没有转发队列,接着执行后面的逻辑。
                const rd_bool_t can_q_contain_fetched_msgs =
                    rd_kafka_q_can_contain_fetched_msgs(rkq, RD_DONT_LOCK);

                struct timespec timeout_tspec;

                rd_timeout_init_timespec_us(&timeout_tspec, timeout_us);

                if (can_q_contain_fetched_msgs)
                        rd_kafka_app_poll_start(rkq->rkq_rk, 0, timeout_us);

                while (1) { // 无限循环,除非从内部退出
                        rd_kafka_op_res_t res;
                        /* Keep track of current lock status to avoid
                         * unnecessary lock flapping in all the cases below. */
                        rd_bool_t is_locked = rd_true;

                        /* Filter out outdated ops */
                retry:
                        /**
                         * 队列为空时,rko 会被设置为 NULL,并跳出循环。此时程序会等待新的操作到来,直到条件变量被触发。
                         * 队列中有操作但都不符合条件时,rko 将为 NULL,也会跳出循环,等待新的操作。
                         */
                        while ((rko = TAILQ_FIRST(&rkq->rkq_q)) &&
                               !(rko = rd_kafka_op_filter(rkq, rko, version)))
                                ;
                        // 退出上面的while循环,说明rkq->rkq_q中已经有了新的消息,并且经过filter以后判断符合要求
                        rd_kafka_q_mark_served(rkq); // 标记这个queue为已经被处理
                        // 执行到这里,rko可能不为空,代表一个合法有效的operation,也有可能为空
                        if (rko) {
                                /* Proper versioned op */
                                rd_kafka_q_deq0(rkq, rko);

                                /* Let op_handle() operate without lock
                                 * held to allow re-enqueuing, etc. */
                                mtx_unlock(&rkq->rkq_lock);
                                is_locked = rd_false;

                                /* Ops with callbacks are considered handled
                                 * and we move on to the next op, if any.
                                 * Ops w/o callbacks are returned immediately */
                                res = rd_kafka_op_handle(rkq->rkq_rk, rkq, rko,
                                                         cb_type, opaque,
                                                         callback);

                                if (res == RD_KAFKA_OP_RES_HANDLED ||
                                    res == RD_KAFKA_OP_RES_KEEP) {
                                        mtx_lock(&rkq->rkq_lock);
                                        is_locked = rd_true;
                                        goto retry; /* Next op */
                                } else if (unlikely(res ==
                                                    RD_KAFKA_OP_RES_YIELD)) {
                                        if (can_q_contain_fetched_msgs)
                                                rd_kafka_app_polled(
                                                    rkq->rkq_rk);
                                        /* Callback yielded, unroll */
                                        return NULL;
                                } else {
                                        if (can_q_contain_fetched_msgs)
                                                rd_kafka_app_polled(
                                                    rkq->rkq_rk);
                                        break; /* Proper op, handle below. */
                                }
                        }
                        // 执行到这里,说明队列为空
                        // unlikely 是一个宏,用来提示编译器这条分支不常走,提高分支预测效率,没有逻辑含义
                        if (unlikely(rd_kafka_q_check_yield(rkq))) { // 如果yield标志位的确置位了
                                if (is_locked)
                                        mtx_unlock(&rkq->rkq_lock);
                                if (can_q_contain_fetched_msgs)
                                        rd_kafka_app_polled(rkq->rkq_rk);
                                return NULL;
                        }

                        if (!is_locked)
                                mtx_lock(&rkq->rkq_lock);
                        //  使用条件变量 (rkq->rkq_cond) 来等待队列中是否有新操作可处理。
                        //  如果队列为空或没有满足条件的操作,它会在这个条件变量上阻塞,直到有新的操作到来或超时。
                        // 在我们的场景下, timeout是INFINITE,因此会一直等待
                        // 很显然,队列有元素插入的时候,会在rkq上发送通知。 参考方法: static RD_INLINE RD_UNUSED int rd_kafka_q_enq1
                        if (cnd_timedwait_abs(&rkq->rkq_cond, &rkq->rkq_lock,
                                              &timeout_tspec) != thrd_success) { // 条件变量在等待通知期间,是会释放互斥锁的
                                mtx_unlock(&rkq->rkq_lock); //条件满足,释放锁
                                if (can_q_contain_fetched_msgs)
                                        rd_kafka_app_polled(rkq->rkq_rk);
                                return NULL;
                        }
                }

        } else {
                // 如果rkq本身还有转发队列(记住,rd_kafka_resp_err_t rd_kafka_consumer_close方法中,这个rke本身就是consumer group队列的转发队列),函数会将当前的队列操作转发到子队列进行处理。
                /* Since the q_pop may block we need to release the parent
                 * queue's lock. */
                mtx_unlock(&rkq->rkq_lock);
                rko = rd_kafka_q_pop_serve(fwdq, timeout_us, version, cb_type,
                                           callback, opaque);
                rd_kafka_q_destroy(fwdq);
        }


        return rko;
}

可以看到,这个方法的作用,是以阻塞等待的方式,从队列中弹出一个可以"返回给应用层"的操作对象 rd_kafka_op_t。我们在本文看到的CleanupThread的阻塞,就是Consumer在关闭的过程中,CleanupThread一直在方法rd_kafka_q_pop_serve()中阻塞式等待响应导致针对CleanupThread的join()操作始终无法结束。

  1. 关键逻辑,也是看当前用来接收相应的队列是否有转发队列,如果有转发队列,那么当前队列就不是最终接收者,因此会基于转发链条的最终节点(转发队列链条的最后一个队列)来调用rd_kafka_q_pop_serve()方法:

    cpp 复制代码
            } else {
                    mtx_unlock(&rkq->rkq_lock);
                    rko = rd_kafka_q_pop_serve(fwdq, timeout_us, version, cb_type,
                                               callback, opaque); // 递归调用
                    rd_kafka_q_destroy(fwdq);
            }
  2. 如果当前队列没有转发队列,即,当前队列是转发链条的最后一个节点,那么就开始运行处理逻辑。

    • 进入主循环,直到拿到可返回的操作,或超时、yield、中断等:

      c 复制代码
          while (1) { // 无限循环,除非从内部退出
                  rd_kafka_op_res_t res;
                  rd_bool_t is_locked = rd_true;
    • 不断检查当前响应队列的头部是否有对应的operation(响应)进来,经过适当的条件过滤,一旦拿到了合法的一个operation(响应),就标记该队列为已经使用的状态,并开始处理该请求:

      cpp 复制代码
          retry:
                while ((rko = TAILQ_FIRST(&rkq->rkq_q)) &&
                       !(rko = rd_kafka_op_filter(rkq, rko, version)))
                        ;
                
                rd_kafka_q_mark_served(rkq);

      注意,宏定义方法TAILQ_FIRST是一个非阻塞式的方法,类似于Java中的peek()(区别于poll()),因此,这里while循环退出的两种情况是:

      • 队列中没有元素任何元素,while循环退出,后面会通过cnd_timedwait_abs(&rkq->rkq_cond, &rkq->rkq_lock,&timeout_tspec)进行阻塞式等待
      • 队列中有元素,但是元素不满足过滤条件。这时候会继续等待新的元素进入队列
    • 如果的确获取到了一个满足条件的rd_kafka_op_t *rko,就开始对这个rko进行处理,处理逻辑这里不做赘述,比如,对callback的调用等等,处理完成以后就会跳出主循环了

      cpp 复制代码
      if (rko) { // 获取到的响应
              .....
              res = rd_kafka_op_handle(rkq->rkq_rk, rkq, rko,
                                       cb_type, opaque,
                                       callback);
      
              ....
          }
    • 无论是队列中没有元素,还是刚刚取出的元素已经被处理,都需要进入阻塞式等待,直到队列中有新的元素进来:

      cpp 复制代码
          if (cnd_timedwait_abs(&rkq->rkq_cond, &rkq->rkq_lock,
                                &timeout_tspec) != thrd_success) { // 条件变量在等待通知期间,是会释放互斥锁的
                  mtx_unlock(&rkq->rkq_lock); //条件满足,释放锁
                  if (can_q_contain_fetched_msgs)
                          rd_kafka_app_polled(rkq->rkq_rk);
                  return NULL;
          }
  3. 返回rko,即响应结构体

librdkafka底层队列的转发逻辑
  1. 写入的转发

    写入的转发是很好理解的,即,如果存在比如转发逻辑src => target,那么写入到src的消息会被转发到 target队列。这个写入时候对转发的处理可以从下面的入队列操作的代码得到印证:

    c 复制代码
    static RD_INLINE RD_UNUSED
    int rd_kafka_q_enq1 (rd_kafka_q_t *rkq,        // 当前递归所到达的队列
                         rd_kafka_op_t *rko,       // 待插入的op
                         rd_kafka_q_t *orig_destq, // 最原始的目标队列
                         int at_head, int do_lock) {
            rd_kafka_q_t *fwdq;
            ...
            if (!(fwdq = rd_kafka_q_fwd_get(rkq, 0))) { // 如果没有转发队列,即当前已经到达了整个转发链的最末端,或者,这个队列本身就的确只有一个元素
                if (!rko->rko_serve && orig_destq->rkq_serve) { // 如果原始的目标队列有serve回调,那么就把这个serve回调设定到
                        /* Store original queue's serve callback and opaque
                         * prior to forwarding. */
                        rko->rko_serve = orig_destq->rkq_serve; // 使用最原始队列的serve callback设置到待插入的op中
                        rko->rko_serve_opaque = orig_destq->rkq_opaque;
                }
    
                rd_kafka_q_enq0(rkq, rko, at_head);     // 执行插入
                rdk_thread_cond_signal(&rkq->rkq_cond); // 通知当前队列rkq,唤醒在这个队列上的消费者
            } else {
                    if (do_lock)
                            rdk_thread_mutex_unlock(&rkq->rkq_lock);
                    rd_kafka_q_enq1(fwdq, rko, orig_destq, at_head, 1/*do lock*/); // 如果存在转发队列,那么消息就直接递归地写入到转发队列
                    rd_kafka_q_destroy(fwdq);
            }
    
            return 1;
    }

    从上面的代码可以看到,

    • 如果存在转发队列,那么消息会递归并写入到最终的转发队列,而不会再写入到src队列。
    • 在递归过程中,rd_kafka_q_enq1()方法也通过参数orig_destq记录了整个转发链的尾部节点,即整个转发链的起始节点,这主要是为了将起始节点中的serve callback设定给即将入队列的op上(rd_kafka_op_t *rko),这样,后续当从队列中弹出一个op以后,会使用这个attach的serve callback来处理这个OP。

    我们以A ⇒ B ⇒ C 的转发链为例,看一下rd_kafka_q_enq1()方法的递归调用过程。其中最需要关注的,是对于rkq和orig_destq的参数变化

    text 复制代码
    A.rkq_fwdq = B
    B.rkq_fwdq = C
    C.rkq_fwdq = NULL(链尾)

    你可以把 A/B 当成"代理壳",真正收/发都落到 C。

    text 复制代码
    enqueue 调用者
      |
      v
    rd_kafka_q_enq1(rkq=A, orig_destq=A, rko_serve=NULL)
      A 有 fwdq=B  => 递归到 B(orig_destq 仍是 A)
            |
            v
       rd_kafka_q_enq1(rkq=B, orig_destq=A, rko_serve=NULL)
         B 有 fwdq=C  => 递归到 C(orig_destq 仍是 A)
               |
               v
          rd_kafka_q_enq1(rkq=C, orig_destq=A, rko_serve=NULL)
            C 无 fwdq => 真正入队到 C
                     => 这里才会设置 rko_serve = orig_destq->rkq_serve (= A->rkq_serve)
  2. 读取的转发

    队列的转发逻辑在读取过程中也会生效,即,如果存在转发逻辑比如src => target(下文会讲到),那么读取队列src会导致被递归读取到target。这其实是一种无奈之举:即,我们希望,在设定了从src => target的转发逻辑以后,原有的读取src的操作不会由于转发逻辑的设定而再也读不到消息。

c 复制代码
/**
 * Serve q like rd_kafka_q_serve() until an op is found that can be returned
 * as an event to the application.
 *
 * @returns the first event:able op, or NULL on timeout.
 *
 * Locality: any thread
 */
rd_kafka_op_t *rd_kafka_q_pop_serve (rd_kafka_q_t *rkq, rd_ts_t timeout_us,
                                     int32_t version,
                                     rd_kafka_q_cb_type_t cb_type,
                                     rd_kafka_q_serve_cb_t *callback,
                                     void *opaque) {
	rd_kafka_op_t *rko;
        rd_kafka_q_t *fwdq;

        rd_dassert(cb_type);

	rdk_thread_mutex_lock(&rkq->rkq_lock);

        rd_kafka_yield_thread = 0;
        if (!(fwdq = rd_kafka_q_fwd_get(rkq, 0))) {
                ....
        } else {
                /* Since the q_pop may block we need to release the parent
                 * queue's lock. */
                rdk_thread_mutex_unlock(&rkq->rkq_lock);
		         rko = rd_kafka_q_pop_serve(fwdq, timeout_us, version,
					   cb_type, callback, opaque); // 递归调用
                rd_kafka_q_destroy(fwdq);
        }


	return rko;
}

通过rd_kafka_q_serve()也能看到相关读取逻辑:

c 复制代码
int rd_kafka_q_serve (rd_kafka_q_t *rkq, int timeout_ms,
                      int max_cnt, rd_kafka_q_cb_type_t cb_type,
                      rd_kafka_q_serve_cb_t *callback, void *opaque) {
        ....
        if ((fwdq = rd_kafka_q_fwd_get(rkq, 0))) {
		          ret = rd_kafka_q_serve(fwdq, timeout_ms, max_cnt,
                                       cb_type, callback, opaque);
                rd_kafka_q_destroy(fwdq);
        }
        ....
		 return ret;
	}

队列的转发逻辑对于了解整个消息传递链路非常关键。下文中,我们会详细讲解从rkcg_ops => rk_ops的转发逻辑,从rk_rep => rkcg_q的转发逻辑,以及,在close的过程中构建的从 rk_rep => rkcg_q => rk_tmp 的转发逻辑。

close操作和普通操作的响应机制的区别

Close操作是整个Consumer的生命周期的终结,因此,cppkafka::Consumer对Close操作的处理方式和对于普通操作(unsubscribe,commit,等等)的处理方式完全不同,其基本出发点是:Close操作发生的时候,很可能上层应用已经不关心任何其他消息了,因此,Close操作最好接管整个响应队列,然后一次性处理完整个响应队列中的所有消息。但是,普通的控制操作虽然和Close一样都是基于异步事件队列实现的阻塞式请求,但是,它只想实现一种点对点的消息回复。

  1. Close请求的响应设置方式

    cpp 复制代码
    rd_kafka_resp_err_t rd_kafka_consumer_close (rd_kafka_t *rk) {
        rd_kafka_cgrp_t *rkcg;
        rkq = rd_kafka_q_new(rk);                   // 临时"专属"事件队列
        rd_kafka_q_fwd_set(rkcg->rkcg_q, rkq);      // 把 group 事件总线改道到 rkq
        rd_kafka_cgrp_terminate(... replyq = rkq);  // 异步贴 TERMINATE
        
        // 关闭线程阻塞等待
        while ((ev = rd_kafka_q_pop(rkq, INFINITE)))
            ...

    可以看到,这里创建了一个全新的响应队列(我们命名为rkq_tmp),同时,将整个Consumer Group的响应队列rkcg->rkcg_q完全转发给新创建的队列rkq,因此,后续所有 group 事件(TERMINATE、REBALANCE、OFFSET_COMMIT ...)都会流向这个新创建的rkq,由当前的关闭线程独占消费所有事件,所以,即使应用程序的代码此时已经不再Poll消息(是否还继续Poll消息由应用程序自己的实现决定,但是,总的说来,既然已经开始关闭了,应用程序是不希望继续收到新消息的),新消息消费也会被当前的关闭线程收到。至于怎么处理新消息,那是这个关闭线程的事情。

  2. 普通控制请求的响应设置方式

    但是对于普通的控制请求,也是先创建一个响应队列recvq。但是,像Close操作一样独占(转发)整个主队列显然是不合适的。这里的处理方式是,临时响应队列recvq只挂在这一个rd_kafka_op_t *rkorko_replyq上,属于"点对点"返回通道, cgrp线程处理完rko请求以后,通过rd_kafka_op_reply()把结果写回该rko->rko_replyq,完成后应用销毁 recvq,不会影响其它事件的路由,也不影响 rkcg_q 的转发设置。

    cpp 复制代码
        recvq = rd_kafka_q_new(rk); // 创建一个响应队列用来接收响应或者event
        rd_kafka_op_set_replyq(rko, recvq, NULL);   // 仅这一条请求用
        rd_kafka_q_enq(rkcg_ops, rko);              // 送给 cgrp 线程
        
        // 应用线程阻塞等待
        reply = rd_kafka_q_pop(recvq, timeout);
librdkakfa中的关键输入、输出队列和响应线程解析

在这里,我们会从cppkafka::Consumerlibrdkafka两个层面介绍整个close请求的发送、响应的接收整个过程。

可以看到,Cleanup Thread在调用StorageKafka::cleanConsumers()的时候,会先通过StorageKafka::moveConsumer()进行取消订阅cppkafka::Consumer::unsubscribe(),然后执行cppkafka::Consumer的析构 Consumer::~Consumer

由于取消订阅unsubscribe是阻塞的,因此,既然我们在堆栈中看到Consumer::~Consumer,那说明这个cppkafka::Consumer的unsubscribe是成功的,

很显然,与unsubscribe()相比,close的请求也是基于跟unsubscribe一模一样的生产者-消费者和事件触发的消息传递机制,即他们在下层的基本代码是一致的,我们仅仅看一下cppkafka::Consumer::close()在上层的调用逻辑:

cpp 复制代码
/**
 * 在 Consumer::~Consumer() 中被调用
 * 搜索  rd_kafka_resp_err_t rd_kafka_consumer_close(
 */
void Consumer::close() {
    rd_kafka_resp_err_t error = rd_kafka_consumer_close(get_handle());
    check_error(error);
}

上文讲过,get_handle()返回的是当前的cppkafka::Consumer所持有的librdkafka的consumer结构体指针rd_kafka_t*。我们看一下rd_kafka_consumer_close()方法:

c 复制代码
/**
 * close的时候发出的是 RD_KAFKA_OP_TERMINATE
 * unsubsribe的时候,发出的是 RD_KAFKA_OP_SUBSCRIBE
 * @param rk
 * @return
 */
rd_kafka_resp_err_t rd_kafka_consumer_close (rd_kafka_t *rk) {
        rd_kafka_error_t *error;
        rd_kafka_resp_err_t err = RD_KAFKA_RESP_ERR__TIMED_OUT;
        rd_kafka_q_t *rkq;

        // 临时队列,用来存放TERMINATE的响应消息
        rkq = rd_kafka_q_new(rk);
        // 将整个Consumer Group的响应队列设置为这个新建的队列,这样,发送到Consumer Group响应队列rkcg->rkcg_q的消息都会被发送到rkq了
        rd_kafka_q_fwd_set(rkcg->rkcg_q, rkq);
        /* Initiate the close (async) */
        /**
         * 这个函数会向后台发送关闭请求,并传入 rkq 作为"回应队列",也就是说一旦关闭完成,对应的 TERMINATE 信号会写到这个队列里。
         */
       rd_kafka_cgrp_terminate(rkcg, RD_KAFKA_REPLYQ(rkq, 0)); /* async */
        ......
        if (rd_kafka_destroy_flags_no_consumer_close(rk)) {
                // 立即关闭,不再等待的场景
        } else {
                rd_kafka_op_t *rko;
                rd_kafka_dbg(rk, CONSUMER, "CLOSE", "Waiting for close events");
                /**
                 * 在通过方法 rd_kafka_consumer_close_q(rk, rkq); 将rkq设置为consumer group的转发队列以后,
                 * consumer group收到的消息就会进入到这个新建的rkq中,因此,在这里,不断等待来自rkq中的消息
                 */
                while ((rko = rd_kafka_q_pop(rkq, RD_POLL_INFINITE, 0))) {
                        rd_kafka_op_res_t res;
                        if ((rko->rko_type & ~RD_KAFKA_OP_FLAGMASK) ==
                            RD_KAFKA_OP_TERMINATE) { // 等到了terminiate,退出
                                err = rko->rko_err;
                                rd_kafka_op_destroy(rko);
                                break; // 跳出循环
                        }
                        // 只要不是terminate消息,就在rd_kafka_poll_cb中进行处理
                        /* Handle callbacks */
                        res = rd_kafka_poll_cb(rk, rkq, rko,
                                               RD_KAFKA_Q_CB_RETURN, NULL);
                        if (res == RD_KAFKA_OP_RES_PASS)
                                rd_kafka_op_destroy(rko);
                        /* Ignore YIELD, we need to finish */
                }
        }

        rd_kafka_q_destroy_owner(rkq);
        

        return err;
}

可以看到,该方法是关闭一个Kafka消费者的关键步骤,再次记住,在关闭的时候,我们已经对这个Consumer成功进行了unsubscribe:

  1. 和unsubscribe一样,关闭的时候也会创建一个临时响应队列,用来存放关闭请求对应的响应信息:

    cpp 复制代码
    rkq = rd_kafka_q_new(rk);
  2. 确认该 consumer 属于某一个Consumer Group,并且确认该group并不是处于TERMINIATED状态。上文说过,在close以前,已经进行了unsubscribe,但是unsubscribe不是leave group,unsubscribe并不会导致Consumer离开group:

    cpp 复制代码
    if (!(rkcg = rd_kafka_cgrp_get(rk)))
            return RD_KAFKA_RESP_ERR__UNKNOWN_GROUP;
    
    /* If a fatal error has been raised and this is an
     * explicit consumer_close() from the application we return
     * a fatal error. Otherwise let the "silent" no_consumer_close
     * logic be performed to clean up properly. */
    if (rd_kafka_fatal_error_code(rk) &&
        !rd_kafka_destroy_flags_no_consumer_close(rk))
            return RD_KAFKA_RESP_ERR__FATAL;
  3. 将 group 的内部事件响应队列(rkcg->rkcg_q)重定向到用户指定的响应队列 rkq

    默认情况下,consumer group 的内部事件(如 rebalance、关闭通知)是发到 rkcg->rkcg_q 中,此处将其转发到应用提供的 rkq 队列(应用层临时创建的响应队列),使得用户在 poll() 中能收到TERMINATE和其他事件,这是整个关闭流程中非常关键的一步,保证后续的异步事件流转路径正确。上文已经通过rd_kafka_q_enq1()等方法讲过,将消息插入到队列的时候,如果发现该队列有转发队列,那么消息会被插入到最终的转发队列:

    cpp 复制代码
         rd_kafka_q_fwd_set(rkcg->rkcg_q, rkq);

    注意,这里的rkcg->rkcg_q是rkcg的响应队列,而不是其请求队列rkcg->rkcg_ops,我们下文会通过示意图详细讲解。

  4. 异步发送关闭请求并立刻返回,消息的调用者rd_kafka_consumer_close(....)会负责poll对应的响应队列:

    cpp 复制代码
    rd_kafka_cgrp_terminate(rkcg, RD_KAFKA_REPLYQ(rkq, 0)); /* async */

    我们进一步查看rd_kafka_cgrp_terminate()方法的具体实现(下文会详细讲解),可以看到,它其实是向ConsumerGroup的请求队列rkcg_ops中插入了对应的关闭请求事件RD_KAFKA_OP_TERMINATE,也就是说,这个关闭请求事件是发送给对应的Consumer Group来处理的,这和unsubscribe的过程是一样的,都是把请求插入到Consumer Group的请求队列rkcg->rkcg_ops中,并且自行创建响应队列,并把响应队列设置到Consumer Group的响应队列指针rko->rko_replyq中:

    cpp 复制代码
         void rd_kafka_cgrp_terminate(rd_kafka_cgrp_t *rkcg, rd_kafka_replyq_t replyq) {
             .....
             // 向ConsumerGroup的请求队列发送消息
             rd_kafka_cgrp_op(rkcg, NULL, replyq, RD_KAFKA_OP_TERMINATE, 0); 
         }

    向Consumer Group的请求队列中发送消息是通过方法rd_kafka_cgrp_op(...)完成的:

    cpp 复制代码
    /**
     * Send an op to a cgrp.
     *
     * Locality: any thread
     */
    void rd_kafka_cgrp_op(rd_kafka_cgrp_t *rkcg,
                          rd_kafka_toppar_t *rktp,
                          rd_kafka_replyq_t replyq,
                          rd_kafka_op_type_t type,
                          rd_kafka_resp_err_t err) {
            rd_kafka_op_t *rko;
    
            rko             = rd_kafka_op_new(type); // 根据请求类型,创建对应的请求结构体
            rko->rko_err    = err;
            rko->rko_replyq = replyq; // 将临时创建的队列放入到请求结构体中,进行点对点响应
            ......
            // 将请求消息放入到consumer group的rkcg_ops中,同时,已经将对应的响应对垒设置到了rko->rko_replyq中
            rd_kafka_q_enq(rkcg->rkcg_ops, rko);
    }    
  5. 判断当前是否是NO_ONSUMER_CLOSE模式。在NO_CONSUMER_CLOSE模式下,会直接disable+purge 临时队列,不进入 "Waiting for close events" 的无限 pop 循环:

    cpp 复制代码
    if (rd_kafka_destroy_flags_no_consumer_close(rk)) { // NO_CONSUMER_CLOSE模式
                    rd_kafka_q_disable(rkq);
                    /* Purge ops already enqueued */
                    rd_kafka_q_purge(rkq);
            } 

    这个NO_CONSUMER_CLOSE模式是通过RD_KAFKA_DESTROY_F_NO_CONSUMER_CLOSE很显然,在我们的case下面,整个assign卡主,正是因为没有处于NO_CONSUMER_CLOSE模式,因此需要等待CLOSE Event,但是却意外地等到了对应的ASSIGN Event,然后走ASSIGN/UNASSIGN流程,然后,这个流程又被block住了。

  6. 当关闭请求发送以后,就开始阻塞等待响应队列中有响应消息:

    cpp 复制代码
    while ((rko = rd_kafka_q_pop(rkq, RD_POLL_INFINITE, 0))) {
    • 如果等到了TERMINATE响应消息,则跳出循环

      cpp 复制代码
      rd_kafka_op_res_t res;
      if ((rko->rko_type & ~RD_KAFKA_OP_FLAGMASK) ==
          RD_KAFKA_OP_TERMINATE) { // 等到了terminiate,退出
              err = rko->rko_err;
              rd_kafka_op_destroy(rko);
              break; // 跳出循环
      }
    • 如果不是TERMINATE响应消息,则进行进一步处理,用rd_kafka_poll_cb()统一调度。我们在客户端堆栈中看到CleanupThread被卡主,就是在这里 ,等待TERMINATE请求的时候出现了其他的响应信息(rebalance),在处理这个rebalance的响应信息的时候被block住了:

      cpp 复制代码
      res = rd_kafka_poll_cb(rk, rkq, rko, RD_KAFKA_Q_CB_RETURN, NULL);
  7. 成功等到并处理了RD_KAFKA_OP_TERMINATE消息,因此销毁临时临时响应队列并结束:

    cpp 复制代码
     rd_kafka_q_destroy_owner(rkq);

所以,可以看到,rd_kafka_consumer_close()中非常关键的步骤,即有别于普通请求操作的步骤,是在方法内部创建了一个临时响应队列,并强行将Consumer Group的响应队列rkcg->rkcg_q的目标转发队列设置到这个临时创建的响应队列rk_tmp。

cpp 复制代码
/* consumer_close() */
rkq = rd_kafka_q_new(rk);                     // 临时 recvq
rd_kafka_q_fwd_set(rkcg->rkcg_q, rkq);        // 把 rkcg_q 改道到专用 rkq

这个动作意味着,App层所设置的转发规则已经被强行覆盖,因此,应用层已经不可能再收到来自ConsumerGroup的响应队列所转发的消息了,所有消息只能被转发到当前临时创建的响应队列了。

运行过程和关闭过程中的三套forward链路

上文已经讲过转发逻辑对于队列读写行为的影响。这里,关于转发逻辑,除了在consumer close的特殊阶段设定了rkcg_q -> rk_tmp的转发逻辑的设定,还存在另外两个转发逻辑的设定:

  1. rkcg_ops => rk_ops 的转发逻辑:

    c 复制代码
    static int rd_kafka_thread_main (void *arg) {
            ....
            if (rk->rk_cgrp)
                    rd_kafka_q_fwd_set(rk->rk_cgrp->rkcg_ops, rk->rk_ops);

    这里,设定rkcg_ops => rk_ops的意图,是为了让main thread只需要专注处理rk_ops队里即可,因为来自rkcg_ops的消息会自动转发到这里。

  2. rk_rep => rkcg_q 的转发逻辑

    c 复制代码
    rd_kafka_resp_err_t rd_kafka_poll_set_consumer (rd_kafka_t *rk) {
            rd_kafka_cgrp_t *rkcg;
    
            if (!(rkcg = rd_kafka_cgrp_get(rk)))
                    return RD_KAFKA_RESP_ERR__UNKNOWN_GROUP;
    
            rd_kafka_q_fwd_set(rk->rk_rep, rkcg->rkcg_q);
            return RD_KAFKA_RESP_ERR_NO_ERROR;
    }

    这个转发逻辑的设定主要是为了兼容两种读取接口

    • 写入的时候,由于转发队列的存在,broker线程(客户端)会写入到转发目标 rkcg_q
    • 读取的时候,新的读取接口consumer_poll() 直接读 rkcg_q,没有问题,而旧的读取接口rd_kafka_poll()由于是读取rk_rep,由于转发逻辑的存在,会递归读取到转发逻辑的最终目标 rkcg_q
  3. rkcg_q -> rk_tmp的转发逻辑

    这是close阶段的特殊逻辑,这样,写入到响应队列rkcg_q的响应消息都将被统一拦截到rk_tmp,从而,在close阶段,我们可以只专注于与close相关的响应时间,而忽略掉任何其他的响应消息。

    c 复制代码
    rd_kafka_resp_err_t rd_kafka_consumer_close(rd_kafka_t *rk) {
            rd_kafka_error_t *error;
            rd_kafka_resp_err_t err = RD_KAFKA_RESP_ERR__TIMED_OUT;
            rd_kafka_q_t *rkq;
    
            // 临时队列,用来存放TERMINATE的响应消息
            rkq = rd_kafka_q_new(rk);
            // 将整个Consumer Group的响应队列设置为这个新建的队列,这样,发送到Consumer Group响应队列rkcg->rkcg_q的消息都会被发送到rkq了
            rd_kafka_q_fwd_set(rkcg->rkcg_q, rkq);

异步的消息传递链路概览

整个librdkafka客户端和kafka服务端的角色示意图如下:

text 复制代码
┌──────────────────────────────────────────────────────────────────────────────┐
│                                Kafka 集群(服务端)                            │
│                                                                              │
│   (多个 broker 进程)                                                         │
│                                                                              │
│   ┌──────────────────────────────┐      ┌──────────────────────────────┐     │
│   │   GroupCoordinator (B)       │      │    普通 Broker (A, C, ...)      │     │
│   └──────────────────────────────┘      └──────────────────────────────┘     │
│            ▲     ▲                               ▲     ▲                      │
│            │     │                               │     │                      │
│            │     │ Join / Sync / Leave /         │     │ Fetch / Produce /    │
│            │     │ Heartbeat                     │     │ Metadata / ...         │
└────────────┼─────┼───────────────────────────────┼─────┼──────────────────────┘
             │     │                               │     │
             │     │  TCP                          │     │  TCP
             ▼     ▼                               ▼     ▼
┌──────────────────────────────────────────────────────────────────────────────┐
│                             本地进程(librdkafka)                               │
│                                                                              │
│ (1) Broker 线程(客户端内网络 I/O 线程)                                       │
│     ┌────────────────────────────────────────────────────────────────────┐    │
│     │ rd_kafka_broker_thread / fetcher / admin / log ...                    │    │
│     │ -- 负责 TCP I/O:与远程 Broker/Coordinator 收发协议消息               │    │
│     │ -- 收到响应后封装为 RD_KAFKA_OP_* 并 enqueue 到 rk_rep (⑦')           │    │
│     └────────────────────────────────────────────────────────────────────┘    │
│                                   │                                            │
│                                   │ ⑦' enqueue(FETCH/Admin/Log/Err...)          │
│                                   │     (写入 rk_rep)                           │
│                                   ▼                                            │
│     ┌──────────────────────────────────────────────────────────────────┐      │
│     │          应用队列 rk_rep  (legacy poll queue)                     │      │
│     └───────────────────────────────┬──────────────────────────────────┘      │
│                                     │ ③ forward: rk_rep ⇒ rkcg_q              │
│                                     │     (读写都代理)                         │
│                                     ▼                                          │
│                      ┌───────────────────────────────────┐                    │
│                      │ rkcg_q (consumer delivery queue)  │                    │
│                      └───────────────────────────────────┘                    │
│                                                                              │
│ (2) main 线程(不是 cgrp thread)                                              │
│     ┌──────────────────────────────────────────────────────────────┐         │
│     │ rd_kafka_thread_main()                                        │         │
│     │ ④ serve/pop rk_ops                                            │         │
│     │ ⑥ 调用 rd_kafka_cgrp_serve() 驱动 consumer-group 状态机        │         │
│     └───────────────────────▲──────────────────────────────────────┘         │
│                             │ ④ serve/pop                                    │
│                     ┌───────┴────────────────────────┐                       │
│                     │ rk_ops (main-thread in-queue,    │                      │
│                     │        generic dispatcher)       │                      │
│                     └───────▲────────────────────────┘                       │
│                             │ ② forward: rkcg_ops ⇒ rk_ops                    │
│                     ┌───────┴────────────────────────┐                       │
│                     │ rkcg_ops (CGRP control requests │                      │
│                     │          IN: SUBSCRIBE/ASSIGN/  │                      │
│                     │          SEEK/TERMINATE...)       │                      │
│                     └────────────────────────────────┘                       │
│                         ⑦ enqueue by API (subscribe/assign/seek/close...)      │
│                                                                              │
│ (3) 应用线程                                                                  │
│     ┌──────────────────────────────────────────────────────┐                 │
│     │ rd_kafka_poll() / rd_kafka_consumer_poll() /          │                 │
│     │ cppkafka::Consumer::poll()                            │                 │
│     └───────────────┬──────────────────────────────────────┘                 │
│                     │ ⑧ serve/pop rk_rep (rd_kafka_poll)                     │
│                     │    或 ⑤ serve/pop rkcg_q (consumer_poll)               │
│                     └───────────────────────────────────────────────────────┘
│                                                                              │
│ (close 阶段附加)                                                              │
│     ⑨ consumer_close(): 建 rkq_tmp 并设置 forward: rkcg_q ⇒ rkq_tmp            │
│     ⑩ 链路变为: rk_rep ⇒ rkcg_q ⇒ rkq_tmp                                     │
│     ⑪ close 线程(调用 close 的应用线程) serve/pop rkq_tmp 直到 TERMINATE       │
└──────────────────────────────────────────────────────────────────────────────┘



注意:
- "Broker 线程"是 **客户端进程内的网络 I/O 线程**
- "Coordinator"运行在 **远程 Kafka Broker 进程中**

相应步骤的解释:

  • ② forward: rkcg_opsrk_ops
    main 线程启动后设置转发,使 rkcg_ops 成为 rk_ops 的代理入口;对 rkcg_ops 的 enqueue/serve 都会落到 rk_ops,从而由 main 线程统一消费处理。
  • ③ forward: rk_reprkcg_q(读写都代理)
    Consumer 模式下设置转发后,rk_rep 成为 rkcg_q 的代理入口:写入 rk_rep 的 op 会落到 rkcg_q;在 rk_rep 上的 serve/pop 会递归到 rkcg_q。该机制用于兼容旧接口 rd_kafka_poll()
  • ④ main 线程 serve/pop rk_ops
    rd_kafka_thread_main() 周期性从 rk_ops 取出 op 并分发处理;包括来自 rkcg_opsConsumerGroup 控制请求。
  • consumer_poll() serve/pop rkcg_q
    rd_kafka_consumer_poll()/cppkafka::Consumer::poll() 直接消费 rkcg_q,获取 consumer 的交付内容:FETCH 消息、REBALANCE 事件、提交结果、错误等。
  • ⑥ main 线程调用 rd_kafka_cgrp_serve() 推进 ConsumerGroup 状态机
    main 线程通过 rd_kafka_cgrp_serve() 推动 join/sync/heartbeat/revoke/assign/terminate 等状态机步骤;网络收发由 broker 线程执行,但状态机推进点在 main 线程。
  • ⑦ 应用 API enqueue 到 rkcg_ops
    应用线程调用 subscribe/assign/seek/commit/close 等 API 构造控制类 op 并 enqueue 到 rkcg_ops;由于步骤②,最终由 main 线程从 rk_ops 消费并处理。
  • ⑦' Broker 线程 enqueue 到 rk_rep
    broker I/O 线程(以及 fetcher/admin/log 子系统线程)负责 TCP I/O;与远程普通 broker 收发 Fetch/Produce/Metadata 等协议消息,与远程 Coordinator 收发 Join/Sync/Leave/Heartbeat 等组协议消息。收到响应或生成异步事件后,封装为 RD_KAFKA_OP_* 并 enqueue 到 rk_rep;若步骤③生效,则这些 op 被代理到 rkcg_q
  • rd_kafka_poll() serve/pop rk_rep(但会因步骤③递归到 rkcg_q
    rd_kafka_poll() 的入口队列是 rk_rep。当 rk_reprkcg_q 生效时,rd_kafka_poll()rk_rep 上的 serve/pop 会递归到 rkcg_q,从而也能获取 consumer 的交付内容。
  • ⑨ close:rkcg_qrkq_tmp
    rd_kafka_consumer_close() 创建临时队列 rkq_tmp,并将 rkcg_q 转发到该临时队列,以便在关闭过程中集中处理关闭相关事件与终止完成信号。
  • ⑩ close 后链路:rk_reprkcg_qrkq_tmp
    close 阶段新增 rkcg_qrkq_tmp 后,与既有的 rk_reprkcg_q 形成转发链路,使进入 consumer 交付通道的事件最终都改道到 close 临时队列。
  • ⑪ close 线程 serve/pop rkq_tmp 直到 TERMINATE 完成
    调用 close 的应用线程循环从 rkq_tmp 取出并处理事件,直到收到终止完成信号后结束关闭流程,并撤销转发、销毁临时队列。

总之,从上图,我们可以总结如下:

  • 在服务器端,普通的broker(Broker A, C)能serve 普通的produce/consume请求,而对于一些管理请求,比如Join/Sync,必须是具有Coordinator角色的特殊broker(Broker B)才能进行serve
  • librdkafka客户端的线程有main线程(负责app层发送的消息的处理)、broker线程(broker端处理来自远程Kafka服务器的响应消息)、应用层的consumer线程(处理来自Broker线程投递的响应消息)
  • 对于来自app层的请求消息,存在着rkcg_ops => rk_ops的转发逻辑,这样,main线程只需要专注于serve rk_ops队列,就可以处理来自客户端的所有请求消息
  • 对于来自broker(客户端)层的响应消息,存在从rk_rep => rkcg_q的转发逻辑,这个转发逻辑在读写的时候有两层含义:
    • broker层写入的时候,写入到rk_rep,因此进一步被转发到rkcg_q队列
    • app层读取的时候,分为两种读取方式:
      • rd_kafka_consumer_poll()/cppkafka::Consumer::poll()(新版API) 直接消费 rkcg_q,获取 consumer 的交付内容:FETCH 消息、REBALANCE 事件、提交结果、错误等。由于存在 rk_rep => rkcg_q的转发逻辑,所以rkcg_q中有来自rk_rep的所有消息
      • rd_kafka_poll()(旧版API) 的入口队列是 rk_rep。当 rk_rep ⇒ rkcg_q 生效时,rd_kafka_poll()rk_rep 上的 serve/pop 会递归到 rkcg_q,从而也能获取 consumer 的交付内容。

注意: 每个 rd_kafka_t 逻辑上属于同一个 Kafka Consumer Group(同一 group.id,由远程 coordinator 分配分区),但进程内线程与队列完全独立, 每一个rd_kafka_t 都有自己独立的一套(main thread + broker I/O threads + rkcg_ops/rk_ops/rk_rep/rkcg_q 等),这些队列和线程(包括main线程)完全独立和隔离。

到了close阶段,会创建一个临时队列rkq_tmp,同时增加设定新的响应的转发逻辑rkcg_q => rkq_tmp,因此整个转发路径变成了 rk_rep => rkcg_q => rkq_tmp 。这时候:

  • close 线程(就是调用 close 的应用线程)循环 serve/pop rkq_tmp队列,目的是完全消耗掉rebalance callbacks、终止事件等,直到收到 terminate完成close。这里的消耗的意思是,忽略掉除了terminate以外的其他响应。
  • close 同时会发 TERMINATE/LEAVE 等控制请求, 请求的处理逻辑还是通过转发链路 rkcg_ops => rk_ops 由 main thread 统一处理并与 broker 协商。
  • close 结束:撤销 rkcg_q 的 forward,销毁 rkq_tmp

当通过方法rd_kafka_consumer_close()完成的请求的发送,就开始通过rko = rd_kafka_q_pop(rkq, RD_POLL_INFINITE, 0)来等待响应消息的到来。

在上文讲到rd_kafka_consumer_close()方法的时候可以看到,如果这时候正常等来了TERMINATE响应,则正常退出。如果不是,则会进行额外的处理逻辑,这就是我们上文中讲到的CleanupThread所引发的堆栈的处理逻辑和阻塞位置,即:本来发出了close请求,但是却等来了其他的响应(assign),这时候只能"硬着头皮"去处理,却在处理过程中永远阻塞了:

cpp 复制代码
if ((rko->rko_type & ~RD_KAFKA_OP_FLAGMASK) ==
                            RD_KAFKA_OP_TERMINATE) { // 等到了terminiate,退出
                                err = rko->rko_err;
                                rd_kafka_op_destroy(rko);
                                break; // 跳出循环
                        }
                        // 只要不是terminate消息,就在rd_kafka_poll_cb中进行处理
                        /* Handle callbacks */
                        res = rd_kafka_poll_cb(rk, rkq, rko,
                                               RD_KAFKA_Q_CB_RETURN, NULL);

所以,从上面的代码可以看到,如果在等待TERMINATE的过程中意外收到了其他消息,那么会调用rd_kafka_poll_cb()进行处理。

我们看一下意外收到 非TERMINATE消息 的时候,调用rd_kafka_poll_cb()方法的处理逻辑:

cpp 复制代码
rd_kafka_op_res_t rd_kafka_poll_cb(rd_kafka_t *rk, // 对应的kafka客户端
                                   rd_kafka_q_t *rkq, // 用来接收消息的消息队列
                                   rd_kafka_op_t *rko,  // 收到的消息所对应的op 结构体
                                   rd_kafka_q_cb_type_t cb_type,
                                   void *opaque) {
        .....

        switch ((int)rko->rko_type) {
        case RD_KAFKA_OP_FETCH: // 获取到了新的Kafka 消息
                ....
                break;

        case RD_KAFKA_OP_REBALANCE: // // 预期收到Terminate消息,但是却收到了Rebalance消息
                if (rk->rk_conf.rebalance_cb)  // 如果有callback,那么就调用callback,这里调用的是 cppkafka::Consumer::rebalance_proxy,
                        rk->rk_conf.rebalance_cb(
                            // Kafka 原生协议里面,Rebalance 既有可能是"让你分配新的 partition"(assign),也有可能是"取消已有的 partition"(revoke)
                            // 为了复用统一的 callback 接口、避免重新设计 event 类型,librdkafka 选择了:用一个 op(RD_KAFKA_OP_REBALANCE)表示 "发生 Rebalance", 用 rko_err 告诉你是哪种情况
                            // 为什么发生RD_KAFKA_OP_REBALANCE的时候,这里会被认为是一种error?这里的error其实是事件分类,而不是传统意义上的失败
                            rk, rko->rko_err, rko->rko_u.rebalance.partitions,
                            rk->rk_conf.opaque);

                else {
                        /** If EVENT_REBALANCE is enabled but rebalance_cb
                         *  isn't, we need to perform a dummy assign for the
                         *  application. This might happen during termination
                         *  with consumer_close() */
                        rd_kafka_dbg(rk, CGRP, "UNASSIGN",
                                     "Forcing unassign of %d partition(s)",
                                     rko->rko_u.rebalance.partitions
                                         ? rko->rko_u.rebalance.partitions->cnt
                                         : 0);
                        rd_kafka_assign(rk, NULL); // 进行一个空的assign
                }
                break;

Close的基本过程和竞态分析

起初,我们怀疑assign没有收到响应的原因,是因为在close()过程中设置了一个terminate标记位RD_KAFKA_CGRP_F_TERMINATE,一旦该标记位被设定,librdkafka将不再处理任何请求, 这对应到我们遇到的问题:我们的close请求触发了标记位RD_KAFKA_CGRP_F_TERMINATE的设定,随后,我们的assign/unassign请求不再被处理,因此永远等不到响应。

但是随后,我们的这种猜想被否定了。即使设定了 RD_KAFKA_CGRP_F_TERMINATE, librdkafka也会对assign/unassign请求进行明确、直接的响应,不会出现close以后assign/unassign请求得不到响应的情况。

我们先看一下终止标记是在哪里设定的。

终止标记位的设定,就是在方法rd_kafka_consumer_close() -> rd_kafka_cgrp_terminate()中进行的:

我们上文讲解了 rd_kafka_consumer_close()的具体代码,可以看到,rd_kafka_consumer_close()先通过调用方法rd_kafka_cgrp_terminate() 来发送终止请求,并设置终止标记位,然后才开始等待终止相应

这个所谓的先设置终止标记位,发送终止请求,然后等待Server端发过来的终止响应 的过程,也许就是问题所在:已经在本地设置了终止标记位,然后才开始接受并等待终止请求。如果等待的不是终止相应,但是由于已经设置了终止标记位,那么下一步是继续处理这个请求,还是不处理这个请求呢

这里来看一下用户通过调用rd_kafka_consumer_close()中调用的方法rd_kafka_cgrp_terminate()是怎么设定终止标记位的。

我们看一下方法rd_kafka_cgrp_terminate()的具体实现,该方法定义在rdkafka_cgrp.c中,属于Consumer Group的方法:

cpp 复制代码
/**
 * Terminate and decommission a cgrp asynchronously.
 *
 * Locality: any thread
 */
void rd_kafka_cgrp_terminate (rd_kafka_cgrp_t *rkcg, rd_kafka_replyq_t replyq) {
        rd_kafka_cgrp_op(rkcg, NULL, replyq, RD_KAFKA_OP_TERMINATE, 0);
}

方法 rd_kafka_cgrp_terminate()实际上是调用rd_kafka_cgrp_op()方法,从方法名字可以看到,这个rd_kafka_cgrp_op()方法专门用来发送Consumer Group的各种请求的:

cpp 复制代码
/**
 * Send an op to a cgrp.
 *
 * Locality: any thread
 */
void rd_kafka_cgrp_op (rd_kafka_cgrp_t *rkcg, rd_kafka_toppar_t *rktp,
                       rd_kafka_replyq_t replyq, rd_kafka_op_type_t type,
                       rd_kafka_resp_err_t err) {
        rd_kafka_op_t *rko;

        rko = rd_kafka_op_new(type);
        rko->rko_err = err;
	     rko->rko_replyq = replyq;

	if (rktp)
        rko->rko_rktp = rd_kafka_toppar_keep(rktp);
   rd_kafka_q_enq(rkcg->rkcg_ops, rko); // 将请求交付给 rkcg_ops
}

这个rd_kafka_cgrp_op()发送请求是一个完全异步的方式,即它只负责将请求传递给Consumer Group的ops队列rkcg_ops即返回,调用者rd_kafka_consumer_close()自己负责通过while循环不断poll响应消息。

我们已经讲过,发送到rkcg_ops的请求会被转发给rk_ops,有一个main线程专门负责处理rk_ops中发送过来的所有请求。 即,会有一个内部 main 线程(rd_kafka_thread_main())不断循环做两类事情:

  • serve/pop rk_ops:处理应用线程发起的控制类请求(如 SUBSCRIBE/ASSIGN/UNSUBSCRIBE/TERMINATE...),这些请求是经由 rkcg_ops ⇒ rk_ops 的 forward 进入 main 线程的 rk_ops
  • 周期性调用 rd_kafka_cgrp_serve():驱动 Consumer Group 的状态机(FindCoordinator、Join/Sync、Heartbeat、超时扫描、terminate 条件检查等),决定"下一步要不要发请求、发什么请求"。

NOTE: The note content.

所以,这里 rd_kafka_thread_main() 处理的是从app层到Consumer Group层的消息请求,而与之相反的是,客户端内的 broker thread/fetcher thread负责和远程的Kafka Broker进行TCP通信;在收到远端 broker/coordinator 的响应后,通常会通过回调更新内部状态,并将对应用可见的事件/消息(例如 FETCH/REBALANCE/...)enqueue 到 rk_rep(可能再经 rk_rep => rkcg_q 的转发逻辑forward 到 rkcg_q);这些事件/消息最终由应用线程通过 rd_kafka_poll() / rd_kafka_consumer_poll() 去消费,而不是由 main 线程消费。

cpp 复制代码
/**
 * @brief Handle cgrp queue op.
 * @locality rdkafka main thread
 * @locks none
 */
static rd_kafka_op_res_t
rd_kafka_cgrp_op_serve (rd_kafka_t *rk, rd_kafka_q_t *rkq,
                        rd_kafka_op_t *rko, rd_kafka_q_cb_type_t cb_type,
                        void *opaque) {
        .....
        switch ((int)rko->rko_type)
        {
        case RD_KAFKA_OP_NAME: ...

        case RD_KAFKA_OP_CG_METADATA:...
        case RD_KAFKA_OP_OFFSET_FETCH:...
        case RD_KAFKA_OP_PARTITION_JOIN:...
        case RD_KAFKA_OP_PARTITION_LEAVE:...
        case RD_KAFKA_OP_OFFSET_COMMIT:...
        case RD_KAFKA_OP_COORD_QUERY:...
        case RD_KAFKA_OP_SUBSCRIBE:...
        case RD_KAFKA_OP_ASSIGN:
                rd_kafka_cgrp_handle_assign_op(rkcg, rko); // 对应用层的assign请求进行处理
                rko = NULL;
                break;

        case RD_KAFKA_OP_GET_SUBSCRIPTION: ....
        case RD_KAFKA_OP_GET_ASSIGNMENT> ...
        case RD_KAFKA_OP_GET_REBALANCE_PROTOCOL:
        case RD_KAFKA_OP_TERMINATE:
                rd_kafka_cgrp_terminate0(rkcg, rko); // 对应用层的close请求进行处理
                rko = NULL; /* terminate0() takes ownership */
                break;
        }

        if (rko)
                rd_kafka_op_destroy(rko);

        return RD_KAFKA_OP_RES_HANDLED;
}

然后,我们看一下rd_kafka_cgrp_handle_assign_op()对assign OP的处理,可以看到,即使当前rkcg_flags已经被RD_KAFKA_CGRP_F_TERMINATE置位,也照样会对assign请求进行处理:

cpp 复制代码
/**
 * @brief Handle an assign op.
 * @locality rdkafka main thread
 * @locks none
 */
static void rd_kafka_cgrp_handle_assign_op (rd_kafka_cgrp_t *rkcg,
                                            rd_kafka_op_t *rko) {
        rd_kafka_error_t *error = NULL;
        ....
        else if (rd_kafka_fatal_error_code(rkcg->rkcg_rk) ||
                 rkcg->rkcg_flags & RD_KAFKA_CGRP_F_TERMINATE) { // 如果已经设定了RD_KAFKA_CGRP_F_TERMINATE flag
                if (rko->rko_u.assign.partitions) {
                        rd_kafka_topic_partition_list_destroy(
                                rko->rko_u.assign.partitions);
                        rko->rko_u.assign.partitions = NULL; // 如果已经设定了RD_KAFKA_CGRP_F_TERMINATE flag, 那么就distroy掉partitions,将assign的partitinon为null
                }
                rko->rko_u.assign.method = RD_KAFKA_ASSIGN_METHOD_ASSIGN;
        }

        if (!error) {
                switch (rko->rko_u.assign.method)
                {
                case RD_KAFKA_ASSIGN_METHOD_ASSIGN: 
                        ......
                        break;
                case RD_KAFKA_ASSIGN_METHOD_INCR_ASSIGN: 
                        ......
                        break;
                case RD_KAFKA_ASSIGN_METHOD_INCR_UNASSIGN:
                        .....
                        break;
                default: ....
                }

                /* If call succeeded serve the assignment */
                if (!error)
                        rd_kafka_assignment_serve(rkcg->rkcg_rk);


        }
        ....
        rd_kafka_op_error_reply(rko, error); // 这里会进行reply,即使已经设置了 `RD_KAFKA_CGRP_F_TERMINATE`
}

所以,我们基本上排除了由于RD_KAFKA_CGRP_F_TERMINATE过早被置位、导致close以后发生的assign op请求不被处理的可能性。

我们看一下主线程队请求消息的处理逻辑:

c 复制代码
static int rd_kafka_thread_main (void *arg) {
        rd_kafka_t *rk = arg;
        ...
        // 所有投递到 cgrp 控制队列 rkcg_ops 的 op,最终都会进入主线程服务的 rk_ops(因为 forward 是读写都重定向)
        if (rk->rk_cgrp)
                rd_kafka_q_fwd_set(rk->rk_cgrp->rkcg_ops, rk->rk_ops); 

        while (likely(!rd_kafka_terminating(rk) ||  // 还没进入 terminating 状态
                      rd_kafka_q_len(rk->rk_ops) || // 即使 terminating 了,但 rk_ops 还有待处理 op
                      (rk->rk_cgrp && (rk->rk_cgrp->rkcg_state != RD_KAFKA_CGRP_STATE_TERM)))) { // 即使 terminating 了,甚至rk_ops也空了, 但 cgrp 还没到 TERM:rk->rk_cgrp && rkcg_state != TERM
                rd_ts_t sleeptime = rd_kafka_timers_next(
                        &rk->rk_timers, 1000*1000/*1s*/, 1/*lock*/); // 计算一个最长阻塞时间的时间片duration,上限1s
                rd_kafka_q_serve(rk->rk_ops, (int)(sleeptime / 1000), 0,
                                 RD_KAFKA_Q_CB_CALLBACK, NULL, NULL); //从rk_ops取出op进行处理,会进一步调用 rk_kafka_
                if (rk->rk_cgrp) // 驱动 cgrp 状态机
                        rd_kafka_cgrp_serve(rk->rk_cgrp);
                rd_kafka_timers_run(&rk->rk_timers, RD_POLL_NOWAIT);
        }
        ...
        // main循环退出,开始disable掉rk_ops,并清空rk_ops中的所有op
        rd_kafka_q_disable(rk->rk_ops); 
        rd_kafka_q_purge(rk->rk_ops);
        ...
}

可以看到,rd_kafka_thread_main()的核心功能是:

  • 会通过rd_kafka_q_serve()来处理rk->rk_ops的消息,这里的消息基本上都是从rkcg_ops转发过来的消息,即rd_kafka_q_serve()是ops队列的分发器,但是具体的处理逻辑是通过挂载在具体时间上面的serve方法完成的,rd_kafka_q_serve()不负责具体某个事件的处理逻辑;

  • 会通过rd_kafka_cgrp_serve()来推进cgrp的状态机往前进。我们下文会讲解 rd_kafka_cgrp_serve(),与我们的case相关的,rd_kafka_cgrp_serve()每次被调用都会检查当前是否是一个可终止状态,如果是,则对这个cgrp进行最后的清理;

    c 复制代码
    	if (unlikely(rd_kafka_cgrp_try_terminate(rkcg))) {
                    rd_kafka_cgrp_terminated(rkcg);
                    return; /* cgrp terminated */
            }

并且,我们需要看到,while循环的判断条件表明,即使rk_ops为空,while循环里面的代码也会被反复执行,即,rd_kafka_thread_main() -> rd_kafka_cgrp_serve() -> rd_kafka_cgrp_try_terminate() 会被反复调用和检查,直到最后rd_kafka_cgrp_try_terminate()发现终止条件满足,因此rd_kafka_cgrp_terminated()被调用,cgrp被close和销毁。

我们看一下ops queue的分派,这是通过方法rd_kafka_q_serve()来完成的。rd_kafka_q_serve()本身不关心每一个op该怎么处理,以为每一个op的处理方式(callback)都被attach到op本身了,所以rd_kafka_q_serve()只需要负责取出op然后根据attached callback进行op的处理即可。对于rkcg_ops转发过来的op,他们上面挂载的回调是rd_kafka_cgrp_op_serve()

c 复制代码
/**
 * Pop all available ops from a queue and call the provided 
 * callback for each op.
 * `max_cnt` limits the number of ops served, 0 = no limit.
 *
 * Returns the number of ops served.
 *
 * Locality: any thread.
 */
int rd_kafka_q_serve (rd_kafka_q_t *rkq, // 需要服务的队列
                      int timeout_ms,    // 如果队列当前为空,最多等多久"等到第一个 op"
                      int max_cnt,  // 本次调用最多服务多少个op
                      rd_kafka_q_cb_type_t cb_type, // 直接传递给rd_kafka_op_handle,表示怎么处理op
                      rd_kafka_q_serve_cb_t *callback,  // 自定义的serve回调
                      void *opaque // 上下文
                      ) {
        rd_kafka_t *rk = rkq->rkq_rk;
	     rd_kafka_op_t *rko;
	     rd_kafka_q_t localq;
        rd_kafka_q_t *fwdq;
        int cnt = 0;
        struct timespec timeout_tspec;


	     rdk_thread_mutex_lock(&rkq->rkq_lock);

        rd_dassert(TAILQ_EMPTY(&rkq->rkq_q) || rkq->rkq_qlen > 0);
        if ((fwdq = rd_kafka_q_fwd_get(rkq, 0))) { // 如果有转发队列,那么递归的进行server
                int ret;
                /* Since the q_pop may block we need to release the parent
                 * queue's lock. */
                rdk_thread_mutex_unlock(&rkq->rkq_lock);
		         ret = rd_kafka_q_serve(fwdq, timeout_ms, max_cnt,
                                       cb_type, callback, opaque); // 递归地进行serve
                rd_kafka_q_destroy(fwdq); // 递归处理完成,销毁转发队列
		return ret;
	   } 

    rd_timeout_init_timespec(&timeout_tspec, timeout_ms);

    // 队列为空 + 没 yield + timedwait 成功 → 继续等
    while (!(rko = TAILQ_FIRST(&rkq->rkq_q)) &&
           !rd_kafka_q_check_yield(rkq) &&
           cnd_timedwait_abs(&rkq->rkq_cond, &rkq->rkq_lock,
                             &timeout_tspec) == thrd_success)
            ;
            
    // 如果执行到这里依然没有取出来任何一个op(rko),说明直到超时也没活儿干,返回0
	if (!rko) {
		rdk_thread_mutex_unlock(&rkq->rkq_lock);
		return 0;
	}
	// 队列不为空(队列尾部有元素),说明有活儿可干了
	/* Move the first `max_cnt` ops. */
	rd_kafka_q_init(&localq, rkq->rkq_rk);
	rd_kafka_q_move_cnt(&localq, rkq, max_cnt == 0 ? -1/*all*/ : max_cnt,
			    0/*no-locks*/); // 把rkq中最多max_cnt个元素取出到localq中进行处理,避免承诺哦图

   rdk_thread_mutex_unlock(&rkq->rkq_lock); // 元素已经从rkq中move到了localq中,因此可以对rkq解锁了

   rd_kafka_yield_thread = 0;

	// 对每一个op调用回调
        while ((rko = TAILQ_FIRST(&localq.rkq_q))) { // 只要localq中还有元素
                rd_kafka_op_res_t res;

                rd_kafka_q_deq0(&localq, rko); // 从 localq中取出op
                res = rd_kafka_op_handle(rk, &localq, rko, cb_type,
                                         opaque, callback); // 对这个op进行处理
                cnt++;
                // 如果 res== YIELD 或 rd_kafka_yield_thread = 1 , 那么可以退出循环了
                if (unlikely(res == RD_KAFKA_OP_RES_YIELD ||
                             rd_kafka_yield_thread)) {
                        /* Callback called rd_kafka_yield(), we must
                         * stop our callback dispatching and put the
                         * ops in localq back on the original queue head. */
                        if (!TAILQ_EMPTY(&localq.rkq_q))
                                rd_kafka_q_prepend(rkq, &localq); // 把 localq中的剩余没有处理的元素放回到rkq中
                        break; 
                }
	}

	rd_kafka_q_destroy_owner(&localq); // rd_kafka_q_destroy_owner(&localq);

	return cnt; 返回处理的op的数量
}

上面我们已经通过详细的注释解释了rd_kafka_q_serve()方法的代码片段,总之,rd_kafka_q_serve()会对队列rkq中的op通过调用调用rd_kafka_op_handle() -> rd_kafka_cgrp_op_serve()进行逐个处理,该方法的返回值是一个int,代表已经处理的op的数量。

rd_kafka_q_serve()会通过调用链 rd_kafka_op_handle() -> rd_kafka_cgrp_op_serve(),因为在Consumer Group初始化的时候,会把这个队列的处理回调挂载到这个rkcg_ops队列上,即,这里的处理逻辑是,如果有terminate请求,会导致挂载在这个请求上的serve callback被调用,这里的serve callback其实就是rd_kafka_cgrp_op_serve()方法。

关于为什么 rd_kafka_thread_main() 中可以通过方法 rd_kafka_q_serve()最终调用到 rd_kafka_cgrp_op_serve(),其逻辑链路是:

  1. 在 cgrp 初始化时,把 rkcg_ops 队列的 rkq_serve 指向 rd_kafka_cgrp_op_serve

    c 复制代码
    rd_kafka_cgrp_t *rd_kafka_cgrp_new (rd_kafka_t *rk,
                                        const rd_kafkap_str_t *group_id,
                                        const rd_kafkap_str_t *client_id) {
            rd_kafka_cgrp_t *rkcg;
            ....
            rkcg->rkcg_ops = rd_kafka_q_new(rk);
            rkcg->rkcg_ops->rkq_serve = rd_kafka_cgrp_op_serve;
            ....
            rkcg->rkcg_q = rd_kafka_q_new(rk);

    这一步的含义是:"凡是要 serve rkcg_ops 上的 op,就用 rd_kafka_cgrp_op_serve 来处理"。

  2. 当把一个OP(比如TERMINATE请求)插入到队列时,会把"原始目的队列"的 rkq_serve 塞进对应的 op 的 rko->rko_serve。这里我们在上文中已经详细讲解过对于转发队列的写入操作的基本原理,具体参考方法 rd_kafka_q_enq1()。在这里,会把原始队列上设置的rkq_server方法设置到这个op上,随后,rd_kafka_q_serve()在处理到这个op的时候,就知道这个op的对应处理逻辑。

  3. main 线程 serve rk_ops 弹出的op后,会通过方法rd_kafka_op_handle() 优先使用 rko->rko_serve来处理这个op:

    c 复制代码
    rd_kafka_op_res_t
    rd_kafka_op_handle (rd_kafka_t *rk, rd_kafka_q_t *rkq, rd_kafka_op_t *rko,
                        rd_kafka_q_cb_type_t cb_type, void *opaque,
                        rd_kafka_q_serve_cb_t *callback) {
            rd_kafka_op_res_t res;
    
            if (rko->rko_serve) {
                    callback = rko->rko_serve;
                    opaque   = rko->rko_serve_opaque;
                    rko->rko_serve        = NULL;
                    rko->rko_serve_opaque = NULL;
            }
            .....
            if (callback) 
                    res = callback(rk, rkq, rko, cb_type, opaque); // 使用挂载的rd_kafka_cgrp_op_serve来处理这个op
    
            return res;
    }

就这样,main thread在处理rk_ops中的op时,如果遇到的与consumer group相关的op,那么会调用这个op上挂载的回调(rd_kafka_cgrp_op_serve()方法)来对这个OP进行处理。

rd_kafka_cgrp_op_serve()其实就是对应的rkcg的事件处理器,这里,我们只关心RD_KAFKA_OP_TERMINATE请求:

c 复制代码
/**
 * @brief Handle cgrp queue op.
 * @locality rdkafka main thread
 * @locks none
 */
static rd_kafka_op_res_t
rd_kafka_cgrp_op_serve (rd_kafka_t *rk, rd_kafka_q_t *rkq,
                        rd_kafka_op_t *rko, rd_kafka_q_cb_type_t cb_type,
                        void *opaque) {
        ......

        switch ((int)rko->rko_type)
        {
        .......

        case RD_KAFKA_OP_TERMINATE:
                rd_kafka_cgrp_terminate0(rkcg, rko);
                rko = NULL; /* terminate0() takes ownership */
                break;

        if (rko)
                rd_kafka_op_destroy(rko);

        return RD_KAFKA_OP_RES_HANDLED;
}

可以看到,rd_kafka_cgrp_op_serve()是调用 rd_kafka_cgrp_terminate0()来处理Close请求的,即,如果收到RD_KAFKA_OP_TERMINATE响应消息,会调用rd_kafka_cgrp_terminate0()方法来进行处理。我们下文会讲到,这个响应消息是通过rd_kafka_cgrp_try_terminate()方法判定Consumer Group是否满足关闭条件,并在满足关闭条件的时候通过调用rd_kafka_cgrp_terminated()来进行Close操作的最后清理。

需要跟上面的 rd_kafka_cgrp_terminate()方法区分开:

  • rd_kafka_cgrp_terminate():这是"发起终止"的入口。它只负责把"终止请求"异步投递到 consumer group 的控制通道,交给 librdkafka 主线程后续处理;它本身不推进 cgrp 状态机,也不标记"正在终止"。
  • rd_kafka_consumer_close():这是应用层发起关闭的主流程。它会把 consumer 的事件/消息队列临时重定向到一个"只供 close 期间消费的队列",然后发起 rd_kafka_cgrp_terminate(),并在一个循环里持续消费这个临时队列,直到收到"终止完成"的通知才返回。你们的 hang 就发生在这个循环里:close 在"等终止完成"的过程中处理到了 rebalance 事件,回调里又同步调用了 rd_kafka_assign(),从而进入潜在的死锁路径。
  • rd_kafka_cgrp_terminate0():这是"真正开始执行终止"的逻辑入口。它由 librdkafka 主线程rd_kafka_thread_main()在处理到"终止请求"后调用。它会标记 consumer group 进入终止流程,保存"关闭流程需要的最终回复",并启动一系列收尾动作(比如退出订阅、触发撤销分配、推动 assignment 清理等),为最终彻底终止创造条件。
  • rd_kafka_cgrp_try_terminate():这是"判断是否已满足终止条件"的函数。它在终止流程期间被反复调用:只要当前甚至还没有进入终止流程(RD_KAFKA_CGRP_F_TERMINATE还没有被置位),而且还有任何必须完成的事项(例如仍在等待应用完成 assign/unassign、仍有分区对象未完全退场、assignment 内部仍在推进、仍有 commit/leave 等待),它就会返回"还不能终止";当所有条件都满足时,它会把 cgrp 的状态推进到"已可终止"但是并不执行最后的清理。
  • rd_kafka_cgrp_terminated():这是"最终收尾 + 通知 close 完成"的函数。它在 cgrp 已经进入"可终止状态"之后由驱动层调用,负责做最后的清理(包括让控制通道不再接受新请求、清空残留请求、释放协调器相关资源),并把"终止完成"的通知发回 rd_kafka_consumer_close(),从而唤醒 close 的等待循环并最终返回。

我们看一下rd_kafka_cgrp_terminate0()方法的执行逻辑:

cpp 复制代码
void
rd_kafka_cgrp_terminate0 (rd_kafka_cgrp_t *rkcg, rd_kafka_op_t *rko) {
        if (unlikely(rkcg->rkcg_state == RD_KAFKA_CGRP_STATE_TERM ||
		     (rkcg->rkcg_flags & RD_KAFKA_CGRP_F_TERMINATE) ||
		     rkcg->rkcg_reply_rko != NULL)) {
                /* Already terminating or handling a previous terminate */
    		if (rko) {
    			rd_kafka_q_t *rkq = rko->rko_replyq.q;
    			rko->rko_replyq.q = NULL;
    			// 这里不是打印日志,而是把RD_KAFKA_RESP_ERR__IN_PROGRESS插入到对应的请求结构体中的响应队列中
             rd_kafka_consumer_err(rkq, RD_KAFKA_NODEID_UA,
                              RD_KAFKA_RESP_ERR__IN_PROGRESS,
                              rko->rko_replyq.version,
                              NULL, NULL,
                              RD_KAFKA_OFFSET_INVALID,
                              "Group is %s",
                              rkcg->rkcg_reply_rko ?
                              "terminating":"terminated");
    			rd_kafka_q_destroy(rkq);  // 销毁请求结构体中的响应队列
    			rd_kafka_op_destroy(rko); // 销毁请求结构体
    		}
          return;
        }

        /* Mark for stopping, the actual state transition
         * is performed when all toppars have left. */
        rkcg->rkcg_flags |= RD_KAFKA_CGRP_F_TERMINATE; // 设定标记,从这一刻起,cgrp 进入"终止收尾"模式
	     rkcg->rkcg_ts_terminate = rd_clock(); // 用于后续超时/强制推进(尤其是 wait_coord_q 超时逻辑)。
        rkcg->rkcg_reply_rko = rko;

        if (rkcg->rkcg_flags & RD_KAFKA_CGRP_F_SUBSCRIPTION)
                rd_kafka_cgrp_unsubscribe(
                        rkcg,
                        /* Leave group if this is a controlled shutdown */
                        !rd_kafka_destroy_flags_no_consumer_close(
                                rkcg->rkcg_rk));

        if (rd_kafka_destroy_flags_no_consumer_close(rkcg->rkcg_rk))
                rkcg->rkcg_flags &= ~RD_KAFKA_CGRP_F_WAIT_LEAVE;
        if (!RD_KAFKA_CGRP_WAIT_ASSIGN_CALL(rkcg) ||
            rd_kafka_destroy_flags_no_consumer_close(rkcg->rkcg_rk))
                rd_kafka_cgrp_unassign(rkcg);

        /* Serve assignment so it can start to decommission */
        rd_kafka_assignment_serve(rkcg->rkcg_rk);

        // 判定器,立刻开始判定cgrp是否已经满足"可以终止"的条件,如果满足则设置cgrp的state为RD_KAFKA_CGRP_STATE_TERM 并返回1。这个方法
        // 在rd_kafka_thread_main()的反复循环中还会
        rd_kafka_cgrp_try_terminate(rkcg);
}
  1. 检查当前状态是不是已经处于终止流程中,如果是的,则不做任何处理,指向往请求结构体的响应队列中塞一个"错误事件",错误码是 RD_KAFKA_RESP_ERR__IN_PROGRESS,错误信息是 "Group is terminating" 或 "Group is terminated"。

    c 复制代码
    if (unlikely(rkcg->rkcg_state == RD_KAFKA_CGRP_STATE_TERM ||
    	     (rkcg->rkcg_flags & RD_KAFKA_CGRP_F_TERMINATE) ||
    	     rkcg->rkcg_reply_rko != NULL)) {
                /* Already terminating or handling a previous terminate */
    		if (rko) {
    			rd_kafka_q_t *rkq = rko->rko_replyq.q; // 取出请求结构体中的响应队列
    			rko->rko_replyq.q = NULL; // 指针置为空
    			// 这里不是打印日志,而是把RD_KAFKA_RESP_ERR__IN_PROGRESS插入到对应的请求结构体中的响应队列中
             rd_kafka_consumer_err(....);
    			rd_kafka_q_destroy(rkq);  // 销毁请求结构体中的响应队列
    			rd_kafka_op_destroy(rko); // 销毁请求结构体
    		}
          return;
        }
  2. 如果当前的确不是在一个TERMINATE状态,那么就设置rkcg的状态标志位RD_KAFKA_CGRP_F_TERMINATE

    c 复制代码
            /* Mark for stopping, the actual state transition
             * is performed when all toppars have left. */
            rkcg->rkcg_flags |= RD_KAFKA_CGRP_F_TERMINATE; // 设定标记,从这一刻起,cgrp 进入"终止收尾"模式
    	     rkcg->rkcg_ts_terminate = rd_clock(); // 用于后续超时/强制推进(尤其是 wait_coord_q 超时逻辑)。
            rkcg->rkcg_reply_rko = rko;
  3. 如果当前是订阅模式,触发 unsubscribe/leave group(取决于 destroy flags ),并且,如果是NO_CONSUMER_CLOSE模式,会清除RD_KAFKA_CGRP_F_WAIT_LEAVE标记位,这个标记位的含义是需要等待LEAVE GROUP完成,因此清除该标志位意味着不需要等待LEAVE GROUP 完成。我们在讲解rd_kafka_consumer_close()和我们的堆栈的时候讲过,从我们的堆栈中可以看到,ClickHouse在Close的时候不是走的NO_CONSUMER_CLOSE模式,即,需要等待LEAVE GROUP等操作完成,而这个等待正是Close被hang住的原因:

    cpp 复制代码
            if (rkcg->rkcg_flags & RD_KAFKA_CGRP_F_SUBSCRIPTION)
                rd_kafka_cgrp_unsubscribe(
                        rkcg,
                        
                        !rd_kafka_destroy_flags_no_consumer_close(
                                rkcg->rkcg_rk));
            if (rd_kafka_destroy_flags_no_consumer_close(rkcg->rkcg_rk))
                rkcg->rkcg_flags &= ~RD_KAFKA_CGRP_F_WAIT_LEAVE;

    这里的rk_kafka_cgrp_unsubscribe()方法的第二个参数很关键,它的含义是是否需要leave group:

    • 如果是正常的 controlled shutdown(即不是 NO_CONSUMER_CLOSE模式),就会优雅地leave group;
    • 如果设置了 NO_CONSUMER_CLOSE(不走阻塞 close,快速销毁),就不等 leave(后面会走"立即终止"路径)。
  4. 如果有 outstanding rebalance 没被应用层处理,正常情况下会在 consumer_close() 里处理;但如果 NO_CONSUMER_CLOSE(应用层不会再 poll),那就必须在这里直接 unassign,否则会"卡在等待应用层回调队列被服务"。所以,这里通过调用方法rd_kafka_cgrp_unassign(),

    c 复制代码
        if (!RD_KAFKA_CGRP_WAIT_ASSIGN_CALL(rkcg) ||
            rd_kafka_destroy_flags_no_consumer_close(rkcg->rkcg_rk))
                rd_kafka_cgrp_unassign(rkcg);
  5. 把assignment状态机往前推进,例如开始停止 fetchers、decommission toppars

    c 复制代码
     /* Serve assignment so it can start to decommission */
    rd_kafka_assignment_serve(rkcg->rkcg_rk);
    • rd_kafka_cgrp_unassign():把"我要撤销分配"这个意图写进 assignment 状态(把分区搬到 removed),并把 cgrp join_state 推到"等待撤销完成"。
    • rd_kafka_assignment_serve():则是通过反复跑,真正执行 removed/pending 的异步工作(停 fetcher、offset query/commit、等待 stop 完成),直到全部完成,然后回调通知 cgrp "done"。

    这里不做赘述。

  6. 检查"是否已满足终止条件",满足就把 state 置为 TERM。

    然后 在下一次/同一次 rd_kafka_thread_main() -> rd_kafka_cgrp_serve() 迭代里,看到 try_terminate() 返回 1,就调用 rd_kafka_cgrp_terminated() 做最终清理和回包。下文会详细讲解rd_kafka_cgrp_try_terminaterd_kafka_cgrp_terminated():

    c 复制代码
    /* Try to terminate right away if all preconditions are met. */
            rd_kafka_cgrp_try_terminate(rkcg);

所以,rd_kafka_cgrp_try_terminate()会在rd_kafka_cgrp_terminate0()中被调用,即尝试判定一下cgrp是否满足"可以终止"的条件,如果满足,则将cgrp的State设置为RD_KAFKA_CGRP_STATE_TERM表示可以终止cgrp了,但是此时并不进行最终最终的清理。最终的清理,是由后续的rk_kafka_thread_main(...)的循环再次到来,然后通过调用 rd_kafka_cgrp_serve(...) -> rd_kafka_cgrp_try_terminate(...)的调用过程再次判定,如果返回1代表已经完全满足了清理条件,就会执行rd_kafka_cgrp_terminated(...) 进行针对cgrp的清理和销毁,完成了清理和销毁,就会对应的reply,这时候,在等待reply的 rd_kafka_consumer_close就会收到temrinate的响应消息。

c 复制代码
/**
 * If a cgrp is terminating and all outstanding ops are now finished
 * then progress to final termination and return 1.
 * Else returns 0.
 */
static RD_INLINE int rd_kafka_cgrp_try_terminate (rd_kafka_cgrp_t *rkcg) {

   // 如果已经处于terminate状态,那么就立刻退出,返回可以terminate
   if (rkcg->rkcg_state == RD_KAFKA_CGRP_STATE_TERM)
       return 1;
    // 如果连RD_KAFKA_CGRP_F_TERMINATE都还没有置位,那么显然是不可能terminate的。
	if (likely(!(rkcg->rkcg_flags & RD_KAFKA_CGRP_F_TERMINATE)))
		return 0;

	/* Check if wait-coord queue has timed out. */
	if (rd_kafka_q_len(rkcg->rkcg_wait_coord_q) > 0 &&
	    rkcg->rkcg_ts_terminate +
	    (rkcg->rkcg_rk->rk_conf.group_session_timeout_ms * 1000) <
	    rd_clock()) {
    		rd_kafka_q_disable(rkcg->rkcg_wait_coord_q);
    		if (rd_kafka_q_concat(rkcg->rkcg_ops,
    				      rkcg->rkcg_wait_coord_q) == -1) {
    			/* ops queue shut down, purge coord queue */
    			rd_kafka_q_purge(rkcg->rkcg_wait_coord_q);
    		}
	 }
    if (!RD_KAFKA_CGRP_WAIT_ASSIGN_CALL(rkcg) &&
        rd_list_empty(&rkcg->rkcg_toppars) &&
        !rd_kafka_assignment_in_progress(rkcg->rkcg_rk) &&
        rkcg->rkcg_rk->rk_consumer.wait_commit_cnt == 0 &&
        !(rkcg->rkcg_flags & RD_KAFKA_CGRP_F_WAIT_LEAVE)) {
            rd_kafka_cgrp_set_state(rkcg, RD_KAFKA_CGRP_STATE_TERM);
            return 1; // 可以终止,进而进一步调用 `rd_kafka_cgrp_terminate0()` 进行清理
    } else {
            
            return 0;
    }
}

可以看到, rd_kafka_cgrp_try_terminate()的判断标准是:

  • 如果当前的CGRP已经处于终止状态(RD_KAFKA_CGRP_STATE_TERM),那么返回1,表示可以进行终止:

    c 复制代码
     // 如果已经处于terminate状态,那么就立刻退出,返回可以terminate
       if (rkcg->rkcg_state == RD_KAFKA_CGRP_STATE_TERM)
           return 1;
  • 如果当前的CGRP压根还没有进入终止流程,那么返回0,肯定不能终止。我们上文讲过,在客户端调用close()以后,会在rd_kafka_consumer_close()->rd_kafka_cgrp_terminate()中设置标记位RD_KAFKA_CGRP_F_TERMINATE,代表开始进入终止流程:

c 复制代码
    // 如果连RD_KAFKA_CGRP_F_TERMINATE都还没有置位,那么显然是不可能terminate的。
    	if (likely(!(rkcg->rkcg_flags & RD_KAFKA_CGRP_F_TERMINATE)))
    		return 0;
  • 如果当前的确已经进入了终止流程,那么开始对状态进行判断,以决定是否可以进行真正的终止了:

    • 如果 rkcg_wait_coord_q 里还有 op,并且从开始 terminate (rkcg_ts_terminate) 到现在已经超过 group_session_timeout_ms,认为"等 coordinator"已经不值得继续等了。
      • disable rkcg_wait_coord_q(防止后续再往里塞新东西)
      • 尝试把 rkcg_wait_coord_q 里的 op 拼接回 rkcg_ops(让它们走正常的 op 处理/失败路径)
      • 如果 rkcg_ops 也已经关了(concat == -1),那只能 purge 掉 rkcg_wait_coord_q
    • 只要下面条件任何一个满足,就不可以进行terminate
      • 在等待应用层调用 assign/unassign(rebalance 回调尚未完成)"的 join-state,
      • 只要还有 toppar 没完全离开 cgrp,
      • 只要 "分配/撤销分配"的内部流程(assignment state machine)没有正在进行,
      • 只要还有 commit 没回收
      • 只要还在等 leave
    c 复制代码
        if (!RD_KAFKA_CGRP_WAIT_ASSIGN_CALL(rkcg) &&
            rd_list_empty(&rkcg->rkcg_toppars) &&
            !rd_kafka_assignment_in_progress(rkcg->rkcg_rk) &&
            rkcg->rkcg_rk->rk_consumer.wait_commit_cnt == 0 &&
            !(rkcg->rkcg_flags & RD_KAFKA_CGRP_F_WAIT_LEAVE)) {
                rd_kafka_cgrp_set_state(rkcg, RD_KAFKA_CGRP_STATE_TERM);
                return 1; // 可以终止,进而进一步调用 `rd_kafka_cgrp_terminate0()` 进行清理
        }

其实,因为我们在本文最后对hang住的原因分析是,在close()过程中,由于 rd_kafka_cgrp_try_terminate()返回true,因此 rd_kafka_cgrp_terminated()执行了清理,将rk_ops中的关于ASSIGN的请求进行的清空,因此导致这个ASSIGN请求永远无法响应。因此,我们的疑问是,在ASSIGN请求处于rk_ops中的时候, rd_kafka_cgrp_try_terminate()是否有可能返回True呢?假如在ASSIGN请求处于rk_ops中的时候, rd_kafka_cgrp_try_terminate()不可能返回True,那么我们关于hang住的原因假设就不可能发生。

至少,从目前 rd_kafka_cgrp_try_terminate()的判断标准我们看到,rd_kafka_cgrp_try_terminate()没有检查rk_ops队列中是否已经没有请求或者是否有ASSIGN op。

rd_kafka_cgrp_serve(rd_kafka_cgrp_t *rkcg) 是 Consumer Group(cgrp)状态机的"驱动器",由 rd_kafka_thread_main() 线程周期性调用,并不是专属于close的过程。

必须把rd_kafka_q_serve()这个通用的时间调度器和rd_kafka_cgrp_serve(cgrp)区分开:

  • rd_kafka_q_serve()是一个事件处理器,用来处理主队列 rk_ops 上的 op,如果这个队列有转发队列,那么会递归去serve对应的转发队列;
  • rd_kafka_cgrp_serve(cgrp) 是专门用来给Consumer Group用的状态机驱动器,根据当前ConsumerGroup的状态进行各种各样的行为,比如:
    • 可能发送网络请求(FindCoordinatorHeartbeatJoinGroup/SyncGroup 等)
    • 可能移动队列里的 op(把 wait_coord_q 挪回 rkcg_ops
    • 可能改变 rkcg_state / rkcg_join_state
    • 可能触发最终终止:调用 rd_kafka_cgrp_terminated(),其中会 rd_kafka_replyq_enq(),从而让 rd_kafka_consumer_close()q_pop() 被唤醒并退出。

rd_kafka_cgrp_serve(cgrp)每次被rd_kafka_thread_main()调用的时候,都会通过rd_kafka_cgrp_try_terminate()方法检查Consumer Group的terminate状态,如果rd_kafka_cgrp_try_terminate()方法返回结果表明这个Consumer Group是一个可以终止的状态,那么就调用rd_kafka_cgrp_terminated(rkcg)进行Consumer Group状态的终止。

我们看一下rd_kafka_cgrp_serve()的基本过程。

c 复制代码
void rd_kafka_cgrp_serve (rd_kafka_cgrp_t *rkcg) {
    rd_kafka_broker_t *rkb = rkcg->rkcg_coord;
    int rkb_state = RD_KAFKA_BROKER_STATE_INIT;
    rd_ts_t now;

    if (rkb) { ... }

    now = rd_clock();

    /* Check for cgrp termination */
    if (unlikely(rd_kafka_cgrp_try_terminate(rkcg))) { // 检查终止条件是否满足
        // 返回 1 就会立刻调用 rd_kafka_cgrp_terminated():做最终清理,并且通过调用 rd_kafka_replyq_enq() 构造reply op 唤醒 rd_kafka_consumer_close(),因此此时rk_kafka_consumer_close正在等待terminate的响应信息
        rd_kafka_cgrp_terminated(rkcg);
        return;
    }

    /* Bail out if we're terminating. */
    if (unlikely(rd_kafka_terminating(rkcg->rkcg_rk))) // 虽然没有完成终止,但是目前正在终止状态,因此什么都不做,直接退出
        return;
    ....
retry:
    switch (rkcg->rkcg_state) {
        case RD_KAFKA_CGRP_STATE_INIT: ...;
        case RD_KAFKA_CGRP_STATE_QUERY_COORD: ...;
        case RD_KAFKA_CGRP_STATE_WAIT_COORD: ...;
        case RD_KAFKA_CGRP_STATE_WAIT_BROKER: ...;
        case RD_KAFKA_CGRP_STATE_WAIT_BROKER_TRANSPORT: ...;
        case RD_KAFKA_CGRP_STATE_UP:
            rd_kafka_q_concat(rkcg->rkcg_ops, rkcg->rkcg_wait_coord_q);
            ...coord_query...
            rd_kafka_cgrp_join_state_serve(rkcg);
            break;
        case RD_KAFKA_CGRP_STATE_TERM:
            break; // 直接退出
    }
    ....
}

所以,可以看到,在close的状态下,Consumer Group的状态机驱动器每次被rd_kafka_main_thread调用的时候都会检查Consumer Group的状态,如果完全满足了终止条件,就执行终止过程:

cpp 复制代码
	if (unlikely(rd_kafka_cgrp_try_terminate(rkcg))) {
                rd_kafka_cgrp_terminated(rkcg);
                return; /* cgrp terminated */
        }

上文已经讲过rd_kafka_cgrp_try_terminate()基本过程,我们看一下 rd_kafka_cgrp_terminated()的基本实现,即它是怎么decommission一个cgrp的:

cpp 复制代码
/**
 * Cgrp is now terminated: decommission it and signal back to application.
 */
static void rd_kafka_cgrp_terminated (rd_kafka_cgrp_t *rkcg) {
    if (rkcg->rkcg_flags & RD_KAFKA_CGRP_F_TERMINATED) // 幂等保护,如果已经是TERMINATED状态,则什么都不做
            return; /* terminated() may be called multiple times,
                     * make sure to only terminate once. */

   rd_kafka_cgrp_group_assignment_set(rkcg, NULL); // 清空assignment
  	rd_kafka_q_purge(rkcg->rkcg_wait_coord_q); // 清空rkcg_wait_coord_q

	// disable掉rkcg_ops队列并直接清空队列
	rd_kafka_q_disable(rkcg->rkcg_ops);
	rd_kafka_q_purge(rkcg->rkcg_ops);

	if (rkcg->rkcg_curr_coord)
		rd_kafka_cgrp_coord_clear_broker(rkcg);

        if (rkcg->rkcg_coord) {
                rd_kafka_broker_destroy(rkcg->rkcg_coord);
                rkcg->rkcg_coord = NULL;
        }

        if (rkcg->rkcg_reply_rko) {
                /* Signal back to application. */
                rd_kafka_replyq_enq(&rkcg->rkcg_reply_rko->rko_replyq,
				    rkcg->rkcg_reply_rko, 0);  // 向这个rkcg_reply_rko的响应队列中插入响应消息,告诉等待中的rd_kafka_consumer_close() terminate已经完成
                rkcg->rkcg_reply_rko = NULL;
        }

        rkcg->rkcg_flags |= RD_KAFKA_CGRP_F_TERMINATED;  // 设置RD_KAFKA_CGRP_F_TERMINATED  flag
}

rd_kafka_cgrp_terminated(rd_kafka_cgrp_t *rkcg) 是 cgrp(consumer group)终止流程的"最终收尾函数":当 rd_kafka_cgrp_try_terminate() 判定 终止条件已经满足后,由 rd_kafka_cgrp_serve() 调用它来做三件事:

  • 把 cgrp 彻底"退役"(decommission):停止相关 timer、清空内部状态、销毁 coordinator 相关对象

    c 复制代码
       rd_kafka_cgrp_group_assignment_set(rkcg, NULL); // 清空assignment
      	rd_kafka_q_purge(rkcg->rkcg_wait_coord_q); // 清空rkcg_wait_coord_q
  • 关闭并清空 cgrp 的内部队列(尤其是 rkcg_ops / rkcg_wait_coord_q),因为后续不会再有线程去服务它们

    c 复制代码
      	rd_kafka_q_purge(rkcg->rkcg_wait_coord_q); // 清空rkcg_wait_coord_q
    
    	// disable掉rkcg_ops队列并直接清空队列
    	rd_kafka_q_disable(rkcg->rkcg_ops);
    	rd_kafka_q_purge(rkcg->rkcg_ops);
  • 把"terminate 完成"的回复 op 发回给等待方(通常是 rd_kafka_consumer_close() 创建的临时队列),从而唤醒 rd_kafka_consumer_close()q_pop() 循环退出

    c 复制代码
            if (rkcg->rkcg_reply_rko) {
                    /* Signal back to application. */
                    rd_kafka_replyq_enq(&rkcg->rkcg_reply_rko->rko_replyq,
    				    rkcg->rkcg_reply_rko, 0);  // 向这个rkcg_reply_rko的响应队列中插入响应消息,告诉等待中的rd_kafka_consumer_close() terminate已经完成
                    rkcg->rkcg_reply_rko = NULL;
            }

Rebalance和对应的assign操作

可以看到,rd_kafka_poll_cb()方法接收会对收到的响应消息体rd_kafka_op_t *rko进行解析,提取出其中的消息类型,然后对不同的消息类型进行不同的处理。

从上文中CleanupThread的堆栈,我们基本能够推断出,这里收到的响应是RD_KAFKA_OP_REBALANCE,即收到了Broker发过来的、要求客户端进行Rebalance的消息,并且消息中包含了需要Assign或者Revoke的Parttion的list。然后,就会触发客户端的REBALANCE的callback进行处理:

cpp 复制代码
 case RD_KAFKA_OP_REBALANCE: // // 预期收到Terminate消息,但是却收到了Rebalance消息
                if (rk->rk_conf.rebalance_cb)  // 如果有callback,那么就调用callback,这里调用的是 cppkafka::Consumer::rebalance_proxy,
                        rk->rk_conf.rebalance_cb(
                            // Kafka 原生协议里面,Rebalance 既有可能是"让你分配新的 partition"(assign),也有可能是"取消已有的 partition"(revoke)
                            // 为了复用统一的 callback 接口、避免重新设计 event 类型,librdkafka 选择了:用一个 op(RD_KAFKA_OP_REBALANCE)表示 "发生 Rebalance", 用 rko_err 告诉你是哪种情况
                            // 为什么发生RD_KAFKA_OP_REBALANCE的时候,这里会被认为是一种error?这里的error其实是事件分类,而不是传统意义上的失败
                            rk, rko->rko_err, rko->rko_u.rebalance.partitions,
                            rk->rk_conf.opaque);
shell 复制代码
Thread 1207 (Thread 0x7f607c9ee700 (LWP 3826922)):
#0  futex_wait_cancelable (private=<optimized out>, expected=0, futex_word=0x7fe090df05d0) at ../sysdeps/nptl/futex-internal.h:183
#1  __pthread_cond_wait_common (abstime=0x0, clockid=0, mutex=0x7fe090df0580, cond=0x7fe090df05a8) at pthread_cond_wait.c:508
#2  __pthread_cond_wait (cond=0x7fe090df05a8, mutex=0x7fe090df0580) at pthread_cond_wait.c:647
#3  0x0000000015bd7b37 in rd_kafka_q_pop_serve ()
#4  0x0000000015bc8cab in rd_kafka_op_req ()
#5  0x0000000015c0a373 in rd_kafka_assign ()
#6  0x0000000015b1dcb1 in cppkafka::Consumer::rebalance_proxy(rd_kafka_s*, rd_kafka_resp_err_t, rd_kafka_topic_partition_list_s*, void*) ()
#7  0x0000000015b49522 in rd_kafka_poll_cb ()
#8  0x0000000015b4b5d8 in rd_kafka_consumer_close ()

这里的rebalance_cb是一个function,由cppkafka::Consumer在构造的时候设置进去的,我们可以从cppkafka::Consumer的构造方法中看出:

cpp 复制代码
Consumer::Consumer(Configuration config)
: KafkaHandleBase(move(config)) {
    char error_buffer[512];
    rd_kafka_conf_t* config_handle = get_configuration_handle(); // 在这里设置了自己的opaque_handle
    // Set ourselves as the opaque pointer
    // 将当前的CPPKafka绑定到当前创建的rd_kafka_conf_t对象中,相当于绑定到了即将创建的rd_kafka_t上面
    rd_kafka_conf_set_opaque(config_handle, this); // 将opaque设置为当前的Consumer对象
    /**
     * 在构造Consumer的时候,这里设置了rebalance_callback,同时在上层调用者KafkaConsumer::createConsumer调用的时候,
     * 设置了assignment, revoke和rebalance_error callback。 但是在Consumer析构的时候,只是先将assignment, revoke和rebalance_error callback给设置为0了
     */
    rd_kafka_conf_set_rebalance_cb(config_handle, &Consumer::rebalance_proxy); // 设置了rebalance的callback
    rd_kafka_t* ptr = rd_kafka_new(RD_KAFKA_CONSUMER, //构造一个 rd_kafka_t对象
                                   rd_kafka_conf_dup(config_handle),
                                   error_buffer, sizeof(error_buffer));
    if (!ptr) {
        throw Exception("Failed to create consumer handle: " + string(error_buffer));
    }
    rd_kafka_poll_set_consumer(ptr);
    set_handle(ptr); // 设置handle,即下层的librdkafka consumer
}

其中,rd_kafka_conf_set_rebalance_cb()方法的声明如下:

cpp 复制代码
RD_EXPORT
void rd_kafka_conf_set_rebalance_cb(
    rd_kafka_conf_t *conf,
    void (*rebalance_cb)(rd_kafka_t *rk,
                         rd_kafka_resp_err_t err,
                         rd_kafka_topic_partition_list_t *partitions,
                         void *opaque));

关于rebalance callback, 根据librdkafka的协议,上层用户(比如cppkafka)可以选择设置或者不设置rebalance callback:

  • 如果用户没有设置rebalance callback,librdkafka将会负责自动进行rebalance的分区分配
  • 用户也有可能设置了自定义的rebalance callback,这时候,rebalance的过程则交给用户负责,但是用户必须在callback中调用相应的 rd_kafka_assign()rd_kafka_incremental_assign() 等方法来设置分配。根据返回的状态码,表示要分配或者取消的TopicPartition:
    • RD_KAFKA_RESP_ERR__ASSIGN_PARTITIONS:表示要分配这些 partition;
    • RD_KAFKA_RESP_ERR__REVOKE_PARTITIONS:表示要取消这些 partition;
  • 对于老的、EAGER 模式的分配器(比如 range 和 roundrobin):
    • 用户需要一次性地 assign 所有新的分区,调用 rd_kafka_assign(partitions)
    • 或者一次性清空所有分区,调用 rd_kafka_assign(NULL)
  • 对于新的、COOPERATIVE 模式的分配器(比如 cooperative-sticky):
    • 如果是 ASSIGN,用户需要调用 rd_kafka_incremental_assign(partitions)
    • 如果是 REVOKE,用户需要调用 rd_kafka_incremental_unassign(partitions)
  • 用户自定义rebalance callback的原因,可能是需要在 assign 之前从外部存储恢复 offset、或者在 revoke 时手动提交 offset,或者打印对应日志等等,所以需要更细致的控制。但是核心的assign和unassign过程必须遵循上述的libirdkafka规范。

下面的伪代码显示的是librdkafka所推荐的处理rebalance消息的标准流程伪代码,cppkafka::Consumer对该流程的处理是在Consumer::rebalance_proxy()方法中,基本上遵循了该流程:

cpp 复制代码
switch (err) {
  case RD_KAFKA_RESP_ERR__ASSIGN_PARTITIONS:
    if (is_cooperative)
        rd_kafka_incremental_assign(rk, partitions);
    else
        rd_kafka_assign(rk, partitions);
    break;

  case RD_KAFKA_RESP_ERR__REVOKE_PARTITIONS:
    if (manual_commits)
        rd_kafka_commit(rk, partitions, 0); // 手动同步提交位点

    if (is_cooperative)
        rd_kafka_incremental_unassign(rk, partitions);
    else
        rd_kafka_assign(rk, NULL);  // 清空所有 assignment
    break;

  default:
    handle_unexpected_error();
    rd_kafka_assign(rk, NULL); // 状态同步,避免挂死
    break;
}

在这里,cppkafka::Consumer根据librdkafka的协议,设置的rebalance_cb是cppkafka::Consumer::rebalance_proxy():

所以,rk_conf.rebalance_cb(...)实际上调用的是cppkafka::Consumer::rebalance_proxy():

cpp 复制代码
/**
 * 发生rebalance时候的回调函数
 * 调用者是 rd_kafka_poll_cb
 */
void Consumer::rebalance_proxy(rd_kafka_t*, rd_kafka_resp_err_t error,
                               rd_kafka_topic_partition_list_t *partitions, void *opaque) {
    TopicPartitionList list = convert(partitions);
    static_cast<Consumer*>(opaque)->handle_rebalance(error, list);
}

/**
 * callback调用,注意,这里的handle_rebalance是per-consumer的回调
 * @param error
 * @param topic_partitions 会进行全量分配的TopicPartition,而不是增量的TopicPartition
 * @return
 */

void Consumer::handle_rebalance(rd_kafka_resp_err_t error,
                                TopicPartitionList& topic_partitions) {
    // 调用assignment callback,但是在Consumer::~Consumer的析构发生的时候,第一步就是已经把assignment callback清空了,因此这个assignment callback时间上已经为空了
    if (error == RD_KAFKA_RESP_ERR__ASSIGN_PARTITIONS) { // 为什么这里里叫做error?里的error只是发生了rebalance以后的操作的分类,比如是assign还是unassign等
        CallbackInvoker<AssignmentCallback>("assignment", assignment_callback_, this)(topic_partitions);
        // 尽管没有用户自定义的assignment callback,但是主assignment流程还是会执行
        assign(topic_partitions); // 这里会调用 rd_kafka_assign
    }
    // 调用assignment callback,但是在Consumer::~Consumer的析构发生的时候,第一步就是已经把assignment callback清空了,因此这个assignment callback时间上已经为空了
    else if (error == RD_KAFKA_RESP_ERR__REVOKE_PARTITIONS) { // 为什么这里里叫做error?这里的error只是发生了rebalance以后的操作的分类,比如是assign还是unassign等
        CallbackInvoker<RevocationCallback>("revocation", revocation_callback_, this)(topic_partitions);
        unassign(); // 这里会调用 rd_kafka_assign
    }
    else {
        CallbackInvoker<RebalanceErrorCallback>("rebalance error", rebalance_error_callback_, this)(error);
        unassign();
    }
}

可以看到,调用的cppkafka::Consumer::handle_rebalance(...)的时候,告知了对应的rebalance的类型信息rd_kafka_resp_err_t error(注意,这里虽然叫做error,但是其实并不是指常规类型的错误,就好像我们将stderr并不一定就是错误输出一样),以及TopicPartition的list(假如需要进行assign).

必须对方法cppkafka::Consumer::handle_rebalance()的每一行代码有准确的理解:

  • 如果对应的类型码为RD_KAFKA_RESP_ERR__ASSIGN_PARTITION,即 assign 类型的 rebalance,那么会做两件事:

    • 调用用户注册的 assignment_callback_(如果有), 包装在 CallbackInvoker 中调用, 这只是通知层,不决定实际分配行为 。我们在Consumer::~Consumer()的析构方法中可以看到,在发送close()请求以前,其实已经将客户端定义的assignment的callback设置为空了,所以这里其实没有可调用的东西;

    • 调用 assign(topic_partitions), 这是关键逻辑,底层调用 rd_kafka_assign(), 向 Kafka 底层确认"我接受这些 partition 的分配", 如果没有调用这个函数,Consumer 不会开始消费这些 partition:

      cpp 复制代码
          if (error == RD_KAFKA_RESP_ERR__ASSIGN_PARTITIONS) { // 为什么这里里叫做error?里的error只是发生了rebalance以后的操作的分类,比如是assign还是unassign等
              CallbackInvoker<AssignmentCallback>("assignment", assignment_callback_, this)(topic_partitions);
              // 尽管没有用户自定义的assignment callback,但是主assignment流程还是会执行
              assign(topic_partitions); // 这里会调用 rd_kafka_assign
          }
  • 如果类型码为RD_KAFKA_RESP_ERR__REVOKE_PARTITIONS, 即 revoke 类型的 rebalance,那么同样会首先尝试调用客户端定义的revocation的callback(同样也已经置为null了),然后执行对应的unassign操作。

    cpp 复制代码
    if (error == RD_KAFKA_RESP_ERR__REVOKE_PARTITIONS) { // 为什么这里里叫做error?这里的error只是发生了rebalance以后的操作的分类,比如是assign还是unassign等
            CallbackInvoker<RevocationCallback>("revocation", revocation_callback_, this)(topic_partitions);
            unassign(); // 这里会调用 rd_kafka_assign
        }
  • 其它预期以外的消息,这里也会进行unassign操作,unassign掉所有的TopicPartition。

总之,可以看到, cppkafka::Consumer::rebalance_proxy()才是根据librdkafka的协议设定的rebalance_callback,根据librdkafka协议的要求,必须在rebalance_callback中进行assign/unassign操作。同时,我们在ClickHouse的KafkaConsumer中可以看到也有对应的assignment_callback_revocation_callback_rebalance_error_callback_,这些与librdkafka的协议无关,完全是应用层(ClickHouse KafkaConsumer)自己定义的行为,在cppkafka::Consumer::rebalance_proxy()中会使用这3个callback进行相应的辅助操作,比如记录metrics,打印日志等等,没有具体的assign/unassign行为,具体的assign/unassign行为是单独调用的:

cpp 复制代码
    // called (synchronously, during poll) when we enter the consumer group
    // 从这里可以看到,assignment_callback 的调用只是用来告诉客户端assignment已经成功,客户端也只是把assignment更新到本地
    consumer->set_assignment_callback([this](const cppkafka::TopicPartitionList & topic_partitions)
    {
        CurrentMetrics::add(CurrentMetrics::KafkaAssignedPartitions, topic_partitions.size());
        ProfileEvents::increment(ProfileEvents::KafkaRebalanceAssignments);
    
        if (topic_partitions.empty())
        {
            LOG_INFO(log, "Got empty assignment: Not enough partitions in the topic for all consumers?");
        }
        else
        {
            LOG_TRACE(log, "Topics/partitions assigned: {}", topic_partitions);
            CurrentMetrics::add(CurrentMetrics::KafkaConsumersWithAssignment, 1);
        }
    
        assignment = topic_partitions;
        num_rebalance_assignments++;
    });

既然assignment_callback_等不负责assign/unassign,因此,在cppkafka::Consumer析构的时候将这些callback设置为nullptr并没有过多作用,与assign/unassign的整个流程不产生直接影响。

cpp 复制代码
Consumer::~Consumer() {
    assignment_callback_ = nullptr;
    revocation_callback_ = nullptr;
    rebalance_error_callback_ = nullptr;
    close();
}

我们看一下assign和unassign的过程,下面是cppkafka::Consumer::assign()cppkafka::Consumer::unassign()方法的具体实现:

cpp 复制代码
void Consumer::assign(const TopicPartitionList& topic_partitions) {
    rd_kafka_resp_err_t error;
    TopicPartitionsListPtr topic_list_handle = convert(topic_partitions);  // 将cppkafka的消息转换成底层librdkafka的消息
    error = rd_kafka_assign(get_handle(), topic_list_handle.get()); // 都是调用 rd_kafka_assign,由于是全量的,因此assign和unassign都是相同的处理逻辑
    check_error(error, topic_list_handle.get());
}

void Consumer::unassign() { // 在这里处理rd_kafka的unassign的消息
    rd_kafka_resp_err_t error = rd_kafka_assign(get_handle(), nullptr); // 由于是全量的,因此unassign全部的意思,就是assign nothing
    check_error(error);
}

我们首先可以看到,cppkafkaConsumer::assign()cppkafka::Consumer::unassign()的底层居然都是调用librdkafka的rd_kafka_assign(...)方法,只不过方法的参数不同。这再次印证了我们在上文讲到过的:在librdkafka中,在全量模式(eager模式)下,unassign和assign都是全量的,因此,unassign其实等价于Assign Nothing。Eager模式是默认的kafka客户端模式,也是ClickHouse用到的模式。Eager模式和COOPERATIVE两种rebalance模式的区别如下所示:

项目 EAGER 协议(默认、旧协议) COOPERATIVE 协议(新协议,自 Kafka 2.4 起支持)
触发 Rebalance 的机制 Kafka 触发 rebalance 时,所有消费者必须放弃全部分区 只撤销有冲突(要转移)的分区,其余分区可保留继续消费
分区回收行为 consumer->unassign()rd_kafka_assign(nullptr) 清空所有分区 使用 incremental_unassign() 仅撤销部分分区
分区再分配行为 使用 assign() 整体替换分区分配 使用 incremental_assign() 增量地添加新分区
是否支持保留旧分区 必须放弃全部分区 未冲突分区可以保留
重平衡是否抖动大 ✅ 抖动大(全部清空再分配) ❌ 抖动小(仅变更部分分区)
延迟/吞吐影响 📉 容易出现消费中断和延迟高峰 📈 更加平滑,数据延迟更低
客户端支持要求 所有 Kafka 客户端默认都支持 需要 Kafka 客户端显式启用 cooperative-sticky 协议
典型使用场景 老版本 Kafka、无需低延迟时可使用 高可用、低抖动、严格 SLA 的系统中推荐启用
实现示例方法 rd_kafka_assign(nullptr) assign(full_list) incremental_unassign(subset) incremental_assign(subset)

所以,我们继续看一下librdkafka的assign过程:

cpp 复制代码
rd_kafka_resp_err_t
rd_kafka_assign(rd_kafka_t *rk,
                const rd_kafka_topic_partition_list_t *partitions) {
        rd_kafka_error_t *error;
        rd_kafka_resp_err_t err;
        // 可以看到,这里使用的是RD_KAFKA_ASSIGN_METHOD_ASSIGN,即全量的ASSIGN(既然是全量的,那么这个assign肯定也就包含了unassgin的过程)
        // 但是如果是增量,那么必须清楚地区分开是assign还是unassign
        // 搜索 Enumerates the assign op sub-types
        error = rd_kafka_assign0(rk, RD_KAFKA_ASSIGN_METHOD_ASSIGN, partitions);

        if (!error)
                err = RD_KAFKA_RESP_ERR_NO_ERROR;
        else {
                err = rd_kafka_error_code(error);
                rd_kafka_error_destroy(error);
        }

        return err;
}

rd_kafka_assign(...)下层调用了rd_kafka_assign0():

cpp 复制代码
rd_kafka_error_t *
rd_kafka_assign0(rd_kafka_t *rk,
                 rd_kafka_assign_method_t assign_method, // 这里是 RD_KAFKA_ASSIGN_METHOD_ASSIGN,即进行eager的assignment(全量的assignment)
                 const rd_kafka_topic_partition_list_t *partitions) {
        rd_kafka_op_t *rko;
        rd_kafka_cgrp_t *rkcg; // 这个Kafka Client的Consumer Group
        // 对应的group其实是保存在对应的consumer中的
        if (!(rkcg = rd_kafka_cgrp_get(rk))) // 必须有Consumer Group
                return rd_kafka_error_new(RD_KAFKA_RESP_ERR__UNKNOWN_GROUP,
                                          "Requires a consumer with group.id "
                                          "configured");

        rko = rd_kafka_op_new(RD_KAFKA_OP_ASSIGN); // 创建 rd_kafka_op_t 对象,这个对象的 rko_type 是 RD_KAFKA_OP_ASSIGN
        // 一个类型为RD_KAFKA_OP_ASSIGN的 rd_kafka_op_t,它的union结构中的
        rko->rko_u.assign.method = assign_method; // 设置为  RD_KAFKA_ASSIGN_METHOD_ASSIGN

        if (partitions) // 如果有partitions,那么设置partitions
                rko->rko_u.assign.partitions =
                    rd_kafka_topic_partition_list_copy(partitions);

        return rd_kafka_op_error_destroy(
            rd_kafka_op_req(rkcg->rkcg_ops, rko, RD_POLL_INFINITE)); // 发送Kafka的PartitionTopic的Assign请求
}

可以看到:

  1. rd_kafka_assign0和unsubscribe以及close在这个层面的处理方式是一样的,都是通过异步消息队列进行通信,即最终也是通过方法rd_kafka_op_req()向Consumer Group的请求队列rkcg->rkcg_ops中插入一个rd_kafka_op_t结构体,这个结构体中封装了对应的请求类型和请求信息,然后阻塞等待响应。我们在上文中已经讲解过rd_kafka_op_req()方法,这里不再赘述。

  2. 这里,同样通过rd_kafka_op_new()方法来构造对应的请求结构体,使用的rd_kafka_op_type_t是RD_KAFKA_OP_ASSIGN。上文在讲解unsubscribe的时候已经讲过rd_kafka_op_new(),这里不做赘述:

    cpp 复制代码
        rko = rd_kafka_op_new(RD_KAFKA_OP_ASSIGN); 
  3. 同时,设置了rko.rko_u这个union结构为assign

    • 将assign的method设置为RD_KAFKA_ASSIGN_METHOD_ASSIGN
    • 将assign的partitions设置为需要进行assign的partition
    cpp 复制代码
    rko->rko_u.assign.method = assign_method; // 设置为  RD_KAFKA_ASSIGN_METHOD_ASSIGN
    
            if (partitions) // 如果有partitions,那么设置partitions
                    rko->rko_u.assign.partitions =
                        rd_kafka_topic_partition_list_copy(partitions);

    上文在unsubscribe中已经讲过rko_u这个union结构,这里不再赘述。

  4. 然后,通过调用方法rd_kafka_op_req发送assign请求,并且,最关键的,陷入无限等待:

    cpp 复制代码
            if (partitions)
                    rko->rko_u.assign.partitions =
                            rd_kafka_topic_partition_list_copy(partitions);
    
            return rd_kafka_op_error_destroy(
                    rd_kafka_op_req(rkcg->rkcg_ops, rko, RD_POLL_INFINITE));

    这里的等待时间是RD_POLL_INFINITE,即,如果收不到请求对应的response,将会永久等待。这个正是我们遇到的情况,assign请求由于某种原因无法收到响应,而调用者又没有对应的超时机制,因此,ClickHouse陷入的永久等待。 我们下文在讲解相应fix的时候,有一个librdkafka的PR正是避免永久等待,设定了一个超时时间。

普通的消息队列和控制事件队列的区别

我们从改进PR-76621中可以看到,它通过方法get_partition_queue()来切断从Topic Partition Queue到主消息队列的消息队列,这样,应用层就再也无法从主消息队列中获取到新的Topic Partition消息。下文会讲解。

在 librdkafka 的内部设计里,普通消息和控制事件并不是存在两个完全不同的队列里,而是:

  • 每个Topic Partition有自己的 fetch 队列(存放从 broker 拉下来的 record)。
  • 这些Topic Partition队列默认会forward到一个主队列(consumer queue / main queue)
  • 应用层调用 consumer.poll()/consume() 时,实际上就是从主队列取消息,因为来自TopicPartition的消息已经被汇总到了主消息队列
  • 除了普通 record,主队列里还会有 控制类事件:rebalance 通知、错误事件、统计回调、OAuth 刷新等。

所以:

  • 默认模式下:应用看到的都是"一个队列",既包含普通消息,也包含控制事件。
  • 调用 get_partition_queue(partition) 时:你拿到分区队列对象,同时切断它向主队列的转发。这样做之后:
  • 主队列就只剩控制事件(和其他未切断分区的消息)。

如果你想继续消费这个分区的普通消息,必须自己去 poll 这个分区队列。

永久hang住的原因总结分析

所以,目前为止,我们已经找到了持有锁并等待另外的锁(条件通知)的线程堆栈,这个没有被释放的锁导致后续针对该表的DDL都无法成功。而锁没有释放,是因为在获取锁以后的后续操作陷入了永久等待状态,即持有并等待。

在这里,上层获取了DDLGuard因此阻塞了其他DDL的原因和过程已经非常清楚,我们不再赘述。这里,我们将下层永久等待的原因进行了如下总结。

必须清楚,librdkafka的所有调用都是基于队列的异步调用,异步调用和同步调用相比,一个最大的不同是

  • 基于队列的异步调用(也许是异步阻塞或者异步非阻塞,但是无所谓), 即使我们是在系统hang住的状态下打印堆栈,堆栈本身体现出来的也只是一个业务流转发生问题以后导致的状态和结果,从这个堆栈中我们往往没有足够的信息去反推出造成这个结果的前期流转过程。比如,我们的堆栈反映出,客户端一直在等待assign请求的的响应,并且rd_kafka_thread_main()一直在不断的serve,这说明rk_ops是空的,但是背后到底发生了什么,堆栈的确无法给我们提供任何信息。除了堆栈,即使我们打印出整个内存状态,也没有这种历史过程的变化信息,而是一个变化后的结果。
  • 但是如果是同步调用,整个因果关系就全部体现在堆栈中了。在hang住的状态下我们打印堆栈,堆栈中包含因果关系。

所以,下面的整个过程,是我们根据我们当前的堆栈所反推出来的当时的整个因果关系,所以,这个推论并不完全是当时的真实情况。我们从ClickHouse和liibrdkafka的相关社区的PR中可以看到,Kafka hang住的情况别人也遇到过,并且从他们的fix中可以看到他们的猜想与我一致(下文会详细讲解对应的PR Fix)。

  1. 首先是一个准备只是,即,请求队列rkcg_ops中的的op实际是被转发到rk_ops队列,然后由rdk:main 线程来进行集中处理,即rk:main启动后会把 rkcg_ops forward 到 rk_ops,并且主循环只 serve(rk_ops)

    c 复制代码
    static int rd_kafka_thread_main (void *arg) {
            rd_kafka_t *rk = arg;
            ...
            if (rk->rk_cgrp)
                    rd_kafka_q_fwd_set(rk->rk_cgrp->rkcg_ops, rk->rk_ops);
    
            while (...) {
                    ...
                    rd_kafka_q_serve(rk->rk_ops, ..., RD_KAFKA_Q_CB_CALLBACK, NULL, NULL);
                    if (rk->rk_cgrp)
                            rd_kafka_cgrp_serve(rk->rk_cgrp);
                    ...
            }
    }

  1. ClickHouse层面的close()操作实际上会调用 consumer_close() 会执行rd_kafka_consumer_close()rd_kafka_consumer_close()会首先将响应队列的转发准备好,即将响应队列rkcg_q forward 到临时队列 rkq,然后循环 pop(rkq),当这种转发和pop准备好了,就开始通过方法rd_kafka_cgrp_terminate()来发送TERMINATE OP了:

    c 复制代码
    rd_kafka_resp_err_t rd_kafka_consumer_close (rd_kafka_t *rk) {
            ...
            rkq = rd_kafka_q_new(rk);
            rd_kafka_q_fwd_set(rkcg->rkcg_q, rkq);
    
            rd_kafka_cgrp_terminate(rkcg, RD_KAFKA_REPLYQ(rkq, 0));// 发送RD_KAFKA_OP_TERMINATE消息到rkcg_ops
    
            while ((rko = rd_kafka_q_pop(rkq, RD_POLL_INFINITE, 0))) {
                    if ((rko->rko_type & ~RD_KAFKA_OP_FLAGMASK) ==
                            RD_KAFKA_OP_TERMINATE) {
                                err = rko->rko_err;
                                rd_kafka_op_destroy(rko);
                                break;
                    }
                    // 只要这个rko的rko_type没有RD_KAFKA_OP_FLAGMASK, 那么就调用 rd_kafka_poll_cb()
                    res = rd_kafka_poll_cb(rk, rkq, rko,
                                           RD_KAFKA_Q_CB_RETURN, NULL);
                    ...
            }
    }

    可以看到,这里是通过调用rd_kafka_cgrp_terminate()来把对应的RD_KAFKA_OP_TERMINATE发送到Consumer Group的rkcg_ops队列中(并随后转发到rk_ops队列中由main线程处理,main线程随后会通过调用 rd_kafka_thread_main() -> rd_kafka_cgrp_serve() -> rd_kafka_cgrp_terminate0()的调用链路,最终使用方法rd_kafka_cgrp_terminate0() 来处理这个RD_KAFKA_OP_TERMINATE请求)

    c 复制代码
    void rd_kafka_cgrp_terminate (rd_kafka_cgrp_t *rkcg, rd_kafka_replyq_t replyq) {
    	rd_kafka_assert(NULL, !thrd_is_current(rkcg->rkcg_rk->rk_thread));
            rd_kafka_cgrp_op(rkcg, NULL, replyq, RD_KAFKA_OP_TERMINATE, 0);
    }

    这就是我们的事故栈里"close drain 队列时触发 rebalance callback"的入口。


  1. rd_kafka_consumer_close()收到的rko_type不包含RD_KAFKA_OP_FLAGMASK,因此会通过rd_kafka_poll_cb()来进行相关的回调调用。 rd_kafka_assign() 会走 op_req(... RD_POLL_INFINITE)
    rd_kafka_assign0() 创建 OP_ASSIGNrd_kafka_op_req(rkcg->rkcg_ops, ..., RD_POLL_INFINITE)

    c 复制代码
    rd_kafka_assign0 (...) {
            ...
            rko = rd_kafka_op_new(RD_KAFKA_OP_ASSIGN); // 构造一个ASSIGN请求结构体
            ...
            return rd_kafka_op_error_destroy(
                    rd_kafka_op_req(rkcg->rkcg_ops, rko, RD_POLL_INFINITE));
    }

    rd_kafka_op_req 会构造一个响应队列,然后调用 rd_kafka_op_req0()进行请求。rd_kafka_op_req0()是一个阻塞式方法,会发送请求并阻塞等待响应消息:

    c 复制代码
     rd_kafka_op_t *rd_kafka_op_req (rd_kafka_q_t *destq, // 请求队列,对应与rkcg_ops
                                        rd_kafka_op_t *rko,
                                        int timeout_ms) {
            rd_kafka_q_t *recvq; // 响应队列
            rd_kafka_op_t *reply;  // 存放响应结果
    
            recvq = rd_kafka_q_new(destq->rkq_rk); // 创建响应队列
             // 发送请求并阻塞等待,即方法返回的时候说明已经完成了响应的等待过程,或者拿到了响应,或者超时
            reply = rd_kafka_op_req0(destq, recvq, rko, timeout_ms);
    
            rd_kafka_q_destroy_owner(recvq);
    
            return reply;
    }

    rd_kafka_op_req0() 会:

    • 先先把创建好的请求结构体recvq放到请求结构体rko->rko_replyq中,这样,请求的处理者就知道把对应的响应放到这个队列中;
    • rd_kafka_q_enq(destq, rko),即将请求放到目标队列中。从目标队列中拿出op进行处理是这个目标队列对应的处理线程处理的。比如,rkcg_ops的处理者是rk_main线程
    • 然后 rd_kafka_q_pop(recvq, RD_POLL_INFINITE, 0) 阻塞等待响应。可以看到,这里的等待方式是无限等待: RD_POLL_INFINITE
    c 复制代码
    rd_kafka_op_t *rd_kafka_op_req0 (rd_kafka_q_t *destq, // // 请求队列,对应与rkcg_ops
                                     rd_kafka_q_t *recvq, // 响应队列
                                     rd_kafka_op_t *rko, // 请求消息体
                                     int timeout_ms) {
            ...
            rd_kafka_op_set_replyq(rko, recvq, NULL); // 把响应队列设置到请求op rko中
    
            if (!rd_kafka_q_enq(destq, rko)) // 把请求的op塞到请求队列中去
                    return NULL;
            
            reply = rd_kafka_q_pop(recvq, rd_timeout_us(timeout_ms), 0); // // 阻塞等待响应消息
            return reply;
    }

  1. OP_ASSIGN 的 enqueue 会沿 forward 递归落到最终队列(一般是 rk_ops
    rd_kafka_q_enq1() 的关键逻辑:
  • 如果目标队列 disabled ,会立即 rd_kafka_op_reply(... __DESTROY)

  • 否则如果队列是一个被转发的队列,就递归 rd_kafka_q_enq1(fwdq, ...)

    c 复制代码
    int rd_kafka_q_enq1 (...) {
            ...
            if (unlikely(!(rkq->rkq_flags & RD_KAFKA_Q_F_READY))) { // 如果这个队列已经被disable,那么会立刻进行响应
                    return rd_kafka_op_reply(rko, RD_KAFKA_RESP_ERR__DESTROY);
            }
    
            if (!(fwdq = rd_kafka_q_fwd_get(rkq, 0))) { // 没有转发链路
                    ...
                    rd_kafka_q_enq0(rkq, rko, at_head); // 没有forward链路,进行入队列操作
                    rdk_thread_cond_signal(&rkq->rkq_cond); // 有新op进来,执行通知
                    ...
            } else { // 是一个被转发的队列
                    ...
                    rd_kafka_q_enq1(fwdq, rko, orig_destq, at_head, 1/*do lock*/); // 有forward链路,递归进行enq1
                    rd_kafka_q_destroy(fwdq);
            }
    
            return 1;
    }

  1. 如果 rdk:main 能 serve 到这个 OP_ASSIGN,它一定会 reply(不会卡死)
    OP_ASSIGN 处理函数最后一定 rd_kafka_op_error_reply(rko, error)
c 复制代码
static rd_kafka_op_res_t
rd_kafka_cgrp_op_serve (rd_kafka_t *rk, rd_kafka_q_t *rkq,
                        rd_kafka_op_t *rko, rd_kafka_q_cb_type_t cb_type,
                        void *opaque) {
        ......
        switch ((int)rko->rko_type)
        {
        case RD_KAFKA_OP_NAME:
        case RD_KAFKA_OP_CG_METADATA:
        case RD_KAFKA_OP_OFFSET_FETCH:
        case RD_KAFKA_OP_PARTITION_JOIN:
        case RD_KAFKA_OP_PARTITION_LEAVE:
        case RD_KAFKA_OP_OFFSET_COMMIT:
        case RD_KAFKA_OP_COORD_QUERY:
        case RD_KAFKA_OP_SUBSCRIBE:
                break;
        case RD_KAFKA_OP_ASSIGN:
                rd_kafka_cgrp_handle_assign_op(rkcg, rko); // 调用rd_kafka_cgrp_handle_assign_op来处理ASSIGN请求
                rko = NULL;
                break;
        case RD_KAFKA_OP_GET_SUBSCRIPTION:
        case RD_KAFKA_OP_GET_ASSIGNMENT:
        case RD_KAFKA_OP_GET_REBALANCE_PROTOCOL:
        case RD_KAFKA_OP_TERMINATE:
        default:
                rd_kafka_assert(rkcg->rkcg_rk, !*"unknown type");
                break;
        }

        if (rko)
                rd_kafka_op_destroy(rko);

        return RD_KAFKA_OP_RES_HANDLED;
}

可以看到,rk:main()线程通过rd_kafka_cgrp_op_serve()来处理cgrp的请求,并且调用 rd_kafka_cgrp_handle_assign_op()来处理RD_KAFKA_OP_ASSIGN请求:

c 复制代码
static void rd_kafka_cgrp_handle_assign_op (rd_kafka_cgrp_t *rkcg,
                                            rd_kafka_op_t *rko) {
        .......

        if (!error) {
                switch (rko->rko_u.assign.method)
                {
                case RD_KAFKA_ASSIGN_METHOD_ASSIGN:
                        .....
                        break;
                case RD_KAFKA_ASSIGN_METHOD_INCR_ASSIGN:
                        .....
                        break;
                case RD_KAFKA_ASSIGN_METHOD_INCR_UNASSIGN:
                        .....
                        break;
                default:
                        RD_NOTREACHED();
                        break;
                }

                /* If call succeeded serve the assignment */
                if (!error)
                        rd_kafka_assignment_serve(rkcg->rkcg_rk);


        }
        ....
        rd_kafka_op_error_reply(rko, error); 
}

这里可以看到,只要OP_ASSIGN进入到了队列,那么这个请求一定会被处理,不存在说因为当前处于close状态(设置了与close相关的flag)而导致这个请求不进行处理:

所以"卡死"必然意味着:**OP_ASSIGN 从未走到 rd_kafka_cgrp_handle_assign_op()**方法


  1. cgrp 进入最终 rd_kafka_cgrp_terminated() 时,会 disable + purge(rkcg_ops)

    调用链为 rd_kafka_thread_main() -> rd_kafka_cgrp_serve() -> rd_kafka_cgrp_terminated()

    c 复制代码
    static void rd_kafka_cgrp_terminated (rd_kafka_cgrp_t *rkcg) {
            ...
            rd_kafka_q_disable(rkcg->rkcg_ops);
            rd_kafka_q_purge(rkcg->rkcg_ops);
            ...
    }

    上文中已经详细讲过 rd_kafka_cgrp_terminated()方法;

    rd_kafka_q_purge(rkcg_ops) 执行的时候,如果该队列是被转发的,那么会先递归purge掉它的转发目标队列,(也就是 rk_ops
    rd_kafka_q_purge0() 一上来就检查 forward;存在就 purge(fwdq) 并返回:

c 复制代码
int rd_kafka_q_purge0 (rd_kafka_q_t *rkq, int do_lock) {
    if ((fwdq = rd_kafka_q_fwd_get(rkq, 0))) {
                ......
                cnt = rd_kafka_q_purge(fwdq); // 对转发队列进行递归purge
                rd_kafka_q_destroy(fwdq);
                return cnt;
    }
    ....
    rd_kafka_q_reset(rkq);

	// 直接销毁所有的ops
	next = TAILQ_FIRST(&tmpq);
	while ((rko = next)) {
		next = TAILQ_NEXT(next, rko_link);
		rd_kafka_op_destroy(rko);
       cnt++;
	}
   return cnt;
}
  1. 显然,如果一个队列已经被disable掉了,那么后续新的op就无法再进入这个队列,这个我们可以参考方法rd_kafka_q_enq1(),但是,假如这些op是在队列被disable以前进入队列的,那么,由于rd_kafka_cgrp_terminated()调用的过程中会强行disable然后清空队列,对于队列中已有的操作不会进行任何处理和响应,导致调用者会永远等不到这些操作的响应,即永远hang住
    我们可以通过方法rd_kafka_q_enq1()看到,如果队列已经被disable,那么会直接响应 RD_KAFKA_RESP_ERR__DESTROY

    c 复制代码
    static RD_INLINE RD_UNUSED
    int rd_kafka_q_enq1 (rd_kafka_q_t *rkq, rd_kafka_op_t *rko,
                         rd_kafka_q_t *orig_destq, int at_head, int do_lock) {
            .....
            if (unlikely(!(rkq->rkq_flags & RD_KAFKA_Q_F_READY))) {
                    /* Queue has been disabled, reply to and fail the rko. */
                    if (do_lock)
                            rdk_thread_mutex_unlock(&rkq->rkq_lock);
    
                    return rd_kafka_op_reply(rko, RD_KAFKA_RESP_ERR__DESTROY);
            }

所以,整个purge发生的时序可以总结为:

  1. 客户端通过rd_kakfa_consumer_close()发送请求并等待响应,但是由于集群rebalance的进行,它收到了ASSIGN相关的消息;
  2. 在处理ASSIGN消息的时候,客户端会通过方法rd_kafka_assign0()生成OP_ASSIGN并无线等待。这个ASSIGN请求会通过转发链rkcg_ops -> rk_ops进入队列
  3. main线程在收到终止请求以后,进入终止流程,调用rd_kafka_cgrp_terminated(),对 rkcg_ops 执行 disable+purge,从而disable并请求rkcg_ops -> rk_ops队列
  4. OP_ASSIGN 如果当时还在 rk_ops 里没被 main线程 serve 到,就会在 purge 中被 rd_kafka_op_destroy() 直接销毁
  5. 因此,OP_ASSIGN 从未被 rd_kafka_cgrp_handle_assign_op()处理,因此永远不会执行 rd_kafka_op_error_reply()
  6. 从而,rd_kafka_op_req(... RD_POLL_INFINITE) 永久阻塞

PR分析

我们在两个PR钟都找到了相应的可能的解决方案。我们在这里对这两个PR进行分析.

避免close以前进行主动的unsubscribe

对应的PR在这里: PR-76621,我们可以看到,这个PR不仅仅将依赖的librdkafka submodule更新到了2.8,同时,也更新了ClikHouse本身的一些处理逻辑,核心修改在于close操作之前不再显式进行unsubscribe操作,从而在一定程度上减少rebalance的发生,进而减少对应的竞态的发生

cpp 复制代码
    using ConsumerPtr = std::shared_ptr<cppkafka::Consumer>;
    
    ConsumerPtr && KafkaConsumer::moveConsumer()
    {
        cleanUnprocessed();
        if (!consumer->get_subscription().empty()) // 如果有订阅
        {
            try
            {
                // unsubscribe只能在cppkafka::Consumer级别进行, 这是 Kafka 消费者(rd_kafka_t)提供的方法,它用于取消对已订阅主题的订阅。
                // 当调用cppkafka::Consumer::unsubscribe() 时,消费者将不再从当前订阅的主题中拉取消息,但并不会改变消费者的分区分配策略,只是停止从所有订阅的主题中拉取消息。
                // 当cppkafka::Consumer::unsubscribe() 方法返回,说明已经unsubscribe成功了
                consumer->unsubscribe(); // 调用 cppkafka::Consumer::unsubscribe()
            }
            catch (const cppkafka::HandleException & e)
            {
                LOG_ERROR(log, "Error during unsubscribe: {}", e.what());
            }
            drain(); // 等待,一直到Kafka收不到消息了
        }
        return std::move(consumer);
    }

修改以后,没有再进行unsubscribe操作,取而代之的是一个consumerGracefulStop()

cpp 复制代码
ConsumerPtr && KafkaConsumer::moveConsumer()
{
    // messages & assignment should be destroyed before consumer
    cleanUnprocessed();
    assignment.reset();

    StorageKafkaUtils::consumerGracefulStop(*consumer, DRAIN_TIMEOUT_MS, log, [this](const cppkafka::Error & err) { setExceptionInfo(err); });

    return std::move(consumer);
}
cpp 复制代码
void consumerGracefulStop(
    cppkafka::Consumer & consumer, const std::chrono::milliseconds drain_timeout, const LoggerPtr & log, ErrorHandler error_handler)
{
    // Note: librdkafka is very sensitive to the proper termination sequence and have some race conditions there.
    // Before destruction, our objectives are:
    //   (1) Process all outstanding callbacks by polling the event queue.
    //   (2) Ensure that only special events (e.g. callbacks, rebalances) are polled (we don't want to poll regular messages).
    //
    // Previously, we performed an unsubscribe to stop message consumption and clear 'read' messages.
    // However, unsubscribe triggers a rebalance that schedules additional background tasks, such as locking
    // and removal of internal toppar queues. Meanwhile, polling to release callbacks may concurrently
    // cause those same queues to be destroyed.
    // This can lead to a situation where the background thread doing rebalance and the current thread doing polling access
    // the toppar queues simultaneously, potentially locking them in a different order, which risks a deadlock.
    //
    // To mitigate this, we now:
    //   (1) Avoid calling unsubscribe (letting rebalance occur naturally via consumer group timeout).
    //   (2) Set up different rebalance callbacks to repeat (3) if a rebalance will occur before consumer destruction.
    //   (3) Pause the consumer to stop processing new messages.
    //   (4) Disconnect the toppar queues to reduce the risk of lock inversion (less cascading locks).
    //   (5) Poll the event queue to process any remaining callbacks.

    consumer.set_revocation_callback(
        [](const cppkafka::TopicPartitionList &)
        {
            // we don't care during the destruction
        });

    consumer.set_assignment_callback(
        [&consumer](const cppkafka::TopicPartitionList & topic_partitions)
        {
            if (!topic_partitions.empty())
            {
                consumer.pause_partitions(topic_partitions); // 收到rebalance的assignment,由于目前即将进行close,因此pause掉这些TopicPartitions
            }

            // it's not clear if get_partition_queue will work in that context
            // as just after processing the callback cppkafka will call run assign
            // and that can reset the queues

        });

    try
    {
        auto assignment = consumer.get_assignment();

        if (!assignment.empty())
        {
            consumer.pause_partitions(assignment);

            for (const auto& partition : assignment)
            {
                // that call disables the forwarding of the messages to the customer queue
                consumer.get_partition_queue(partition);
            }
        }
    }
    catch (const cppkafka::HandleException & e)
    {
        LOG_ERROR(log, "Error during pause (consumerGracefulStop): {}", e.what());
    }

    drainConsumer(consumer, drain_timeout, log, std::move(error_handler));
}

这个consumerGracefulStop()在注释中其实解释了它的基本思想:

  • 尽量不再主动触发unsubscribe: unsubscribe 会显式触发整个集群的rebalance,rebalance 会安排后台任务去lock/移除 toppar queues;与此同时代码中还在调用 drain() 从队列中消费消息;锁顺序不同会死锁。因此要:

    • 注意,不主动触发unsubscribe不代表就不会引发rebalance,不主动触发unsubscribe只是将rebalance的概率和频率缩小。比如,我们ON CLUSTER DROP Kafka Table的情形下,即使所有的Close操作都不主动触发unsubscribe,一个Consumer主动close成功,必然引起整个Consumer Group的重新rebalance。
  • 先停止处理新消息: 通过更新assignment_callback,在assignment_callback中pause调rebalance所对应的回调(停止处理新消息):

    cpp 复制代码
    consumer.set_assignment_callback(
        [&consumer](const cppkafka::TopicPartitionList & topic_partitions)
        {
            if (!topic_partitions.empty())
                consumer.pause_partitions(topic_partitions);
        });

    我们上文讲过,在handle_rebalance这个注册到librdkafka的callback里面,cppkafka::Consumer会先调用assignment_callback进行一些比如metrics收集、日志等工作然后进行真正的assign()操作。在这个PR里面,直接修改了assignment_callback,从而在assign()以前先pause掉TopicPartition的处理:

    cpp 复制代码
        void Consumer::handle_rebalance(rd_kafka_resp_err_t error,
                                        TopicPartitionList& topic_partitions) {
            // 调用assignment callback,但是在Consumer::~Consumer的析构发生的时候,第一步就是已经把assignment callback清空了,因此这个assignment callback时间上已经为空了
            if (error == RD_KAFKA_RESP_ERR__ASSIGN_PARTITIONS) { // 为什么这里里叫做error?里的error只是发生了rebalance以后的操作的分类,比如是assign还是unassign等
                CallbackInvoker<AssignmentCallback>("assignment", assignment_callback_, this)(topic_partitions);
                // 尽管没有用户自定义的assignment callback,但是主assignment流程还是会执行
                assign(topic_partitions); // 这里会调用 rd_kafka_assign
            }
            ....
        }
  • 断开 toppar queue forwarding,减少级联锁: 通过get_partition_queue().disable_queue_forwarding()),降低"销毁内部队列"时的级联锁风险。这个在本文没有进行特别详细的介绍

    cpp 复制代码
    for (const auto& partition : assignment)
                {
                    // that call disables the forwarding of the messages to the customer queue
                    consumer.get_partition_queue(partition);
                }
    cpp 复制代码
    Queue Consumer::get_partition_queue(const TopicPartition& partition) const {
        Queue queue = Queue::make_queue(rd_kafka_queue_get_partition(get_handle(),
                                                                     partition.get_topic().c_str(),
                                                                     partition.get_partition()));
        queue.disable_queue_forwarding();
        return queue;
    }
  • 最后再 poll/drain,把 outstanding callbacks 处理掉

    cpp 复制代码
        drainConsumer(consumer, drain_timeout, log, std::move(error_handler));

在清空rkcg_ops以前,先全部处理完rkcg_ops

对应的PR在这里: PR-4883,这是在librdkafka层面进行的修改。

我们在上文讲过,rd_kafka_thread_main()会反复通过调用rd_kafka_cgrp_try_terminate()判定是否已经完成了终止流程,如果完成了,就调用 rd_kafka_cgrp_terminated()执行最后的清理逻辑。

我们从 rd_kafka_cgrp_terminated()的清理逻辑可以看到,这里会将rkcg->rkcg_ops队列(这个队列实际上有转发链路rkcg_ops -> rk_ops)先进行disable然后进行purge(清空),这时候就可能出现问题:

  • 如果一个操作(比如unassign)是在rkcg_ops被disable以后进入的,那么会立刻被响应,这时候不应该出现rd_kafka_q_pop_serve()hang住的情况。上文已经讲过。
  • 但是,如果一个操作是在disable以前进入队列的,那么由于disable+purge的发生,这个ops会被清空,不会有reply给到rd_kafka_consumer_close(),这也许就是我们遇到的问题:rebalance_proxy永远收不到response。。。

基于这种猜测,我们在librdkafka的repo中发现了一个fix,它的核心意图就是:

  • **不要把"需要 reply 的请求 op"直接 purge 掉,而是让这些 op 先被 rd_kafka_q_serve() 走一遍正常的处理路径(调用 rko->rko_serve / 标准 handler),从而主动给等待方发 reply(常见是 __DESTROY / "终止中"类错误),然后队列就可以安全 purge。
  • 并且,在调用rd_kafka_q_serve的时候,它的等待逻辑变成了RD_POLL_NOWAIT(我们出问题的时候,是rd_kafka_op_req(..., RD_POLL_INFINITE, ...),即永久等待), 即,通过一个 while 循环只把当前已有的 op 全部 serve 掉,serve 到空就停。
cpp 复制代码
static void rd_kafka_cgrp_terminated(rd_kafka_cgrp_t *rkcg) {
        if (rd_atomic32_get(&rkcg->rkcg_terminated))
                return; /* terminated() may be called multiple times,
                         * make sure to only terminate once. */
        rd_kafka_cgrp_group_assignment_set(rkcg, NULL);
        ......
        rd_kafka_q_purge(rkcg->rkcg_wait_coord_q);
        /* Disable and empty ops queue since there will be no
         * (broker) thread serving it anymore after the unassign_broker
         * below.
         * This prevents hang on destroy where responses are enqueued on
         * rkcg_ops without anything serving the queue. */
        rd_kafka_q_disable(rkcg->rkcg_ops);
        // 这是PR增加的部分,在purge掉rkcg->rkcg_ops以前,需要对这个rkcg_ops进行处理
        /*
         * Need to drain all ops in rkcg_ops,
         * in case some ops are waiting for reply and hang forever
         */
        while(rd_kafka_q_serve(rkcg->rkcg_ops, RD_POLL_NOWAIT, 0,
                         RD_KAFKA_Q_CB_CALLBACK, NULL, NULL));

相关引用

相关推荐
编程彩机3 小时前
互联网大厂Java面试:从Spring Security到微服务架构场景解析
kafka·spring security·微服务架构·jwt·java面试·分布式追踪
斯普信专业组7 小时前
Nomad组件部署clickhouse-job
clickhouse·nomad
小程故事多_8010 小时前
深度解析Kafka重平衡,触发机制、执行流程与副本的核心关联
分布式·kafka
【赫兹威客】浩哥12 小时前
【赫兹威客】伪分布式Kafka测试教程
分布式·kafka
Jackyzhe12 小时前
从零学习Kafka:集群架构和基本概念
学习·架构·kafka
indexsunny14 小时前
互联网大厂Java面试实战:Spring Boot微服务在电商场景中的应用
java·数据库·spring boot·redis·微服务·kafka·电商
yumgpkpm14 小时前
在AI语言大模型时代 Cloudera CDP(华为CMP 鲲鹏版)对自有知识的保护
人工智能·hadoop·华为·zookeeper·spark·kafka
Linux蓝魔14 小时前
mysql-redis-kafka-es-ngnix安装调试
linux·服务器·mysql·kafka·es
没有bug.的程序员14 小时前
Spring Cloud Stream:消息驱动微服务的实战与 Kafka 集成终极指南
java·微服务·架构·kafka·stream·springcloud·消息驱动