问题描述
1. 背景
需要对clickhouse里的日志表进行导出,原有的API接口是通过REST API从clickhouse查询并通过HTTP传输,虽然给clickhouse的HTTP接口启用了压缩,但效率和资源利用仍然很低。要等待很长时间,页面可能会超时。原有的API值保留了很少的数据,不存在这个问题。但使用clickhouse后,数据保存的非常大,所以请求时间就很长了。
2. 目标
所以接下来的目标就是很明确了,因为时间长,要使用异步。因为数据量大,所以要更换更高效的导出方式。显然直接用数据库本身的导出方式,直接导出文件更高效。因为要防止用户反复请求统一个参数,因此任务要有管理。首先就是去重,防止重复下发同样的导出任务,第二个就是要限制并发或同时下载的任务数,防止吃太多CPU资源。因为clickhouse的查询通常都会消耗大量的硬盘、CPU、内存资源,尤其是CPU。
目标:异步,利用数据库的导出功能,任务要有数量和状态管理。
3. 方案
根据以往的经验,celery就可以做异步任务。所以直接选定了celery,clickhouse-client的导出。而任务要有状态管理,可以在redis里管理状态。
方案:celery做异步,clickhouse-client直接导出压缩文件。对任务在redis管理。
接下来,就是如何实现。首先从celery执行一个简单任务开始。
一、clickhouse表的导出
clickhouse可以直接导出到压缩文件(见引用),格式也可以支持多种多样。我们需要的压缩的csv格式,因此只需要提供csv.gz的文件名后缀即可。
SELECT *
FROM nyc_taxi
INTO OUTFILE 'taxi_rides.tsv.gz'
根据不同表、不同起止时间查询,可以得出SQL模板:
select * from {table_name} where {time_field} >= {start_time} and {time_field} < {end_time}
INTO OUTFILE '{filename}'
format CSVWithNames
SETTINGS max_threads = {max_threads}
这里有两个问题需要解决:
1. clickhouse的HTTP客户端不能使用导出的INTO OUTFILE语句。
- This functionality is available in the command-line client and clickhouse-local. Thus a query sent via HTTP interface will fail.
所以只能封装一个使用clickhouse-client的命令调用导出,celery的worker也需要和clickhouse运行在一起。
def execute_query_local(query, database):
# 启动子进程执行命令:
# clickhouse-client --query query
ret = subprocess.check_call(["clickhouse-client","-d", database, "--query", query], shell=False)
return ret
2. 导出数据的CPU消耗很大,因此要限制一下线程数,以降低消耗。我使用了10个线程。时间长是可以忍受的,但崩溃是无法忍受的。
SETTINGS max_threads = 10
二、celery异步任务
1. 最小可用
首先,一个celery的最小可用任务,这没有什么难度。可以借助chatgpt完成。这里直接贴出相关的代码。
要利用Redis进行异步任务处理,你可以使用一个Python库如
Celery
。Celery
是一个简单、灵活且可靠的分布式系统来处理大量消息,旨在实现实时操作。它的设计灵活,能够与各种各样的队列、数据库和消息传递系统进行集成。以下是一个基本示例,展示了如何使用
Celery
和Redis
来执行异步任务、跟踪任务状态并获取任务结果。
安装依赖:
首先,确保你已经安装了
Celery
和Redis
:
pip install celery redis
创建Celery应用:
这是你的
celery_app.py
文件:
from celery import Celery
# 创建Celery实例,并设置Redis为broker和backend
app = Celery('tasks', broker='redis://localhost:6379/0', backend='redis://localhost:6379/0')# 定义一个简单的任务
@app.task
def add(x, y):
return x + y
编写异步任务处理代码:
这是你的
main.py
文件,用于发送任务并获取其状态和结果:from celery_app import add # 发送任务 result = add.delay(4, 6) # 跟踪任务状态 print(f'Task state: {result.state}') # 等待任务完成并获取结果 result_value = result.get(timeout=10) print(f'Task result: {result_value}')启动Celery Worker:
在命令行中运行以下命令以启动Celery worker:
celery -A celery_app worker --loglevel=info
这会启动一个Celery worker进程,监听任务并处理它们。
运行主程序:
在另一个命令行窗口中运行你的主程序:
python main.py
你应该会看到任务的状态和结果:
Task state: PENDING Task result: 10
这个例子展示了如何利用
Celery
和Redis
实现一个简单的异步任务处理系统。你可以根据需要扩展和自定义任务、状态跟踪和结果处理等功能。
需要注意的是windows要使用eventlet:
celery -A tasks.export_tasks worker --loglevel=info -P eventlet
2. 去重的任务
celery基本上就是任务发出去就不管了,要看任务状态,拿着task_id去查询。我们想要对请求参数进行去重。这里有找到一个celery-unique(见引用)的组件,可以对任务去重。
其使用方法是(来源于github,见引用):
a. 设置celery
# my_application/__init__.py
#
# Create and configure Celery app object, as usual
from celery import Celery
celery_app = Celery()
# Add celery-unique capabilities to the original Celery Task class
from celery_unique import unique_task_factory
task_base_cls = celery_app.Task
new_task_cls = unique_task_factory(task_base_cls)
celery_app.Task = new_task_cls
b.设置唯一任务
# my_application/celery_tasks.py
from . import celery_app
from redis import Redis
my_redis_client = Redis()
# Configure a unique task by providing a key-generator and Redis
# connection as `unique_key` and `redis_client` keyword arguments,
# respectively.
@celery_app.task(
unique_key=lambda a, b, c=0: '{} with {}'.format(a, c),
redis_client=my_redis_client
)
def add_first_and_last(a, b, c=0):
return a + c
c. 运行任务
import time
from my_application.celery_tasks import add_first_and_last
# Unique-handling will only take effect when the above functions are called
# via `apply_async()` with an ETA or countdown...
async_result_1 = add_first_and_last.apply_async(args=(1, 2, 3), countdown=100)
async_result_2 = add_first_and_last.apply_async(args=(3, 2, 1), countdown=100)
async_result_3 = add_first_and_last.apply_async(
args=(1, 2),
kwargs={'c': 3},
countdown=50
)
# Wait 100 seconds for all tasks to complete
time.sleep(100)
# Check and see the status of each task
assert async_result_1.status == 'REVOKED'
assert async_result_2.status == 'SUCCESS'
assert async_result_3.status == 'SUCCESS'
在使用时,发现其功能和我们的目的不太相同,因此这里做了一些简单的修改。
a. 首先是判断是否超出限制数量,如果超出限制抛出异常。
def _check_task_limit(self, unique_redis_key, options):
if 'max_task_limit' in options:
max_task_limit = options['max_task_limit']
if max_task_limit is not None:
redis_key = '{prefix}:{task_name}:*'.format(
prefix=UNIQUE_REDIS_KEY_PREFIX,
task_name=self.name)
keys = self.redis_client.keys(redis_key)
if unique_redis_key in keys:
return
if len(keys) > max_task_limit:
raise TaskLimitExceeded('Task limit exceeded')
b. 其次是判断任务状态,如果是重复任务则查询旧任务的任务结果,并返回。
def _check_task_return(self, redis_key):
"""Given a Redis key, return the corresponding record if one exists.
@param redis_key: The string (potentially) used by Redis as the key for the record
@type redis_key: str | unicode
"""
task_id = self.redis_client.get(redis_key)
if task_id is not None:
async_result = AsyncResult(task_id,app=self.app)
if async_result.successful():
self.redis_client.delete(redis_key)
if async_result.failed():
self.redis_client.delete(redis_key)
async_result.forget()
return async_result
这样,我们就有了一个可以去重、判断任务数量、获取当前任务状态的组件了。需要celery-unique的,可以去资源下载源文件。实际使用是:
import os
import time
from celery import Celery
from .celery_unique import unique_task_factory, TaskLimitExceeded
from redis import Redis
from .config import REDIS_HOST, REDIS_PORT
import subprocess
my_redis_client = Redis(host=REDIS_HOST, port=REDIS_PORT,db=0)
app = Celery('tasks',
broker=f'redis://{REDIS_HOST}:{REDIS_PORT}/0',
backend=f'redis://{REDIS_HOST}:{REDIS_PORT}/0'
)
task_base_cls = app.Task
new_task_cls = unique_task_factory(task_base_cls)
app.Task = new_task_cls
def export_table_query(id, table_name, start_time, end_time, filename, max_threads=10):
...
return query
def execute_query_local(query, database='cdn'):
...
return ret
@app.task(
unique_key=lambda id,*args: '{}'.format(id),
redis_client=my_redis_client,
)
def export_table(id, table_name, start_time, end_time, filename, max_threads=10):
query = export_table_query(id, table_name, start_time, end_time, filename, max_threads=max_threads)
if os.path.exists(filename):
return os.path.split(filename)[-1]
ret = execute_query_local(query, 'mydb')
return os.path.split(filename)[-1]
其中,去重的是id,当然也可以修改成其它的方式,只需要修改unique_key即可。接下来就要在API里调用了这个方法了。
三、导出表的API接口
1. API调用
try:
filename = get_file_name(id, table, start_time, end_time, format, CACHE_PATH)
task = export_table.apply_async(args=(id, table, start_time, end_time, filename),
task_record_timeout=MAX_WAIT_SEC, expires=MAX_WAIT_SEC,
max_task_limit=MAX_TASK_LIMIT)
try:
task.wait(timeout=1)
except:
pass
except TaskLimitExceeded:
return error_return("并行任务数量超过限制,请等待现有完成。")
2. 状态查询
可以直接将task的state返回,如果是相同的请求参数(去重规则相同),就会获取到同样的任务id。无需提供额外的任务id存储或者记录。
return dict(
task_id=task.id,
task_status=task.state,
filename=str(task.get()) if task.state == 'SUCCESS' else ""
)
3. 下载文件
在下载文件的时候,要调整media_type和文件名。由于使用的是FastAPI框架,以此为例,其他框架同理:
from fastapi.responses import StreamingResponse, FileResponse
@router.get("/download", summary="下载文件", response_class=FileResponse)
async def download(filename):
if os.path.exists(CACHE_PATH) and filename in os.listdir(CACHE_PATH):
if filename.endswith('.gz'):
return FileResponse(f'{CACHE_PATH}/{filename}', media_type='application/x-gzip', filename=filename)
else:
return FileResponse(f'{CACHE_PATH}/{filename}', filename=filename)
else:
raise HTTPException(status_code=404, detail="File not found")
总结:
至此所有功能就完成。主要内容:
1. 导出clickhouse数据,对其封装。
2. 确定异步执行任务,并对任务数量进行管控,及状态查询。并编写导出任务。
3. 封装API,以及提供文件下载接口。
引用:
How do I export data from ClickHouse to a file? | ClickHouse Docs
Formats for Input and Output Data | ClickHouse Docs
FORMAT Clause | ClickHouse Docs
GitHub - shiftgig/celery-unique: Python factory for limiting Celery tasks by configuration