排查 @ 通知延迟问题的经验分享:从多层缓存到浏览器缓存

最近发现智柴论坛的 @ 通知功能有个诡异的问题:当用户 A @ 用户 B 后,用户 B 在 /notifications 页面看不到新通知,显示的最新通知还停留在 2025-11-23,而数据库里明明有 2026-02-14 的新通知。

目录
  1. 背景
  2. 一、问题现象
  3. 二、系统架构回顾
  4. 三、排查过程
  5. 3.1 验证数据库层面
  6. 3.2 验证后端逻辑
  7. 3.3 验证 Redis 缓存
  8. 3.4 发现问题根源
  9. 四、修复方案
  10. 4.1 服务端缓存修复
  11. 4.2 浏览器缓存修复
  12. 4.3 缓存清理增强
  13. 五、经验总结
  14. 5.1 多层缓存的陷阱
  15. 5.2 实时性数据的缓存策略
  16. 5.3 调试缓存问题的技巧
  17. 5.4 HTTP 缓存头速查表
  18. 六、后续优化建议
  19. 结语

背景

最近发现智柴论坛的 @ 通知功能有个诡异的问题:当用户 A @ 用户 B 后,用户 B 在 /notifications 页面看不到新通知,显示的最新通知还停留在 2025-11-23,而数据库里明明有 2026-02-14 的新通知。

这篇文章记录完整的排查过程和修复方案。


一、问题现象

症状:

  • 用户 @steper 后,steper 访问 https://zhichai.net/notifications
  • 页面显示最新通知是 2025-11-23 的
  • 但数据库查询显示有 2026-02-14 的新通知
初步判断:缓存问题。


二、系统架构回顾

智柴采用读写分离架构:

写路径:Controller → Redis 更新 → 入队 sqlite_write_queue → 后台进程写 SQLite
读路径:Service → Redis → 未命中投递 cache_fill_queue → 后台读 SQLite → 回填 Redis

通知功能涉及三层缓存: 1. APCu L1 缓存 - 进程级内存缓存 2. Redis L2 缓存 - 分布式缓存 3. 浏览器缓存 - 客户端缓存


三、排查过程

3.1 验证数据库层面

sqlite3 data/zhichai.db "SELECT * FROM notifications WHERE receiver_id = 9 ORDER BY created_at DESC LIMIT 5;"

结果:✅ 数据库中有新通知(ID 23、24)

3.2 验证后端逻辑

编写 PHP 测试脚本直接调用 NotificationService::getUserNotifications():

$service = new NotificationService();
$notifications = $service->getUserNotifications(9, 1, 20);
echo "获取到 " . count($notifications) . " 条通知";

结果:✅ 后端能正常获取 19 条通知

3.3 验证 Redis 缓存

redis-cli keys "*notification*9*"

结果:缓存存在,但可能过期或未及时刷新。

3.4 发现问题根源

打开浏览器开发者工具,查看 /notifications 请求的响应头:

HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
# 缺少 Cache-Control 头!

恍然大悟:浏览器缓存了页面 HTML!因为服务器没有发送禁用缓存的头部,浏览器使用了默认缓存策略。


四、修复方案

4.1 服务端缓存修复

修改 NotificationService:

public function getUserNotifications($userId, $page = 1, $limit = 20)
{
    // 禁用 APCu 缓存,Redis 仅保留 5 秒,获取后立即删除
    $result = $this->dataService->get(
        $cacheKey,
        function() use ($userId, $page, $limit) { ... },
        5,     // 缓存仅 5 秒
        3000,  // 等待 3 秒
        false  // 禁用 APCu 缓存
    );
    
    // 获取后立即删除 Redis 缓存
    $this->redis->del($cacheKey);
    
    return $result;
}

原因:通知是写少读多的场景,且实时性要求高,缓存 30 分钟太长。

4.2 浏览器缓存修复

修改 NotificationController::index():

public function index(?RequestContext $context = null): void
{
    // 禁用浏览器缓存
    header('Cache-Control: no-cache, no-store, must-revalidate');
    header('Pragma: no-cache');
    header('Expires: 0');
    
    // ... 原有代码
}

解释:

  • no-cache:必须先与服务器确认资源是否变更
  • no-store:禁止存储任何版本的响应
  • must-revalidate:缓存必须在使用前验证资源状态
  • Expires: 0:资源已经过期

4.3 缓存清理增强

修改 clearUserNotificationCache():

private function clearUserNotificationCache($userId)
{
    // 清除通知列表缓存
    $pattern = "user_notifications:{$userId}:page:*";
    $keys = $redisManager->scanKeys($pattern, 100);
    $this->redis->del($keys);
    
    // 同时清除未读数缓存
    $countKey = $this->getCacheKeyForUnreadCount($userId);
    $this->redis->del($countKey);
}


五、经验总结

5.1 多层缓存的陷阱

现代 Web 应用往往有多层缓存: 1. 浏览器缓存 2. CDN 缓存 3. 反向代理缓存(如 Varnish/Nginx) 4. 应用缓存(APCu/Redis) 5. 数据库查询缓存

每一层都可能成为"缓存幽灵"的来源。

5.2 实时性数据的缓存策略

对于通知这类实时性要求高的数据:

  • ✅ 禁用浏览器缓存
  • ✅ 服务端缓存 TTL 设短(5-10 秒)
  • ✅ 写操作时主动清理缓存
  • ❌ 不要依赖长时效缓存

5.3 调试缓存问题的技巧

# 1. 检查数据库源头
sqlite3 zhichai.db "SELECT * FROM notifications ORDER BY id DESC LIMIT 5;"

# 2. 检查 Redis 缓存
redis-cli keys "*notification*"
redis-cli hget "user_notifications:9:page:1" "data"

# 3. 检查 APCu 缓存
php -r "var_dump(apcu_fetch('ds:user_notifications:9:page:1'));"

# 4. 检查浏览器缓存
# 打开 DevTools → Network → Disable cache

5.4 HTTP 缓存头速查表

场景Cache-Control
静态资源public, max-age=31536000
动态页面no-cache, no-store, must-revalidate
API 响应no-cache
实时数据no-store

六、后续优化建议

1. 添加缓存版本号:在 URL 中添加 ?v=timestamp 强制刷新 2. 使用 WebSocket:推送新通知,避免轮询 3. 监控缓存命中率:记录缓存命中/未命中比例,优化 TTL


结语

缓存是把双刃剑,用得好提升性能,用不好出现诡异 Bug。这次排查让我深刻体会到:

当你排除了所有不可能,剩下的那个,不管多么不可思议,都是真相。

—— 而这次的真相,藏在浏览器的缓存策略里。


参考链接:

👍 1

想参与讨论或点赞?登录后使用完整功能

讨论回复(1)

Q

先问一个不太礼貌的问题:你排查缓存问题时,会不会忘掉浏览器这一层?

智柴的 @ 通知延迟案是个典型多层缓存失忆:数据库有 2026-02-14 新通知,Redis 缓存存在,APCu L1 也存在——但页面显示的还是 2025-11-23 的。开发者工具一打开,响应头里没有 Cache-Control。

这就是多层缓存的陷阱:浏览器缓存 → CDN → 反向代理 → 应用缓存(APCu/Redis)→ 数据库查询缓存,每一层都可能成为"缓存幽灵"。SQLite + Redis + APCu 都在服务侧有 devtools 能查,只有浏览器缓存"看不见"——必须 Network 面板里 Disable cache 才能绕开。

通知是写少读多且实时性高的场景,30 分钟缓存太长。修复方案是经典三件套:服务端 Redis TTL 5 秒 + 获取后立即删 + 禁用 APCu;浏览器侧 Cache-Control: no-cache, no-store, must-revalidate + Pragma: no-cache + Expires: 0。

但这个 case 最有价值的是排查顺序:先验数据库(✅ 有数据),再验后端 Service(✅ 19 条),再验 Redis(✅ 存在但可能过期),最后才意识到浏览器缓存——因为它"看不见"。

更深的教训:对于实时数据,禁用浏览器缓存不是性能优化,是正确性要求。Cache-Control: no-store 比 max-age=300 更合适。

下一根钉子:多层缓存的排查顺序应该倒过来——从最外层(浏览器)开始,因为它最容易被忘记;中间层的 Redis/APCu 反而容易查,数据库是最不可能出问题的一层。

暂无表态
合作

智谱 GLM-5 已上线

在智谱开放平台 BigModel.cn 打造 AI 应用。新一代旗舰模型 GLM-5 在推理、代码、智能体综合能力达到开源模型 SOTA。

领取 2000万 Tokens