数据归档与清理模式

数据库中的数据并非永远都需要高速访问。当一条订单完成交付、一条日志被写入数月之后,它们的热数据属性逐渐消失,变成业务上偶尔需要查询、但不需要毫秒级响应的「冷数据」。如果任由这些数据堆积在主数据库中,不仅会推高存储成本,还会拖慢核心业务的查询性能。数据归档(Data Archiving) 就是解决这一问题的系统性方法。

为什么要做数据归档

合规要求是归档的最强驱动力。金融行业的监管规定通常要求交易数据保存 5-7 年,医疗行业的 HIPAA 法规要求患者记录长期留存,互联网公司也需要满足《网络安全法》的日志留存要求。这些数据不能删除,但也不需要经常访问,存放在高性能数据库中是一种资源浪费。

性能优化是另一个核心诉求。数据库的查询性能与数据量呈负相关——表越大,全表扫描越慢,索引维护成本越高。阿里巴巴曾公开分享过一个案例:某核心订单表从 5000 万行缩减到 500 万行后,复杂查询的 P99 延迟从 800ms 降低到 50ms。归档掉历史数据,主库始终保持「苗条」,这是最简单粗暴也最有效的性能优化手段。

成本控制在云时代变得更加重要。以 AWS RDS 为例,高性能数据库实例的存储成本约为 $0.23/GB/月,而 S3 Glacier 的存储成本仅为 $0.004/GB/月,相差近 60 倍。将冷数据从 RDS 迁移到 S3,每年可以节省可观的存储费用。

归档策略设计

制定归档策略需要综合考虑时间窗口访问频率数据分层三个维度。

时间窗口是最常见的归档触发条件。一般以数据创建时间或最后修改时间作为判定依据。典型的做法是归档超过 6 个月或 1 年的历史数据。但对于某些业务场景,如保险行业的长期保单、制造业的质量追溯记录,时间窗口可能需要延长到数年。

访问频率是更精细的判定标准。即使数据已经「老化」,如果某些记录被频繁查询(如大客户的历史订单),也应该保留在主库或近线存储中。可以通过分析历史查询日志,识别高频访问的历史数据,进行差异化处理。

数据冷热分层是成熟的工程实践:

分层存储介质访问延迟典型场景
热数据SSD/内存\<10ms最近 7-30 天的数据
温数据普通磁盘10-100ms30-180 天的数据
冷数据对象存储秒级180 天以上的数据
归档数据磁带/Glacier分钟级合规留存的历史数据

归档到对象存储的实现方案

将数据从关系型数据库迁移到对象存储,需要解决几个技术问题。

数据导出格式的选择很重要。Parquet 或 ORC 等列式存储格式在归档场景下比 CSV 更优:压缩率高、查询时可以只读取需要的列、支持 schema evolution。对于需要频繁聚合分析的历史数据,还可以考虑预聚合为分析友好的格式。

# 使用 Python 将数据导出为 Parquet 格式
import pandas as pd
from sqlalchemy import create_engine

def archive_orders_to_s3(batch_id: int, batch_size: int = 10000):
    engine = create_engine('mysql+pymysql://...')
    
    # 分批读取并转换
    query = """
        SELECT id, customer_id, total_amount, created_at, status
        FROM orders
        WHERE status = 'completed' 
          AND created_at < DATE_SUB(NOW(), INTERVAL 1 YEAR)
          AND archive_batch IS NULL
        LIMIT {}
    """.format(batch_size)
    
    df = pd.read_sql(query, engine)
    
    if df.empty:
        return 0
    
    # 转换为 Parquet 并上传到 S3
    buffer = df.to_parquet(index=False)
    s3_client.put_object(
        Bucket='data-archive',
        Key=f'orders/year={df["created_at"].dt.year}/batch_{batch_id}.parquet',
        Body=buffer
    )
    
    # 标记已归档(两步提交确保一致性)
    with engine.begin() as conn:
        conn.execute(text("""
            UPDATE orders 
            SET archive_batch = :batch_id, archived_at = NOW()
            WHERE id IN :ids
        """), {'batch_id': batch_id, 'ids': tuple(df['id'])})
    
    return len(df)

一致性保证是数据迁移中最容易出问题的环节。上述代码采用了「先写后标记」的两步策略:数据先完整写入 S3,确认成功后才在原表中标记。这种方式虽然不是原子操作,但可以保证数据的可重入性——即使迁移中断,已归档的数据不会重复归档,未标记的记录会在下次运行时继续处理。

归档后的数据访问需要单独设计。大多数场景下,归档数据只需要支持按 ID 或时间范围查询。可以开发一个统一的查询接口,根据数据所在层自动路由到对应存储。对于需要频繁分析归档数据的场景,可以考虑在对象存储之上构建 Presto 或 Athena 查询层,用 SQL 直接分析冷数据。