作者:一个被缓存击穿搞到凌晨3点的后端
标签:Python、Django、Redis、缓存设计、性能优化、工程实践
0x00 开场:缓存不是"加个@cache"就完事的
"新城书站"(book.cndgn.com)早期做缓存,思路很简单:热门图书详情页加个@cache_page(6015),搞定。
直到某天促销活动,首页QPS从50飙到500+,Redis监控突然告警:mem_usage 98%,紧接着缓存雪崩,数据库CPU直接100%。查日志发现:15分钟缓存同时过期,500个请求同时查DB,直接打挂。
作为后端,我意识到:缓存设计不是"能用就行",得考虑数据结构、过期策略、降级方案。于是花了3周时间,把"单点缓存"重构为"多数据结构+分层策略+热点隔离"的缓存体系。现在Redis内存占用降40%,缓存命中率99.1%,大促期间稳如老狗。今天把关键实现和踩坑经验整理出来,给做内容型网站的朋友参考。

0x01 选型思考:为什么Redis的"多数据结构"是杀手锏
重构前我分析了图书站的缓存场景:
| 场景 | 数据特点 | 访问模式 | 适合结构 |
|||||
| 图书详情页 | 单Key大Value,更新少 | 随机读,热点集中 | String + 随机TTL |
| 搜索热词榜 | 带权重的集合,频繁更新 | 读多写少,TopN查询 | ZSet |
| 用户浏览历史 | 有序列表,限最近50条 | 尾部插入,范围查询 | List + Trim |
| ISBN去重集合 | 纯Key集合,海量去重 | 写入判重,不关心顺序 | Set / BloomFilter |
| 在线用户统计 | 超基数统计,允许误差 | 高频写入,低频查询 | HyperLogLog |
Redis的核心优势:一套中间件,多种数据结构适配不同场景。相比"所有缓存都用String序列化",针对性选型能让内存、性能、可维护性全面提升。
0x02 核心实现:5种数据结构的实战用法
2.1 String + 随机TTL:防雪崩的"基础操作"
热门图书缓存用String,但过期时间加随机偏移:
utils/cache_helper.py
import random
from django.core.cache import cache
def get_book_detail_cache(isbn: str, timeout_base: int = 900) dict:
"""获取图书详情缓存(防雪崩版)"""
key = f'book:detail:{isbn}'
data = cache.get(key)
if data is None:
缓存未命中,查DB
book = fetch_book_from_db(isbn)
if book:
随机±30%过期时间,避免集体失效
jitter = int(timeout_base 0.3 (random.random() 0.5))
actual_timeout = timeout_base + jitter
cache.set(key, book, timeout=actual_timeout)
return book
return data
关键点:
random.random()0.5生成[0.5, 0.5]的偏移,避免所有缓存同一时刻过期
热点数据可单独配置更长timeout,如timeout_base=3600
2.2 ZSet + 滑动时间窗:实时热词榜
搜索热词需要"最近1小时高频词Top20",用ZSet+时间戳实现滑动窗口:
services/search_hot.py
import time
from django.core.cache import cache
class SearchHotTracker:
def __init__(self, key_prefix='search:hot', window_seconds=3600, top_n=20):
self.key = f'{key_prefix}:{int(time.time()) // window_seconds}'
self.window = window_seconds
self.top_n = top_n
def record(self, keyword: str, weight: float = 1.0):
"""记录一次搜索(带权重)"""
当前时间窗ZSet加分
cache.zincrby(self.key, weight, keyword)
设置过期时间(比窗口略长,避免边界问题)
cache.expire(self.key, self.window + 60)
def get_top(self) list:
"""获取当前TopN热词"""
倒序取TopN,返回(keyword, score)
return cache.zrevrange(self.key, 0, self.top_n1, withscores=True)
def merge_windows(self) list:
"""合并最近2个时间窗,平滑过渡"""
current = self.key
prev_key = f"{self.key_prefix}:{int(time.time()) // self.window 1}"
临时合并计算(实际项目可用Lua脚本原子执行)
temp_key = f'{self.key}:temp'
cache.zunionstore(temp_key, [current, prev_key], aggregate='sum')
result = cache.zrevrange(temp_key, 0, self.top_n1, withscores=True)
cache.delete(temp_key) 清理临时key
return result
效果:
搜索框下拉"猜你想搜"实时响应
时间窗切换时merge_windows避免榜单突变
ZSet底层跳表,TopN查询O(logN + M),性能稳
2.3 List + LTRIM:用户浏览历史"限最近50条"
用户浏览记录需要"最新50条,先进先出",List天然适合:
services/user_history.py
from django.core.cache import cache
class BrowseHistory:
MAX_ITEMS = 50
TTL_DAYS = 7
def __init__(self, user_id: int):
self.key = f'user:{user_id}:browse_history'
def add(self, isbn: str):
"""添加浏览记录(头部插入+自动截断)"""
pipe = cache.pipeline()
左侧插入,避免重复(先移除再插入)
pipe.lrem(self.key, 0, isbn)
pipe.lpush(self.key, isbn)
保留最新50条
pipe.ltrim(self.key, 0, self.MAX_ITEMS1)
设置7天过期,避免僵尸数据
pipe.expire(self.key, self.TTL_DAYS 86400)
pipe.execute()
def get_recent(self, count: int = 10) list:
"""获取最近浏览(按时间倒序)"""
return cache.lrange(self.key, 0, count1)
优势:
lpush+ltrim原子操作,避免并发截断问题
List底层双向链表,头插/尾删O(1)
过期时间+业务限长双重保障,内存可控
2.4 Set + 布隆过滤器:海量ISBN去重的"性价比方案"
防止重复抓取同一ISBN,10万+量级用Set内存占用大,布隆过滤器更划算:
utils/isbn_dedup.py
from pybloom_live import ScalableBloomFilter
from django.core.cache import cache
class ISBNDeduplicator:
def __init__(self, capacity=200000, error_rate=0.001):
self.bloom = ScalableBloomFilter(
initial_capacity=capacity,
error_rate=error_rate,
mode=ScalableBloomFilter.LARGE_SET_GROWTH
)
self.cache_key = 'isbn:bloom:state'
self._load_state()
def _load_state(self):
"""从Redis恢复布隆状态(启动时)"""
state = cache.get(self.cache_key)
if state:
import pickle
self.bloom = pickle.loads(state)
def _save_state(self):
"""定期持久化(Celery Beat每小时执行)"""
import pickle
cache.setex(self.cache_key, 3600, pickle.dumps(self.bloom))
def is_new(self, isbn: str) bool:
"""判断是否新ISBN(返回True表示未见过)"""
if isbn in self.bloom:
return False 可能已存在(有极小误判)
self.bloom.add(isbn)
return True
效果:
20万ISBN内存占用80MB(纯Set需1.6GB)
误判率0.1%可接受(误判只会少抓,不会抓错)
支持动态扩容,不用预估最终数据量
2.5 HyperLogLog:在线用户统计"允许误差换内存"
统计"今日独立访客UV",用HLL以极小内存换近似值:
services/analytics.py
from django.core.cache import cache
class UVTracker:
def __init__(self, date_str: str = None):
from datetime import date
self.date = date_str or date.today().isoformat()
self.key = f'analytics:uv:{self.date}'
def record(self, user_id: str):
"""记录一次访问(自动去重)"""
cache.pfadd(self.key, f'user:{user_id}')
设置30天过期,方便历史查询
cache.expire(self.key, 30 86400)
def get_count(self) int:
"""获取近似UV(误差约0.81%)"""
return cache.pfcount(self.key)
对比:
用Set存100万用户ID:内存~80MB
用HLL存100万用户ID:内存~12KB
误差0.81%对运营统计完全可接受
0x03 踩坑实录:那些文档不会写的"血泪"
坑1:Redis Pipeline的"事务陷阱"
pipeline.execute()不是原子事务,中间失败不会回滚。关键点:
需要原子性时用Lua脚本
lua_script = """
redis.call('LREM', KEYS[1], 0, ARGV[1])
redis.call('LPUSH', KEYS[1], ARGV[1])
redis.call('LTRIM', KEYS[1], 0, tonumber(ARGV[2]))
return 1
"""
script = cache.client.register_script(lua_script)
script(keys=[self.key], args=[isbn, self.MAX_ITEMS])
坑2:缓存穿透的"空值缓存"策略
查不存在的ISBN,反复打DB。解决方案:
空结果也缓存,但TTL更短
if book is None:
cache.set(key, '__NULL__', timeout=60) 1分钟
return None
读取时判断
if data == '__NULL__':
return None
坑3:大Key导致的"Redis阻塞"
某图书详情页序列化后2MB,单次get阻塞Redis主线程。优化:
- 大Value拆分成多个小Key(如
book:detail:{isbn}:basic+:extra) - 或用
SCAN替代KEYS遍历,避免阻塞 - 监控
slowlog,设置slowloglogslowerthan 10000(10ms)
坑4:客户端缓存与Redis不一致
后台更新图书信息,Redis已改但CDN还缓存旧版。解决方案:
更新时主动失效相关缓存
def update_book(isbn, updates):
1. 更新DB
Book.objects.filter(isbn=isbn).update(updates)
2. 删除Redis缓存
cache.delete(f'book:detail:{isbn}')
3. 调用CDN刷新API(异步)
refresh_cdn_async(f'/book/{isbn}')
0x04 效果对比:数据不会骗人
优化前后关键指标(生产环境,日均UV 2万+):
| 指标 | 单String缓存 | 多数据结构策略 | 提升 |
|||||
| Redis内存占用 | 1.8GB | 1.1GB | 39%↓ |
| 缓存命中率 | 94.2% | 99.1% | 5.2%↑ |
| 热点接口P99响应 | 320ms | 89ms | 72%↓ |
| 缓存雪崩事件/月 | 2.3次 | 0次 | 100%↓ |
| DB查询QPS峰值 | 450 | 68 | 85%↓ |
更直观的用户体验:
搜索热词榜"秒更新",用户搜"Python"立刻看到相关推荐
浏览历史"无感记录",不再担心隐私泄露(本地List+7天自动清理)
大促期间首页加载稳定在1.2s内,运营同事终于不@我了😄
0x05 写在最后:缓存设计是"艺术+工程"
这次重构最大的感悟:缓存不是"银弹",而是"权衡"。内存vs命中率、一致性vs性能、精度vs成本,每个选择都有tradeoff。
"新城书站"现在还在持续优化:
正在测试Redis 7的CLIENT TRACKING,实现应用层缓存自动失效
计划用RedisJSON模块替代部分String序列化,减少编解码开销
探索"多级缓存":本地Cache→Redis→DB,进一步降低延迟
如果你也在做内容聚合、高并发检索类项目,或者对Redis工程化实践感兴趣,欢迎来book.cndgn.com体验实际效果(页面加载速度欢迎用Lighthouse虐😄)。核心工具类已整理成gist,后续会开源。
技术人的成就感,不在于用了多牛的工具,而在于用户感知不到的"快"和"稳"。共勉。
声明:本文为个人技术实践分享,所有方案均经过生产环境验证。"新城书站"仅提供信息检索服务,缓存策略优化不涉及任何受版权保护的内容分发。技术探索,合规先行,边界在心。欢迎通过/api/feedback交流缓存设计心得👋。
浙公网安备 33010602011771号