本文是从零开始学软件架构系列文章的第六篇,主题为核心服务层架构设计,主要解读核心服务中的任务调度和池化技术。本文的主要内容包括: Timer 定时器调度机制、ScheduledExecutor 调度机制、Quartz 等单机调度机制、以及分布式调度架构、线程池、连接池、对象池等池化技术等内容。
缓存技术
缓存是分布式系统中的重要组件,主要解决高并发、大数据场景下,热点数据访问的性能问题。提供高性能的数据快速访问。应用中使用缓存技术,往往可以大大减少计算量,有效提升响应速度,让有限的资源服务更多的用户。
什么是缓存?
侠义的缓存:
缓存指的是 CPU 缓存,当 CPU 要读取一个数据时,首先从 CPU 缓存中查找,找到就立即读取并送给 CPU 处理;没有找到,就从速率相对较慢的内存中读取并送给 CPU 处理,同时把这个数据所在的数据块调入缓存中,可以使得以后对整块数据的读取都从缓存中进行,不必再调用内存。
广义的缓存:
凡是位于速度相差较大的两种硬件/软件之间的,用于协调两者数据传输速度差异的结构,均可称之为缓存。
为什么要有缓存?
缓存的优点:
- 性能:将相应数据存储起来以避免数据的重复创建、处理和传输,可有效提高性能。
- 稳定性:同一个应用中,对同一数据、逻辑功能和用户界面的多次请求时经常发生的。当用户基数很大时,如果每次请求都进行处理,消耗的资源是很大的浪费,也同时造成系统的不稳定。而缓存数据能降低对数据库的访问次数,降低数据库的负担和提高数据库的服务能力;
- 可用性:如果提供数据信息的服务意外停止,缓存技术可以在一定时间内仍正常提供对最终用户的支持,提高了系统的可用性。
SQL 数据库和内存缓存在千万级别数据的比较:

设计原则
缓存的设计原则:
- 将数据缓存到写入/读取速度更快的存储(设备)。
- 将数据缓存到离应用最近的位置。
- 将数据缓存到离用户最近的位置。
缓存的分类
缓存常见分类:
- CDN 缓存(CDN 服务器):CDN(Content delivery networks)缓存,也叫网关缓存、反向代理缓存。
- 反向代理缓存(Nginx Proxy Cache)
- 分布式 Cache (Redis)
- 本地应用缓存(JVM Guava)
参考:缓存分类(详细)
如何缓存
- 实时写入
- 异步写入
- 读取时实时写入
- 读取时异步写入


如何失效
- 固定时间
- 相对时间
多级缓存
- CDN 缓存(静态资源)
- 接入层→接入层 Nginx 代理缓存(nginx proxy cache)→ 应用层 Nginx lua shared dict 缓存 → 应用层 Nginx lua redis 缓存 → Tomcat 本地缓存 → Tomcat redis 缓存 → 数据库。
缓存不一致
- 容忍性
- 异步更新性
参考:库和缓存的一致性方案;
分布式缓存原理
本地缓存
本地缓存是最常见的一种缓存方式,如 Ehcache、Guava Cache 等。
优点:应用和 cache 是在同一个进程内,请求缓存非常快速,没有过多的网络开销。
缺点:因为缓存跟应用耦合,多个应用无法直接共享缓存,各应用或集群的各节点都需要维护自己的缓存,对内存是一种浪费。
场景:在单体应用不需要集群支持、各节点无需互相通知的场景下使用本地缓存较合适。
分布式缓存特性
分布式缓存能够高性能地读取数据、动态地扩展缓存节点、自动发现和切换故障节点、自动均衡数据分区,而且能够为使用者提供图形化的管理界面,部署和维护都十分方便。优秀的分布式缓存系统有 Memcached、Redis,还有阿里自主开发的 Tair 等。
分布式缓存实现原理
数据读取
分布式缓存由一个服务端实现管理和控制,多个客户端节点存储数据,以提高数据的读取速率。在读取数据的时候,根据哈希算法确定数据的存储和读取节点。
哈希算法的好处在于节点个数发生变化时无需重新计算哈希值,保证数据储存或读取时可以正确、快速地找到对应的节点。
数据均匀分布
由多个客户端节点存储数据时,需要保证数据均匀分布。以免造成有些服务器存储的数据较多,承担压力较大的问题。
解决办法:把一台服务器虚拟成多台服务器(如在计算服务器对应的哈希值时,在IP地址字符串加多个“尾缀”,比如:10.0.0.1#1 10.0.0.1#2 10.0.0.1#3...)。
数据热备份
计算多台服务器的 IP 地址哈希值时,将这些哈希值从小到大按顺时针排序组成一个“服务器节点环”。以顺时针方向看“服务器环”,当有客户端把数据存储在第1台服务器上后,第1台服务器负责把该数据拷贝一份给第 2 台服务器,以此类推。也就是说“服务器环”上的每一个节点,都是上一个节点的热备份节点。
一个服务器上存两类数据,一类是自身的业务数据,一类是上一节点的热备数据。
影响缓存性能因素
序列化
访问本地缓存,对于 JVM 语言(参考:JVM支持的语言),有堆内和堆外缓存可以进行选择。由于堆内直接以对象的形式进行存储,不需要考虑序列化,而堆外是以字节类型进行存储,就需要进行序列化和反序列化。
序列化一般需要解析对象的结构,而解析对象结构,会带来较大的 CPU 消耗,所以一般的序列化(比如 fastJson)均会缓存对象解析的对象结构,来减少 CPU 的消耗。
命中率
通常来讲,缓存的命中率越高则表示使用缓存的收益越高,应用的性能越好(响应时间越短、吞吐量越高),抗并发的能力越强。那么影响缓存命中率因素有哪些呢?
业务场景和业务需求
- 缓存适合“重复读较多”的业务场景,反之,使用缓存的意义其实并不大,命中率会很低;
- 业务需求决定了对时效性的要求,直接影响到缓存的过期时间和更新策略。时效性要求越低,就越适合缓存。在相同 key 和相同请求数的情况下,缓存时间越长,命中率会越高;
- 互联网应用的大多数业务场景下都是很适合使用缓存的。
缓存的设计粒度和策略
- 通常情况下,缓存的粒度越小,命中率会越高。
- 缓存的更新/过期策略也直接影响到缓存的命中率。一般有如下几种方式:
- 固定过期时间,被动失效;
- 感知数据变更,主动更新;
- 感知数据变更,主动更新。并设置过期时间被动失效兜底;
- 按照数据冷热性制定策略,如热数据主动失效并 reload,冷数据只失效不 reload 等。
然而,当数据发生变化时,直接更新缓存的值会比移除缓存(或者让缓存过期)的命中率更高,当然,系统复杂度也会更高。
缓存容量和基础设施
缓存的容量有限,则容易引起缓存失效和被淘汰(目前多数的缓存框架或中间件都采用了 LRU 算法)。同时,缓存的技术选型也是至关重要的,比如采用应用内置的本地缓存就比较容易出现单机瓶颈,而采用分布式缓存则比较容易扩展。所以需要做好系统容量规划,并考虑是否可扩展。此外,不同的缓存框架或中间件,其效率和稳定性也是存在差异的。
其他因素
缓存故障处理:当缓存节点发生故障时,需要避免缓存失效并最大程度降低影响,业内比较典型的做法就是通过 Hash 算法,或者通过节点冗余的方式。
以上可见,想要提高缓存收益,需要应用尽可能的通过缓存直接获取数据,并避免缓存失效。需要在业务需求,缓存粒度,缓存策略,技术选型等各个方面去通盘考虑并做权衡。尽可能的聚焦在高频访问且时效性要求不高的热点业务上,通过缓存预加载(预热)、增加存储容量、调整缓存粒度、更新缓存等手段来提高命中率。
缓存清空策略
通过前面介绍,我们知道缓存策略对于缓存的性能具有很大的影响。那么,缓存策略是为了解决什么问题,又有哪些方案可选呢?
面临的问题
主存容量远大于 CPU 缓存,磁盘容量远大于主存,因此无论是哪一层次的缓存都面临一个同样的问题:当容量有限的缓存的空闲空间全部用完后,又有新的内容需要添加进缓存时,如何挑选并舍弃原有的部分内容,从而腾出空间放入这些新的内容。
解决方案
解决这个问题的算法有几种,如最久未使用算法(LRU)、先进先出算法(FIFO)、最近最少使用算法(LFU)、非最近使用算法(NMRU)等,这些算法在不同层次的缓存上执行时拥有不同的效率和代价,需根据具体场合选择最合适的一种。下面针对每一种算法做一个简单介绍:
- FIFO(first in first out)先进先出策略,最先进入缓存的数据在缓存空间不够的情况下(超出最大元素限制)会被优先被清除掉,以腾出新的空间接受新的数据。策略算法主要比较缓存元素的创建时间。在数据实效性要求场景下可选择该类策略,优先保障最新数据可用。
- LFU(less frequently used)最少使用策略,这个缓存算法使用一个计数器来记录条目被访问的频率。通过使用LFU缓存算法,最低访问数的条目首先被移除。这个方法并不经常使用,因为它无法对一个拥有最初高访问率之后长时间没有被访问的条目缓存负责。策略算法主要比较元素的hitCount(命中次数)。在保证高频数据有效性场景下,可选择这类策略。
- LRU(least recently used)最近最少使用策略,这个缓存算法将最近使用的条目存放到靠近缓存顶部的位置。当一个新条目被访问时,LRU 将它放置到缓存的顶部。当缓存达到极限时,较早之前访问的条目将从缓存底部开始被移除。这里会使用到昂贵的算法,而且它需要记录“年龄位”来精确显示条目是何时被访问的。此外,当一个 LRU 缓存算法删除某个条目后,“年龄位”将随其他条目发生改变。策略算法主要比较元素最近一次被 get 使用时间。在热点数据场景下较适用,优先保证热点数据的有效性。
- 自适应缓存替换算法(ARC):在 IBM Almaden 研究中心开发,这个缓存算法同时跟踪记录 LFU 和 LRU,以及驱逐缓存条目,来获得可用缓存的最佳使用。
- 最近最常使用算法(MRU):这个缓存算法最先移除最近最常使用的条目。一个 MRU 算法擅长处理一个条目越久,越容易被访问的情况。
高并发场景常见缓存问题
通常来讲,在相同缓存时间和 key 的情况下,并发越高,缓存的收益会越高,即便缓存时间很短。而高并发应用场景下一般会引发以下常见的三个问题。
缓存穿透问题
问题描述
出现场景:指查询一个一定不存在的数据,由于缓存是不命中时被动写的,并且出于容错考虑,如果从存储层查不到数据则不写入缓存,这将导致这个不存在的数据每次请求都要到存储层去查询,失去了缓存的意义。在流量大时,可能 DB 就挂掉了。要是有人利用不存在的key频繁攻击我们的应用,这就是漏洞。
解决方案
- 方法 1. 在封装的缓存 SET 和 GET 部分增加个步骤,如果查询一个 KEY 不存在,就以这个 KEY 为前缀设定一个标识 KEY;以后再查询该 KEY 的时候,先查询标识 KEY,如果标识 KEY 存在,就返回一个协定好的值(如:&&),然后 APP 做相应的处理(如:检查 KEY 是否合法,是否需要查询 DB,是否需要设置缓存等),这样缓存层就不会被穿透。当然这个验证 KEY 的失效时间不能太长。
- 方法 2. 如果一个查询返回的数据为空(不管是数据不存在,还是系统故障),我们仍然把这个空结果进行缓存,但它的过期时间会很短,一般只有几分钟。
- 方法 3. 采用布隆过滤器,将所有可能存在的数据哈希到一个足够大的 bitmap 中,一个一定不存在的数据会被这个 bitmap 拦截掉,从而避免了对底层存储系统的查询压力。
缓存并发问题
问题描述
有时候如果网站并发访问高,一个缓存如果失效,可能出现多个进程同时查询 DB,同时设置缓存的情况,如果并发确实很大,这也可能造成 DB 压力过大,还有缓存频繁更新的问题。
解决方案
可以对缓存查询加锁,如果 KEY 不存在,就加锁,然后查 DB 入缓存,然后解锁;其他进程如果发现有锁就等待,然后等解锁后返回数据或者进入 DB 查询。
缓存失效问题
问题描述
引起这个问题的主要原因还是高并发的时候,平时我们设定一个缓存的过期时间时,可能有一些会设置 1 分钟啊,5 分钟这些,并发很高时可能会出在某一个时间同时生成了很多的缓存,并且过期时间都一样,这个时候就可能引发一当过期时间到后,这些缓存同时失效,请求全部转发到 DB,DB 可能会压力过重。
解决方案
其中的一个简单方案就是将缓存失效时间分散开,比如我们可以在原有的失效时间基础上增加一个随机值,比如 1-5 分钟随机,这样每一个缓存的过期时间的重复率就会降低,就很难引发集体失效的事件。
参考:
隔离技术
隔离术
隔离是指将系统或资源分割开,系统隔离是为了在系统发生故障时能限定传播范围和影响范围,即发生故障后不会出现滚雪球效应,从而保证只有出问题的服务不可用,其他服务仍可用。
常见隔离维度:
- 硬件隔离(虚拟机)
- 操作系统隔离(容器虚拟化)
- 进程隔离(系统拆分)
- 线程隔离(线程池独立)
- 读写隔离(读写分离)
- 动静隔离(动态资源静态资源分离)
- 热点隔离(热点账户,热点数据等)