进程内缓存

概念

java应用缓存一般分两种,一是进程内缓存,就是使用java应用虚拟机内存的缓存;另一个是进程外缓存,现在我们常用的各种分布式缓存。相比较而言,进程内缓存比进程外缓存快很多,而且编码也简单;但是,进程内缓存的存储量有限,使用的是java应用虚拟机的内存,而且每个应用都要存储一份,有一定的资源浪费。进程外缓存相比进程内缓存,会慢些,但是,存储空间可以横向扩展,不受限制。

优缺点

进程内缓存和进程外缓存,各有优缺点,针对不同场景,可以分别采用不同的缓存方案。

对于数据量不大的,我们可以采用进程内缓存。或者只要内存足够富裕,都可以采用,但是不要盲目以为自己富裕,不然可能会导致系统内存不够。

  • 优点

没有IO开销会有更高的效率,使用起来也更加灵活。

  • 缺点

不太容易实现多实例应用间共享。

但是公用jvm内存也会对应用本身有影响,另外多个应用共享就比较苦难了。(ehcache有一个不使用jvm内存的方案)

ehcache这种jvm的缓存和memcache这种io级别的缓存还是有明显差别的,以下区别决定了他们会在不同的场景下使用。

但是公用jvm内存也会对应用本身有影响,另外多个应用共享就比较苦难了。(ehcache有一个不使用jvm内存的方案)

成熟中间件

  • 进程内缓存

Ehcache

Guava-Cache

Caffeine

MapDB

LevelDB

  • 进程外内存

SSDB

Redis

Memcached

OSCache

服务间传递数据

缓存不同服务器之间传递数据,合适吗

+-----------+  put   +-------+  get   +-----------+
| service-A | -----> | cache | -----> | service-B |
+-----------+        +-------+        +-----------+

观点

58 反对这种做法。

我个人也反对。感觉不同服务之间,可以使用 rpc/mq。

感觉缓存,和个人的数据库是一样的,这种内部的细节永远不应该暴露给外部。

反对理由

数据管道场景,MQ比cache更加适合

如果只是单纯的将cache作为两个服务数据通讯的管道,service-A生产数据,service-B(当然,可能有service-C/service-D等)订阅数据,MQ比cache更加合适:

  1. MQ是互联网常见的逻辑解耦,物理解耦组件,支持1对1,1对多各种模式,非常成熟的数据通道

  2. 而cache反而会将service-A/B/C/D耦合在一起,大家要彼此协同约定key的格式,ip地址等

  3. MQ能够支持push,而cache只能拉取,不实时,有时延

  4. MQ天然支持集群,支持高可用,而cache未必

  5. MQ能支持数据落地,cache具备将数据存在内存里,具有“易失”性,当然,有些cache支持落地,但互联网技术选型的原则是,让专业的软件干专业的事情:nginx做反向代理,db做固化,cache做缓存,mq做通道

数据共管场景,两个(多个)service同时读写一个cache实例会导致耦合

如果不是数据管道,是两个(多个)service对一个cache进行数据共管,同时读写,也是不推荐的,这些service会因为这个cache耦合在一起:

  1. 大家要彼此协同约定key的格式,ip地址等,耦合

  2. 约定好同一个key,可能会产生数据覆盖,导致数据不一致

  3. 不同服务业务模式,数据量,并发量不一样,会因为一个cache相互影响,例如service-A数据量大,占用了cache的绝大部分内存,会导致service-B的热数据全部被挤出cache,导致cache失效;又例如service-A并发量高,占用了cache的绝大部分连接,会导致service-B拿不到cache的连接,从而服务异常

数据访问场景,两个(多个)service有读写一份数据的需求

根据服务化的原则,数据是私有的(本质也是解耦):

  1. service层会向数据的需求方屏蔽下层存储引擎,分库,chace的复杂性

  2. 任何需求方不能绕过service读写其后端的数据

假设有其他service要有数据获取的需求,应该通过service提供的RPC接口来访问,而不是直接读写后端的数据,无论是cache还是db。

数据一致性问题

主从复制导致的不一致

在从库同步完成之后,如果有旧数据入缓存,应该及时把这个旧数据淘汰掉。

在并发读写导致缓存中读入了脏数据之后:

1、主从同步

2、通过工具订阅从库的 binlog,这里能够最准确的知道,从库数据同步完成的时间

订阅工具是 DTS,可以是 cannal,也可以自己订阅和分析 binlog

3、从库执行完写操作,向缓存再次发起删除,淘汰这段时间内可能写入缓存的旧数据

淘汰 VS 修改

KV 的值一般是什么

  1. 朴素类型的数据,例如:int

  2. 序列化后的对象,例如:User实体,本质是binary

  3. 文本数据,例如:json或者html

淘汰和缓存的区别

  1. 淘汰某个key,操作简单,直接将key置为无效,但下一次该key的访问会cache miss

  2. 修改某个key的内容,逻辑相对复杂,但下一次该key的访问仍会cache hit

可以看到,差异仅仅在于一次 cache miss。

缓存中的 value 数据一般是怎么修改的

  1. 朴素类型的数据,直接set修改后的值即可

  2. 序列化后的对象:一般需要先get数据,反序列化成对象,修改其中的成员,再序列化为binary,再set数据

  3. json或者html数据:一般也需要先get文本,parse成doom树对象,修改相关元素,序列化为文本,再set数据

结论

  1. 对于对象类型,或者文本类型,修改缓存value的成本较高,一般选择直接淘汰缓存。

  2. 对于基础类型,视情况而定。

  3. 为了简单,可以统一使用淘汰策略。

并发下缓存的问题

业务场景:

(1)调用第三方服务,例如微信,一般会分配一个token,每次访问接口需要带上这个token;

(2)这个token是有有效期的,当token过期时,需要去重新认证申请;

(3)也可以在token过期前重新申请,但此时旧token会失效。

            Update
  +---------------------+
  |                     v
+---------+  Put      +-------+
| service | --------> | cache |
+---------+           +-------+
  |
  | Apply
  v
+---------+
|   sso   |
+---------+

并发下的问题

(1)取旧token,访问接口,发现token过期;

(2)并发请求,取旧token,访问接口,也发现token过期;

(3)去申请新token1;

(3)并发申请新token2(此时token1会过期);

(4)把token1放入缓存,同时使用token1访问接口(此时token1已经过期),发现token1过期,可能会递归申请新token3(此时token2过期);

(5)把token2放入缓存,同时使用token2访问接口(此时token2已经过期),发现token2过期,可能会递归申请新token4(此时token3过期);

常见解决方案

  1. 线上s1和s2只从缓存读取token

  2. 更新token异步,asy-Master定期更新token,避免并发更新

  3. 使用shadow-master保证token更新高可用,asy-Master挂了,asy-Backup顶上

  • 潜在缺点

s1/s2/asy-master 直接调用同一个缓存实例,如果缓存实例变更,可能需要同步变更,导致耦合。

  • 潜在优化:

(1)asy-Master 利用多线程,实现在s1/s2里,保证高可用;

(2)redis里用一个时间戳表示token的更新时间,更新token时,查看token的时间戳,如果token刚更新过,并发的请求便不再更新。

究竟先操作缓存,还是数据库

读操作

读操作,如果没有命中缓存,流程是怎么样的?

(1)尝试从缓存get数据,结果没有命中;

(2)从数据库获取数据,读从库,读写分离;

(3)把数据set到缓存,未来能够命中缓存;

ps: 这里对于 NULL 值有一个缓存穿透的问题。

写操作

写操作,既要操作数据库中的数据,又要操作缓存里的数据。

这里,有两个方案:

(1)先操作数据库,再操作缓存;

(2)先操作缓存,再操作数据库;

并且,希望保证两个操作的原子性,要么同时成功,要么同时失败。

这演变为一个分布式事务的问题,保证原子性十分困难,很有可能出现一半成功,一半失败,接下来看下,当原子性被破坏的时候,分别会发生什么。

先操作数据库,再操作缓存

正常情况下:

(1)先操作数据库,成功;

(2)再操作缓存(delete或者set),也成功;

  • 原子性破坏

第一步成功,第二步失败,会导致,数据库里是新数据,而缓存里是旧数据,业务无法接受。

画外音:如果第一步就失败,可以返回调用方50X,不会出现数据不一致。

先操作缓存,再操作数据库

正常情况下:

(1)先操作缓存(delete或者set),成功;

(2)再操作数据库,也成功;

画外音:如果第一步就失败,也可以返回调用方50X,不会出现数据不一致。

  • 原子性破坏

这里又分了两种情况:

(1)操作缓存使用set

(2)操作缓存使用delete

使用set的情况:第一步成功,第二步失败,会导致,缓存里是set后的数据,数据库里是之前的数据,数据不一致,业务无法接受。

并且,一般来说,数据最终以数据库为准,写缓存成功,其实并不算成功。

使用delete的情况:第一步成功,第二步失败,会导致,缓存里没有数据,数据库里是之前的数据,数据没有不一致,对业务无影响。只是下一次读取,会多一次cache miss。

画外音:此时可以返回调用方50X。

结论

缓存更新问题 主要考虑了操作间隙问题。

大部分公司

FaceBook 推荐:先更新数据库,再删除缓存

58

(1)读请求,先读缓存,如果没有命中,读数据库,再set回缓存

(2)写请求

(2.1)先缓存,再数据库

(2.2)缓存,使用delete,而不是set

个人建议

  1. 读实践,全部一致。

  2. 写实践,使用 缓存模式 推荐方案。

也就是先更新数据库,再删除缓存。

  • 弊端

存在删除缓存失败的问题。

  • 解决方案

所有的 redis cache 操作失败都存放起来,比如 mq 或者其他。

使用异步删除。

开启定时线程池/定时任务/mq也好,只要解决这个问题即可。

Cache Aside Pattern

缓存模式

根据需要将数据从数据存储加载到缓存中。这可以提高性能,还有助于保持缓存中的数据与底层数据存储中的数据之间的一致性。

ps: 微软 AZURE 云,有很多有用的模式,有时间可以自主系统学习一遍。

读请求

  1. 先读cache,再读db

  2. 如果,cache hit,则直接返回数据

  3. 如果,cache miss,则访问db,并将数据set回缓存

写操作

  1. 淘汰缓存,而不是更新缓存

  2. 先操作数据库,再淘汰缓存

淘汰缓存

如果更新缓存,在并发写时,可能出现数据不一致。

在1和2两个并发写发生时,由于无法保证时序,此时不管先操作缓存还是先操作数据库,都可能出现:

(1)请求1先操作数据库,请求2后操作数据库

(2)请求2先set了缓存,请求1后set了缓存

导致,数据库与缓存之间的数据不一致。

先操作数据库,再淘汰缓存

  • 先操作缓存。在读写并发时,可能出现数据不一致。

在1和2并发读写发生时,由于无法保证时序,可能出现:

(1)写请求淘汰了缓存

(2)写请求操作了数据库(主从同步没有完成)

(3)读请求读了缓存(cache miss)

(4)读请求读了从库(读了一个旧数据)

(5)读请求set回缓存(set了一个旧数据)

(6)数据库主从同步完成

导致,数据库与缓存的数据不一致。

参考资料

  • redis

缓存那些事

缓存架构,一篇足够?

Redis 系统学习

  • cache

通过“缓存”传递数据,是否可行?

缓存传递数据,绝不推荐

缓存,你真的用对了么?

  • 数据一致性

缓存不一致性

缓存与数据库不一致,咋办?

缓存,究竟是淘汰,还是修改?

缓存,并发更新的大坑?

究竟先操作缓存,还是数据库?

Cache Aside Pattern

  • 进程内缓存

进程内缓存,究竟怎么玩?

JAVA缓存框架有哪些意义呢?

聊聊轻量级本地缓存设计

进程内缓存和进程外缓存的对比