
半年前,半夜两点我在线上修bug。我们的API响应时间已经飙到3.2秒,用户在群里炸锅,数据库CPU打满快要冒烟了。组里一个刚入职的小兄弟出的主意是:"要不加台配置更高的服务器?"
那天晚上我悟出一个道理:给一辆轮胎没气的车换大排量发动机,能跑快才怪。真正的问题几乎永远是缓存——但不是你从StackOverflow搜到的第一种解法那种缓存。
下面是真正有效的方案,按效果排序。
没人会告诉你的是:80%的流量集中在20%的接口上。先把这些接口找出来。
我用一个简单的nginx日志分析脚本,把请求量最高的接口扒了出来:
awk '{print $7}' /var/log/nginx/access.log | sort | uniq -c | sort -rn | head -10
结果一出来,我的/api/products和/api/products/{id}两个接口干掉了73%的请求量。就从这儿开刀。
最简单的缓存就是HTTP响应缓存。如果数据不是每秒都在变,把整个响应缓存起来。
优化前(3.2秒):
@app.get("/api/products")
def get_products(): products = db.query("SELECT * FROM products JOIN categories JOIN inventory...") return format_response(products) # 3.2秒都在表关联和序列化上了
优化后(45毫秒):
import functools
from cachetools import TTLCache cache = TTLCache(maxsize=1000, ttl=300) # TTL 5分钟 @app.get("/api/products")
def get_products(): cache_key = "products:list" if cache_key in cache: return cache[cache_key] products = db.query("SELECT * FROM products JOIN categories JOIN inventory...") response = format_response(products) cache[cache_key] = response return response
就这一下,响应时间从3.2秒掉到45毫秒。用户群里瞬间安静了。
但这里有个坑——我第二天凌晨3点又收到了一条bug报告:
数据变化时必须清理缓存。 我忘了这茬。有个商品改了价格,但用户看到的还是5分钟前的缓存价格。
修复方案:
@app.put("/api/products/{id}")
def update_product(id: int, data: dict): db.update("products", id, data) cache.pop("products:list", None) # 清理列表缓存 cache.pop(f"products:{id}", None) # 清理单个商品缓存 return {"status": "ok"}
响应缓存很好用,但如果不同调用方需要不同格式的数据怎么办?这时候就要在查询层面做缓存。
我用的是Redis(现在阿里云、腾讯云都有托管服务,部署很方便)。核心思路是这样的:
import json
import redis redis_client = redis.Redis(host='localhost', port=6379, decode_responses=True) def cached_query(query_key: str, ttl: int = 600): """ 把函数结果缓存到Redis的装饰器 """ def decorator(func): @functools.wraps(func) def wrapper(*args, **kwargs): cached = redis_client.get(query_key) if cached: return json.loads(cached) result = func(*args, **kwargs) redis_client.setex(query_key, ttl, json.dumps(result)) return result return wrapper return decorator @cached_query("products:featured", ttl=600)
def get_featured_products(): return db.query("SELECT * FROM products WHERE featured = true ORDER BY score DESC")
这一层又砍掉了200毫秒的平均响应时间。
如果你的接口返回的是变化频率很低的数据——商品详情、配置信息、参考数据之类的——在前面加一层CDN。
我的nginx配置:
location /api/products/ { proxy_pass http://backend; proxy_cache my_cache; proxy_cache_key "$scheme$request_method$host$request_uri"; proxy_cache_valid 200 5m; proxy_cache_valid 404 1m; add_header X-Cache-Status $upstream_cache_status;
}
X-Cache-Status这个响应头是调试神器。HIT说明命中缓存,MISS说明走了后端。如果全是MISS,那你的缓存根本没生效。
当一个热key过期了,100个请求同时打进来查数据库——这就是缓存雪崩。我的处理方式:
import time def get_with_stampede_protection(key, ttl, loader): cached = redis_client.get(key) if cached: return json.loads(cached) # 用锁确保只有一个请求去刷新缓存 lock_key = f"lock:{key}" if redis_client.setnx(lock_key, "1"): redis_client.expire(lock_key, 30) try: result = loader() redis_client.setex(key, ttl, json.dumps(result)) return result finally: redis_client.delete(lock_key) # 其他请求短暂等待后重试 time.sleep(0.1) return get_with_stampede_protection(key, ttl, loader)
三层缓存全部上线后,数据是这样的:
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 平均响应时间 | 3.2秒 | 180毫秒 |
| P99延迟 | 8.7秒 | 450毫秒 |
| 数据库CPU | 78% | 23% |
| 每秒处理请求数 | 120 | 850 |
响应时间降低了94%。文章标题我都说保守了。
先上监控。 我之前花了两周优化了一批没人访问的接口。一定要先量清楚哪些接口是热点,再动手缓存。
TTL要按数据特性来定,别拍脑袋。 商品价格一天变一次,5分钟缓存没问题。用户登录态这种实时变化的,干脆就别缓存。
从第一天就加上雪崩防护。 第一次遇到热key过期撞上流量高峰,数据库差点扛不住。这个坑别踩。
监控缓存命中率。 没法度量的优化就是耍流氓。加个Prometheus指标,记录缓存命中和未命中的次数,一眼就能看出缓存策略有没有真正起作用。
缓存是成本最低的性能优化手段。不需要重构整个代码库,不需要买更多服务器,需要的只是搞清楚数据怎么变的、变得多快、谁在用。
先从流量最高的接口入手,一层层加缓存,每加一层都看效果。还有最重要的一点——数据变化时记得清理缓存。
凌晨两点被报警叫醒的时候,你会感谢现在的自己的。