导航菜单

加上缓存

方案设计

我决定引入缓存来解决问题。

为什么选择缓存?

从上一节的分析我知道:

  • 同一个城市的天气,1 小时内基本不变
  • 但用户可能在 1 小时内请求多次
  • 重复调用外部 API 是巨大的浪费

缓存的工作原理

缓存工作流程
用户请求北京天气
检查缓存
有北京的天气数据吗?
缓存命中
直接返回缓存数据
响应时间:20ms
缓存未命中
调用外部 API
响应时间:2000ms
存入缓存
过期时间:1 小时
返回给用户

技术选型

我选择使用 Redis 作为缓存:

为什么是 Redis?

  • 速度快:内存存储,读写都在毫秒级
  • 简单易用:支持键值对存储
  • 支持过期时间:自动删除过期数据
  • 免费开源:有免费版本可用

那时候的我还没想到,这个选择会在未来发挥更大的作用。

实现逻辑

核心流程

def get_weather(city):
    cache_key = f"weather:{city}"

    # 1. 先查缓存
    cached_data = cache.get(cache_key)
    if cached_data:
        return cached_data

    # 2. 缓存未命中,调用外部 API
    data = external_api.get_weather(city)

    # 3. 存入缓存,过期时间 1 小时
    cache.set(cache_key, data, ttl=3600)

    return data

部署架构

每个请求都直接调用外部 API,响应慢
客户端
用户请求 200 个开发者
应用服务
应用服务器 处理请求
缓存层 / 外部服务
外部天气 API 响应时间 2 秒
引入 Redis 缓存,大幅降低响应时间
客户端
用户请求 高并发访问
应用服务
应用服务器 处理请求
缓存层 / 外部服务
Redis 缓存 1 小时过期
外部天气 API 响应时间 2 秒
增加空值缓存,防止缓存穿透
客户端
用户请求 包含恶意请求
应用服务
应用服务器 参数校验
缓存层 / 外部服务
Redis 缓存 包含空值缓存
外部天气 API 有调用限制
使用布隆过滤器提前过滤无效请求
客户端
用户请求 包含恶意请求
应用服务
应用服务器 布隆过滤器校验
缓存层 / 外部服务
Redis 缓存 包含空值缓存
外部天气 API 有调用限制
使用互斥锁防止缓存击穿
客户端
用户请求 高并发访问
应用服务
应用服务器 互斥锁控制
缓存层 / 外部服务
Redis 缓存 热点 key 防护
外部天气 API 有调用限制
熔断器 + 降级策略应对缓存雪崩
客户端
用户请求 高并发访问
应用服务
应用服务器 熔断器 + 降级
缓存层 / 外部服务
Redis Sentinel 主从高可用
外部天气 API 有调用限制
多地域部署的高可用 API 平台
客户端
用户请求 10 万用户
应用服务
应用服务器集群 26 台,3 地域
缓存层 / 外部服务
Redis 集群 6 主 6 从
MySQL 主从 1 主 5 从
消息队列 RabbitMQ 3 台

效果验证

上线后,我观察了一天的数据:

性能对比

指标优化前优化后
平均响应时间2000ms35ms
缓存命中率-95%
外部 API 调用量10 万次/天5000 次/天

成本分析

Redis 成本:
- 内存占用:约 100MB
- 服务器资源:可忽略不计

外部 API 调用量:
- 优化前:10 万次/天(接近限流)
- 优化后:5000 次/天(远低于限流)
- 节省:95% 的调用额度

问题解决

  • ✅ 响应时间从 2 秒降到 35ms,用户不再抱怨
  • ✅ 外部 API 调用量降低 95%,不再担心限流
  • ✅ 成本几乎没增加(Redis 资源占用很小)

那一刻,我觉得所有的纠结和尝试都是值得的。

缓存命中率分析

我统计了缓存的命中率情况:

缓存命中率:95%

未命中情况:
- 第一次访问某个城市(缓存还没有数据)
- 缓存过期后再次访问(1 小时后)
- 新增的城市

命中情况:
- 1 小时内重复访问同一城市

95% 的命中率意味着:

  • 100 个请求中,95 个直接从缓存返回
  • 只有 5 个需要调用外部 API
  • 平均响应时间大幅降低

新的思考

虽然缓存解决了性能问题,但带来了一些新问题:

问题 1:缓存过期时间

我设置了 1 小时过期,这个时间合适吗?

  • 太短:缓存命中率降低
  • 太长:数据可能不新鲜
  • 不同天气数据的更新频率可能不同

问题 2:缓存数据更新

如果气象局更新了天气数据,但我的缓存还没过期:

  • 用户会看到旧数据
  • 如何主动更新缓存?

问题 3:缓存容量

如果接入的城市越来越多:

  • 100 个城市需要多少内存?
  • 1000 个城市呢?
  • Redis 内存满了怎么办?

课后练习

练习 1

Redis 中设置键值对过期时间的命令是?

参考答案 (3 个标签)
Redis 过期时间 基本命令

答案EXPIRE key seconds

扩展知识

  • EXPIRE key seconds - 设置键的过期时间(秒)
  • PEXPIRE key milliseconds - 设置键的过期时间(毫秒)
  • EXPIREAT key timestamp - 设置键在指定时间戳过期
  • TTL key - 查看键的剩余生存时间(秒)

示例

SET name "Redis"
EXPIRE name 3600  # 1 小时后过期

练习 2

缓存穿透、缓存击穿和缓存雪崩的区别是什么?

参考答案 (3 个标签)
Redis 缓存问题 系统设计

三种缓存问题的区别

问题原因解决方案
缓存穿透查询不存在的数据,请求直接打到外部 API布隆过滤器、缓存空对象
缓存击穿热点 key 过期,大量请求同时访问该 key互斥锁、逻辑过期
缓存雪崩大量 key 同时过期或 Redis 宕机随机过期时间、高可用架构

详细解释

  1. 缓存穿透:用户查询的数据在缓存和外部 API 中都不存在,每次请求都会访问外部 API。

  2. 缓存击穿:某个热点 key 突然过期,此时大量请求同时访问这个 key,导致请求全部打到外部 API。

  3. 缓存雪崩:大量缓存 key 在同一时间过期,或者 Redis 服务器宕机,导致所有请求都涌向外部 API。

练习 3

如何保证缓存与外部 API 的数据一致性?

参考答案 (3 个标签)
Redis 缓存一致性 数据同步

常见方案

方案一:缓存失效策略

当外部 API 数据更新时,主动使缓存失效:

# 伪代码
def update_weather_data(city, new_data):
    # 1. 外部 API 数据已更新
    # 2. 使缓存失效
    redis.delete(f"weather:{city}")

方案二:设置缓存过期时间

  • 给缓存设置合理的过期时间
  • 过期后自动从外部 API 重新获取

方案三:主动更新缓存

def refresh_cache(city):
    data = external_api.get_weather(city)  # 调用外部 API
    redis.setex(f"weather:{city}", 3600, data)  # 更新缓存

各种方案对比

方案优点缺点
缓存失效简单高效下次请求会变慢
设置过期时间自动恢复过期前数据可能不一致
主动更新一致性最好需要额外逻辑维护

练习 4

Redis 有哪些常用的数据结构?分别适用于什么场景?

参考答案 (3 个标签)
Redis 数据结构 应用场景

Redis 五大基本数据结构

数据结构命令示例适用场景
StringSET/GET缓存、计数器、分布式锁
ListLPUSH/RPOP消息队列、最新列表
HashHSET/HGET存储对象、购物车
SetSADD/SMEMBERS去重、好友关系、抽奖
ZSetZADD/ZRANK排行榜、带权重的排序

扩展数据结构

  • Bitmap - 签到统计、用户在线状态
  • HyperLogLog - UV 统计
  • Geospatial - 附近的人、地理位置

练习 5

设计一个 Redis 缓存策略,要求:

  • 用户信息查询先查缓存,缓存未命中再调用外部 API
  • 缓存过期时间设置为 30 分钟
  • 需要处理缓存未命中的情况

请用伪代码实现核心逻辑。

参考答案 (3 个标签)
Redis 缓存策略 实战编程

参考答案

import redis
import json

r = redis.Redis(host='localhost', port=6379, db=0)

def get_user_info(user_id):
    cache_key = f"user:{user_id}"

    # 1. 先查缓存
    cached = r.get(cache_key)
    if cached:
        return json.loads(cached)

    # 2. 缓存未命中,调用外部 API
    user = external_api.get_user(user_id)

    if not user:
        # 处理数据不存在的情况,避免缓存穿透
        r.setex(cache_key, 60, json.dumps(None))  # 空值也缓存,1 分钟过期
        return None

    # 3. 存入缓存,30 分钟过期
    r.setex(cache_key, 1800, json.dumps(user))

    return user

关键点

  1. 使用 user: 命名规范,便于管理
  2. 使用 SETEX 原子操作设置值和过期时间
  3. 空值也缓存,防止缓存穿透
  4. 合理设置过期时间,平衡性能和数据新鲜度

搜索