多任务定时调度的冲突解决:错峰执行方案

多任务定时调度的冲突解决:错峰执行方案

定时任务在量化系统里是标配。但任务多了以后,冲突就来了------不是逻辑冲突,是资源争抢。今天聊聊我们系统里一个实际踩过的坑,以及怎么用错峰解决。

冲突场景分析

我们的系统里有两个核心定时任务:

  • MA信号任务 :基于均线策略生成交易信号,每小时整点执行(如 09:00, 10:00, 11:00...)
  • WL更新任务:更新股票池的WL数据(权重列表),原本也设定在整点执行

问题很直观:两个任务都在整点触发。当 09:00 到来时,MA信号要拉行情数据、计算均线、生成信号;WL更新也要拉数据、计算权重、写回数据库。两者同时启动,CPU 和 I/O 争抢严重,导致:

  1. MA信号计算延迟,信号生成时间从秒级拉长到分钟级
  2. WL更新任务偶发超时,数据库连接池被打满
  3. 日志里频繁出现 TimeoutError 和 ConnectionPoolError

我们最初的想法是"机器性能够好,扛得住"。但实际观察下来,两个任务叠加时的资源峰值远高于单个任务,磁盘 I/O 更是瓶颈。

错峰策略设计

解决方案不复杂:错峰 。把 WL 更新任务从整点挪到 04:30 执行。

选择 04:30 有几个考虑:

  • 凌晨 4:30 不是交易时段,没有行情数据更新压力
  • 与 MA 信号的整点执行完全错开,不存在时间重叠
  • 凌晨系统负载低,WL 更新即使耗时较长也不影响其他任务

当然,这个时间点不是固定的。如果你有其他凌晨任务,可以根据实际负载情况调整。核心原则是:让高资源消耗的任务彼此避开。

实现方案

我们使用 APScheduler 来管理定时任务。改造前的代码大致如下:

python 复制代码
from apscheduler.schedulers.blocking import BlockingScheduler
from apscheduler.triggers.cron import CronTrigger

def ma_signal_job():
    """MA信号任务 - 每小时整点执行"""
    print(f"[MA Signal] {datetime.now()} - 开始生成信号")
    # 拉取行情数据
    data = fetch_market_data()
    # 计算均线
    signals = calculate_ma_signal(data)
    # 写入信号库
    save_signals(signals)
    print(f"[MA Signal] {datetime.now()} - 信号生成完成")

def wl_update_job():
    """WL更新任务 - 原本也在整点执行"""
    print(f"[WL Update] {datetime.now()} - 开始更新权重")
    # 拉取股票池数据
    pool = fetch_stock_pool()
    # 计算权重
    weights = calculate_weights(pool)
    # 更新数据库
    update_weights(weights)
    print(f"[WL Update] {datetime.now()} - 权重更新完成")

scheduler = BlockingScheduler()

# 两个任务都在整点执行
scheduler.add_job(
    ma_signal_job,
    CronTrigger(minute=0),  # 每小时整点
    id='ma_signal',
    max_instances=1,
    coalesce=True
)

scheduler.add_job(
    wl_update_job,
    CronTrigger(minute=0),  # 同样在整点执行
    id='wl_update',
    max_instances=1,
    coalesce=True
)

scheduler.start()

改造后,WL 任务的时间改为 04:30:

python 复制代码
# WL更新任务错峰至04:30执行
scheduler.add_job(
    wl_update_job,
    CronTrigger(hour=4, minute=30),  # 每日04:30执行
    id='wl_update',
    max_instances=1,
    coalesce=True
)

就这么一行改动,问题解决了。

任务调度时间表

改造后的任务调度时间表如下:

任务名称 执行频率 执行时间 资源消耗
MA信号 每小时 整点(如 09:00, 10:00) 高(行情+计算)
WL更新 每日 04:30 中高(全量权重)

从时间分布看,两个高消耗任务完全错开,不存在同时执行的情况。

验证方法

改完之后不能光看代码,要验证实际效果。我们做了三件事:

1. 日志时间戳分析

在任务入口和出口分别打印时间戳,统计任务实际执行时长:

python 复制代码
import time
from datetime import datetime

def ma_signal_job():
    start = time.time()
    print(f"[MA Signal] {datetime.now()} - 开始")
    # ... 任务逻辑 ...
    elapsed = time.time() - start
    print(f"[MA Signal] {datetime.now()} - 完成,耗时 {elapsed:.2f}s")

对比改造前后 MA 信号任务的耗时:

ini 复制代码
改造前: [MA Signal] 09:00:00 - 开始
        [MA Signal] 09:01:23 - 完成,耗时 83.45s
        [WL Update] 09:00:01 - 开始
        [WL Update] 09:00:47 - 完成,耗时 46.12s

改造后: [MA Signal] 09:00:00 - 开始
        [MA Signal] 09:00:08 - 完成,耗时 8.12s
        [WL Update] 04:30:00 - 开始
        [WL Update] 04:30:35 - 完成,耗时 35.20s

MA 信号从 83 秒降到 8 秒,效果立竿见影。

2. 资源监控

用 psutil 记录任务执行期间的 CPU 和内存使用:

python 复制代码
import psutil
import threading

class ResourceMonitor:
    def __init__(self):
        self.records = []
        self._stop = threading.Event()
    
    def start(self):
        self._stop.clear()
        self.thread = threading.Thread(target=self._monitor)
        self.thread.start()
    
    def stop(self):
        self._stop.set()
        self.thread.join()
    
    def _monitor(self):
        while not self._stop.is_set():
            cpu = psutil.cpu_percent(interval=1)
            mem = psutil.virtual_memory().percent
            self.records.append((datetime.now(), cpu, mem))
            time.sleep(1)

改造前,09:00 时刻的 CPU 峰值经常到 90%+,改造后峰值降到 40% 以下。

3. 任务执行成功率

统计连续 7 天的任务执行情况:

python 复制代码
from collections import defaultdict

# 模拟统计逻辑
stats = defaultdict(lambda: {'total': 0, 'failed': 0})

def track_job(job_name, func):
    def wrapper(*args, **kwargs):
        stats[job_name]['total'] += 1
        try:
            return func(*args, **kwargs)
        except Exception as e:
            stats[job_name]['failed'] += 1
            print(f"[ERROR] {job_name}: {e}")
    return wrapper

# 7天后输出统计
for job_name, s in stats.items():
    success_rate = (s['total'] - s['failed']) / s['total'] * 100
    print(f"{job_name}: 成功率 {success_rate:.1f}%")

改造前 WL 更新任务成功率约 95%(有超时失败),改造后 100%。

扩展:多任务错峰的通用方案

如果你的系统不止两个任务,可以用更灵活的方式管理。比如用配置文件定义任务时间:

python 复制代码
# config.yaml
tasks:
  ma_signal:
    cron: "0 * * * *"  # 每小时整点
    enabled: true
  wl_update:
    cron: "30 4 * * *"  # 每日04:30
    enabled: true
  data_backup:
    cron: "0 3 * * *"  # 每日03:00
    enabled: true

然后用 Python 加载配置并动态注册任务:

python 复制代码
import yaml
from apscheduler.schedulers.background import BackgroundScheduler
from apscheduler.triggers.cron import CronTrigger

def load_tasks_from_config():
    with open('config.yaml', 'r') as f:
        config = yaml.safe_load(f)
    
    scheduler = BackgroundScheduler()
    
    task_mapping = {
        'ma_signal': ma_signal_job,
        'wl_update': wl_update_job,
        'data_backup': data_backup_job
    }
    
    for task_name, task_config in config['tasks'].items():
        if not task_config['enabled']:
            continue
        
        # 将 cron 表达式转换为 CronTrigger
        minute, hour, day, month, day_of_week = task_config['cron'].split()
        scheduler.add_job(
            task_mapping[task_name],
            CronTrigger(
                minute=minute,
                hour=hour,
                day=day,
                month=month,
                day_of_week=day_of_week
            ),
            id=task_name,
            max_instances=1,
            coalesce=True
        )
    
    return scheduler

这样改任务时间只需要改配置文件,不用动代码。

几点经验

  1. 先分析再动手:改之前先确认冲突确实存在,用日志和监控数据说话
  2. 错峰是简单有效的方案:相比加锁、加队列,错峰更直接,几乎没有额外复杂度
  3. 时间点选择要合理:避开交易时段,避开其他任务高峰
  4. 验证必须量化:用耗时、CPU、成功率这些指标对比前后效果

定时任务冲突是量化系统里很常见的问题。错峰执行不是唯一方案,但往往是最省事的。如果你的系统任务不多,优先考虑错峰;如果任务很多,再考虑任务队列、分布式调度这些更重的方案。

更多内容请关注本站,后续会继续分享量化系统实战中的工程问题。