惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Hugging Face - Blog
Hugging Face - Blog
腾讯CDC
阮一峰的网络日志
阮一峰的网络日志
博客园_首页
Last Week in AI
Last Week in AI
月光博客
月光博客
D
DataBreaches.Net
WordPress大学
WordPress大学
雷峰网
雷峰网
酷 壳 – CoolShell
酷 壳 – CoolShell
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
博客园 - 叶小钗
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
U
Unit 42
Recent Announcements
Recent Announcements
宝玉的分享
宝玉的分享
MyScale Blog
MyScale Blog
C
Check Point Blog
F
Fortinet All Blogs
B
Blog
小众软件
小众软件
Vercel News
Vercel News
罗磊的独立博客
有赞技术团队
有赞技术团队

Aimee's Blog

秒杀怎么防机器人:验证码之外的几道防线 防薅羊毛怎么做:号码风控、设备指纹与分层策略 接口限流怎么选:固定窗口、滑动窗口、令牌桶 批量打包下载怎么设计:流式 ZIP 与异步任务 图片处理怎么做:异步处理与 CDN 实时参数 大文件上传怎么设计:分片、断点续传、秒传 UV 统计为什么有误差:精确计数与近似计数怎么选 大数据量导出怎么做:流式写入与异步任务 排行榜的两类需求,难度差了一个数量级 内容审核怎么选:先审后发,还是先发后审 实时热榜怎么设计:Redis ZSet 与热度分值 互关状态怎么保持同步:双向关系的维护与缓存失效 并发点赞的计数设计:原子操作与最终对账 删了父评论,子评论怎么处理——先想清楚用户看到的是什么 定时消息任务触发了两次 消息量大,写扩散还是读扩散——接到这个需求先问清楚规模 同一条消息推送了三次 活动结束了,用户还在收短信 接到注销需求,先问两个问题 微服务与服务拆分:何时拆、怎么拆 异步与事件驱动架构:把协作从「打电话」改成「发消息」 高可用设计:怎么让系统尽量不宕机 可扩展性设计:怎么让系统加机器就能扛更多 缓存架构:多级缓存怎么搭 高并发三板斧:限流、熔断、降级 架构设计到底在设计什么 —— 从单体到微服务的演进 服务成本账:一个服务一个月烧多少钱 可观测性:线上出问题怎么查 API 设计:好接口长什么样 消息队列:为什么要 MQ,以及丢失、重复、顺序怎么破
报表可重跑的设计:快照、水位线与幂等
Aimee · 2026-08-13 · via Aimee's Blog

报表可重跑的设计:快照、水位线与幂等

「同一张报表,昨天跑出来的数和今天重跑的不一样」——这类问题的原因通常不是计算 bug,而是两次计算之间有数据补录了进来:报表第一次跑的时候这批数据还不在,重跑就多了。

这暴露的是设计问题:报表要不要支持重跑?重跑结果要和原来一样,还是允许更新?


幂等计算的前提:数据快照

如果要求"重跑结果和第一次一样",那就需要在任务第一次跑之前,给数据打快照——基于快照计算,而不是基于当前实时数据。

public void runDailyReport(LocalDate date) {
    // 先检查:今天的快照是否已存在
    if (snapshotDao.exists(date)) {
        log.info("Snapshot exists for {}, skip snapshot creation", date);
    } else {
        // 第一次跑:把当天的原始数据快照到报表库
        snapshotService.createSnapshot(date);
    }
    
    // 基于快照计算,无论重跑多少次结果都一样
    List<RawRecord> records = snapshotDao.queryByDate(date);
    ReportResult result = calculate(records);
    reportDao.upsert(date, result);
}

快照一旦创建就不再修改,后续补录的数据进不了这份快照,重跑结果天然幂等。


增量计算的水位线问题

另一类报表是增量的:每次只处理"上次处理到哪里"之后的新数据,用水位线(watermark)或游标记录进度:

public void processIncremental() {
    long lastId = watermarkDao.getLastProcessedId("order_report");
    List<Order> batch = orderDao.findAfter(lastId, 1000);
    
    if (batch.isEmpty()) return;
    
    // 处理这批数据
    process(batch);
    
    // 更新水位线
    watermarkDao.update("order_report", batch.get(batch.size() - 1).getId());
}

增量计算的陷阱:水位线是按 ID(自增)还是按时间?

按时间水位线容易出问题:WHERE created_at > lastWatermark 会漏掉迟到数据(网络延迟、批量补录、时钟不一致导致的数据乱序)。

按 ID 水位线相对可靠(自增 ID 单调递增),但补录历史数据时 ID 在水位线之前,一样会漏。


增量 vs 全量

增量全量
计算量小,只处理新数据大,每次重算所有数据
迟到数据处理复杂,需补偿机制自然覆盖
重跑需要回退水位线直接重跑
适合场景数据量大、实时性高数据量可接受、准确性优先

"昨天的数和今天重跑对不上",通常是用了增量计算但没有处理迟到数据。解法有两种:

  1. 全量重算:接受计算代价,每次跑T日报表就重算T日全部数据
  2. 迟到数据补偿:增量计算 + 单独的补偿逻辑——迟到数据入库后,触发对应日期的报表重跑

时区和"昨天"的边界

还有一类对不上是时区导致的:服务器存的是 UTC,报表按北京时间的"昨天"切割,边界处的数据因为时区换算错误被切到了错的天。

报表时间边界要明确定义并写在代码注释里:

// 以北京时间 00:00:00 为日边界,转成 UTC 后是前一天 16:00:00
LocalDate reportDate = LocalDate.now(ZoneId.of("Asia/Shanghai")).minusDays(1);
ZonedDateTime start = reportDate.atStartOfDay(ZoneId.of("Asia/Shanghai")).toInstant();
ZonedDateTime end = reportDate.plusDays(1).atStartOfDay(ZoneId.of("Asia/Shanghai")).toInstant();

时区错误是低级但高频的坑,尤其在有海外用户或多机房的系统里。