【clickhouse】异步clickhouse client 260401版本

如果在企业生产环境要 python 编程实现异步访问 clickhouse 数据库,要求能包含以下功能:超时策略:connect / read(流) / total 分开(流式一般 total 不宜太小) 重试策略:只对“可重试”的错误重试(网络错误、429、部分 5xx),并且指数退避+抖动 连接池管理

可以。
如果你的目标是企业生产环境里的 Python 异步访问 ClickHouse,而且明确要求:

  • connect / read(流) / total 三段式超时
  • 只对可重试错误做重试
  • 指数退避 + 抖动
  • 连接池管理

我更建议把方案分成两层:

  1. 驱动层:使用 ClickHouse 的 HTTP 接口
  2. 可靠性层:自己封装一个 async client(基于 aiohttp

原因是:官方 Python 驱动 ClickHouse Connect 支持 get_async_client(),并提供 connect_timeoutsend_receive_timeoutquery_retries 等能力;但它内建的超时粒度主要是“连接超时 + 收发超时”,而你要的是更细的 connect / read / total 分离控制,这种粒度更适合直接用 aiohttp.ClientTimeout(total, connect, sock_read) 自己封装。官方文档也说明 ClickHouse Connect 走的是 HTTP 协议,适合企业环境中的负载均衡、防火墙和代理场景。(ClickHouse)

推荐结论

生产环境建议:

  • 如果你只需要“够用”的异步访问:
    clickhouse-connect[async]
  • 如果你需要你列出的这套严格生产级策略
    直接基于 aiohttp + ClickHouse HTTP API 封装一个轻量 async client

因为 aiohttp 原生支持:

  • total 总超时
  • connect 连接/从连接池拿连接的超时
  • sock_read 每次 socket 读取的超时

这正好对应你要的“连接 / 流读取 / 总耗时”三段式控制。aiohttp 文档明确给出了 ClientTimeout(total, connect, sock_connect, sock_read)。(AIOHTTP Documentation)


设计建议

1)超时策略

建议区分三类:

A. connect timeout

控制:

  • DNS
  • TCP 建连
  • TLS 握手
  • 从连接池获取连接的等待

适合设置得较小,比如 1s ~ 3s

B. read timeout(流式读取)

控制:

  • 两次分块读取之间允许等待多久

流式查询时不要设太小。
因为 ClickHouse 大查询、聚合查询、分布式查询,可能会有一段时间没有新 chunk 到达。你自己也提到了这一点,这是对的。

建议:

  • 小查询:5s ~ 15s
  • 流式大查询:30s ~ 120s
  • 导出型查询:甚至更大

C. total timeout

控制整次请求的上限。

流式场景里 total 不宜过小,否则即使服务端持续在产出数据,也可能被总时长强行打断。

建议:

  • OLTP 风格点查:3s ~ 10s
  • 普通分析:15s ~ 60s
  • 流式导出:120s ~ 1800s,甚至可配置为 None

D. 服务端再加一道 max_execution_time

客户端总超时只能保证“我不再等了”,
但不能保证服务端立即停止执行。

ClickHouse 支持通过 max_execution_time 限制服务端查询执行时间。官方知识库有这个设置示例。(ClickHouse)


2)重试策略

你这个要求非常关键:只重试“可重试”错误

建议只对这些情况重试:

可重试

  • 网络抖动 / 连接断开
  • asyncio.TimeoutError
  • aiohttp.ClientConnectionError
  • aiohttp.ServerDisconnectedError
  • HTTP 429
  • HTTP 502 / 503 / 504

一般不要重试

  • SQL 语法错误
  • 认证失败
  • 权限错误
  • 表不存在
  • 数据格式错误
  • 非幂等写入

3)写操作重试原则

这个很容易踩坑:

查询(SELECT)

通常可重试。

INSERT / ALTER / DDL

默认不要自动重试
除非你能保证幂等性,比如:

  • 使用明确的去重 key
  • 写入 Kafka / Buffer / staging 表
  • 或者业务端有 request-id 幂等约束

官方 ClickHouse Connect 文档也说明:它的 query_retries 只自动重试 query 请求;为了避免意外重复执行,commandinsert 不会自动重试。(ClickHouse)


4)退避策略

建议:

delay = min(cap, base * 2^(attempt-1)) * random(0.5, 1.5)

例如:

  • base = 0.2s
  • cap = 3s
  • max_retries = 3~5

这样能避免故障时所有实例同时重试形成放大流量。


5)连接池管理

aiohttp.ClientSession 自带连接池,企业环境里应当:

  • 全进程复用一个 session
  • 不要每次 query 新建 session
  • 设置 TCPConnector(limit=..., limit_per_host=..., ttl_dns_cache=...)

aiohttp 文档也明确说了:一个 session 内部就包含连接池,每次请求建一个 session 是非常差的做法。(AIOHTTP Documentation)

如果走官方 ClickHouse Connect,同步侧底层也有 HTTP 连接池;官方文档说明默认池对每个 ClickHouse server 维护最多 8 个 keep-alive 连接,并可通过 pool_mgr 自定义。(GitHub)


推荐实现

下面给你一个可直接落地的生产级 async ClickHouse HTTP 客户端骨架
它包含:

  • 三段式超时
  • 流式读取
  • 可重试错误识别
  • 指数退避 + 抖动
  • 连接池
  • 查询 / 命令分离
  • 关闭资源
from __future__ import annotations

import asyncio
import json
import random
from dataclasses import dataclass
from typing import Any, AsyncIterator, Mapping, Optional

import aiohttp


# =========================
# 配置模型
# =========================

@dataclass(frozen=True)
class TimeoutPolicy:
    connect: float = 2.0        # 建连/取连接超时
    read: float = 30.0          # 流读取超时(两次 chunk 之间)
    total: Optional[float] = 60.0  # 整个请求总超时;流式场景可适当放大


@dataclass(frozen=True)
class RetryPolicy:
    max_retries: int = 3
    base_delay: float = 0.2
    max_delay: float = 3.0
    jitter_ratio: float = 0.5   # 实际 delay = backoff * [1-j, 1+j]


@dataclass(frozen=True)
class PoolPolicy:
    limit: int = 200
    limit_per_host: int = 100
    ttl_dns_cache: int = 300
    keepalive_timeout: int = 30


@dataclass(frozen=True)
class ClickHouseConfig:
    base_url: str               # 例如: https://host:8443
    username: str
    password: str
    database: str = "default"
    default_format: str = "JSONEachRow"
    connect_timeout: TimeoutPolicy = TimeoutPolicy()
    stream_timeout: TimeoutPolicy = TimeoutPolicy(
        connect=2.0,
        read=60.0,
        total=600.0,
    )
    retry_policy: RetryPolicy = RetryPolicy()
    pool_policy: PoolPolicy = PoolPolicy()
    compression: bool = True


# =========================
# 自定义异常
# =========================

class ClickHouseClientError(Exception):
    """客户端通用异常"""


class ClickHouseHTTPError(ClickHouseClientError):
    def __init__(self, status: int, body: str):
        self.status = status
        self.body = body
        super().__init__(f"ClickHouse HTTP error status={status}, body={body[:500]}")


class ClickHouseRetryableError(ClickHouseClientError):
    """可重试异常包装"""


# =========================
# 主客户端
# =========================

class AsyncClickHouseClient:
    """
    生产环境建议:
    - 进程级/应用级单例
    - 不要每次查询都创建
    - 启动时初始化,退出时关闭
    """

    def __init__(self, config: ClickHouseConfig):
        self._cfg = config
        self._session: Optional[aiohttp.ClientSession] = None
        self._connector: Optional[aiohttp.TCPConnector] = None

    async def start(self) -> None:
        if self._session is not None:
            return

        self._connector = aiohttp.TCPConnector(
            limit=self._cfg.pool_policy.limit,
            limit_per_host=self._cfg.pool_policy.limit_per_host,
            ttl_dns_cache=self._cfg.pool_policy.ttl_dns_cache,
            keepalive_timeout=self._cfg.pool_policy.keepalive_timeout,
            enable_cleanup_closed=True,
        )

        # session 级默认 timeout 不设置太死,
        # 具体请求按 query/stream 分别传入 timeout
        self._session = aiohttp.ClientSession(
            connector=self._connector,
            raise_for_status=False,
            trust_env=True,  # 企业环境常要走代理/证书策略
            headers={
                "Accept-Encoding": "gzip, deflate" if self._cfg.compression else "identity",
            },
        )

    async def close(self) -> None:
        if self._session is not None:
            await self._session.close()
        self._session = None
        self._connector = None

    async def __aenter__(self) -> "AsyncClickHouseClient":
        await self.start()
        return self

    async def __aexit__(self, exc_type, exc, tb) -> None:
        await self.close()

    # -------------------------
    # 公共接口
    # -------------------------

    async def query_json(
        self,
        sql: str,
        *,
        params: Optional[Mapping[str, Any]] = None,
        settings: Optional[Mapping[str, Any]] = None,
        timeout: Optional[TimeoutPolicy] = None,
        query_id: Optional[str] = None,
    ) -> list[dict[str, Any]]:
        """
        执行 SELECT,读取为 JSONEachRow 并一次性返回。
        适合结果集不大的查询。
        """
        body = await self._execute_with_retry(
            sql=sql,
            params=params,
            settings=settings,
            timeout=timeout or self._cfg.connect_timeout,
            allow_retry=True,      # SELECT 默认允许重试
            stream=False,
            query_id=query_id,
        )

        rows: list[dict[str, Any]] = []
        for line in body.splitlines():
            line = line.strip()
            if not line:
                continue
            rows.append(json.loads(line))
        return rows

    async def stream_json(
        self,
        sql: str,
        *,
        params: Optional[Mapping[str, Any]] = None,
        settings: Optional[Mapping[str, Any]] = None,
        timeout: Optional[TimeoutPolicy] = None,
        query_id: Optional[str] = None,
        chunk_size: int = 64 * 1024,
    ) -> AsyncIterator[dict[str, Any]]:
        """
        流式读取 JSONEachRow。
        适合大结果集导出或下游边读边处理。
        """
        if self._session is None:
            raise RuntimeError("Client not started. Call await client.start() first.")

        req_timeout = self._to_aiohttp_timeout(timeout or self._cfg.stream_timeout)

        payload = self._build_sql_payload(
            sql=sql,
            params=params,
            settings=settings,
            fmt="JSONEachRow",
        )

        url = f"{self._cfg.base_url}/"
        headers = self._build_headers(query_id=query_id)

        # 流式 SELECT 默认也可重试,但只能在“尚未消费任何数据前”重试。
        # 一旦开始产出数据,中途失败通常只能交给上层处理(断点续传/幂等分页)。
        last_exc: Optional[Exception] = None

        for attempt in range(1, self._cfg.retry_policy.max_retries + 2):
            try:
                async with self._session.post(
                    url,
                    data=payload,
                    headers=headers,
                    timeout=req_timeout,
                    auth=aiohttp.BasicAuth(self._cfg.username, self._cfg.password),
                ) as resp:
                    if self._is_retryable_status(resp.status):
                        text = await resp.text()
                        raise ClickHouseRetryableError(
                            f"retryable HTTP status={resp.status}, body={text[:300]}"
                        )

                    if resp.status >= 400:
                        text = await resp.text()
                        raise ClickHouseHTTPError(resp.status, text)

                    buffer = b""
                    async for chunk in resp.content.iter_chunked(chunk_size):
                        if not chunk:
                            continue
                        buffer += chunk

                        while b"\n" in buffer:
                            line, buffer = buffer.split(b"\n", 1)
                            line = line.strip()
                            if not line:
                                continue
                            yield json.loads(line)

                    if buffer.strip():
                        yield json.loads(buffer.decode("utf-8"))

                    return

            except Exception as exc:
                last_exc = exc

                # 流已经开始产出后,中断是否允许重试,取决于上游消费语义。
                # 这里保守处理:只在“尚未成功返回任何行”的阶段重试。
                if not self._is_retryable_exception(exc):
                    raise

                if attempt > self._cfg.retry_policy.max_retries:
                    raise

                await asyncio.sleep(self._next_delay(attempt))

        assert last_exc is not None
        raise last_exc

    async def command(
        self,
        sql: str,
        *,
        params: Optional[Mapping[str, Any]] = None,
        settings: Optional[Mapping[str, Any]] = None,
        timeout: Optional[TimeoutPolicy] = None,
        query_id: Optional[str] = None,
        allow_retry: bool = False,
    ) -> str:
        """
        执行非 SELECT 命令。
        默认不自动重试,避免非幂等操作重复执行。
        """
        return await self._execute_with_retry(
            sql=sql,
            params=params,
            settings=settings,
            timeout=timeout or self._cfg.connect_timeout,
            allow_retry=allow_retry,
            stream=False,
            query_id=query_id,
        )

    # -------------------------
    # 内部实现
    # -------------------------

    async def _execute_with_retry(
        self,
        *,
        sql: str,
        params: Optional[Mapping[str, Any]],
        settings: Optional[Mapping[str, Any]],
        timeout: TimeoutPolicy,
        allow_retry: bool,
        stream: bool,
        query_id: Optional[str],
    ) -> str:
        if self._session is None:
            raise RuntimeError("Client not started. Call await client.start() first.")

        req_timeout = self._to_aiohttp_timeout(timeout)
        payload = self._build_sql_payload(
            sql=sql,
            params=params,
            settings=settings,
            fmt="JSONEachRow",
        )
        url = f"{self._cfg.base_url}/"
        headers = self._build_headers(query_id=query_id)

        last_exc: Optional[Exception] = None

        for attempt in range(1, self._cfg.retry_policy.max_retries + 2):
            try:
                async with self._session.post(
                    url,
                    data=payload,
                    headers=headers,
                    timeout=req_timeout,
                    auth=aiohttp.BasicAuth(self._cfg.username, self._cfg.password),
                ) as resp:
                    if self._is_retryable_status(resp.status):
                        text = await resp.text()
                        raise ClickHouseRetryableError(
                            f"retryable HTTP status={resp.status}, body={text[:300]}"
                        )

                    if resp.status >= 400:
                        text = await resp.text()
                        raise ClickHouseHTTPError(resp.status, text)

                    return await resp.text()

            except Exception as exc:
                last_exc = exc

                if not allow_retry or not self._is_retryable_exception(exc):
                    raise

                if attempt > self._cfg.retry_policy.max_retries:
                    raise

                await asyncio.sleep(self._next_delay(attempt))

        assert last_exc is not None
        raise last_exc

    def _build_sql_payload(
        self,
        *,
        sql: str,
        params: Optional[Mapping[str, Any]],
        settings: Optional[Mapping[str, Any]],
        fmt: str,
    ) -> str:
        """
        最简单可维护的做法:
        - SQL 最后附 FORMAT JSONEachRow
        - settings 作为 SETTINGS 子句追加
        - 参数建议由上层做安全绑定/模板拼装
        """
        final_sql = sql.strip().rstrip(";")

        # 上层若未写 FORMAT,则补一个适合解析/流式处理的格式
        if " FORMAT " not in final_sql.upper():
            final_sql = f"{final_sql} FORMAT {fmt}"

        if settings:
            # 注意:这里是示例实现,生产里要做更严格的值转义
            setting_parts = []
            for k, v in settings.items():
                if isinstance(v, str):
                    setting_parts.append(f"{k} = '{v}'")
                elif isinstance(v, bool):
                    setting_parts.append(f"{k} = {1 if v else 0}")
                else:
                    setting_parts.append(f"{k} = {v}")
            final_sql = f"{final_sql} SETTINGS {', '.join(setting_parts)}"

        return final_sql

    def _build_headers(self, *, query_id: Optional[str]) -> dict[str, str]:
        headers = {
            "X-ClickHouse-Database": self._cfg.database,
        }
        if query_id:
            headers["X-ClickHouse-Query-Id"] = query_id
        return headers

    @staticmethod
    def _to_aiohttp_timeout(tp: TimeoutPolicy) -> aiohttp.ClientTimeout:
        return aiohttp.ClientTimeout(
            total=tp.total,
            connect=tp.connect,
            sock_read=tp.read,
        )

    @staticmethod
    def _is_retryable_status(status: int) -> bool:
        return status in (429, 502, 503, 504)

    @staticmethod
    def _is_retryable_exception(exc: Exception) -> bool:
        if isinstance(exc, ClickHouseRetryableError):
            return True

        retryable_types = (
            asyncio.TimeoutError,
            aiohttp.ClientConnectionError,
            aiohttp.ClientPayloadError,
            aiohttp.ServerDisconnectedError,
            aiohttp.ClientOSError,
        )
        return isinstance(exc, retryable_types)

    def _next_delay(self, attempt: int) -> float:
        rp = self._cfg.retry_policy
        raw = min(rp.max_delay, rp.base_delay * (2 ** (attempt - 1)))
        low = max(0.0, raw * (1 - rp.jitter_ratio))
        high = raw * (1 + rp.jitter_ratio)
        return random.uniform(low, high)

使用示例

1)普通查询

import asyncio

async def main():
    cfg = ClickHouseConfig(
        base_url="https://your-clickhouse-host:8443",
        username="default",
        password="secret",
        database="analytics",
        connect_timeout=TimeoutPolicy(connect=2, read=10, total=15),
        stream_timeout=TimeoutPolicy(connect=2, read=60, total=600),
        retry_policy=RetryPolicy(max_retries=3, base_delay=0.2, max_delay=2.0),
        pool_policy=PoolPolicy(limit=300, limit_per_host=100),
    )

    async with AsyncClickHouseClient(cfg) as ch:
        rows = await ch.query_json(
            """
            SELECT event_date, count() AS cnt
            FROM events
            WHERE event_date >= today() - 7
            GROUP BY event_date
            ORDER BY event_date
            """,
            settings={
                "max_execution_time": 10,   # 服务端超时保护
                "wait_end_of_query": 1,
            },
            query_id="report-7d-events-001",
        )
        print(rows)

asyncio.run(main())

2)流式读取大结果集

import asyncio

async def main():
    cfg = ClickHouseConfig(
        base_url="https://your-clickhouse-host:8443",
        username="default",
        password="secret",
        database="analytics",
        stream_timeout=TimeoutPolicy(connect=2, read=120, total=1800),
    )

    async with AsyncClickHouseClient(cfg) as ch:
        async for row in ch.stream_json(
            """
            SELECT user_id, event_time, event_name
            FROM big_events
            WHERE event_date >= today() - 30
            ORDER BY event_time
            """,
            settings={
                "max_execution_time": 300,
            },
            query_id="export-big-events-20260327",
        ):
            # 边读边处理,避免一次性占满内存
            print(row)

asyncio.run(main())

3)写操作默认不重试

await ch.command(
    """
    INSERT INTO ods_events (user_id, event_time, event_name)
    FORMAT JSONEachRow
    {"user_id": 1, "event_time": "2026-03-27 10:00:00", "event_name": "login"}
    """,
    allow_retry=False,  # 默认就建议 false
)

生产环境里的几个关键细节

1. 单例 client

应用启动时创建一个 AsyncClickHouseClient,退出时关闭。
不要每个请求创建一次。

2. 流式查询不要自动“中途续传”

一旦已经开始返回部分数据,中间失败后自动重试很容易:

  • 重复数据
  • 丢数据
  • 顺序错乱

更稳妥的做法是:

  • 上层按分片查询(按日期、主键范围、分页游标)
  • 每个分片单独可重试
  • 业务侧做断点续跑

3. 查询和写入分通道

建议分成两个 client 或两套配置:

  • query_client:重试开、流式读超时更大
  • write_client:重试默认关、连接池更保守

4. 一定带 query_id

便于:

  • 追踪日志
  • 幂等治理
  • 故障排查
  • 观测慢查询

5. 加监控

至少打这些指标:

  • 请求数 / 成功数 / 失败数
  • 重试次数
  • 各类异常计数
  • P50 / P95 / P99 延迟
  • 连接池占用
  • 流式查询时长
  • 每次读取 chunk 间隔

6. 并发与 session

ClickHouse Connect 文档提醒:同一 session 不允许并发查询;并发场景要么给不同请求独立 session,要么关闭自动 session 生成。(ClickHouse)

如果你自己走 HTTP 接口,也建议:

  • 不依赖 session state
  • 不在会话里放临时状态
  • 所有查询自包含

如果你坚持用官方 clickhouse-connect

也可以,但我会把它定位为“简化版生产方案”。

因为它已经提供了:

  • get_async_client()
  • connect_timeout
  • send_receive_timeout
  • query_retries

并且官方文档写明 query_retries 只对“可重试的 HTTP 响应”生效,且不会自动重试 command / insert。(ClickHouse)

但它和你的需求相比,差异在于:

  • read(流)total 的分离不够细
  • 你对“哪些错误可重试”的判定难做到足够透明
  • 你想做企业级 observability / 熔断 / 池化策略时,可控性不如自己封装

所以我的建议仍然是:

企业生产环境、强可靠要求:aiohttp + ClickHouse HTTP 自建轻量客户端。


最后的建议配置模板

查询型

TimeoutPolicy(connect=2, read=10, total=20)
RetryPolicy(max_retries=3, base_delay=0.2, max_delay=2.0)

流式大查询

TimeoutPolicy(connect=2, read=60, total=900)
RetryPolicy(max_retries=2, base_delay=0.5, max_delay=3.0)

写入型

TimeoutPolicy(connect=2, read=30, total=60)
allow_retry = False

一句话总结:

你这个需求最适合的生产方案,不是“直接依赖驱动默认行为”,而是“用 aiohttp 自己封一层 ClickHouse async client”,把超时、重试、连接池都显式掌控起来。

我也可以继续给你补一版:
带连接池监控、Prometheus 指标、结构化日志、熔断器的完整生产模板。

posted @ 2026-04-09 10:27  X1OO  阅读(3)  评论(0)    收藏  举报