1. 从零搭建历史数据获取通道的完整思路
拿到“获得武汉疫情历史数据(紧急上线)”这个需求时,我第一反应不是打开编辑器写代码,而是先问了自己三个问题:数据从哪来、要什么粒度、上线后谁来维护。这三个问题决定了后面所有技术选型的走向。很多同行一接到“紧急上线”四个字就条件反射地开始堆代码,结果往往是接口跑通了、数据也拉下来了,但字段对不上、时间戳错乱、历史数据缺段,最后返工的时间比开发还长。我踩过这个坑,所以这次先把思路理清楚再动手。
这个需求的核心价值在于:把分散在不同渠道、不同格式的历史公共卫生事件数据,整合成一份结构统一、时间连续、可直接用于分析和展示的数据集。它解决的是“数据散、格式乱、无法直接使用”的问题。适合谁来参考?我认为三类人最需要:一是需要快速搭建数据管道的数据工程师,二是要做数据可视化或趋势分析的前端与分析师,三是负责应急响应系统搭建的技术负责人。哪怕你之前没接触过公共卫生领域的数据,只要理解“采集—清洗—存储—输出”这条链路,就能把方法迁移过去。
为什么强调“历史数据”而不是实时数据?因为历史数据的难点不在采集频率,而在时间跨度的完整性和字段口径的一致性。实时数据你只要保证接口通、频率稳就行;历史数据你要面对的是:不同时期发布口径变了、字段名改了、甚至某些日期根本没有记录。这些坑不提前想清楚,上线当天就会被打回。所以我的整体思路是:先确定数据源的可获取性,再设计一个能容纳“缺失”和“变更”的存储结构,最后才是写采集和清洗逻辑。顺序反了,后面全是补丁。
在工具选型上,我倾向于用 Python 做采集和清洗,原因很直接:处理时间序列、缺失值填充、字段映射这些事,pandas 生态最成熟,社区里能搜到的解决方案最多,紧急上线时“能搜到答案”比“性能极致”重要得多。存储层用关系型数据库(比如 PostgreSQL)而不是直接扔 CSV,是因为历史数据需要频繁按时间范围查询和聚合,关系型数据库的索引和 SQL 能力能省掉大量手写过滤逻辑。输出层则根据消费方决定:给前端用就出 JSON,给分析师用就出 CSV 或直接连库。这套组合不是唯一解,但它是在“紧急”约束下容错率最高的方案。
提示:紧急上线不等于跳过设计。恰恰相反,越是时间紧,越要把数据模型定死,否则后期改字段的成本会指数级上升。
2. 数据源摸底与采集策略的取舍
2.1 为什么不能只依赖单一数据源
历史公共卫生事件数据有一个很现实的问题:没有任何一个单一来源能覆盖全部时间跨度且字段完整。官方发布渠道可能只有汇总数据,媒体报道可能只有某几天的明细,社区整理的数据又可能存在口径不一致。我实际处理时的做法是:至少准备两个独立来源做交叉验证。一个作为主源,提供结构化的时间序列;另一个作为辅源,用来补全主源缺失的日期或校验异常值。
这里有个经验:不要试图找到一个“完美数据源”再开始。紧急上线场景下,正确的做法是先用主源把主干数据拉下来,跑通全链路,然后再用辅源做增量补全。我见过有同行为了等一个“更全”的数据源,硬生生拖了三天,结果主源接口变了,之前写的解析逻辑全废。先跑通、再优化,这是紧急项目的铁律。
2.2 采集方式的选择:接口、页面还是文件
数据获取方式无非三种:调接口、抓页面、下文件。优先级应该是:有稳定接口就用接口,没有接口但有结构化页面就用页面解析,两者都没有才考虑人工整理文件。接口的好处是字段明确、格式稳定、容易做增量;页面解析的坏处是页面结构一变就得改代码,维护成本高;文件方式最不可靠,因为版本管理和更新同步全靠人工。
我在这个项目里遇到的情况是:主源提供的是分页接口,每页返回一个时间段的汇总数据。这种接口的坑在于分页参数和总页数不一定可靠,有时候总页数字段是错的,你按它循环会漏数据。我的处理方式是:不信任总页数,而是用“返回结果为空”作为终止条件,同时记录每次请求的时间戳和页码,方便出问题时回溯。这个细节看起来小,但上线后如果发现数据缺了几天,没有请求日志你根本查不出是漏请求还是源本身就没有。
2.3 请求频率与重试机制的设计
紧急上线不代表可以粗暴地高频请求。一方面高频请求容易被限流甚至封禁,另一方面历史数据本身不会瞬间变化,没必要抢那几秒。我的策略是:单次请求间隔控制在合理范围,遇到失败采用指数退避重试,最多重试三次,三次都失败就记录到失败队列,等主流程跑完再统一处理。
重试机制里有一个容易被忽略的点:区分可重试错误和不可重试错误。网络超时、服务端 5xx 可以重试;参数错误、资源不存在(4xx)重试多少次都没用,只会浪费时间。我在代码里用状态码做了分流,4xx 直接跳过并记录,5xx 才进入重试逻辑。这个判断省下来的时间,在紧急上线时非常可观。
import time import requests def fetch_with_retry(url, params, max_retries=3): for attempt in range(max_retries): try: resp = requests.get(url, params=params, timeout=10) if resp.status_code == 200: return resp.json() elif 400 <= resp.status_code < 500: # 客户端错误,重试无意义 return None else: time.sleep(2 ** attempt) except requests.exceptions.RequestException: time.sleep(2 ** attempt) return None上面这段代码就是我当时用的简化版逻辑。注意timeout一定要设,不设的话遇到慢响应会一直挂着,整个采集流程卡死。2 ** attempt就是指数退避,第一次等 1 秒,第二次 2 秒,第三次 4 秒,给服务端喘息时间,也给自己留出容错空间。
3. 数据清洗中最容易翻车的几个环节
3.1 时间字段的标准化没有想象中简单
历史数据里最让人头疼的就是时间字段。我遇到过的情况包括:有的源用“2020-01-01”,有的用“2020/1/1”,有的用时间戳,还有的只给“1月1日”不带年份。如果不统一,后面做时间序列分析时排序会乱、聚合会错。我的做法是:所有时间字段进入清洗流程后,第一步就强制转成 ISO 8601 格式(YYYY-MM-DD),转不了的记录单独打标,不混入主数据集。
这里有个细节:时区问题。如果数据源没有明确标注时区,默认按数据发布地的本地时间处理,并在元数据里记录这个假设。不要小看这一点,跨天聚合时差几个小时就可能导致某一天的数据被算到前一天或后一天。我在项目里专门加了一个字段记录“原始时间字符串”,就是为了出问题时能追溯到底是解析错了还是源本身就有问题。
3.2 缺失日期的处理:补零、插值还是留空
历史数据几乎不可能每一天都完整。缺失日期怎么处理,直接决定了后续分析的准确性。我的原则是:区分“真实为零”和“未记录”。如果源明确表示某天没有新增,那就是零;如果源只是没有那天的记录,那就是缺失,不能补零。把缺失当零处理,会严重拉低趋势线的准确性。
具体操作上,我会先生成一个完整的日期序列作为基准,然后把采集到的数据左连接到这个序列上。连接后为空的日期,根据源的性质决定填零还是留空。如果留空,在输出时用null而不是0,让消费方自己决定怎么处理。这个区分在紧急上线时特别重要,因为一旦上线后数据被引用,再改口径就要通知所有下游,成本极高。
3.3 字段映射与口径变更的记录
不同来源的字段名和含义可能不一样。比如同样是“新增人数”,有的源叫new_cases,有的叫newly_confirmed,有的叫新增。清洗层必须做一层映射,把所有来源统一到一套标准字段名上。但更重要的是:记录口径变更。如果某个源在某个时间点之后改了统计口径,你必须在数据里标记出来,否则前后数据放在一起对比就是错的。
我的做法是在标准字段之外,额外维护一张“口径变更记录表”,记录变更时间、变更内容、影响范围。这张表不直接参与分析,但它是数据可信度的依据。上线后如果有人质疑某段时间的数据异常,你可以快速定位是源的问题还是清洗的问题。
| 常见问题 | 错误做法 | 正确做法 |
|---|---|---|
| 时间格式不统一 | 直接字符串排序 | 统一转 ISO 8601 后再排序 |
| 缺失日期 | 全部补零 | 区分真实零和未记录 |
| 字段名不一致 | 在每个消费方单独映射 | 清洗层统一映射并记录 |
| 口径变更 | 忽略,直接拼接 | 标记变更点,分段处理 |
4. 存储结构设计与上线前的压力测试
4.1 表结构怎么定才能扛住后续查询
存储结构决定了上线后查询的快慢和扩展的难易。我用的是一张主表加一张元数据表的结构。主表存核心时间序列数据,字段包括:日期、地区标识、指标名、指标值、数据来源、采集时间。元数据表存数据源信息、口径变更记录、采集批次日志。为什么拆两张表?因为主表会被高频查询,字段越少索引越快;元数据表查询频率低,但需要保留完整历史,拆开后互不影响。
主表的索引我建了两个:一个是日期加地区标识的联合索引,用于按时间范围和地区查询;另一个是采集时间的索引,用于排查某次采集是否成功。索引不是越多越好,每多一个索引就多一份写入开销。紧急上线时数据量可能不大,但你要为后续数据增长留出余地,所以索引策略要提前想清楚。
4.2 上线前的数据完整性校验清单
上线前必须做一轮完整性校验,不能只看“接口通了”就发布。我的校验清单包括:日期范围是否覆盖预期区间、每天是否有记录、关键字段是否有空值、数值是否在合理范围内、不同来源的交叉验证是否一致。这些检查写成脚本,每次采集完自动跑一遍,输出校验报告。
其中“数值是否在合理范围内”这一条最容易被忽略。历史数据里偶尔会出现异常大的值,可能是录入错误也可能是真实峰值。我的处理是:不自动修正,但标记出来,让人工确认。自动修正的风险在于你可能把真实信号当噪声抹掉了,这在公共卫生数据分析里是大忌。
4.3 紧急上线后的监控与回滚方案
上线不是终点。紧急上线的项目往往没有完善的监控,但至少要有两个东西:采集成功率监控和数据量突变告警。采集成功率低于阈值时通知负责人;数据量突然翻倍或归零时也要告警,因为这通常意味着源变了或者采集逻辑出问题了。
回滚方案同样重要。我的做法是每次采集写入新批次数据时保留上一批次,如果新批次校验不通过,可以快速切回上一批次。这听起来有点笨,但在紧急场景下,能快速回滚比追求零错误更实际。你不可能保证第一次就完美,但你可以保证出问题时影响可控。
-- 按日期和地区查询的典型 SQL SELECT date, region, metric_name, metric_value FROM historical_data WHERE date BETWEEN '2020-01-01' AND '2020-03-31' AND region = 'target_region' ORDER BY date ASC;上面这条查询就是主表联合索引的典型使用场景。注意ORDER BY date能利用索引顺序,避免额外排序。如果你的查询里经常出现按指标名过滤,那可能还需要考虑把指标名加入索引,但不要盲目加,先看实际查询模式。
5. 实操中积累的几条硬核经验
5.1 不要在上线当天改数据模型
这是我用血换来的教训。曾经有一次紧急上线,采集和清洗都跑通了,结果上线前两小时有人提出“能不能加一个字段”,我想着加个字段而已就改了。结果那个字段在部分历史数据里不存在,导致清洗逻辑报错,整个上线推迟了半天。从那以后我定了一条规矩:上线当天只修 bug,不加功能、不改模型。任何新需求记下来,上线稳定后再排期。
5.2 日志要记到“能复现问题”的程度
紧急项目出问题时,你没有时间慢慢调试。日志必须记录:请求的完整 URL 和参数、响应状态码、响应体的关键字段、处理到哪一步失败。我甚至会把失败记录的原始响应存一份到单独的文件里,方便事后分析。日志级别用 INFO 记录正常流程,用 ERROR 记录异常,不要什么都打 DEBUG,否则日志量大到没法看。
5.3 给下游消费方留一份“数据说明书”
数据上线后,消费方会来问各种问题:这个字段什么意思、为什么这天是空的、数值单位是什么。与其一个个回答,不如提前写一份数据说明书,包含字段定义、时间范围、缺失说明、口径变更记录。这份文档不需要多正式,一个 Markdown 文件就够,但能省掉大量沟通成本。我在项目里把说明书和数据一起发布,消费方自助查阅,效率提升非常明显。
5.4 关于“紧急”的心态调整
最后说一点心态上的经验。“紧急上线”四个字容易让人焦虑,但焦虑不会让代码跑得更快。我的做法是把任务拆成最小可交付单元:先保证主干数据能采、能存、能查,再考虑补全和优化。主干跑通的那一刻,压力就小了一半。剩下的时间用来做校验和文档,而不是继续加功能。这个节奏控制,比任何技术技巧都重要。
数据获取这件事,技术难度往往不是最高的,难的是对数据本身的理解和对异常情况的预判。你把数据源摸透了、把缺失和变更想清楚了、把回滚方案准备好了,上线就是水到渠成的事。反过来,如果只盯着代码,忽略了数据层面的复杂性,那上线才是麻烦的开始。