加上缓存
方案设计
我决定引入缓存来解决问题。
为什么选择缓存?
从上一节的分析我知道:
- 同一个城市的天气,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 台
效果验证
上线后,我观察了一天的数据:
性能对比
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 平均响应时间 | 2000ms | 35ms |
| 缓存命中率 | - | 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 宕机 | 随机过期时间、高可用架构 |
详细解释:
缓存穿透:用户查询的数据在缓存和外部 API 中都不存在,每次请求都会访问外部 API。
缓存击穿:某个热点 key 突然过期,此时大量请求同时访问这个 key,导致请求全部打到外部 API。
缓存雪崩:大量缓存 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 五大基本数据结构:
| 数据结构 | 命令示例 | 适用场景 |
|---|---|---|
| String | SET/GET | 缓存、计数器、分布式锁 |
| List | LPUSH/RPOP | 消息队列、最新列表 |
| Hash | HSET/HGET | 存储对象、购物车 |
| Set | SADD/SMEMBERS | 去重、好友关系、抽奖 |
| ZSet | ZADD/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关键点:
- 使用
user:命名规范,便于管理 - 使用
SETEX原子操作设置值和过期时间 - 空值也缓存,防止缓存穿透
- 合理设置过期时间,平衡性能和数据新鲜度
