什么是HTTP缓存?
HTTP 缓存是一种用于提高网页加载速度、减少网络流量和降低服务器负载的技术。它允许客户端(浏览器)或中间代理(CDN)存储网页资源的副本,比如浏览器将缓存数据存储在用户计算机的本地磁盘中,而CDN将缓存数据存储在 CDN 服务提供商管理的、离用户最近的CDN服务器当中。这样后续请求就能直接从这些缓存中获取资源,而无需再从后端数据库检索数据。
图1和图2分别来自CSDN的头像请求(注意要关闭"缓存禁用"选项),图1使用内存缓存,因为是短时间的连续请求,图2使用磁盘缓存,因为是隔了相对较长的时间发起的请求
请求头和响应头中哪些字段与HTTP缓存相关?
- 请求头:
- Cache-Control:客户端用来告知服务器或缓存代理它希望如何控制缓存行为(
no-cache
、no-store
、max-age=0
)。 - If-Modified-Since / If-None-Match:客户端用来询问服务器资源是否有更新,如果资源未修改,服务器返回 304 状态码,客户端则使用本地缓存版本。
- **If-Modified-Since:**上一次接收到资源时服务器返回的
Last-Modified
值 - If-None-Match:上一次接收到资源时服务器返回的
ETag
值
- **If-Modified-Since:**上一次接收到资源时服务器返回的
- Cache-Control:客户端用来告知服务器或缓存代理它希望如何控制缓存行为(
- 响应头:
- Cache-Control:服务器设置规则,以确定该用户是否将从其本地缓存加载资源(
no-cache
、no-store
、max-age=3600
)。 - Expires:指定客户端缓存过期的时间。
- Last-Modified:表示资源的最后修改时间,在下一次请求时,客户端可以用
If-Modified-Since
来判断缓存是否有效。 - ETag:资源的唯一标识符,基于内容生成的哈希值。让客户端使用
If-None-Match
与资源当前状态做比较,以判断缓存是否有效。
- Cache-Control:服务器设置规则,以确定该用户是否将从其本地缓存加载资源(
Etag和Last-Modified的区别
-
Last-Modified
: 精度有限,只能精确到秒。这可能导致:- 一秒内多次修改的文件无法精确标记。
- 内容未变但定期保存的文件,
Last-Modified
也会更新,导致缓存失效。
-
ETag
通过资源内容的哈希值来标识,解决了Last-Modified
的精度问题。然而,计算哈希值比获取修改时间消耗更多服务器资源。 -
ETag
和Last-Modified
协同工作:服务器会优先验证
ETag
。如果ETag
匹配,服务器才会进一步检查Last-Modified
,防止一些 HTTP 服务器未将文件修改日期纳入哈希范围内。
HTTP缓存类别
- 强制缓存:缓存有效期间不需要请求后端服务器,由客户端自身进行控制,比如设置HTTP响应头中的
max-age=3600
- 协商缓存:缓存有效期需要与后端服务器进行交互才能判断,比如后端服务器保存
Last-Modified
或Etag
,与客户端请求头的If-Modified-Since
或If-None-Match
进行比较,如果相同则返回304,客户端会使用HTTP缓存响应数据,否则经过
协商缓存相比服务端Redis缓存或其本地缓存的优势是?
- 协商缓存将缓存数据保存在用户计算机的磁盘当中,减轻了服务端的内存压力并节省了带宽。
如何应用HTTP缓存?
-
应用场景:HTTP缓存一般用于静态资源传输(html、css、js)
-
强制缓存使用:对于文件数据,如果采用minio分布式存储或云OSS服务来存储文件,允许设置HTTP响应头,设置
max-age
值进行强制缓存public class MinioCacheExample { public static void main(String[] args) { String minioUrl = "http://localhost:9000"; // 你的 MinIO 服务器地址 String accessKey = "minioadmin"; // 你的 MinIO access key String secretKey = "minioadmin"; // 你的 MinIO secret key String bucketName = "my-test-bucket"; // 你的桶名称 String objectName = "my-cached-file.txt"; // 要上传的对象名称 String filePath = "/path/to/your/local/file.txt"; // 本地文件路径 try { // 创建 MinIO 客户端 MinioClient minioClient = MinioClient.builder() .endpoint(minioUrl) .credentials(accessKey, secretKey) .build(); // 检查桶是否存在,如果不存在则创建 boolean found = minioClient.bucketExists(io.minio.BucketExistsArgs.builder().bucket(bucketName).build()); if (!found) { minioClient.makeBucket(io.minio.MakeBucketArgs.builder().bucket(bucketName).build()); System.out.println("桶 '" + bucketName + "' 已创建。"); } else { System.out.println("桶 '" + bucketName + "' 已存在。"); } // 设置自定义 HTTP 头用于缓存 Map<String, String> headers = new HashMap<>(); // Cache-Control: 设置缓存策略 // public: 允许任何缓存机制缓存响应 // max-age=<seconds>: 缓存的最大秒数。这里设置为1小时(3600秒) // immutable: 指示资源内容在一段时间内不会改变,有助于浏览器更积极地缓存 // other common options: no-cache, no-store, must-revalidate, proxy-revalidate long maxAgeSeconds = TimeUnit.HOURS.toSeconds(1); // 缓存 1 小时 headers.put("Cache-Control", "public, max-age=" + maxAgeSeconds + ", immutable"); // 上传文件并设置自定义头 try (InputStream is = new FileInputStream(filePath)) { minioClient.putObject( PutObjectArgs.builder() .bucket(bucketName) .object(objectName) .stream(is, -1, 10485760) // -1 表示未知大小,10MB 的分块大小 .headers(headers) // 设置自定义 HTTP 头 .build()); System.out.println("文件 '" + objectName + "' 已成功上传到桶 '" + bucketName + "',并设置了缓存头。"); } } catch (Exception e) { System.err.println("发生错误: " + e.getMessage()); e.printStackTrace(); } } }
-
协商缓存使用:对于可能被修改的数据,需要采用协商缓存,在数据被修改时,及时提醒客户端缓存数据已经失效。此时通过
Last-Modified
或Etag
来控制流程:检查查询接口(包括参数)请求头的
If-Modified-Since
值与后端保存的Last-Modified
比较,如果相同则返回304状态码,否则进行正常的查询并更新响应头Last-Modified
字段的值。在调用修改接口时更新服务端保存的Last-Modified
为当前时间。import org.springframework.web.servlet.HandlerInterceptor; import org.springframework.web.servlet.ModelAndView; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.time.ZonedDateTime; import java.time.format.DateTimeFormatter; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; public class CacheInterceptor implements HandlerInterceptor { // 使用 ConcurrentMap 来保证并发安全 // 存储 URI -> 最后修改时间(RFC 1123 格式) private static final ConcurrentMap<String, String> LAST_MODIFIED_MAP = new ConcurrentHashMap<>(); @Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String uri = request.getRequestURI(); // --- 处理 /notice/selectAll 接口的缓存 --- if ("/notice/selectAll".equals(uri)) { // 获取客户端发送的 If-Modified-Since 头 String clientIfModifiedSince = request.getHeader("If-Modified-Since"); // 从 Map 中获取该资源最新的 Last-Modified 时间 // 注意:首次启动应用或首次请求时,Map 中可能没有这个值,需要初始化 String latestLastModified = LAST_MODIFIED_MAP.computeIfAbsent(uri, k -> getLatestNoticeModifiedTime()); // 比较客户端缓存时间和服务器最新修改时间 if (clientIfModifiedSince != null && clientIfModifiedSince.equals(latestLastModified)) { // 如果客户端缓存是最新的,则返回 304 Not Modified response.setStatus(HttpServletResponse.SC_NOT_MODIFIED); return false; // 不继续处理请求 } else { // 如果客户端缓存不是最新的,或者没有缓存,则发送新的内容和 Last-Modified 头 response.addHeader("Last-Modified", latestLastModified); // max-age=3600 表示缓存 1 小时 // public 表示可以被任何缓存机制缓存 // must-revalidate 表示过期后必须重新验证 response.addHeader("Cache-Control", "public, max-age=3600, must-revalidate"); } } if ("/notice/add".equals(uri)) { updateNoticeLastModified(); } return true; } private String getLatestNoticeModifiedTime() { // 模拟从数据库或其他数据源获取最新通知的修改时间 // 示例:每当这个方法被调用时,返回一个稍微不同的时间,模拟数据更新 return DateTimeFormatter.RFC_1123_DATE_TIME.format(ZonedDateTime.now()); } // 在实际的 NoticeService 中更新这个时间 public static void updateNoticeLastModified() { // 当有通知被添加、更新或删除时,调用此方法更新 /notice/selectAll 的最新修改时间 LAST_MODIFIED_MAP.put("/notice/selectAll", DateTimeFormatter.RFC_1123_DATE_TIME.format(ZonedDateTime.now())); System.out.println("通知数据已更新,'/notice/selectAll' 的 Last-Modified 时间已刷新。"); } }