侧边栏壁纸
  • 累计撰写 252 篇文章
  • 累计收到 2 条评论

为了抗住千万级高并发给全站上了多级缓存(Nginx + Caffeine + Redis),搜索收录与关键词排名却断崖式暴跌?排查冷数据击穿致蜘蛛频报 504、节点缓存不一致诱发快照撕裂、ETag 协商失效把 304 吞成 200 榨干抓取配额与多级联动主动失效我踩过的几个深坑

2026-11-1 / 0 评论 / 6 阅读
AI 摘要由 AI 生成

文章描述了在应对高并发时,采用Nginx、Caffeine和Redis构建多级缓存后,搜索收录和关键词排名大幅下跌的问题。原因包括冷数据击穿、节点缓存不一致、ETag协商失效等。作者通过排查发现,搜索引擎爬虫的行为与缓存设计不匹配,导致大量请求直接击穿缓存,造成服务器压力和搜索抓取问题。解决方法包括对缓存策略调整和改进,确保系统稳定和搜索引擎抓取效率。

为了应对月初大促和突发流量,我们把原有的单层架构彻底重构成了一套标准的三级缓存体系:边缘层用 Nginx 的 proxy_cache 挡掉静态请求,应用层用 Spring Boot 进程内的 Caffeine 堆内存做高频热点过滤,底层挂着 Redis Cluster 处理分布式共享数据。

上线当晚做压力测试,单机 QPS 从 800 直接拉到 25000,平均响应时间压缩到 3 毫秒。监控大屏上一片绿色,团队甚至提前开了香槟。

没料到第四天一早,SEO 监控系统开始连续拉响红色告警。百度搜索资源平台和 Google Search Console 里的抓取频次直接下挫了将近七成,过去一个月每日稳定收录的数百篇新文章直接冻结,几个排在核心搜索结果前三名的长尾词,一夜之间跌出了前五页。

翻看 Nginx 的 access.log,单独抓出 Baiduspider 和 Googlebot 的请求记录,满屏都是令人头皮发麻的异常数据:先是一串密集的 504 Gateway Timeout,接着是大量的 200 OK 带着全量 HTML 疯狂吐出,而抓取快照更是出现了新旧内容前后倒错的快照撕裂。

高并发架构往往是为普通用户的访问模式量身定制的,而搜索引擎爬虫的抓取行为,正好踩在了这套缓存设计的所有死角上。


冷数据集中抓取引发缓存击穿,源站被打出 504

普通用户的访问高度符合二八定律甚至一九定律,绝大多数请求集中在最新发布的 5% 热门文章里。上线多级缓存后,热点数据的缓存命中率高达 99.2%,数据库的读压力几乎降到了冰点。

搜索引擎的蜘蛛完全不按这个套路出牌。无论是百度还是谷歌,爬虫系统为了建立完整的链接图谱和验证历史页面存活状态,会定期启动深度爬行队列。那天凌晨两点,Baiduspider 突然开启了 40 个并发连接,顺着历史归档标签,集中抓取两年前到三年前发布的冷门技术文章。

这些旧文章在 Nginx proxy_cache 和 Redis 里面早就过期淘汰了,Caffeine 的堆内存里更没有它们的踪影。

为什么会瞬间击穿?

问题出在 Caffeine 的加载逻辑和 Nginx 的回源控制上。最早的代码写得相当随意:

public ArticleDTO getArticle(Long id) {
    String cacheKey = "article:" + id;
    ArticleDTO data = localCache.getIfPresent(cacheKey);
    if (data != null) {
        return data;
    }
    // 穿透到 Redis
    data = redisTemplate.opsForValue().get(cacheKey);
    if (data != null) {
        localCache.put(cacheKey, data);
        return data;
    }
    // 击穿到 MySQL
    data = articleRepository.findById(id).orElse(null);
    if (data != null) {
        redisTemplate.opsForValue().set(cacheKey, data, Duration.ofHours(2));
        localCache.put(cacheKey, data);
    }
    return data;
}

当蜘蛛针对同一批未命中的历史页面发起并发探测时,getIfPresent 返回全部为 null。上百个线程同时越过本地缓存去打 Redis,Redis 同样没有,随后上百个复杂的文章关联查询(含作者信息、标签列表、评论计数)同时砸向 MySQL。

HikariCP 连接池的 30 个工作连接瞬间被耗尽,数据库 CPU 飙到 98%,后来的请求在连接池队列里排队等待超过 30 秒,直接导致 Nginx upstream 超时返回 504 Gateway Timeout。

搜索引擎对 504 的容忍度极低。短时间内遇到连续的网关超时,爬虫系统会立即判定该站点服务器濒临崩溃,自动启动过载保护机制,将后续的抓取频次与配额直接腰斩。

排坑与改造方案

要解决冷数据并发穿透,必须在本地缓存层建立互斥锁,并在 Nginx 入口对爬虫回源进行强行限流与陈旧缓存保底。

在 Caffeine 中改用原子的 get(key, mappingFunction),利用其内部基于 ConcurrentHashMap 分段锁实现的原子加载机制,确保同一个 ID 在单节点内只有一个线程去回源:

public ArticleDTO getArticle(Long id) {
    String cacheKey = "article:" + id;
    return localCache.get(cacheKey, k -> {
        // 单个 key 在当前 JVM 实例中仅允许一个线程执行此逻辑
        ArticleDTO redisData = redisTemplate.opsForValue().get(cacheKey);
        if (redisData != null) {
            return redisData;
        }
        ArticleDTO dbData = articleRepository.findById(id).orElse(null);
        if (dbData != null) {
            redisTemplate.opsForValue().set(cacheKey, dbData, Duration.ofHours(2));
        }
        return dbData;
    });
}

更关键的是在 Nginx 的 proxy_cache 层面增加保护指令:

# 对蜘蛛请求启用合并回源锁与陈旧数据托底
proxy_cache_path /data/nginx/cache levels=1:2 keys_zone=page_cache:200m max_size=10g inactive=7d use_temp_path=off;

server {
    listen 443 ssl http2;
    server_name www.llbbs.cn;

    location / {
        proxy_pass http://backend_pool;
        proxy_cache page_cache;
        proxy_cache_key $scheme$host$request_uri;

        # 核心防穿透:同一 URL 只有一个请求能回源后端,其余请求等待排队
        proxy_cache_lock on;
        proxy_cache_lock_timeout 5s;
        proxy_cache_lock_age 10s;

        # 遇到后端超时或 504 时,返回过期的旧缓存,坚决不给蜘蛛吐 504
        proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;

        # 针对 200 页面保留缓存,即使冷门数据也维持最低 24 小时
        proxy_cache_valid 200 301 302 24h;
        proxy_cache_valid 404 10m;

        add_header X-Cache-Status $upstream_cache_status;
    }
}

加了 proxy_cache_lock 之后,即使面对上百个并发抓取,打到后端服务器的每个冷门 URL 请求也精确缩减为 1 次,其余请求直接复用这 1 次生成的数据,504 错误彻底绝迹。


节点间缓存数据不同步,快照剧烈撕裂诱发降权

解决了 504 之后,抓取频次开始缓慢回升,但新发文章的快照依然一塌糊涂,部分更新过标题和错别字的文章在搜索结果中的快照反复横跳。

今天抓到了修正后的标题,过了两天快照又退回到了修改前的草稿版本。

为什么会出现版本倒错?

我们的后端服务跑在 4 台容器实例上。每台容器内部各有一份独立的 Caffeine 进程缓存。当时为了省事,文章更新的逻辑只做了这一步:

@Transactional
public void updateArticle(ArticleUpdateCommand cmd) {
    articleRepository.update(cmd);
    // 仅仅清空了 Redis 的共享缓存
    redisTemplate.delete("article:" + cmd.getId());
}

开发人员想当然地认为,只要删了 Redis,下次请求打过来时重新查库不就行了吗?

现实往往更残酷。4 台实例里,只有接收到后台编辑请求的那台实例可能在内存里刷掉了旧值,其余 3 台实例的 Caffeine 依然安然无恙地存着旧文章,有效期足足有 60 分钟。

Nginx upstream 采用默认的加权轮询把爬虫请求打到这 4 台机器上。

  1. 蜘蛛第一次来访,命中了机器 A(旧缓存),抓取到了未修改的内容。
  2. 过了 10 分钟,蜘蛛顺着列表页重新抓取该文章,命中了机器 B(从 Redis 读到了新数据),索引更新为新版。
  3. 再过 15 分钟,另一路蜘蛛爬虫命中了机器 C(旧缓存依旧未到期),重新抓取到修改前的旧内容。

在搜索引擎的质量评估体系中,这种页面在短时间内内容频繁倒错、版本反复横跳的现象被称为“快照颠簸(Content Flapping)”。算法会倾向于判定该站点存在动态伪造内容、针对爬虫与用户做欺骗性展示的风险,从而降低整站信任评级。

搭建基于 Redis Pub/Sub 的全节点联动失效通道

消除进程缓存孤岛的核心在于让失效通知具备广播能力。无论哪台实例触发了内容修改,都必须通知集群中的所有节点同步清除堆内存:

@Component
public class CacheInvalidationService {

    @Autowired
    private StringRedisTemplate redisTemplate;

    @Autowired
    private Cache<String, ArticleDTO> localCache;

    private static final String INVALIDATION_TOPIC = "cache:invalidate:article";

    public void publishInvalidation(Long articleId) {
        // 1. 删除 Redis 缓存
        redisTemplate.delete("article:" + articleId);
        // 2. 广播给集群内所有节点
        redisTemplate.convertAndSend(INVALIDATION_TOPIC, String.valueOf(articleId));
    }

    public void handleMessage(String articleId) {
        // 各节点监听到广播后,清除自己本地的 Caffeine
        localCache.invalidate("article:" + articleId);
    }
}

配合 Spring 的 RedisMessageListenerContainer,监听器配置如下:

@Configuration
public class RedisPubSubConfig {

    @Bean
    RedisMessageListenerContainer container(RedisConnectionFactory connectionFactory,
                                            MessageListenerAdapter listenerAdapter) {
        RedisMessageListenerContainer container = new RedisMessageListenerContainer();
        container.setConnectionFactory(connectionFactory);
        container.addMessageListener(listenerAdapter, new ChannelTopic("cache:invalidate:article"));
        return container;
    }

    @Bean
    MessageListenerAdapter listenerAdapter(CacheInvalidationService receiver) {
        return new MessageListenerAdapter(receiver, "handleMessage");
    }
}

任何文章只要在后台更新,Redis Pub/Sub 会在 5 毫秒内把失效事件推送给全部 4 台实例,所有机器的 Caffeine 缓存同步抹除。爬虫无论被 Nginx 路由到哪个节点,获取到的都是完全一致的实时版本。


ETag 协商缓存被动态组装抹除,304 变成 200 榨干抓取配额

很多技术人员对“抓取配额(Crawl Budget)”缺乏概念。搜索引擎分配给一个域名的日常抓取带宽不是无限的,它取决于服务器的响应速度与历史表现。

在排查日志时,我们发现了极其反常的一幕:全站每天约 15 万次爬虫请求中,HTTP 304 的占比居然不到 3%,剩下的全部是整整齐齐的 HTTP 200!

这意味着爬虫每次访问无论页面内容有没有变动,服务器都在老老实实地把一个 80KB 的完整 HTML 页面从头到尾传给蜘蛛。每天刚爬到两万多页,爬虫当天的带宽配额就耗尽了,剩下的十几万页面根本排不上队。

为什么协商缓存彻底失效了?

改造多级缓存前,页面是直接由轻量级静态化服务处理的,响应头上自带了 Last-Modified 时间戳。

改造后,Controller 层在取出数据后交给了 Thymeleaf 模板动态渲染:

@GetMapping("/article/{id}.html")
public String articleDetail(@PathVariable Long id, Model model) {
    ArticleDTO article = articleCacheService.getArticle(id);
    model.addAttribute("article", article);
    return "article_detail";
}

这个写法漏掉了 HTTP 缓存协商头。客户端和蜘蛛发送请求时带的 If-None-Match 或 If-Modified-Since 被 Spring 完全忽略。另外,由于 Nginx 开启了 gzip on;,即使用户在后端手动设置了强 ETag,Nginx 在对响应内容进行压缩后也会默认将强 ETag 整体抹除,导致协商完全失败。

恢复精确的弱 ETag 与 304 快速短路

解决这个问题的关键是在 Controller 层面引入对请求头的校验,只要文章的最后修改时间(updated_at)没有改变,在业务代码最开头就直接返回 304,完全跳过后续的模板渲染和 HTML 组装:

@GetMapping("/article/{id}.html")
public ResponseEntity<String> articleDetail(@PathVariable Long id,
                                            @RequestHeader(value = "If-None-Match", required = false) String ifNoneMatch,
                                            @RequestHeader(value = "If-Modified-Since", required = false) String ifModifiedSince,
                                            Model model) {
    ArticleDTO article = articleCacheService.getArticle(id);
    if (article == null) {
        return ResponseEntity.status(HttpStatus.NOT_FOUND).body("Article not found");
    }

    // 基于文章 ID 与更新时间戳构造标准弱 ETag
    long updateTimeEpoch = article.getUpdatedAt().toEpochMilli();
    String weakEtag = "W/\"" + id + "-" + updateTimeEpoch + "\"";

    // 1. 优先校验 ETag
    if (weakEtag.equals(ifNoneMatch)) {
        return ResponseEntity.status(HttpStatus.NOT_MODIFIED)
                .eTag(weakEtag)
                .build();
    }

    // 2. 备用校验 Last-Modified
    if (ifModifiedSince != null) {
        try {
            ZonedDateTime clientTime = ZonedDateTime.parse(ifModifiedSince, DateTimeFormatter.RFC_1123_DATE_TIME);
            if (article.getUpdatedAt().isBefore(clientTime.toInstant()) || article.getUpdatedAt().equals(clientTime.toInstant())) {
                return ResponseEntity.status(HttpStatus.NOT_MODIFIED)
                        .eTag(weakEtag)
                        .build();
            }
        } catch (Exception ignored) {
            // 时间解析失败则走正常渲染流程
        }
    }

    // 缓存未命中或内容已变更,执行正常渲染
    String renderedHtml = templateService.render("article_detail", article);
    return ResponseEntity.ok()
            .eTag(weakEtag)
            .lastModified(updateTimeEpoch)
            .header(HttpHeaders.CACHE_CONTROL, "public, max-age=1800, must-revalidate")
            .body(renderedHtml);
}

同时在 Nginx 层面,保留弱 ETag 的传递:

# 允许 gzip 压缩保留弱 ETag(Nginx 1.7.3+ 默认行为)
gzip on;
gzip_types text/html text/css application/javascript application/json;
gzip_vary on;

# 确保后端吐出的 ETag 与 Last-Modified 完整透传给客户端
proxy_pass_header ETag;
proxy_pass_header Last-Modified;

改完上线后,日志里的 304 比例在 24 小时内从 3% 暴拉到了 79%。原本每天只能抓取 2 万次的带宽额度,在 304 仅消耗几十字节协议头的帮助下,爬虫实际能够探测的页面数量直接跃升到了每天 18 万次以上,抓取效率提升了将近十倍。


空对象缓存处理失当,错误状态被固化成 200 软 404

为了避免恶意爬虫扫描或者被黑客伪造不存在的 ID 进行缓存穿透攻击,我们在第一版设计中增加了防穿透的“空对象缓存(Null Object Caching)”。

如果数据库中查不到记录,就在 Redis 和 Caffeine 里写一个空占位符:

{"id": -1, "title": "页面已失效", "placeholder": true}

这个设计本身是业界防御击穿的标准做法,但当它与底层的全局异常捕获以及 Nginx 缓存规则混在一起时,演化成了一场严重的灾难。

怎么演化成软 404 灾难的?

那周周三凌晨,数据库因自动化维护任务发生了短暂的连接抖动,部分只读查询抛出了 CannotGetJdbcConnectionException。

某位同事在业务实现里写了这样一个防御性捕获:

public ArticleDTO getArticleSafely(Long id) {
    try {
        return articleRepository.findById(id).orElse(null);
    } catch (Exception e) {
        log.error("Failed to query article {}", id, e);
        // 错误地把数据库连接异常当成文章不存在处理
        return null; 
    }
}

当连接出现短暂波动时,数千个正常文章在回源时被当作“文章不存在”,在 Redis 和本地缓存里写入了空占位对象,过期时间设置为 15 分钟。

更可怕的是 Controller 层接到占位对象后,直接返回了 HTTP 200 状态码,并渲染了一个“抱歉,该内容不存在或已被删除”的前端展示页。

Nginx 看到后端返回了 HTTP 200,立刻按照配置将其缓存了 24 小时!

在这 24 小时里,正巧赶上百度蜘蛛前来巡检抓取。爬虫看到的 HTTP 状态码是代表正常的 200,正文内容却全变成了“内容不存在”。

这就是技术 SEO 领域最避之不及的“软 404(Soft 404)”。

搜索引擎对真 404(HTTP 状态码显式返回 404)的处理是非常规范的,它知道该页面已下线并会妥善保留权重或延后重试。

但对软 404,算法会判定网站存在低质薄内容欺诈或程序逻辑崩坏,进而对整批受影响的文章执行降权剔除,甚至波及整个分类目录。

严格的状态码边界与缓存隔离

排查出这个问题后,我们对缓存与异常边界做了两项强约束改造:

第一,严禁把任何系统级异常降级为空对象。查询超时必须向外抛出标准的 500 或 503 错误,让 Nginx 命中 proxy_cache_use_stale,坚决不能返回 200 软死链:

public ArticleDTO getArticleSafely(Long id) {
    String cacheKey = "article:" + id;
    ArticleDTO cached = localCache.getIfPresent(cacheKey);
    if (cached != null) {
        return cached;
    }

    try {
        ArticleDTO dbData = articleRepository.findById(id).orElse(null);
        if (dbData == null) {
            // 真正查无此文才写入空占位对象,且过期时间压到 3 分钟以内
            localCache.put(cacheKey, ArticleDTO.EMPTY_PLACEHOLDER);
            redisTemplate.opsForValue().set(cacheKey, ArticleDTO.EMPTY_PLACEHOLDER, Duration.ofMinutes(3));
            return null;
        }
        redisTemplate.opsForValue().set(cacheKey, dbData, Duration.ofHours(2));
        localCache.put(cacheKey, dbData);
        return dbData;
    } catch (CannotGetJdbcConnectionException | QueryTimeoutException e) {
        // 数据库不可用时抛出运行时异常,交给全局处理器返回 HTTP 503
        throw new ServiceUnavailableException("Database temporarily unavailable", e);
    }
}

第二,在 Controller 层对真实不存在的数据显式返回 HttpStatus.NOT_FOUND(HTTP 404),并且在 Nginx 层面只对 404 给予极短的缓存时间:

# Nginx 针对状态码区分缓存时长
proxy_cache_valid 200 301 302 24h;
proxy_cache_valid 404 1m;       # 404 仅缓存 1 分钟,防刷的同时保证新发文章快速恢复
proxy_cache_valid 500 502 503 504 0s; # 异常状态码完全不缓存

改造完成后的技术链路全貌

经过这几轮排查与重构,全站多级缓存与搜索引擎爬虫的协同逻辑理顺为以下确定性链路:

  1. 请求入口层(Nginx):开启 proxy_cache_lock 杜绝高并发爬虫击穿源站,针对异常状态配置 proxy_cache_use_stale 进行陈旧快照保底,完整透传弱 ETag 与 Last-Modified 标头。
  2. 应用进程层(Caffeine):使用原子 loading 函数隔离冷数据并发回源,依托 Redis Pub/Sub 订阅失效消息实现秒级集群清空,确保所有节点版本一致。
  3. 分布式共享层(Redis):严格区分真实空数据与系统异常,空对象缓存时间压到最低,防范软 404 污染。
  4. 协议协商层(HTTP 协议规范):在 Controller 入口精准识别 If-None-Match,命中则直接返回极轻量的 HTTP 304,最大化释放抓取预算。

配置上线一周后,Nginx 的状态码监控图发生了戏剧性的变化:504 错误率降到了 0,304 协商响应比例稳定在 78% 左右,爬虫日抓取量收复了失地,借着极低的响应延迟和充裕的配额增长了近三倍。更重要的是,百度与谷歌的快照更新恢复到了当日级别,核心关键词的排名全部回到了原位。

高并发优化往往不能只盯着 QPS 与延迟这两个单一的工程指标。一旦系统接入了公网,面对成千上万个以极高并发、随机路径、无状态模式巡检的搜索引擎爬虫,任何细微的协议违背或状态不同步,都会在搜索收录的放大镜下演变成惨烈的业务事故。

评论一下?

OωO
取消