【clickhouse】异步clickhouse client 260401版本
如果在企业生产环境要 python 编程实现异步访问 clickhouse 数据库,要求能包含以下功能:超时策略:connect / read(流) / total 分开(流式一般 total 不宜太小) 重试策略:只对“可重试”的错误重试(网络错误、429、部分 5xx),并且指数退避+抖动 连接池管理
可以。
如果你的目标是企业生产环境里的 Python 异步访问 ClickHouse,而且明确要求:
connect / read(流) / total三段式超时- 只对可重试错误做重试
- 指数退避 + 抖动
- 连接池管理
我更建议把方案分成两层:
- 驱动层:使用 ClickHouse 的 HTTP 接口
- 可靠性层:自己封装一个 async client(基于
aiohttp)
原因是:官方 Python 驱动 ClickHouse Connect 支持 get_async_client(),并提供 connect_timeout、send_receive_timeout、query_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.TimeoutErroraiohttp.ClientConnectionErroraiohttp.ServerDisconnectedError- HTTP
429 - HTTP
502 / 503 / 504
一般不要重试
- SQL 语法错误
- 认证失败
- 权限错误
- 表不存在
- 数据格式错误
- 非幂等写入
3)写操作重试原则
这个很容易踩坑:
查询(SELECT)
通常可重试。
INSERT / ALTER / DDL
默认不要自动重试。
除非你能保证幂等性,比如:
- 使用明确的去重 key
- 写入 Kafka / Buffer / staging 表
- 或者业务端有 request-id 幂等约束
官方 ClickHouse Connect 文档也说明:它的 query_retries 只自动重试 query 请求;为了避免意外重复执行,command 和 insert 不会自动重试。(ClickHouse)
4)退避策略
建议:
delay = min(cap, base * 2^(attempt-1)) * random(0.5, 1.5)
例如:
base = 0.2scap = 3smax_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_timeoutsend_receive_timeoutquery_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 指标、结构化日志、熔断器的完整生产模板。

浙公网安备 33010602011771号