前阵子我们把一套跑了大半年的 Airflow 从一个机房搬到另一个机房,原以为只是换台机器的事,结果第二天早上告警响成一片。原来每天凌晨准点跑的数据任务,有的迟了,有的早了几个小时,更麻烦的是那些需要补历史数据的 DAG,全都在错误的时间点上运行,产出的数据对不上账。我们几个排查了半天,最后发现所有异常的根源,居然只是时区没固定住。

一、事故现场

迁移当天晚上,我们按流程把代码、配置、数据库都搬了过去,新环境的机器时区是东八区,旧环境一直用的是 UTC。当时谁也没把“时间”当回事,觉得代码一样、数据库一样,跑起来自然就一样。结果呢?Airflow 的调度时间默认跟着系统走,新机器上的系统时间一变成东八区,原本应该按 UTC 时间凌晨 0 点执行的任务,就变成了东八区的凌晨 0 点执行。而我们历史业务数据都是按东八区的时间口径去算的,这一下子所有调度时间整体偏移了 8 小时。最让人头疼的是那些跑历史补数的任务,因为时间错位,任务以为自己错过了执行窗口,疯狂补跑,结果补出来的数据全都变成了另一天的时间。整个数据仓库的报表像被打乱了一样,业务方连环发问,我们只能一边解释一边回滚。

二、为什么时区会惹出这么大的乱子

说白了,Airflow 本身并不知道你心里想的是哪个时区。你给它一个时间,它得拿到一个参照系才能去安排调度。这个参照系如果没有明确指定,它就会自己去读操作系统的时间设置。新老环境系统时间不一致,就算你的 DAG 代码一个字没改,跑出来的效果也完全是两回事。这就像你约朋友“明天早上 8 点见面”,对方问“你说的 8 点是北京时间还是纽约时间?”你如果不说清楚,第二天就只能在马路上干等。

很多开发者在写 DAG 的时候,习惯直接给一个类似“某年某月某日零点三十分”这样的时间对象,但没带时区信息。这种时间对象在 Python 里叫“不带时区的时间”。Airflow 拿到这种时间,会强行套上运行所在机器的系统时区来理解。机器时区一变,它理解的时间就跟着变,调度自然就乱套了。

还有一个容易忽略的地方:Airflow 的配置项 default_timezone,它控制着调度器解析 cron 表达式时参考哪个时区。很多老项目在初始化的时候根本没管这个配置,走的是默认值。但默认值在不同的安装方式里并不一样,有的场景下会用 UTC,有的场景下会用系统时区。一旦你换了环境,又没有显式地设置这个值,行为就会悄悄改变。总之,时区偏移的根本原因,就是“系统时区”加“隐式依赖”加“没有显式固定”三件事凑到了一起。

三、标准时区:是和稀泥还是立规矩?

怎么解决?有些人说,那就把你的 DAG 都改成系统时区不就行了?但问题是,你这次迁移到东八区,下次迁移到西五区呢?总不能每换一次机器就改一遍代码吧。所以真正需要固定的,不是某一个环境的时间,而是你所有 DAG 共同参照的那把尺子。这把尺子,我们习惯叫它“标准时区”。它不一定非要是 UTC,但必须是一个明确指定的、全公司统一的时区。比如你公司的业务全在中国,那把尺子就可以定成东八区;如果你的业务面向全球,或者你希望彻底避开夏令时这类折腾人的事,直接定成 UTC 是最省心的。

3.1 UTC 的优势

UTC 是世界协调时间,它不受夏令时影响,全球所有国家都认同它。使用 UTC 作为唯一标准,最大的好处是任何时候都不会出现“昨天上午 10 点跑,今天上午 11 点跑”这种因为夏令时来回跳的怪事。Airflow 官方也强烈建议使用 UTC。如果你只有一套集群,并且业务不依赖某个当地时间,那么让所有任务都跑在 UTC 下,是最干净、最不容易出错的做法。

3.2 本地时区的诱惑

本地时区的好处是直观,看日志、看执行列表都符合自己的生活习惯。比如你在上海,看任务显示的是早上 8 点执行,心理上很舒服。但它的代价是,你必须在所有地方都一致地使用这个时区,包括服务器系统、Airflow 配置、DAG 代码里的开始日期、调度表达式等等。任何一个地方漏掉,都会出现前面说的那种错乱。更麻烦的是,如果你的团队分布在多个时区,每个人看到的“本地时间”都不一样,沟通成本会直线上升。

3.3 最终选择

我们这次复盘后,定下的规矩是:不管服务器在哪儿,Airflow 调度统一使用 Asia/Shanghai 作为标准时区。这样做既照顾了业务口径,又避免了系统时区被换成别的地方后再次踩坑。同时,我们在代码里把时区信息写死,这样就算后端机器换了,DAG 里的时间也纹丝不动。

四、DAG 时区固定的正确姿势

4.1 确保调度器时区配置固定

在 Airflow 的配置段 scheduler 下,有一个默认时区配置项,你需要把它显式地设置为选定的标准时区。注意,不要只用环境变量 TZ 去设置,因为 TZ 只影响系统层,Airflow 不一定完全跟着走。你需要在 Airflow 自己的配置里也写上同一句话。我们这次的操作是把默认时区写成了 Asia/Shanghai,并且把之前可能存在的一些旧配置全部清掉,确保每个环境读到的都是唯一确定的时区值。

4.2 在 DAG 里显式声明时区

这里是最关键的一步:每个 DAG 的开始日期必须是一个“带时区的时间对象”,不能是“不带时区的时间对象”。我们来看一个完整的正确写法,技术栈是 Python + Airflow:

# 技术栈:Python + Airflow
from datetime import datetime
from zoneinfo import ZoneInfo  # Python 3.9+ 自带时区库

from airflow import DAG
from airflow.operators.python import PythonOperator

# 先定义一个固定时区对象,整个 DAG 都用它
SHANGHAI_TZ = ZoneInfo("Asia/Shanghai")

def say_hello(**context):
    # 通过 execution_date 拿到每次调度的基准时间
    scheduled_time = context["execution_date"].astimezone(SHANGHAI_TZ)
    # 打印一下,方便观察实际执行时间是否被固定在东八区
    print(f"本次调度时间(上海时间):{scheduled_time.isoformat()}")

# 注意:start_date 必须带 tzinfo,这是固定的关键
default_args = {
    "owner": "data_team",
    "start_date": datetime(2024, 1, 1, 0, 30, tzinfo=SHANGHAI_TZ),
    "retries": 0,
}

with DAG(
    dag_id="fixed_timezone_demo",
    default_args=default_args,
    # 这里的 cron 表达式“30 0 * * *”表示每天 0:30,
    # 因为 start_date 已经带上了上海时区,
    # 所以 Airflow 会严格按照上海时间的 0:30 去调度。
    schedule_interval="30 0 * * *",
    catchup=False,
    description="演示如何固定 DAG 时区",
) as dag:

    # 最简单的示例任务,只做一件事:打印调度时间
    run_me = PythonOperator(
        task_id="run_me",
        python_callable=say_hello,
    )

    run_me

这段代码有两个核心要点。第一,我们用了 Python 自带的 zoneinfo 库来指定“Asia/Shanghai”,这样就可以创建一个带时区信息的时间对象;第二,开始日期在创建的时候直接通过 tzinfo 参数,把时区写死了。这样无论 Airflow 跑到哪个机房,只要调度器读到这段代码,它就知道要按上海时间去算 cron 表达式。

4.3 常见错误示例

很多历史 DAG 之所以出问题,就是因为开始日期忘了写时区。我们再看一个反面教材,同样用的是 Python + Airflow:

# 技术栈:Python + Airflow(错误示范)
from datetime import datetime

from airflow import DAG
from airflow.operators.python import PythonOperator

def bad_task():
    print("任务开始")

# 这里没有给 datetime 传 tzinfo,创建的是“不带时区的时间”
default_args = {
    "owner": "data_team",
    "start_date": datetime(2024, 1, 1, 0, 30),
}

with DAG(
    dag_id="naive_timezone_demo",
    default_args=default_args,
    schedule_interval="30 0 * * *",
    catchup=False,
) as dag:

    bad_run = PythonOperator(
        task_id="bad_run",
        python_callable=bad_task,
    )

    bad_run

这段代码在旧环境里如果系统时区恰好是东八区,那运行起来一切正常。但一旦把系统时区改成 UTC,Airflow 就会把“0:30”这个时间当作 UTC 来理解,等于是早上 8 点 30 分才跑。如果你再补跑历史数据,它给你算出来的“昨天”也会整体偏移。所以,拒绝裸奔的时间对象,是所有 DAG 必须养成的习惯。

五、哪些场景最容易踩坑

第一种典型场景就是环境迁移,比如机房搬迁、容器化改造、灾备切换,只要换了机器,系统时区就可能变,历史依赖本地时区的任务就会翻车。第二种是团队协作场景,如果北京、上海、硅谷的同学都在维护同一套 Airflow,每个人看到的“本地时间”都不一样,沟通时容易互相误解。第三种是历史补数场景,catchup 开启后,Airflow 要拿着开始日期去反推过去每一次执行时间,时区一偏,反推结果就全错了。所以,只要你的任务会用到“过去的时间”,时区就必须钉死。第四种是混合云场景,不同云厂商默认的时区不一致,即使你代码没变,跑到另一朵云上也会发生同样的事故。

六、迁移复盘的完整步骤

如果你也碰到类似情况,或者正准备迁移 Airflow,下面这套复盘步骤可以照搬。

6.1 先摸清家底

在动工之前,先列清楚所有 DAG 当前依赖的时区口径。打开每个 DAG 文件,检查开始日期用的是不是带时区的对象;再查一遍旧环境的 Airflow 默认时区配置到底写的什么。最好写个自动化脚本去扫描,但这里重点不在脚本,而是要把每一个可能引用到时间的环节都挑出来。

6.2 统一配置

确定了标准时区后,分成三步执行:第一步,在所有相关机器的系统环境里,把 TZ 变量设置成标准时区,确保基础系统的日志时间也是一致的;第二步,修改 Airflow 的默认时区配置,注意这次要写明确的值,不能依赖默认;第三步,把所有 DAG 里不带时区的开始日期全部替换成带时区的写法,就像前面正确示例那样。

6.3 验证历史任务

改完以后,不要急着直接停掉老任务。先挑几个没有历史补数需求的 DAG,跑一遍看调度时间是否符合预期。然后再找一个有历史补数的 DAG,在测试环境里把某个历史时间段重新跑一次,比对产出结果的时间戳是不是和你期望的时间窗口一致。确认没有问题后,再逐步切换生产任务。

七、技术优缺点与注意事项

7.1 优点

把标准时区固定下来,带来的第一个好处就是“可预期”。今天看到的调度时间,明天、明年、换到任何机器上,都会是同一个时间。第二个好处是排查问题容易,因为每一个时间点都有明确的时区标签,不需要猜。第三个好处是团队协作顺畅,大家谈起任务执行时间,说的都是同一个时区,不会出现“你说的 9 点是哪里的 9 点?”这种尴尬。

7.2 缺点

缺点也跟着来。首先,统一到一个标准时区后,如果业务本身有跨时区的需求,比如有些任务需要在美国时间凌晨跑,那你必须额外做一次时区换算,代码会多一点点复杂度。其次,如果你选的是 UTC,那么看日志的时候会有些不直观,需要心里把 UTC 时间换算成自己的当地时间,尤其在查问题的时候,容易算错。不过这类缺点可以通过在日志打印时转换成业务时区来缓解,比如我们前面示例里就在任务内部打印了上海时间。

7.3 注意事项

还有几个坑要记牢:第一,千万不要在 DAG 已经发布运行之后再悄悄修改开始日期,因为开始日期改变会影响调度的历史累积逻辑,很容易引发重复调度或漏调度;第二,cron 表达式的含义是相对时区的,不是简单的“服务器本地时间”,所以一旦定了标准时区,就不要频繁改动;第三,Airflow 的界面上显示的时区可能和调度时区不是同一个配置,需要把两个配置都设置一致,否则你会看到界面上的时间和你以为的时间不一样;第四,如果你用了外部系统的触发时间,比如 Kafka 或 MySQL 里的时间戳,也要统一转换成标准时区之后再进入 DAG,避免任务内部计算时又出现偏移。

八、总结

这次迁移事故,表面上是“时区偏移”,本质上是对时间没有敬畏心。我们太习惯依赖操作系统给的那个时间,忘记了 Airflow 调度的每一根发条都需要一个确定的参照系。复盘下来最值钱的收获,就是把“标准时区”这个词刻进了团队规范里:所有服务器、所有配置、所有 DAG,都固定使用同一个明确声明的时区。不要相信默认值,不要相信运气,要相信代码里显式写下来的那一行时区信息。只要做到这一点,以后不管搬多少次机房,你的任务都会踏踏实实地在预订的时间点上醒来。