使用celery做异步的clickhouse表导出--带去重和任务数控制

问题描述

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语句。

所以只能封装一个使用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库如CeleryCelery是一个简单、灵活且可靠的分布式系统来处理大量消息,旨在实现实时操作。它的设计灵活,能够与各种各样的队列、数据库和消息传递系统进行集成。

以下是一个基本示例,展示了如何使用CeleryRedis来执行异步任务、跟踪任务状态并获取任务结果。

  1. 安装依赖:

    首先,确保你已经安装了CeleryRedis

    pip install celery redis

  2. 创建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
     

  3. 编写异步任务处理代码:

    这是你的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}')
    
    
  4. 启动Celery Worker:

    在命令行中运行以下命令以启动Celery worker:

    celery -A celery_app worker --loglevel=info

    这会启动一个Celery worker进程,监听任务并处理它们。

  5. 运行主程序:

    在另一个命令行窗口中运行你的主程序:

    python main.py

    你应该会看到任务的状态和结果:

    Task state: PENDING Task result: 10

这个例子展示了如何利用CeleryRedis实现一个简单的异步任务处理系统。你可以根据需要扩展和自定义任务、状态跟踪和结果处理等功能。

需要注意的是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

Responses - Starlette

  • 4
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Celery是一个Python的分布式任务队列框架,它可以用来实现异步任务的调度与执行。使用Celery,你可以将耗时的任务放入任务队列中,然后由后台的工作进程异步执行,这样可以提高系统的并发能力和响应速度。 要使用Celery,首先需要安装Celery库。你可以使用pip命令进行安装: ``` pip install celery``` 接下来,你需要创建一个Celery实例,并定义任务任务可以是任何可调用对象,通常是一个Python。例如,下面是一个简单的示例: ```pythonfrom celery import Celery# 创建Celery实例app = Celery('myapp', broker='redis://localhost:6379/0') # 定义任务@app.taskdef add(x, y): return x + y``` 在上面的示例中,我们创建了一个名为`myapp`的Celery实例,并指定了一个Redis作为消息代理(broker)。然后,我们定义了一个名为`add`的任务使用`@app.task`装饰器来将其注册为Celery任务。 接下来,你可以使用`delay()`方法调用任务,将任务放入任务队列中异步执行: ```pythonresult = add.delay(4,6) ``` 在上面的示例中,我们使用`delay()`方法调用了`add`任务,并传递了两个参4和6。这会将任务添加到Celery任务队列中,并返回一个`AsyncResult`对象,你可以使用它来获取任务的执行结果。 当任务被放入任务队列后,你需要启动Celery的工作进程来执行任务。可以使用以下命令启动工作进程: ``` celery -A myapp worker --loglevel=info``` 上面的命令中,`-A`参指定了Celery实例所在的模块(在这个例子中是`myapp`),`--loglevel=info`参指定了日志级别为info。 这只是Celery的一些基本用法,还有很多其他功能和配置可以探索。你可以查阅Celery的官方文档来获取更多详细信息:https://docs.celeryproject.org/
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值