标签归档:缓存

聊下缓存

在当今的互联网应用中,缓存作为一种提高系统性能的关键技术,扮演着至关重要的角色。无论是日常浏览网页、使用 APP,还是企业级应用的后台处理,缓存的存在无处不在。那么,什么是缓存?我们应该如何有效地利用缓存来优化系统性能呢?

什么是缓存?

从本质上来看,缓存是将数据暂时存储在比原始数据源更快的存储介质中,以便快速访问。其主要目的是减少数据访问的延迟,提高系统的性能,从而提升用户体验。

从一个 Web 请求的链路来看,主要有以下几种类型的缓存:

  1. 浏览器缓存:浏览器本地的缓存,包括内存缓存和磁盘缓存。可以通过 HTTP 响应头控制缓存策略,如 Cache-Control、Expires 等。
  2. DNS 缓存:本地机器或 DNS 服务器上对域名解析结果的缓存,可以加快后续对同一域名的访问速度。
  3. CDN 缓存:CDN 的边缘节点上的缓存,可以让用户从距离最近的节点获取资源,减轻服务器压力,提高响应速度。
  4. Web 服务器缓存:如 Nginx、Apache 等 Web 服务器自带的缓存功能,或者专门的缓存服务器如 Varnish 等。对请求进行缓存,可减轻后端服务器的负载。
  5. 应用层缓存:在应用程序中自己实现的缓存,如使用 Redis、Memcached 等内存型数据库对数据进行缓存,或者在代码中实现对特定数据的缓存。
  6. 数据库缓存:数据库本身的查询缓存,如 MySQL 的 Query Cache。会对 SELECT 语句及其结果进行缓存。
  7. 操作系统缓存:操作系统级别的文件系统缓存,如 Linux 的 Page Cache。可以加快对磁盘上同一文件的重复读取速度。

这些缓存按照其位置和类型,可以分为客户端缓存、网络缓存和服务端缓存。合理地利用各种缓存,可以显著提升 Web 应用的性能和用户体验。同时也要注意缓存的更新策略,以免数据不一致问题的出现。

1 客户端缓存

客户端缓存是指存储在客户端本地的缓存数据,主要是浏览器缓存。浏览器缓存是最常见的客户端缓存形式,它可以显著减少网络传输,加快页面加载速度,提升用户体验。

浏览器缓存主要包括以下两种类型:

  1. 内存缓存:
    • 内存缓存是指存储在内存中的缓存数据,读取速度非常快。
    • 但是内存缓存的生命周期较短,会在浏览器关闭时被清除。
    • 内存缓存一般用于存储当前页面中已经下载的资源,如页面文档、图片、脚本、样式表等。
  2. 磁盘缓存:
    • 磁盘缓存是指存储在磁盘上的缓存数据,读取速度相对内存缓存慢一些,但是容量更大。
    • 磁盘缓存的生命周期更长,可以在浏览器关闭后继续保留。
    • 磁盘缓存主要用于存储那些可能在将来的请求中重复使用的资源,如静态图片、脚本、样式表等。

浏览器缓存的工作原理涉及到两个重要的概念:强缓存和协商缓存

  1. 强缓存
    • 当资源在缓存有效期内时,浏览器会直接从缓存中读取,不会向服务器发送请求
    • 强缓存由 HTTP 响应头中的 Cache-Control 或 Expires 字段控制。都是表示资源的缓存有效时间。
    • Expires 是 HTTP 1.0 的规范,值是一个 GMT 格式的时间点字符串。缺点是失效时间是一个绝对时间,服务器时间与客户端时间偏差较大时会导致缓存混乱。
    • Cache-Control 是 HTTP 1.1 的规范,一般常用该字段的 max-age 值来进行判断,它是一个相对时间。
    • 常见的应用场景有静态资源缓存,如 CSS、JavaScript、图片等,可以设置较长的缓存时间。
  2. 协商缓存
    • 协商缓存是由服务器来确定缓存资源是否可用,协商缓存会向服务器发送请求,询问资源是否有更新。
    • 协商缓存由 HTTP 响应头中的 Last-Modified/If-Modified-Since 或 ETag/If-None-Match 字段控制。
    • 服务器根据资源的最后修改时间或内容生成的唯一标识(ETag)来判断资源是否有更新。
    • 如果资源没有更新,服务器会返回 304 状态码,告诉浏览器可以直接从缓存中读取。
    • 常见的应用场景有动态内容缓存,如新闻、博客文章等,以及 API 响应缓存。

在以上提到的这些缓存控制标签中,优先级:Cache-Control > Expires > ETag > Last-Modified

浏览器在处理缓存时,会优先执行强缓存策略,如果强缓存失效,则执行协商缓存策略。通过合理设置 HTTP 缓存头部,可以有效利用浏览器缓存,减少不必要的网络传输,提升 Web 应用的性能。

这里想多聊一点关于 stale-while-revalidate 和 stale-if-error。它们都是 HTTP Cache-Control 响应头的扩展指令,用于控制浏览器如何处理陈旧的缓存资源,是两个略小众的指令。

1. stale-while-revalidate

语法:Cache-Control: max-age=, stale-while-revalidate=

stale-while-revalidate 指令用于指定在缓存过期后,允许客户端在异步 revalidate 的同时,继续使用陈旧的缓存资源的最长时间。

工作原理

  • 在 max-age 时间内,浏览器直接从缓存中获取资源,不会发送请求。
  • 当缓存过期后,在 stale-while-revalidate 指定的时间内,浏览器会发送请求到服务器进行revalidate,但同时会立即返回陈旧的缓存资源给客户端使用。
  • 如果 revalidate 成功(即服务器返回304 Not Modified),则缓存更新,并且下次请求会直接从缓存中获取。
  • 如果 revalidate 失败(即服务器返回 200 或其他状态码),则缓存更新为新的响应内容,并且下次请求会直接从缓存中获取新内容。

使用场景

  • 适用于更新不太频繁,但又希望用户总是能看到最新内容的资源,如 CSS、JavaScript 等。
  • 提高了用户体验,避免因等待revalidate而延迟页面呈现。

示例:Cache-Control: max-age=600, stale-while-revalidate=30
说明:资源可以在 600 秒内直接从缓存中获取,在接下来的30秒内,虽然缓存已过期,但浏览器仍可以显示陈旧的缓存资源,同时在后台进行异步revalidate。

2. stale-if-error

语法:Cache-Control: max-age=, stale-if-error=

stale-if-error 指令用于指定在发生错误(如网络错误、服务器错误等)时,允许客户端使用陈旧的缓存资源的最长时间。

工作原理:

  • 在 max-age 时间内,浏览器直接从缓存中获取资源,不会发送请求。
  • 当缓存过期后,浏览器会发送请求到服务器获取最新资源。
  • 如果请求过程中发生错误,并且在 stale-if-error 指定的时间内,浏览器会返回陈旧的缓存资源给客户端使用。
  • 如果请求成功,则缓存更新,并且下次请求会直接从缓存中获取。

使用场景:

  • 适用于更新频率较高,但又希望在请求出错时能显示旧内容,而不是错误页面的资源,如用户生成的内容、新闻文章等。
  • 提高了用户体验,避免因请求错误而显示错误页面。

示例:Cache-Control: max-age=600, stale-if-error=1200
说明:资源可以在 600 秒内直接从缓存中获取,在接下来的 1200 秒内,如果请求发生错误,浏览器仍可以显示陈旧的缓存资源。

这两个指令可以单独使用,也可以组合使用,以实现更灵活的缓存控制策略。它们的目的都是在一定条件下允许使用过期的缓存,以提高性能和用户体验,同时还能保证一定的数据更新。

需要注意的是,这两个指令都是 HTTP Cache-Control 响应头的扩展,并非所有浏览器都支持。目前,Chrome、Firefox 等主流浏览器已经实现了对这两个指令的支持。在实际应用中,还需要进行充分的测试和评估,以确保缓存策略的有效性和可靠性。

并且尽管浏览器已经支持了,但是服务器也需要正确地设置响应头才能生效。

2 服务器缓存

服务器缓存是指将数据临时存储在服务器的内存或磁盘上,以便后续的请求可以直接从缓存中获取数据,而不必每次都重新生成或计算数据。服务器缓存的目的是提高服务器的响应速度,减少数据处理的开销,从而提升整个系统的性能和吞吐量。

服务器缓存可以分为多个层次和类型,包括:

1.Web 服务器缓存:在 Web 服务器上配置的缓存机制,如:

  • Nginx 的 FastCGI 缓存、Proxy 缓存等。
  • Apache 的 mod_cache 模块。
  1. 应用层缓存:在应用程序中实现的缓存机制,通常使用内存缓存技术,如:
    • 对象缓存:将频繁访问的数据对象缓存在内存中,如 Java 的 Guava Cache 等。
    • 查询缓存:将数据库查询的结果缓存起来,下次相同的查询可以直接从缓存中获取,如 Hibernate Second Level Cache。
    • 页面缓存(Page Caching):将动态生成的网页内容缓存起来,下次请求可以直接返回缓存的页面。
  2. 数据库缓存:在数据库系统中实现的缓存机制,如 MySQL Query Cache
  3. 分布式缓存:将数据缓存在独立的分布式缓存服务器上,供多个应用服务器共享使用,如常用的 Redis。

这些服务器缓存的类型可以独立使用,也可以组合使用,形成多级缓存架构。不同类型的服务器缓存在系统架构中的位置不同,缓存的数据类型和粒度也不同,但它们的共同目标都是提高服务器的性能,加快数据访问速度。

在实际应用中,需要根据具体的业务场景和性能瓶颈,选择适当的服务器缓存策略。这包括合理设计缓存的粒度、缓存的过期策略、缓存的更新机制、缓存的容量规划等。同时也要注意缓存的一致性问题,确保缓存的数据与原始数据源保持同步,避免出现脏读或数据不一致的问题。

服务器缓存是提高系统性能的重要手段之一。合理利用服务器缓存,可以显著减少数据处理的开销,提高服务器的响应速度,从而提升整个系统的吞吐量和并发处理能力。但同时也要权衡缓存的成本和收益,避免过度使用缓存而带来的内存开销和维护成本。

3 缓存实现策略或模式

从应用程序角度来看,缓存的实现模式主要涉及如何在应用程序中使用和集成缓存,以提高数据访问的效率和性能。

常用的缓存模式有以下几种:

  1. 旁路缓存(Cache Aside)
    • 应用程序先查询缓存,如果缓存中有数据,则直接返回。
    • 如果缓存中没有数据,则从数据源(如数据库)查询,并将查询结果存入缓存,然后返回。
    • 更新数据时,先更新数据源,然后再删除缓存,保证数据一致性。
    • 优点:简单易懂,应用程序可以控制缓存的生命周期。
    • 缺点:可能出现缓存和数据源不一致的情况,需要合理设计缓存的过期策略和并发策略。
  2. 读写穿透(Read/Write Through)
    • 应用程序只与缓存交互,不直接访问数据源。
    • 缓存服务负责与数据源交互,并将数据在缓存和数据源之间同步。
    • 读取数据时,缓存服务先查询缓存,如果缓存中没有数据,则从数据源查询,并将结果存入缓存,然后返回。
    • 更新数据时,缓存服务先更新缓存,然后再更新数据源,保证数据一致性。
    • 优点:应用程序无需关心缓存和数据源的同步,缓存服务保证了数据一致性。
    • 缺点:缓存服务需要实现与数据源的交互,增加了复杂性;写操作的性能可能较低,因为需要同时更新缓存和数据源。
  3. 异步写入(Write Behind)
    • 应用程序只与缓存交互,不直接访问数据源。应用程序更新缓存,缓存服务在后台异步地将数据更新到数据源。
    • 写入数据时,只更新缓存,并将更新操作加入队列。
    • 异步线程或进程从队列中取出更新操作,并批量写入数据源。
    • 优点:写入操作的性能很高,因为只需要更新缓存;数据源的写入可以批量进行,提高效率。
    • 缺点:缓存和数据源之间可能存在一定的延迟,需要合理设计队列的大小和刷新策略;如果缓存服务崩溃,可能导致数据丢失,因此需要着重考虑缓存服务的可靠性
  4. 预刷新(Refresh Ahead)
    • 定期或在特定条件下,异步地从数据源加载数据到缓存中。
    • 可以通过定时任务、事件触发或者智能预测等方式来触发预刷新操作。
    • 优点:避免了缓存 miss 导致的性能下降,提高了读取操作的响应速度。
    • 缺点:需要额外的计算资源和存储空间来执行预刷新操作;如果预刷新的数据无法准确预测,可能会浪费资源。

在业务场景中我们往往不局限于只使用某一种策略,可能会是使用以上多种模式,,根据不同的数据特点和访问模式,采用不同的策略。例如,对于读多写少的数据,可以使用「旁路缓存」或「读写穿透」策略;对于写多读少的数据,可以使用「异步写入」策略。

计算机领域有个名言警句:

There are only two hard problems in Computer Science: cache invalidation, and naming things.(计算机领域只有有两大难题,「让缓存失效」和「给东西命名」)

接下来我们聊一下缓存过期策略。

4 缓存过期策略

缓存过期策略是指确定缓存数据何时失效并从缓存中移除的规则。合理的缓存过期策略可以帮助控制缓存的数据鲜度,并优化缓存的空间利用率。以下是一些常见的缓存过期策略:

  1. TTL 策略
    • 定义:TTL(Time To Live)策略为每个缓存条目设置一个固定的生存时间。当数据存入缓存时,指定一个过期时间。到达过期时间后,缓存条目将被自动移除,即使它在这段时间内没有被访问过。
    • 优点:TTL 策略实现简单,易于配置,适用于对数据新鲜度有严格要求的场景。
    • 缺点:容易导致缓存抖动,即频繁的缓存失效和重新加载,可能增加系统负载。
    • 场景:TTL策略适用于那些数据变化频繁且需要确保数据新鲜度的场景。例如,实时新闻数据、股票价格、天气预报等。
  2. LRU 策略
    • 定义:LRU(Least Recently Used)策略根据使用频率来决定缓存条目的去留。当缓存空间不足时,会移除最近最少使用的条目,以腾出空间存储新的数据。
    • 优点:LRU 策略能有效利用缓存空间,适用于访问模式有局部性的场景。
    • 缺点:实现较复杂,可能会增加缓存管理的开销,特别是在高并发环境下。
    • 场景:LRU 策略广泛应用于需要频繁访问的大型数据集,例如 Web 服务器的页面缓存、数据库查询缓存等。
  3. LFU 策略
    • 定义:LFU(Least Frequently Used)策略根据访问频率来决定缓存条目的去留。当缓存空间不足时,会移除访问频率最低的条目,以腾出空间存储新的数据。
    • 优点:LFU 策略适用于访问频率有明显差异的场景,能有效缓存高频访问的数据。
    • 缺点:实现复杂度较高,频繁更新访问计数可能会增加系统负载。
    • 场景:LFU 策略适用于用户访问行为具有明显模式的应用,如推荐系统、热点新闻或视频的缓存。
  4. FIFO 策略
    • 定义:FIFO(First In, First Out)策略按照条目加入缓存的顺序来决定去留。最早加入缓存的条目最先被移除,不考虑条目的使用频率或时间。
    • 优点:FIFO 策略实现简单,适用于数据访问模式较为均匀的场景。
    • 缺点:可能会导致热门数据被过早移除,不适合需要缓存热点数据的场景。
    • 场景:FIFO 策略适用于缓存数据生命周期较短且频繁更新的场景,例如某些实时数据流的缓冲。
  5. ARC 策略
    • 定义:ARC(Adaptive Replacement Cache)策略结合了 LRU 和 LFU 的优点,通过动态调整缓存策略来适应不同的访问模式。ARC 维护两个 LRU 列表,一个用于最近访问过的数据,另一个用于以前访问过的数据,并根据缓存命中情况在这两个列表之间调整权重。
    • 优点:ARC策略能够自适应调整缓存替换策略,既考虑了最近使用的频率,又考虑了访问频率,从而提高缓存命中率。
    • 缺点:实现复杂,需要维护多个列表和动态调整算法,可能增加缓存管理的开销。
    • 场景:ARC 策略适用于访问模式多变且无法预知的场景,如混合型工作负载的缓存管理。它在需要高效利用缓存空间且保持高命中率的系统中表现尤为出色,例如数据库管理系统、操作系统的页面缓存等。
  6. SLRU 策略
    • 定义:SLRU(Segmented Least Recently Used)是一种缓存替换算法,它是LRU(Least Recently Used)算法的一个变体。SLRU(Segmented LRU)策略将缓存分为两个段:一个是保护段(probation segment),另一个是优选段(protected segment)。新加入的条目首先进入保护段,如果条目在保护段中被再次访问,则移动到优选段。优选段中的条目如果再次被访问,则保持在优选段,否则会被移除。
    • 优点:SLRU 策略通过分段管理缓存条目,既能保留最近访问的数据,也能保护多次访问的数据,提高缓存命中率。
    • 缺点:实现复杂度较高,需要维护多个段和管理策略,可能增加系统开销。
    • 场景:SLRU 策略适用于需要平衡最近访问和频繁访问需求的场景,例如Web浏览器的缓存管理、文件系统的缓存管理等。

5 一些注意事项

在应用开发中使用缓存虽然可以显著提升系统性能和用户体验,但如果不当使用,也可能导致一些问题和陷阱。

  1. 缓存与数据源的一致性: 缓存数据和原始数据源之间的不一致是常见的问题之一。当数据被更新时,如果缓存没有同步更新,就会出现旧数据被重复使用的情况。
  2. 缓存穿透:缓存穿透指查询不存在的数据时,请求直接穿过缓存访问数据库,如果这种请求非常频繁,将严重影响数据库的性能。
  3. 缓存雪崩:缓存雪崩是指在缓存层面发生大规模的缓存失效,导致所有的请求都去打数据库,可能会因此使数据库压力过大而崩溃。
  4. 缓存预热:系统启动后缓存是空的,直接面对大流量可能会导致短时间内数据库请求量激增。
  5. 脏读问题:在分布式环境中,如果多个节点同时对缓存进行读写操作,可能会读到过期或不一致的数据。

6 小结

缓存不是解决性能问题的银弹,而是一种在适当的场景下能够显著提升系统响应速度和处理能力的工具。在实际应用中,缓存的引入需要仔细考虑其适用性、一致性问题、资源管理和安全性等多方面因素。

缓存最适合用于读操作远多于写操作的数据,以及那些数据更新不频繁、但需要快速访问的场景。然而,对于高度动态的数据,缓存可能不仅无法提供预期的性能提升,反而因为频繁的缓存更新和失效处理增加了额外的复杂性和开销。

在使用缓存的过程中,数据一致性是引入缓存时必须面对的一个挑战。无论是在单体应用还是分布式系统中,如何保证缓存中的数据与数据库中的数据保持一致,是设计缓存策略时必须仔细考虑的问题。不恰当的缓存策略可能导致数据过时或错误,影响业务的正确性。

并且,缓存的管理和维护也是一项不可忽视的任务。正确的缓存大小、适宜的过期策略、有效的内存管理等都是确保缓存系统高效运作的关键。缓存过大可能会消耗过多的内存资源,影响系统的稳定性;缓存过小则可能无法发挥缓存的性能优势。

缓存是一种强大的优化工具,但它并不适合所有情况。只有在正确的场景下,配合合适的策略和周到的管理,才能发挥出缓存的最大效能,帮助提升应用的性能和用户体验。

HTTP缓存算法

HTTP协议缓存的目标是去除许多情况下对于发送请求的需求和去除许多情况下发送完整请求的需求。以不发送请求或减少请求传输的数据量来优化整个HTTP架构,此目标的实现可以产生如下好处:

  • 减少网络传输的冗余信息量
  • 缓解网络瓶颈的问题
  • 降低对原始服务器的请求量
  • 减少了传送距离,降低了因为距离而产生的时延

缓存基本处理过程包括七个步骤。

  1. 接收 – 缓存从网络中读取抵达的请求报文
  2. 解析 – 缓存对报文进行解析,提取出URL和各种首部
  3. 查询 – 缓存查看是否有本地副本可用,如果没有,就获取一份副本,并保存在本地
  4. 新鲜度检测 – 缓存查看已缓存副本是否足够新鲜,如果不是,就询问服务器是否有任何更新
  5. 创建响应 – 缓存会用新的首部和已缓存主体来构建一条响应报文
  6. 发送 – 缓存通过网络将响应发回给客户端
  7. 日志 – 缓存可选地创建一个日志文件条目来描述这个事务

这里的缓存可以是本地客户端缓存,也可以是代理缓存之类的公共缓存。

HTTP缓存模型

HTTP缓存可以在不依赖服务器记住有哪些缓存拥有文档副本,而实现文档的一致。这些机制称为文档过期(document expiration)和服务器再验证(server revalidation),也可以称它们为截止模型和证实模型。

截止模型是HTTP请求中带上标记文档的过期时间,HTTP协议中使用如下两个字段标记过期时间:

  • Expires字段 – 指定一个绝对的过期日期。
  • Cache-control:max-age – 定义文档的最大使用期,从第一次生成文档到文档不再新鲜,无法使用为止,最大的合法生存时间(单位为s)

仅仅使用截止模型还不够,即使文档过期了,也并不意味着当前文档和原始服务器的文档不一致了。此时就到证实模型大显身手的时候了。证实模型需要询问原始服务器文档是否发生了变化。其依赖于HTTP协议的如下字段:

  • If-Modified-Since字段 – 如果从指定日期之后文档被修改了,就执行请求的方法。可以与Last-modified服务器响应首部配合使用。它告诉服务器只有在客户端缓存了对象的副本后,又服务器对其进行了修改的情况下,才在回复中发送此对象。如果服务器对象没有修改,返回304 Not Modified。如果服务器修改了此对象,发送此对象,返回200 OK。如果服务器删除了些对象,返回404 Not Found。
  • If-None-Match字段 – 服务器可以为文档提供特殊的标签(ETag),如果此标签与服务器的标签不一样,就会执行请求的方法。

如果服务器应答中包括一个ETag,又包括一个Last-Mofidied值,则客户端在发送请求时使用两种证实机制,并且只有当两种证实机制都满足时才会返回304 Not Modified。

缓存在新鲜度检测时,只需要计算两个值:已缓存副本的使用期和已缓存副本的新鲜生存期。

HTTP缓存使用期算法

响应的使用期是服务器发布响应(或通过证实模型再验证)之后经过的总时间。使用期包括了因特网中传输的时间,在中间节点缓存的时间,以及在本地缓存中的停留时间。

       /*
       * age_value 当代理服务器用自己的头部去响应请求时,Age标明实体产生到现在多长时间了。
       * date_value HTTP 服务器应答中的Date字段 原始服务器
       * request_time 缓存的请求时间
       * response_time 缓存获取应答的时间
       * now 当前时间
       */
 
      apparent_age = max0, response_time - date_value); //缓存收到响应时响应的年龄 处理时钟偏差存在时,可能为负的情况
 
      corrected_received_age = max(apparent_age, age_value);  //  容忍Age首部的错误
 
      response_delay = response_time - request_time; // 处理网络时延,导致结果保守
 
      corrected_initial_age = corrected_received_age + response_delay;
 
      resident_time = now - response_time; // 本地的停留时间,即收到响应到现在的时间间隔
 
      current_age   = corrected_initial_age + resident_time;

因此,完整的使用期计算算法是通过查看Date首部和Age首部来判断响应已使用的时间,再记录其在本地缓存中的停留时间就是总的使用期。除此之外,HTTP协议对时钟偏差和网络时延进行了一补偿,特别是其对网络时延的补偿,可能会重复计算已使用的时间,从而使整个算法产生保守的结果。这种保守的效果时,如果出错了,算法只会使文档看起来比实际使用期要老,并引发再验证。

HTTP缓存新鲜度算法

通过已缓存文档的使用期,根据服务器和客户端限制来计算新鲜生存期,就可以确定已缓存的文档是否新鲜。已缓存文档的使用期在前面已经介绍过了,这小节我们来看看新鲜生存期的计算。

为了确定一条响应是保鲜的(fresh)还是陈旧的(stale),我们需要将其保鲜寿命(freshness lifetime)和年龄(age)进行比较。年龄的计算见13.2.3节,本节讲解怎样计算保鲜寿命,以及判定一个响应是否已经过期。在下面的讨论中,数值可以用任何适于算术操作的形式表示。

与此相关的首部字段包括(按优先级从高到低): Cache-Control字段中“max-age”控制指令的值、Expires、Last-Modified、默认最小的生存期。用PHP代码体现如下:

    /**
     * $heuristic 启发式过期值应不大于从那个时间开始到现在这段时间间隔的某个分数
     * $Max_Age_value_set  是否存在Max_Age值  Cache-Control字段中“max-age”控制指令的值
     * $Max_Age_value  Max_Age值
     * $Expires_value_set 是否存在Expires值
     * $Expires_value Expires值
     * $Date_value Date头部
     * $default_cache_min_lifetime 
     * $default_cache_max_lifetime
     */
    function server_freshness_limit() {
        global $Max_Age_value_set, $Max_Age_value;
        global $Expires_value_set, $Expires_value;
        global $Date_value, $default_cache_min_lifetime, $default_cache_max_lifetime;
 
        $factor = 0.1; //典型设置为10%
 
        $heuristic = 0; //  启发式 默认为0
 
        if ($Max_Age_value_set) {   // 优先级一为 Max_Age
            $freshness_lifetime = $Max_Age_value;
        }elseif($Expires_value_set) {  //   优先级二为Expires
            $freshness_lifetime = $Expires_value - $Date_value;
        }elseif($Last_Modified_value_set) { //  优先级三为Last_Modified
            $freshness_lifetime = (int)($factor * max(0, $Last_Modified_value - $Date_value));
            $heuristic = 1; //  启发式
        }else{  
            $freshness_lifetime = $default_cache_min_lifetime;
            $heuristic = 1; //  启发式
        }
 
        if ($heuristic) {
            $freshness_lifetime = $freshness_lifetime > $default_cache_max_lifetime ? $default_cache_max_lifetime : $freshness_lifetime;
            $freshness_lifetime = $freshness_lifetime < $default_cache_min_lifetime ? $default_cache_min_lifetime : $freshness_lifetime;
        }
 
        return $freshness_lifetime;
 
    }

计算响应是否过期非常简单: response_is_fresh = (server_freshness_limit() > current_age)

以此为《HTTP权威指南》第七章读书笔记。

PHP缓存之APC-简介、存储结构和操作

APC简介

APC,全称是Alternative PHP Cache,官方翻译叫”可选PHP缓存”。它为我们提供了缓存和优化PHP的中间代码的框架。 APC的缓存分两部分:系统缓存和用户数据缓存。

  • 系统缓存 它是指APC把PHP文件源码的编译结果缓存起来,然后在每次调用时先对比时间标记。如果未过期,则使用缓存的中间代码运行。默认缓存 3600s(一小时)。但是这样仍会浪费大量CPU时间。因此可以在php.ini中设置system缓存为永不过期(apc.ttl=0)。不过如果这样设置,改运php代码后需要重启WEB服务器。目前使用较多的是指此类缓存。
  • 用户数据缓存 缓存由用户在编写PHP代码时用apc_store和apc_fetch函数操作读取、写入的。如果数据量不大的话,可以一试。如果数据量大,使用类似memcache此类的更加专著的内存缓存方案会更好。

在APC中我们也可以享受APC带来的缓存大文件上传进度的特性,需要在php.ini中将apc.rfc1867设为1,并且在表单中加一个隐藏域 APC_UPLOAD_PROGRESS,这个域的值可以随机生成一个hash,以确保唯一。之前的一篇文章PHP文件上传进度的实现原理中有对此更为细致的说明。

APC与PHP内核的交互

APC是作为一个扩展添加到PHP体系中的。因此,按照PHP的扩展规范,它会有PHP_MINIT_FUNCTION、PHP_MSHUTDOWN_FUNCTION、PHP_RINIT_FUNCTION、PHP_RSHUTDOWN_FUNCTION等宏定义的函数。在PHP_MINIT_FUNCTION(apc)中有调用apc_module_init中,并且在此函数中通过重新给zend_compile_file赋值以替换系统自带的编译文件过程,从而将APC自带的功能和相关数据结构插入到整个PHP的体系中。

这里会有一个问题,如果出现多个zend_compile_file的替换操作呢?在实际使用过程,这种情况会经常出现,比如当我们使用xdebug扩展时,又使用了apc,此时PHP是怎么处理的呢?不管是哪个扩展,在使用zend_compile_file替换时,都会有一个自己的compile_file函数(替换用),还有一个作用域在当前扩展的,一个旧的编译函数:old_compile_file。相当于每个扩展当中都保留了一个对于前一个编译函数的引用,形成一个单向链表。并且,所有最终的op_array都是在新的zend_compile_file中通过old_compile_file生成,即都会沿着这条单向链表,将编译的最终过程传递到PHP的zend_compile_file实现。在传递过程中,每经过一个节点,这些节点都会增加一些属于自己的数据结构,以实现特定的需求。

APC内部存储结构

在APC内部,对于系统缓存和用户缓存分别是以两个全局变量存储,从代码逻辑层面就隔离了两种缓存,当然,这两种存储的实现过程和数据结构是一样的,它们都是apc_cache_t类型,如下:

 
    /* {{{ struct definition: apc_cache_t */
    struct apc_cache_t {
        void* shmaddr;                共享缓存的本地进程地址
        cache_header_t* header;       缓存头,存储在共享内存中
        slot_t** slots;               缓存的槽数组,存储在共享内存中
        int num_slots;                存储在缓存中的槽个数
        int gc_ttl;                   GC列表中槽的最大生存时间
        int ttl;                      如果对槽的访问时间大于这个TTL,需要则移除这个槽
        apc_expunge_cb_t expunge_cb;  /* cache specific expunge callback to free up sma memory */
        uint has_lock;                为可能存在的造成同一进程递归锁而存在的标记 /* flag for possible recursive locks within the same process */
    };
    /* }}} */

对于一个缓存,apc_cache_t类型的变量是其入口,它包含了这个缓存的一些全局信息。每个缓存都会有多个缓存槽,包含在slots字段中,slots的个数包含在num_slots字段,槽的过程时间控制在于ttl字段。对于用户缓存和系统缓存,默认情况下系统缓存数量为1000,实际上APC创建了1031个,也就是说默认情况下APC最少可以缓存1031个文件的中间代码。当然这个值还需要考虑内存大小,计算slot的key后的分布等等。更多的关于缓存的统计信息存储在header字段中,header字段结构为cache_header_t,如下:

 
struct cache_header_t {
        apc_lck_t lock;             读写锁,独占阻塞缓存锁
        apc_lck_t wrlock;           写锁,为防止缓存爆满
        unsigned long num_hits;     缓存命中数
        unsigned long num_misses;   缓存未命中数
        unsigned long num_inserts;  插入缓存总次数
        unsigned long expunges;     清除的总次数
        slot_t* deleted_list;       指向被清除的槽的链表
        time_t start_time;          以上计数器被重置的时间
        zend_bool busy;             当apc在忙于清除缓存时告诉客户端此时状态的标记
        int num_entries;            统计的实体数
        size_t mem_size;            统计的被用于缓存的内存大小
        apc_keyid_t lastkey;        用户缓存最后一写入的key
    };

一个缓存包含多个slots,每个slot都是一个slot结构体的变量,其结构如下:

 
    struct slot_t {
        apc_cache_key_t key;        槽的key
        apc_cache_entry_t* value;   槽的值
        slot_t* next;               链表中的下一个槽
        unsigned long num_hits;     这个bucket的命中数/* number of hits to this bucket */
        time_t creation_time;       槽的初始化时间
        time_t deletion_time;       槽从缓存被移除的时间 /* time slot was removed from cache */
        time_t access_time;         槽的最后一次被访问的时间
    };

每个槽包含一个key,以apc_cache_key_t结构体存储;包含一个值,以apc_cache_entry_t结构体存储。如下:

 
    typedef struct apc_cache_key_t apc_cache_key_t;
    struct apc_cache_key_t {
        apc_cache_key_data_t data;
        unsigned long h;              /* pre-computed hash value */
        time_t mtime;                 /* the mtime of this cached entry */
        unsigned char type;
        unsigned char md5[16];        /* md5 hash of the source file */
    };

结构说明如下:

  • data字段 apc_cache_key_data_t类型,一个联合体,存储key的关联信息,比如对于系统缓存,其可能会存储文件的路径或OS的文件device/inode;对于用户缓存可能会存储用户给定的标识或标识长度。
  • h字段 文件完整路径或用户给定的标识的hash值,使用的hash算法为PHP自带的time33算法;或者文件所在device和inode的和
  • mtime字段 缓存实体的修改时间
  • type字段 APC_CACHE_KEY_USER:用户缓存; APC_CACHE_KEY_FPFILE:系统缓存(有完整路径); APC_CACHE_KEY_FILE: 系统缓存(需要查找文件)
  • md5字段 文件内容的MD5值,这个字段与前面四个字段不同,它是可选项,可以通过配置文件的apc.file_md5启用或禁用。并且这个值是在初始化实体时创建的。看到这里源文件的md5值,想起之前做过一个关于MySQL数据表中访问路径查询的优化,开始时通过直接查询路径字段,在数据量达到一定级别时,出现了就算走索引还是会很慢的情况,各种方案测试后,采用了以新增一个关于访问路径的md5值查询解决。

除了入口,APC在最终的数据存储上对于系统缓存和用户缓存也做了区分,在_apc_cache_entry_value_t分别对应file和user。

 
    typedef union _apc_cache_entry_value_t {
        struct {            
            char *filename; /* absolute path to source file */
            zend_op_array* op_array;     存储中间代码的op_array
            apc_function_t* functions; /* array of apc_function_t's */
            apc_class_t* classes; /* array of apc_class_t's */
            long halt_offset; /* value of __COMPILER_HALT_OFFSET__ for the file */
        } file;                         file结构体 系统缓存所用空间,包括文件名,,
        struct {
            char *info;
            int info_len;
            zval *val;
            unsigned int ttl;           过期时间
        } user;                         ser结构体 用户缓存所用空间
    } apc_cache_entry_value_t;

如图所示:

APC缓存存储结构

APC缓存存储结构

初始化

在APC扩展的模块初始化函数(PHP_MINIT_FUNCTION(apc))中,APC会调用apc_module_init函数初始化缓存所需要的全局变量,如系统缓存则调用apc_cache_create创建缓存全局变量apce_cache,默认情况下会分配1031个slot所需要的内存空间,用户缓存也会调用同样的方法创建缓存,存储在另一个全局变量apc_user_cache,默认情况下会分配4099个内存空间。这里分配的空间的个数都是素数,在APC的代码中有一个针对不同数量的素数表primes(在apc_cache.c文件)。素数的计算是直接遍历素数表,找到表中第一个比需要分配的个数大的素数。

缓存key生成规则

APC的缓存中的每个slot都会有一个key,key是 apc_cache_key_t结构体类型,除了key相关的属性,关键是h字段的生成。 h字段决定了此元素落于slots数组的哪一个位置。对于用户缓存和系统缓存,其生成规则不同。

  • 用户缓存通过apc_cache_make_user_key函数生成key。通过用户传递进来的key字符串,依赖PHP内核中的hash函数(PHP的hashtable所使用的hash函数:zend_inline_hash_func),生成h值。
  • 系统缓存通过apc_cache_make_file_key函数生成key。通过APC的配置项apc.stat的开关来区别对待不同的方案。在打开的情况下,即 apc.stat= On 时,如果被更新则自动重新编译和缓存编译后的内容。此时的h值是文件的device和inode相加所得的值。在关闭的情况下,即apc.stat=off时,当文件被修改后,如果要使更新的内容生效,则必须重启Web服务器。此时h值是根据文件的路径地址生成,并且这里的路径是绝对路径。即使你是使用的相对路径,也会查找PG(include_path)定位文件,以取得绝对路径,所以使用绝对路径会跳过检查,可以提高代码的效率。

添加缓存过程

以用户缓存为例,apc_add函数用于给APC缓存中添加内容。如果key参数为字符串中,APC会根据此字符串生成key,如果key参数为数组,APC会遍历整个数组,生成key。根据这些key,APC会调用_apc_store将值存储到缓存中。由于这是用户缓存,当前使用的缓存为apc_user_cache。执行写入操作的是apc_cache_make_user_entry函数,其最终调用apc_cache_user_insert执行遍历查询和写入操作。与此对应,系统缓存使用apc_cache_insert执行写入操作,其最终都会调用_apc_cache_insert。

不管是用户缓存还是系统缓存,大体的执行过程类似,步骤如下:

  1. 通过求余操作,定位当前key的在slots数组中的位置: cache->slots[key.h % cache->num_slots];
  2. 在定位到slots数组中的位置后,遍历当前key对应的slot链表,如果存在slot的key和要写入的key匹配或slot过期,清除当前slot。
  3. 在最后一个slot的后面插入新的slot。