作者:一个被缓存击穿搞到凌晨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%,大促期间稳如老狗。今天把关键实现和踩坑经验整理出来,给做内容型网站的朋友参考。
1 (8)low

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主线程。优化:

  1. 大Value拆分成多个小Key(如book:detail:{isbn}:basic + :extra
  2. 或用SCAN替代KEYS遍历,避免阻塞
  3. 监控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交流缓存设计心得👋。

posted on 2026-03-06 12:53  yqqwe  阅读(1)  评论(0)    收藏  举报