• 本地缓存如何保证跨服务器的数据一致性?
  • 发布于 1周前
  • 63 热度
    0 评论
我们架构团队最近在和业务团队合作,承接一些业务性能优化的小项目。最近遇到的几个项目分别用到了本地环境和分布式缓存。对于各种类型,我们希望做成设计标杆,以后不管是业务团队同学自己开发还是我们架构团队帮助优化,都有一套标准的设计模版。

本地缓存缓存全量还是按请求缓存?
本次优化背景是业务团队的接口包含多个查询数据库的操作。查询的内容基本都是运营手动修改,而且一个数据库查询的数据量与用户ID等随着业务而增长的数据无关,全量数据在几百到几千条之间。也有请求是要请求按条件过滤数据的结果。

在做方案时,我给开发小哥哥提出的要求是:本地缓存只缓存全量。请求带条件的,可直接在内存中根据需求将全量数据做过滤。原因:
1.如果按照请求维度存储数据,数据内存量有笛卡尔积的冗余,占用珍贵且有限、不容易扩展的本地内存。
2.如果按照请求维度来存,触发的时机就需要是请求过来时,这个请求要先查数据库,这样这笔请求的耗时会很长。虽然整体需要查数据库的占比不高,但会造成长尾。缓存全量就可以每次都命中缓存,完美的避免长尾问题。

使用Java的Lambda表达式等手段做过滤是纳秒级别操作,和IO时间相比可以忽略不计。

本地缓存如何保证跨服务器的数据一致性?
之前,业务团队自身做优化时使用的是Redis缓存,但是90%的场景需要从数据库中取全量数据,会造成大Key问题。所以我当时建议使用本地缓存。但是本地缓存有个问题:简单即正义的考虑,这个模块对数据一致性要求不高,所以我们是通过定时任务来刷新缓存。虽然业务上允许缓存和数据库之间的数据不一致,但是跨缓存之间的数据不一致对业务会有影响。因为比如用户查询自己的余额。在做了一笔转账之后,由于更新有延迟,返回发现还是原来的余额,过了一会儿才变成最新的。这是可以理解的。现在绝大多数银行的APP也都是这样的,用户也习惯了。但是用户不能接受每次刷新值都不一样(由于负载均衡流量打到不同的机器上),一会儿是更新前的,一会儿是更新后的。

这个问题的解决方案是:定时任务采用分布式调度,调度可以设置fanout扇出模式,每次更新各个服务器都同步更新,这样数据就一致了。

多级缓存失效?
开发小哥哥做技术调研的时候,调研了Spring 使用@Cacheable 做多级缓存。本来打算一级用本地缓存,本地缓存没命中使用Redis缓存。他求助我说Redis缓存没有生效。我当时在忙别的事情,说晚上才有时间帮他看。等我看时,发现他的思路偏了。这里使用多级缓存做是不合适的。需要单独的定时任务来刷新缓存,取的时候,正常情况下应该都是可以命中缓存的,因为缓存的是全量。而Spring 的@Cacheable是通过定期缓存失效来避免数据一致性问题。所以使用注解的方式做两级缓存不合适。

因为没有采用这个方案,所以我最终没有帮他看问题。但我其实很希望他可以利用一点空余时间自己研究一下。我猜测可能和他存储的返回值对象没有实现序列化有关。

缓存更新如何设计?
正常来说,在更新数据库数据时应该同步更新缓存保证数据的一致性。可本次涉及的接口有些特殊,因为更新频率太低,所以没有设计接口来更新,直接提SQL更新。所以我们设计定时任务来更新缓存。因为之前的项目中遇到过问题:
由于缓存更新时,秉承简单即正义的原则。我们是更新时全量覆盖原来的缓存数据。但是随着我们业务量的增加,生产环境出现了偶尔超时的问题。超时发生原因我们查明是这种全量缓存覆盖会产生大量的JVM垃圾,触发fullGC,而且fullGC回收的垃圾多:一般用map或者list来存储。比如list里有1万条数据,list里的一个对象要占一个单独的内存空间,其实要回收的垃圾就有1万条。当时我们使用了CMS垃圾收集器,会退化成串行化垃圾收集器,造成fullGC耗时非常长,达到了1秒多。

后来为了解决这个问题,我提出了改造方案:有数据变更再更新来代替全量更新。因为这种数据,基本半年才会更新一次,所以这种方案99%情况下不会产生垃圾。但这个有个条件,就是数据库的更新时间字段要是准确的,每次数据更新时间戳就要更新。为此,我特意让开发小哥哥确认了每张表的更新时间都是
NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP

随着当前时间自动更新的。我还让开发小哥哥查了是否有时间为空的历史数据。小哥哥查了要改造的三张数据表中果然有一张表存在创新时间和更新时间为空,我让开发小哥哥弄清楚原因:是最初的几条数据,属于历史遗留问题。弄清楚原因之后我提出了解决方案:那遇到这个数据就把更新时间补充进去。我在review代码阶段看到开发小哥哥的代码是:虽然本来创建时间和更新时间都为空。但是开发小哥哥只把更新时间用jvm的new date()补充进去了,创建时间还是为空的。原因他说因为他只需要更新时间字段。


对此我有2点建议:

1.我们应该站在业务整体出发,这两个not null的字段既然看到了,在不扩大修改范围的情况下,应该都补成正确的形式,不是自己用哪个才处理哪个。所以应该更新创建时间字段,让更新时间字段自动更新。因为这样,更新时间使用数据库的当前时间,不会存在服务器之间时钟同步的问题。

2.更新时不要所有字段都更新,而是哪个字段有变更再更新。如果用mybatis,体现在代码上就是不要用updateByPrimaryKey()而是用updateByPrimaryKeySelective。


果然这个优化在测试阶段被提了一个bug就是由于业务同学之前的代码,更新状态时更新了所有的字段:把原来的更新时间字段覆盖当前的更新时间字段。导致数据实际更新了,但更新时间不变:ON UPDATE CURRENT_TIMESTAMP只有在对此字段不赋值时生效。为了兼容这个既有问题,开发小哥哥没有跟我商量,直接把原来的更新时间发生变化,才更新数据的逻辑改成每次定时任务都更新。我看到了工单,由于现在的量没有上来,目前也不会产生像之前项目那样严重的fullGC问题,并且系统上确实存在一些总体的设计规范问题。工单也是贴在了给我发的日报里,也不算有沟通问题,所以我也没说什么。但后续从数据库设计和使用规范上,代码规范上都是需要规范起来的。



当时开发小哥哥本想把三张表都加上如果更新时间为空就自动填充更新时间的逻辑,我建议他把目前没有线上问题的这个自动容错逻辑去掉。如果发生这个问题,发个告警:因为一旦发生,避免是系统有问题了,有问题就是要查的,而不是自己这块正常就好。

以上就是对实际项目中一些问题的思考总结。技术实力的差距随着时间的增长,两种人的差距会逐渐拉开:一种是有技术追求的,一种是没有技术追求的。只要有点技术追求,就不会随着年龄的增长而被时代淘汰。
用户评论