资讯动态

SeaKurisu的实习日记 | 客户反映可视化数据统计大屏时效性差?看我基于自研缓存框架、搭配分层更新策略大幅降低代码耦合并将时效由小时级降至秒级!

发布时间:2026/8/22 11:59:16 来源:尧图企业网站定制
一、引言前些天接了个比较复杂的需求客户反映在我司运营平台的可视化大屏上他们发现整个上午过去了这些数据压根就没变化。像一些关键的数据如产品故障率如果在短时间内飙升决策人员却无从感知因此错过了最佳决策时机将会造成重大损失。因此客户提出希望对该板块进行优化来厂里实习也有一段时间了前后干的都是一些无足轻重的 dirty work这回 mentor 决定带我参与这个优化需求的设计和实现我仔细研读这一块代码才发现当初在设计统计数据可视化大屏板块时居然这么草率二、背景与问题项目初期将近四十多个统计接口层各自配一个 TTL 长达十三小时的缓存 Key每个接口都会重复写这同样一套的缓存逻辑产生了大量冗余代码它们之间的区别仅仅只有 Key 不同罢了。缓存的更新仅交给每天凌晨、正午这种错差时间段的缓存预热任务即全量查统计接口然后回填。mentor 跟我说最初这个大屏只显示合格率、损耗率、月度汇总这类趋势型指标几小时的延迟不影响决策。换来的是四十多个重聚合查询不会在每次刷屏时轰炸数据库。真正要求实时的操作页面根本不走这套缓存。但现在不一样了大屏又多了产量、在制品数量、设备状态、当日不良数、物料损耗率、检验合格率、班组产量等准实时甚至要求实时的统计数据。之前的缓存框架对要求时效性的这些数据根本行不通了。因此接下来的任务就是消除大量的缓存逻辑冗余代码降低耦合度同时尽可能地根据统计数据的类型设计不同的缓存更新策略提高数据的一个时效性。三、方案调研1.抽取冗余代码第一个问题在于多个接口反复编写了近乎相同的缓存逻辑导致出现大量冗余代码每新增或减少一个接口改动范围较大。1Cacheable相信大部分同学都学过 Cacheable 这个缓存框架它的方便之处就在于基于注解 AOP的形式帮我们省下了一大笔手写缓存读写的代码让方法结果自动进入缓存而我们只需要专注于调用目标方法即可。它的实现原理与流程大致如下调用 userService.getUserById(1) ↓ 进入 AOP 代理 ↓ CacheInterceptor.invoke() ↓ 从 CacheOperationSource 读取该方法上的缓存操作 ↓ 如果没有缓存注解直接执行目标方法 ↓ 如果有 Cacheable ↓ 生成缓存 key例如 user::1 ↓ 根据 cacheNames 找到对应的 Cache 实例 ↓ 判断 condition 是否满足 ↓ 如果满足先查 Cache ↓ 命中缓存 → 直接返回缓存值不执行目标方法 ↓ 未命中 → 执行目标方法 ↓ 拿到方法返回值后判断 unless 是否满足 ↓ 如果不满足 unless则 put 到 Cache ↓ 返回结果该框架不仅支持自定义生成缓存 Key同时还支持 condition 和 unless 自定义是否走缓存的相关逻辑甚至还有 sync 模式缓解缓存击穿RedisCache 支持本地加锁仅允许一个 Key 回源加载其他 Key 都要等待。但是我们综合考虑下来并不打算采用该方案因为它也存在着许多弊端诸如代理自调用失效缓存穿透处理不彻底 缓存击穿保护有限TTL 设置较为麻烦且刷新策略不灵活尤其是当不同业务数据的 TTL各不相同时缓存一致性难以控制无法支持多级缓存等等。。。当然最主要的原因还是因为该缓存框架跟我们的缓存分层更新策略水火不容后面会再次提到这一点。2注解驱动 AOP切面该方案其实就是借鉴了 Cacheable 框架的实现自定义 DashboardCache 注解通过 Spring AOP Around 切面统一处理缓存读写。业务接口只需新增一行注解零侵入接入TTL 等参数可按接口灵活配置。自定义注解有什么好处呢你还可以在注解里面新增字段比如后面我们就会加 TTL、软过期时间、Key、枚举类 Level切面根据 Level 自动匹配对应的硬 TTL、软过期时间及刷新逻辑所以一切的一切都是为了我们后面的缓存分层刷新机制铺垫。参考 Demo 代码如下public enum CacheLevel { REAL_TIME, // 实时型Canal驱动 NEAR_REAL_TIME, // 准实时型软过期异步刷新 DAILY_SUMMARY // 日汇总型定时预热 } Target(ElementType.METHOD) Retention(RetentionPolicy.RUNTIME) public interface DashboardCache { String key(); // 缓存key CacheLevel level() default CacheLevel.NEAR_REAL_TIME; // 可选覆盖默认TTL如果不设置则用配置中心的默认值 long ttl() default -1; long softExpire() default -1; }cache: levels: real-time: ttl: 60 # 硬TTL 60秒 soft-expire: -1 # 不启用软过期 near-real-time: ttl: 1800 # 硬TTL 30分钟 soft-expire: 30 # 软过期 30秒 daily-summary: ttl: 3600 # 硬TTL 1小时 soft-expire: -1 # 不启用软过期Around(annotation(cache)) public Object around(ProceedingJoinPoint pjp, DashboardCache cache) { CacheConfig config configResolver.resolve(cache.level(), cache.ttl(), cache.softExpire()); // 读取缓存 String key keyGenerator.generate(pjp, cache.key()); String value redisTemplate.opsForValue().get(key); if (value ! null) { CacheEntry entry JSON.parseObject(value, CacheEntry.class); long elapsed System.currentTimeMillis() - entry.getTimestamp(); // 如果启用软过期且已超时 - 提交异步刷新并返回旧值 if (config.softExpire 0 elapsed config.softExpire * 1000) { asyncRefresher.submit(key, pjp, cache.level()); return entry.getData(); } // 否则直接返回 return entry.getData(); } // 缓存未命中或Redis异常降级——原方法查库回填 Object result pjp.proceed(); cacheWriter.set(key, result, config.ttl); return result; }3统一 KeyGenerator缓存 Key 的生成我们把它抽象出来避免在同一缓存的不同路径反复拼接。方案是基于URI 排序后参数 MD5生成 Key确保 HTTP 请求、定时预热、Canal 事件刷新三条路径操作的是同一份缓存。2.数据时效性从背景与问题板块我们其实就能发现可视化大屏数据之所以时效性差就是因为我们把上面的所有数据的缓存更新逻辑都不由分说地交给了一个只有在凌晨和正午错峰执行的定时任务。接下来我们就介绍一些常见的业内解决方案。1让预热周期和 TTL 匹配如果你的预热任务是定时执行比如每 5 分钟跑一次那么 TTL 可以设置为比预热周期略长一点比如 5 分钟或 6 分钟。这样一来预热任务每 5 分钟重新计算一次数据并写入缓存缓存最多 5 分钟后就失效然后被新数据覆盖用户看到的数据延迟最多 5 分钟。这是最简单、最稳妥的折中方案适合数据变化不那么频繁、且业务能接受分钟级延迟的大屏场景。不知道大家发现没这其实就是我们项目初期采取的解决方案区别在于 TTL 和 定时任务周期的时间长度我们的初期方案把 TTL 设置为了 13h定时任务周期设置为了 11h所以数据时效性很差。但是这个方案又陷入了另一个极端把 TTL 和 任务周期时间设置的很短时效性虽然上去了但是大屏上不是所有数据都需要这样高频率的刷新的徒增了很多不必要的查询开销。2数据变更时主动失效既然我们有“切面”可以在业务写入数据时通过切面或消息事件触发缓存更新。具体做法就是在数据更新的 Service 方法上切一刀执行完更新后删除对应的缓存 key。下次用户请求时缓存 miss切面从数据库查最新数据并写回缓存。或者更直接更新完数据库后同时把新数据写入缓存需要序列化规则一致。这样缓存里永远是最近一次写操作后的数据实时性几乎可以达到秒级同时仍然保留缓存的性能优势。适合那些数据变更频率低、但发生变更时用户希望尽快看到最新值的场景比如生产异常、报警信息。在后续我们的方案中会针对那些实时数据通过 Canal 监听 Binlog 的形式主动推送实时数据变更事件提交到一个专门的线程池当中进行缓存更新操作。3用时间戳做“懒更新”在缓存里除了存数据还存一个“数据更新时间戳”或“业务版本号”。预热任务仍然定期执行但每次执行时先查一下数据库数据有没有变化比如 MAXupdate_time。如果变了就刷新缓存如果没变就跳过。大屏接口读缓存时可以额外返回一个“最后更新时间”让前端显示“数据更新于 xx:xx”用户也能感知。这种方案比单纯固定 TTL 更智能减少了无意义的缓存重建。所以看起来他其实可以和方案一综合一下子预热周期短一点没关系执行一次索引扫描的轻量查询比如SELECT MAX(update_time) FROM table WHERE ...耗时 10ms几乎不消耗数据库资源 - 然后拿这个时间戳和缓存的旧时间戳对比。这样可能就能节省一次预热任务无效缓存重建带来的开销即一次昂贵的跨表聚合查询耗时 3~5 秒消耗大量数据库 CPU/IO/连接 - 然后把结果写入缓存耗时 50ms。在后续我们真正采用的方案中会针对那些准实时数据在异步刷新它们之前会先事先对比缓存中的软过期时间戳与数据库表中的 MAXupdate_time如果后者还小于前者那就说明数据在软过期时间段内压根就没更新可以重置一下缓存的软过期时间然后跳过异步提交。这样就帮我们节省了许多次无效的昂贵的聚合查询。4短 TTL 长缓存双级策略前面的方案里我们只用 Redis 存缓存但 Redis 再好毕竟是一次网络 IO局域网内 0.5~2ms。而大屏有 39 个接口如果每个请求都打到 Redis峰值时对 Redis 也有压力。两级缓存的核心思路一级缓存本地放在应用 JVM 内存里Caffeine读取速度是纳秒级没有网络开销。二级缓存远程放在 Redis 里解决多实例共享和持久化问题。层级存储速度容量一致性L1 本地缓存Caffeine纳秒级小堆内存每个实例独立可能不一致L2 远程缓存Redis毫秒级大多实例共享一致性好L3 数据库MySQL秒级无限最权威设计核心把“最短期的热点数据”放在离应用最近的地方用短 TTL 容忍本地不一致用长 TTL 保护数据库。基本流程如下1. 查询 L1Caffeine ├── 命中 → 直接返回纳秒级 └── 未命中 ↓ 2. 查询 L2Redis ├── 命中 → 回填 L1 → 返回毫秒级 └── 未命中 ↓ 3. 查询 L3MySQL ├── 得到结果 → 回填 L1 L2 → 返回秒级首次或缓存失效时 └── 数据库异常 → 降级返回空或错误为什么 L1 的 TTL 要短因为本地缓存没有跨实例失效机制除非引入 MQ 广播但成本高。设短 TTL比如 10 秒即使实例间数据不一致也最多相差 10 秒业务可容忍。为什么 L2 的 TTL 要长Redis 是共享缓存承担“兜底”职责。设长 TTL比如 30 分钟即使 L1 全部失效L2 也能扛住大部分请求避免流量打到数据库。L2 的更新由后面的主动刷新机制保证。在后续我们的分层缓存更新策略中双级缓存可以无缝集成进来真正地让L1 的短 TTL 保证用户请求永远不等待慢查询L2 的软过期保证数据时效性。5分层缓存更新分层缓存更新是最后一种策略也是我们最终敲定的方案。它并非空穴来风而是真正基于数据特性和业务场景的必然选择。不论是在背景与问题中还是方案调研中的前几个板块其实我们反复暗示可视化大屏上的数据是可以按照实时数据、准实时数据、日汇总数据三类的决定它们各自归属不是靠拍拍屁股想出来的而是根据客户真实反馈与业务经验决定的。就具体的方法论来说制造业大屏的数据时效性不由技术上能多快决定而由三个业务问题决定谁在看——车间主任盯节拍、质量工程师做日内巡检、老板看月度经营决策周期完全不同看了之后多快要行动——异常上报晚看 1 小时可能整批报废供应商合格率晚一天看毫无损失数据源变化频率——扫码报工每分钟都在发生准时交付率一天只在出货动作后变一次。一句话准则数据滞后造成的业务损失开始大于查询成本的那个时间点就是它的 TTL。Ⅰ.实时数据现场执行层数据消费者是车间主任/产线组长看完立刻要行动追产、停线、调人。滞后半小时就失去存在意义——没人需要半小时前的产线现状。接口业务含义分类原因/mes/sacnRecords/hourProduction每小时产量产线节拍监控判断当前小时是否掉速是典型的安灯类数据/mes/sacnRecords/batchReport批次工序报工记录车间实时进度扫码即变/mes/sacnRecords/snReportSN 测试工序报工同上测试工位实时状态/mes/exception/notes异常上报时效性要求最高异常晚发现 不良品持续产出必须分钟级可见/mes/batchTask/actualPlanNum批量计划 vs 实际产量当天追产的直接依据下午看上午的数没法排今晚加班/sales/memberbatchReportData员工批次报工统计组长日内调度人力用需要看到现在谁干了多少/sales/memberSnReportData员工 SN 测试报工统计同上对于这类实时数据的时效性我们要求是最严格的一旦数据库表中的该类数据发生变更我们可以让 Canal 伪装成 MySQL 的 slave持续接收 Binlog 数据因为当产生聚合报表的表发生 INSERT/UPDATE/DELETE 时Binlog 会记录变更明细。这种模式属于事件驱动作用下的推模式十分适配这种关键且时效性要求极高的实时数据。相较于那些常见的依赖于用户读操作更新缓存的策略它选择主动感知数据变化做到秒级感知。除此之外它还有很多优点例如不侵入业务代码、覆盖所有写入口、比轮询更高效、缓存更新彻底异步化、可扩展性强等等。具体流程如下MySQL Binlog 变更 → Canal 监听捕获 → 发送到 MQKafka/RocketMQ → 消费者Listener收到消息 → 重新计算涉及到的聚合指标 → 直接UPDATE缓存或删除对应Key需要注意的是这里当消费者收到消息时我们是把消费任务提交到一个隔离线程池 criticalEventExecutor 的实现缓存更新的彻底异步化之所以强调隔离线程池因为在后续针对准实时数据的异步刷新处理逻辑也需要用到一个线程池它俩不能共用一个线程池是有业务考量的准实时类型数据是比较多的因而它的缓存更新任务可能也更多为了防止“低优先级”缓存更新任务阻塞“高优先级”缓存更新任务所以选择将二者的线程池隔离开来。实时任务对应的 Canal 事件任务本身很轻量它的对应线程池参数设置如下参数值设计原因核心线程数4假设 4 核 CPUCanal 事件任务本身很轻量解析消息、计算 key、SET Redis。主要是IO 操作Redis 网络 IOCPU 消耗低。4 核机器设 4~8 即可。最大线程数8应对 Binlog 突发峰值比如批量导入、集中报警允许短时间扩展到 2 倍核心数但不超过 CPU 核数太多避免线程上下文切换开销。队列大小500如果 Redis 短暂抖动或下游处理变慢允许积压最多 500 个事件。按平均事件处理 5ms 估算500 个任务约 2.5 秒可消化完不会造成数据严重滞后。回收时间60 秒峰值过后额外线程 60 秒内回收避免一直占用资源。拒绝策略CallerRunsPolicy万一队列也满了让 Canal 拉取线程自己执行该任务。这样不会丢弃事件只是稍微阻塞 Canal 拉取天然限流保证消息不丢失。拒绝策略为什么不选 AbortPolicy 因为实时事件丢了会造成大屏数据错误且无法自动恢复。为什么不选 DiscardOldest 因为旧事件可能对应设备状态恢复丢弃旧保留新会导致状态错过。另外我们的 Canal 只监听实时数据所在的特定库表因为只要监听的表发生任何变更包括频繁的中间态 UPDATE都会触发消费者逻辑。如果监听全库或大表高并发下会产生成千上万条无效消息导致消息队列积压、消费者频繁执行无谓的缓存更新甚至覆盖了刚被软过期刷新写入的正确数据实现起来也比较简单只需在 instance.properties 中添加表过滤规则# 只监听指定数据库下的指定表支持正则 canal.instance.filter.regex mes_db.tb_alarm, mes_db.tb_material_log, mes_db.tb_defect_record至于实时数据缓存的 TTL我们需要明确的是它所承接的语义只是为了避免当 Canal 宕机时能够让旧缓存自动过期避免长时间展示脏数据。配合大屏接口主动查询回填及时展示最新的实时数据。当然一般情况下每次 Canal 收到 Binlog 变更时都会重新 SET 缓存重置 TTL所以只要 Canal 没有故障缓存都是最新的。这也是为什么我们把 TTL 设置的很短就一两分钟毕竟它是用来兜底的。但是这个用户主动查询回填缓存也是存在问题的如果只是单个用户访问他只是感觉到这一次接口变慢了从毫秒级变回秒级。但如果此时恰恰是高峰并发大量用户同时访问同一个 Key就会出现“缓存击穿”——所有请求同时打到数据库。因此我们需要通过分布式锁 DCL 思想控制只有一个请求去重建缓存减轻数据库压力。大体实现代码和思路如下// 伪代码 String lockKey lock:rebuild: key; boolean locked redisTemplate.opsForValue().setIfAbsent(lockKey, 1, Duration.ofSeconds(5)); if (locked) { try { // 再次检查缓存双检锁可能其他线程已经重建了 if (redisTemplate.hasKey(key)) { return cacheGet(key); } Object result pjp.proceed(); cacheWriter.set(key, result, config.ttl); return result; } finally { redisTemplate.delete(lockKey); } } else { // 没抢到锁短暂自旋后重新读缓存 Thread.sleep(50); return cacheGet(key); }当然分布式锁只是其中的一种解决方案甚至它也可以继续进行优化比如说当前请求没有抢到锁的逻辑是短暂自选重新读缓存那如果这次还是没读到缓存呢可能说存在网络延时等场景导致去重建缓存的那个请求卡在半路了这个时候其实其他请求就可以多次自旋重试总时长控制在2~3秒内比如说重试个十次如果还是不行那就可以让这个自旋请求亲自去重建缓存呢。当然以上只是我的一种提议具体还是要根据你们的业务场景敲定实现方案不能说为了技术而技术。Ⅱ.准实时数据日内趋势与质量数据消费者是质量工程师/生产经理/物流主管决策节奏是每隔一两小时巡一眼、趋势不对就介入。分钟级新鲜度没有增量价值但绝不能是昨天的数。接口业务含义分类原因/sales/passRateSN 一次性通过率直通率 FPY制造业核心质量 KPI日内恶化要当班介入但它是比率单条报工撼动不了曲线30 分钟粒度足够/sales/snQualifiedSN 合格数据同上/sales/oneQualified一次合格率同上/sales/randomQualified/Spec/Pie抽检合格率3 个视图抽检本身就是低频采样行为数据源天然低频/sales/batchRepair/snRepair批次/SN 返修统计返修趋势用于日内质量预警小时级足够/mes/workProcess/qualifiedDistribution工序合格率分布定位哪道工序拖质量后腿巡检节奏消费/mes/batch/workNameNum/mes/sn/workNameNum各工序完成量分布进度的汇总视角非单线明细经理看瓶颈工序/mes/product/summary/summarySpec计划/实际/入库汇总产供销平衡视图日内更新即可/delivery/dailyData/dailyDayData当日出货数据出货集中在下午/傍晚物流主管日内要对着它排车但不需要分钟级/imt/inspectionStatus来料检验状态IQC 待检积压影响产线领料主管日内要看积压清况若贵司来料检验节奏很快可上调为实时/sales/batchPlan批次计划计划只在下发/变更时刻变化属低频事件数据准实时数据的时效性要求相比于实时数据就轻的多了所以我们完全没必要采用 Canal 监听 Binlog 的方式做主动感知和秒级刷新我们可以采取相对被动的方式把缓存更新的驱动权交给用户。经过调研后我们决定采用软过期 异步刷新来解决时效性和性能的矛盾。所谓软过期就是在缓存 Value 中同时存储业务数据和写入时间戳。请求到达时若数据未超过软过期阈值如 30 秒直接返回缓存毫秒级若已超过阈值先返回旧数据同时提交异步刷新任务到隔离的独立线程池线程池核心 5、最大 20、队列 200配合 Redis 分布式锁做同一 Key 去重防止并发刷新打垮数据库。这样用户感知到的数据延迟仅为软过期时间且请求不被慢查询阻塞。这个异步刷新任务本质上就是在返回旧数据之后后台重新执行业务查询方法可能耗时几秒然后把新数据覆盖回缓存并更新时间戳。不用线程池会怎样如果直接在请求线程里执行刷新请求会从毫秒级变成秒级用户卡住高并发下大量请求同时触发刷新数据库被打垮。为什么需要队列即使使用线程池如果同时有 1000 个不同 key 需要刷新但线程池只有 10 个线程超出处理能力的任务需要排队等待。这个“排队等待”就是用队列来存放任务。简而言之线程池是真正执行刷新逻辑的员工而队列则可以理解为待处理任务的缓冲区。准实时任务涉及的都是聚合慢查询它对应线程池的设置参数如下参数值设计原因核心线程数2这个线程池执行的是跨表聚合查询如产量 工单表 工序表 合格率表每次耗时可能 3~5 秒属于CPU IO 混合型任务。如果线程太多同时打爆数据库反而不利。核心设 2让查询并发度低数据库压力可控。最大线程数4允许高峰时有 4 个查询并发但不超过数据库连接池可用连接数一般配置 10~20这里取保守值。队列大小100软过期刷新任务本身就是可延迟执行的用户已经拿到旧值不阻塞。队列设 100估算每个任务 3 秒100 个任务排队需要 300 秒5分钟。但如果排队超过这个量说明系统超载此时可以丢弃新任务因为旧缓存还能继续服务。回收时间60 秒同上。拒绝策略DiscardPolicy丢弃新任务保留旧缓存不动。因为软过期只是“建议刷新”不是“必须立即更新”。如果任务积压过多说明当前数据库压力大此时丢弃任务让缓存继续用旧值避免反向加剧数据库压力。下一次请求再次触发软过期时会重新提交。对比Canal 任务不能丢弃丢数据刷新任务可以丢弃丢的是刷新机会旧数据还能用。这是两个线程池拒绝策略不同的核心原因。关于准实时数据的 TTL 设置其实可以稍微设置的长一些因为它本身时效性要求没那么强。那硬 TTL 的作用其实就是防止 Redis 内存堆积或异常场景下的兜底因为每次异步刷新都会重置 TTL所以设长一些不会导致旧数据长时间存在。它与软过期时间分工明确后者决定了“用户最大感知延迟”设得越短数据越新鲜但刷新任务越频繁需要结合数据库压力调整比如 30 秒。Ⅲ.日汇总数据经营与考核指标消费者是高管/采购/品质经理天然按天/周/月为周期做决策且多数是重聚合查询最贵的一批。T1 就是它们正确的语义——供应商考核看昨天为止反而比看5 分钟前更严谨数据是闭合的。接口业务含义分类原因/imt/suppliers/qualifiedDistribution供应商合格率分布供应商考核是月度谈判/评级的输入滞后一天零损失/imt/suppliers/supplierWeekQualified供应商周合格率推移本身就是周粒度指标/imt/suppliers/returnDistribution/returnMaterialDistribution供应商退货/退料分布同上考核性质/imt/suppliers/supplierMaterial/supplierMaterialReturn供应商来料/退料明细统计同上/imt/inspectionNotes来料不良描述汇总帕累托分析TOP 不良项按月做改善课题用/imt/inspectorsInspectNum检验员检验量人员绩效/工作量考核按天结算/mes/batch/materialDay物料日耗用参数就是月份month(monthDate)天然日结/mes/batch/materialScrapRate/BySpec/Pie物料损耗率3 个视图月度成本指标损耗率一天内的波动无决策意义/ltc/delivery/plan准时交付率 OTD核心经营指标向客户承诺和月度复盘用/ltc/delivery/customerS 级客户准时交付率同上客户经营视角这里需要堆日汇总数据的含义做出澄清在 MES 大屏场景下我们说的“日汇总”通常不是指“昨天的历史归档数据”而是指“今日实时累计数据”从当天 00:00 开始到当前时刻的累计值。如果是“昨日归档”比如昨天的生产报表数据是静态的确实凌晨跑一次全量刷新就够了刷多了浪费资源。如果是“今日累计”比如今天截止目前的生产量、今日损耗率数据是动态增长的每分每秒都在变化。如果只在凌晨刷新一次高管早上 10 点看大屏时显示的产量是0因为 10 点的“今日累计”是 0 到 10 点的动态值而不是昨天凌晨刷进去的“0”空值。基于“今日累计”的语义我们把定时任务周期初定在 15 分钟。因为“今日累计”类的数据虽然动态但变化平缓比如 15 分钟内产量只会增长一点不会有断崖式变化。如果把 TTL 设成几秒数据库压力大如果设成几小时大屏滞后严重。15 分钟是性能与时效的中间平衡点。通常我们会把这类数据缓存的 TTL 也定在 10 分钟左右让它跟预热任务配合起来让大屏上的累计值“螺旋式上升”更新。如果不想频繁刷可以加一个阈值判断——预热任务执行时先比较数据库 MAXupdate_time 和缓存里的时间戳差异如果差异极小比如今日累计值没变就跳过刷新降低不必要的计算。四、总结核心原则数据时效性分级经济适用绝不“杀鸡用牛刀”。 1. 请求命中(快)HTTP - AOP切面 - Redis读缓存 - 返回JSON 2. 状态表变更(实时/秒级)MySQL Binlog(只监听报警/停机表) - Canal - MQ - 事件驱动线程池 - 精准更新对应Key 3. 非核心动态指标(准实时/分钟级)请求读到过期缓存 - 返回旧值 提交软过期刷新任务 - 准实时线程池 - 更新数据 4. 历史/汇总兜底(固定频率)定时预热任务 - 检查数据变更 - 有变化则刷新缓存 - 保证稳定输出本文针对 MES 可视化大屏项目初期缓存方案的两大痛点——40 多个统计接口缓存逻辑大量冗余、单一长 TTL 预热导致数据时效性差设计了一套分层缓存更新策略。首先通过自定义DashboardCache注解 AOP 切面统一了缓存读写逻辑支持按接口级别配置 TTL、软过期时间和缓存级别随后把统计数据按业务特性分为实时、准实时、日汇总三类实时数据产量、报警等通过 Canal 监听 Binlog → MQ → 事件驱动线程池主动推送变更并精准更新缓存准实时数据合格率、损耗率等采用软过期 异步刷新用户先拿到旧值后台线程池重新聚合回填日汇总数据供应商考核、月度指标等定时预热 时间戳懒更新避免无效重算。两个异步线程池物理隔离拒绝策略不同保证实时任务不被慢刷新任务阻塞。最终实现了毫秒级响应、故障降级与数据时效性的良好平衡。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价