进程内缓存
概念
java应用缓存一般分两种,一是进程内缓存,就是使用java应用虚拟机内存的缓存;另一个是进程外缓存,现在我们常用的各种分布式缓存。相比较而言,进程内缓存比进程外缓存快很多,而且编码也简单;但是,进程内缓存的存储量有限,使用的是java应用虚拟机的内存,而且每个应用都要存储一份,有一定的资源浪费。进程外缓存相比进程内缓存,会慢些,但是,存储空间可以横向扩展,不受限制。
优缺点
进程内缓存和进程外缓存,各有优缺点,针对不同场景,可以分别采用不同的缓存方案。
对于数据量不大的,我们可以采用进程内缓存。或者只要内存足够富裕,都可以采用,但是不要盲目以为自己富裕,不然可能会导致系统内存不够。
- 优点
没有IO开销会有更高的效率,使用起来也更加灵活。
- 缺点
不太容易实现多实例应用间共享。
但是公用jvm内存也会对应用本身有影响,另外多个应用共享就比较苦难了。(ehcache有一个不使用jvm内存的方案)
ehcache这种jvm的缓存和memcache这种io级别的缓存还是有明显差别的,以下区别决定了他们会在不同的场景下使用。
但是公用jvm内存也会对应用本身有影响,另外多个应用共享就比较苦难了。(ehcache有一个不使用jvm内存的方案)
成熟中间件
- 进程内缓存
- 进程外内存
服务间传递数据
缓存不同服务器之间传递数据,合适吗
+-----------+ put +-------+ get +-----------+
| service-A | -----> | cache | -----> | service-B |
+-----------+ +-------+ +-----------+
观点
58 反对这种做法。
我个人也反对。感觉不同服务之间,可以使用 rpc/mq。
感觉缓存,和个人的数据库是一样的,这种内部的细节永远不应该暴露给外部。
反对理由
数据管道场景,MQ比cache更加适合
如果只是单纯的将cache作为两个服务数据通讯的管道,service-A生产数据,service-B(当然,可能有service-C/service-D等)订阅数据,MQ比cache更加合适:
-
MQ是互联网常见的逻辑解耦,物理解耦组件,支持1对1,1对多各种模式,非常成熟的数据通道
-
而cache反而会将service-A/B/C/D耦合在一起,大家要彼此协同约定key的格式,ip地址等
-
MQ能够支持push,而cache只能拉取,不实时,有时延
-
MQ天然支持集群,支持高可用,而cache未必
-
MQ能支持数据落地,cache具备将数据存在内存里,具有“易失”性,当然,有些cache支持落地,但互联网技术选型的原则是,让专业的软件干专业的事情:nginx做反向代理,db做固化,cache做缓存,mq做通道
数据共管场景,两个(多个)service同时读写一个cache实例会导致耦合
如果不是数据管道,是两个(多个)service对一个cache进行数据共管,同时读写,也是不推荐的,这些service会因为这个cache耦合在一起:
-
大家要彼此协同约定key的格式,ip地址等,耦合
-
约定好同一个key,可能会产生数据覆盖,导致数据不一致
-
不同服务业务模式,数据量,并发量不一样,会因为一个cache相互影响,例如service-A数据量大,占用了cache的绝大部分内存,会导致service-B的热数据全部被挤出cache,导致cache失效;又例如service-A并发量高,占用了cache的绝大部分连接,会导致service-B拿不到cache的连接,从而服务异常
数据访问场景,两个(多个)service有读写一份数据的需求
根据服务化的原则,数据是私有的(本质也是解耦):
-
service层会向数据的需求方屏蔽下层存储引擎,分库,chace的复杂性
-
任何需求方不能绕过service读写其后端的数据
假设有其他service要有数据获取的需求,应该通过service提供的RPC接口来访问,而不是直接读写后端的数据,无论是cache还是db。
数据一致性问题
主从复制导致的不一致
在从库同步完成之后,如果有旧数据入缓存,应该及时把这个旧数据淘汰掉。
在并发读写导致缓存中读入了脏数据之后:
1、主从同步
2、通过工具订阅从库的 binlog,这里能够最准确的知道,从库数据同步完成的时间
订阅工具是 DTS,可以是 cannal,也可以自己订阅和分析 binlog
3、从库执行完写操作,向缓存再次发起删除,淘汰这段时间内可能写入缓存的旧数据
淘汰 VS 修改
KV 的值一般是什么
-
朴素类型的数据,例如:int
-
序列化后的对象,例如:User实体,本质是binary
-
文本数据,例如:json或者html
淘汰和缓存的区别
-
淘汰某个key,操作简单,直接将key置为无效,但下一次该key的访问会cache miss
-
修改某个key的内容,逻辑相对复杂,但下一次该key的访问仍会cache hit
可以看到,差异仅仅在于一次 cache miss。
缓存中的 value 数据一般是怎么修改的
-
朴素类型的数据,直接set修改后的值即可
-
序列化后的对象:一般需要先get数据,反序列化成对象,修改其中的成员,再序列化为binary,再set数据
-
json或者html数据:一般也需要先get文本,parse成doom树对象,修改相关元素,序列化为文本,再set数据
结论
-
对于对象类型,或者文本类型,修改缓存value的成本较高,一般选择直接淘汰缓存。
-
对于基础类型,视情况而定。
-
为了简单,可以统一使用淘汰策略。
并发下缓存的问题
业务场景:
(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过期);
…
常见解决方案
-
线上s1和s2只从缓存读取token
-
更新token异步,asy-Master定期更新token,避免并发更新
-
使用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
个人建议
-
读实践,全部一致。
-
写实践,使用 缓存模式 推荐方案。
也就是先更新数据库,再删除缓存。
- 弊端
存在删除缓存失败的问题。
- 解决方案
所有的 redis cache 操作失败都存放起来,比如 mq 或者其他。
使用异步删除。
开启定时线程池/定时任务/mq也好,只要解决这个问题即可。
Cache Aside Pattern
根据需要将数据从数据存储加载到缓存中。这可以提高性能,还有助于保持缓存中的数据与底层数据存储中的数据之间的一致性。
ps: 微软 AZURE 云,有很多有用的模式,有时间可以自主系统学习一遍。
读请求
-
先读cache,再读db
-
如果,cache hit,则直接返回数据
-
如果,cache miss,则访问db,并将数据set回缓存
写操作
-
淘汰缓存,而不是更新缓存
-
先操作数据库,再淘汰缓存
淘汰缓存
如果更新缓存,在并发写时,可能出现数据不一致。
在1和2两个并发写发生时,由于无法保证时序,此时不管先操作缓存还是先操作数据库,都可能出现:
(1)请求1先操作数据库,请求2后操作数据库
(2)请求2先set了缓存,请求1后set了缓存
导致,数据库与缓存之间的数据不一致。
先操作数据库,再淘汰缓存
- 先操作缓存。在读写并发时,可能出现数据不一致。
在1和2并发读写发生时,由于无法保证时序,可能出现:
(1)写请求淘汰了缓存
(2)写请求操作了数据库(主从同步没有完成)
(3)读请求读了缓存(cache miss)
(4)读请求读了从库(读了一个旧数据)
(5)读请求set回缓存(set了一个旧数据)
(6)数据库主从同步完成
导致,数据库与缓存的数据不一致。
参考资料
- redis
- cache
- 数据一致性
- 进程内缓存