RTA高频请求下的Redis短缓存工程闭环

RTA高频请求下的Redis短缓存工程闭环

1. 背景与问题定义

在实时竞价广告(RTA)系统中,媒体平台会在极短时间内对同一用户多次发起RTA请求。例如,在一次广告曝光机会中可能连续询问多家DSP是否参竞,或者由于重试、多广告位等原因导致同一用户ID在数秒内被重复询价。如果每次请求都直接触发后端的参竞计算(包括用户画像查询、频控判断、预算检查、策略规则等),将导致大量重复计算,增加下游服务压力和响应延迟。

目标:将"是否参竞"的结果写入Redis,并设置短TTL(如5分钟),使得在TTL窗口内相同用户和广告主的重复请求直接命中缓存,无需再次计算。

关键指标(示例,需根据实际环境测定):

  • QPS:媒体侧RTA请求峰值可达数十万QPS;
  • 缓存命中率:目标 >95%;
  • 后端计算耗时:单次参竞计算约 10~50ms;
  • Redis内存占用:取决于Key数量和Value大小;
  • P99延迟:缓存命中时 <1ms,未命中并重建时 <100ms。

本文覆盖范围:

  • 三类缓存故障:穿透、击穿、雪崩;
  • 大Key与热Key的发现与治理;
  • 工程实现与取舍。

2. 核心原理:TTL短缓存如何挡住重复计算

2.1 RTA请求链路

典型的RTA请求经过以下步骤:

  1. 媒体发起RTA请求,包含广告主ID和用户ID等参数;
  2. 网关层接收请求,进行鉴权、限流等;
  3. 缓存查询:根据Key从Redis读取参竞结果;
  4. 若命中,直接返回结果;
  5. 若未命中,调用后端参竞计算服务;
  6. 将计算结果写入Redis,并设置TTL;
  7. 返回结果给媒体。

2.2 5分钟TTL的业务合理性

用户的参竞状态在短时间内通常是稳定的,例如:

  • 频控规则:用户一分钟内只允许展示一次广告;
  • 预算状态:预算消耗是分钟级更新;
  • 用户画像:标签不会秒级变化。

因此,将TTL设为5分钟可以在保证状态新鲜度的同时,大幅降低后端计算压力。过期后重新计算是业务可接受的,因为即使状态有微小变化,对广告投放的影响也有限。

2.3 缓存Key设计

采用层级命名:

css 复制代码
rta:bid:{advertiser_id}:{user_id}

Value使用JSON序列化,包含参竞结果(bid/no-bid)和时间戳:

json 复制代码
{"decision": "bid", "ts": 1710000000}

时间戳用于后续逻辑过期判断。

2.4 伪代码示例

读路径:

sql 复制代码
result = redis.get(key)
if result:
    return deserialize(result)
else:
    result = compute_decision(advertiser_id, user_id)
    redis.set(key, serialize(result), ex=ttl)
    return result

写路径:直接set并设置TTL。

2.5 TTL选择权衡

  • TTL过短(如10秒):缓存命中率低,后端仍承受大量计算;
  • TTL过长(如1小时):状态可能过期,影响广告投放精准度;
  • 需要结合实际业务状态变化频率和容忍度进行压测调优,5分钟是一个常见起点。

3. 完整代码:从缓存读写到三类故障防护

本节提供基于Python + redis-py 的完整可运行示例,集成穿透、击穿、雪崩防护,并封装统一入口。

3.1 环境准备

依赖安装:

bash 复制代码
pip install redis

3.2 基础配置与连接池

python 复制代码
import redis
import json
import time
import random
import hashlib
import threading
from typing import Any, Callable, Optional

# Redis连接配置
REDIS_HOST = '127.0.0.1'
REDIS_PORT = 6379
REDIS_DB = 0
REDIS_PASSWORD = None

# 连接池
pool = redis.ConnectionPool(
    host=REDIS_HOST,
    port=REDIS_PORT,
    db=REDIS_DB,
    password=REDIS_PASSWORD,
    decode_responses=False,  # 使用字节,便于自定义序列化
    max_connections=100,
    socket_timeout=5,
    socket_connect_timeout=5
)
redis_client = redis.Redis(connection_pool=pool)

3.3 序列化与反序列化

python 复制代码
def serialize(data: dict) -> str:
    return json.dumps(data)

def deserialize(data: bytes) -> dict:
    return json.loads(data.decode('utf-8'))

3.4 布隆过滤器(基于Redis bitmap)

实现一个简单的布隆过滤器,用于缓存穿透预判。使用多个哈希函数和Redis的setbit/getbit。

python 复制代码
class SimpleBloomFilter:
    def __init__(self, redis_conn, key: str, bit_size: int = 10000000, hash_count: int = 5):
        self.redis_conn = redis_conn
        self.key = key
        self.bit_size = bit_size
        self.hash_count = hash_count

    def _hashes(self, item: str):
        # 使用双重哈希生成多个哈希值
        h1 = int(hashlib.md5(item.encode('utf-8')).hexdigest(), 16)
        h2 = int(hashlib.sha256(item.encode('utf-8')).hexdigest(), 16)
        for i in range(self.hash_count):
            yield (h1 + i * h2) % self.bit_size

    def add(self, item: str):
        for offset in self._hashes(item):
            self.redis_conn.setbit(self.key, offset, 1)

    def exists(self, item: str) -> bool:
        for offset in self._hashes(item):
            if not self.redis_conn.getbit(self.key, offset):
                return False
        return True

# 初始化布隆过滤器(全局单例)
bloom = SimpleBloomFilter(redis_client, 'bloom:rta:users')

3.5 缓存空值

当后端计算返回"不参竞"或空结果时,也缓存一个特殊标记,防止穿透。

python 复制代码
NULL_CACHE_VALUE = '__NULL__'
NULL_CACHE_TTL = 60  # 空值缓存较短TTL

3.6 互斥锁重建(击穿防护)

使用 SET key value NX PX 实现分布式锁,保证同一时刻只有一个请求重建缓存。

python 复制代码
LOCK_TTL_MS = 30000  # 锁超时30秒
LOCK_RETRY_TIMES = 3
LOCK_RETRY_SLEEP = 0.1  # 重试间隔100ms

def acquire_lock(lock_key: str, token: str) -> bool:
    return redis_client.set(lock_key, token, nx=True, px=LOCK_TTL_MS)

def release_lock(lock_key: str, token: str):
    # 使用Lua脚本保证原子性:仅当锁持有者匹配时删除
    lua_script = """
    if redis.call('get', KEYS[1]) == ARGV[1] then
        return redis.call('del', KEYS[1])
    else
        return 0
    end
    """
    redis_client.eval(lua_script, 1, lock_key, token)

def get_with_lock(key: str, ttl: int, loader: Callable[[], Any], lock_key: str) -> Any:
    token = hashlib.md5(f"{threading.get_ident()}-{time.time()}".encode()).hexdigest()
    for _ in range(LOCK_RETRY_TIMES):
        if acquire_lock(lock_key, token):
            try:
                # 双重检查:可能其他线程已重建
                cached = redis_client.get(key)
                if cached is not None:
                    return deserialize(cached)
                # 执行加载
                value = loader()
                if value is None or value == {}:
                    # 缓存空值
                    redis_client.set(key, serialize({'decision': NULL_CACHE_VALUE, 'ts': int(time.time())}), ex=NULL_CACHE_TTL)
                else:
                    redis_client.set(key, serialize(value), ex=ttl)
                return value
            finally:
                release_lock(lock_key, token)
        else:
            time.sleep(LOCK_RETRY_SLEEP)
    # 获取锁失败,降级为直接读缓存(可能已重建)或返回旧值
    cached = redis_client.get(key)
    if cached is not None:
        return deserialize(cached)
    # 最终降级:直接执行加载(不再加锁,可能导致并发但保证可用)
    return loader()

3.7 逻辑过期(击穿防护另一种实现)

在Value中内嵌过期时间字段,读取时判断是否逻辑过期,若过期则异步重建,同时返回旧值。

python 复制代码
LOGICAL_EXPIRE_FIELD = 'expire_at'

def get_with_logical_expire(key: str, ttl: int, loader: Callable[[], Any], lock_key: str) -> Any:
    cached_bytes = redis_client.get(key)
    if cached_bytes is not None:
        cached_data = deserialize(cached_bytes)
        current_time = time.time()
        if cached_data.get(LOGICAL_EXPIRE_FIELD, 0) > current_time:
            # 未逻辑过期,直接返回
            return cached_data['value']
        # 逻辑过期,尝试获取互斥锁进行异步重建
        token = hashlib.md5(f"{threading.get_ident()}-{time.time()}".encode()).hexdigest()
        if acquire_lock(lock_key, token):
            # 启动线程异步重建
            def rebuild():
                try:
                    new_value = loader()
                    # 重新设置缓存,包含新的逻辑过期时间
                    cache_payload = {
                        'value': new_value,
                        LOGICAL_EXPIRE_FIELD: time.time() + ttl
                    }
                    redis_client.set(key, serialize(cache_payload))
                finally:
                    release_lock(lock_key, token)
            threading.Thread(target=rebuild, daemon=True).start()
        # 返回旧值(即使过期,作为兜底)
        return cached_data['value']
    else:
        # 缓存不存在,使用互斥锁同步重建
        return get_with_lock(key, ttl, loader, lock_key)

注意:逻辑过期方案需要预先填充缓存,否则首次请求仍走互斥锁路径。

3.8 雪崩防护:TTL随机抖动

在设置TTL时加入随机偏移,避免大量Key同时过期。

python 复制代码
def jittered_ttl(base_ttl: int, jitter_range: int = 60) -> int:
    return base_ttl + random.randint(-jitter_range, jitter_range)

3.9 统一封装 get_or_compute

整合以上所有防护,提供统一入口。

python 复制代码
def get_or_compute(
    key: str,
    ttl: int,
    loader: Callable[[], Any],
    use_bloom: bool = True,
    use_lock: bool = True,
    lock_mode: str = 'mutex',  # 'mutex' 或 'logical'
    cache_null: bool = True,
    jitter: int = 60
) -> Any:
    # 1. 穿透防护:布隆过滤器预判
    if use_bloom:
        # 假设key中的user_id部分需要检查,这里简化:提取user_id
        # 实际应用中需从key中解析出user_id
        user_id = key.split(':')[-1]
        if not bloom.exists(user_id):
            # 用户不存在,直接返回不参竞(或空)
            return None

    # 2. 查询缓存
    cached_bytes = redis_client.get(key)
    if cached_bytes is not None:
        cached_data = deserialize(cached_bytes)
        # 如果是空值标记
        if cached_data.get('decision') == NULL_CACHE_VALUE:
            return None
        return cached_data['decision']

    # 3. 未命中,根据锁模式处理击穿
    lock_key = f"lock:{key}"
    if lock_mode == 'mutex':
        value = get_with_lock(key, ttl, loader, lock_key)
    elif lock_mode == 'logical':
        value = get_with_logical_expire(key, ttl, loader, lock_key)
    else:
        # 无击穿防护,直接加载
        value = loader()
        if value is None or value == {}:
            if cache_null:
                redis_client.set(key, serialize({'decision': NULL_CACHE_VALUE, 'ts': int(time.time())}), ex=NULL_CACHE_TTL)
        else:
            effective_ttl = jittered_ttl(ttl, jitter)
            redis_client.set(key, serialize({'decision': value, 'ts': int(time.time())}), ex=effective_ttl)
        return value

    # 4. 统一返回处理
    if value is None or value == {}:
        return None
    if isinstance(value, dict):
        return value.get('decision')
    return value

3.10 业务加载函数示例

python 复制代码
def compute_decision(advertiser_id: str, user_id: str) -> dict:
    # 模拟后端计算:查询数据库、执行策略等
    # 返回 {"decision": "bid"} 或 {"decision": "no-bid"} 或 {}
    # 实际场景应替换为真实逻辑
    time.sleep(0.02)  # 模拟20ms计算
    # 假设50%概率参竞
    if random.random() < 0.5:
        return {"decision": "bid"}
    else:
        return {"decision": "no-bid"}

3.11 使用示例

python 复制代码
if __name__ == '__main__':
    advertiser_id = "10001"
    user_id = "u123456"
    key = f"rta:bid:{advertiser_id}:{user_id}"
    ttl = 300  # 5分钟

    # 预先将已知用户加入布隆过滤器(需在用户数据初始化时完成)
    bloom.add(user_id)

    # 并发模拟
    import concurrent.futures
    def worker():
        return get_or_compute(key, ttl, lambda: compute_decision(advertiser_id, user_id),
                              use_lock=True, lock_mode='mutex', jitter=60)
    with concurrent.futures.ThreadPoolExecutor(max_workers=20) as executor:
        futures = [executor.submit(worker) for _ in range(100)]
        results = [f.result() for f in futures]
    print(f"Completed {len(results)} requests")

3.12 异常处理与降级

在Redis不可用时,应降级为直接计算,保证业务可用。

python 复制代码
try:
    result = get_or_compute(...)
except redis.exceptions.RedisError as e:
    # 记录日志,降级直算
    result = loader()

4. Benchmark与实测数据

注意:本节提供测试方法和示例数据展示对比趋势,实际数值必须根据生产环境实测,不可直接引用。

4.1 压测场景设计

模拟媒体短时间对同一用户重复请求,对比三种方案:

  • 方案A:无缓存,每次请求直接计算;
  • 方案B:纯TTL缓存(无穿透、击穿、雪崩防护);
  • 方案C:带完整防护的TTL缓存(布隆过滤器+互斥锁+TTL抖动)。

请求分布:热点用户(约20%的用户产生80%的请求)与长尾用户结合,模拟真实流量。

4.2 测试环境(示例)

  • Redis版本:7.2(使用RedisBloom模块可选)
  • 服务器:8核16GB内存,SSD
  • 并发数:模拟1000并发连接
  • 测试工具:自定义压测脚本(Python asyncio或wrk)
  • 单次压测时长:10分钟
  • 后端计算延迟:模拟10~50ms随机

4.3 指标对比(示例数据)

指标 方案A 无缓存 方案B 纯TTL缓存 方案C 带防护
QPS(峰值) 约5千 约5万 约8万
缓存命中率 0% 95% 97%
后端计算调用次数 100%请求 5%请求(未命中) 3%请求(未命中且穿透防护过滤)
P50延迟 25ms 2ms(命中)/30ms(未命中) 2ms(命中)/35ms(未命中,锁竞争)
P99延迟 60ms 10ms 12ms
Redis CPU - 30% 40%(布隆过滤器与锁操作)
Redis内存 - 200MB 250MB(含布隆过滤器bitmap)

分析:

  • 短TTL缓存大幅削减重复计算,将后端调用从100%降至5%左右;
  • 互斥锁在击穿场景下会导致少量请求等待,P99略有上升,但避免后端瞬时高并发;
  • 随机抖动将过期时间分散,避免同一时刻大量Key失效引发雪崩;
  • 布隆过滤器增加少量内存和CPU,但显著降低穿透风险。

4.4 布隆过滤器误判率与内存开销

对于1000万用户,使用1亿bit(约12.5MB)和5个哈希函数,误判率约为0.1%。实际可通过公式计算:

  • 位数组大小 m,元素个数 n,哈希函数个数 k,误判率 p ≈ (1 - e^(-kn/m))^k。
  • 选择合适参数平衡内存和误判率。RedisBloom模块提供更高效的实现。

5. 大Key与热Key的发现与治理

5.1 发现手段

  • redis-cli --bigkeys:扫描所有Key,统计大Key(如Value超过10KB或元素数超过阈值);
  • redis-cli --hotkeys:需要Redis配置LFU淘汰策略(maxmemory-policy allkeys-lfu),可以统计访问频率最高的Key;
  • SLOWLOG GET:查看执行时间超过阈值的命令,大Key操作往往导致慢查询;
  • 监控采样:通过客户端埋点统计每个Key的访问次数和大小,上报监控系统;
  • 代理层统计:如果使用Codis、Twemproxy等,可在代理层统计热点。

5.2 大Key治理

在RTA场景中,如果使用一个Hash存储所有广告主的用户参竞结果,会导致大Key。应避免此类设计。治理措施:

  • 拆分Key :按用户维度或广告主维度拆分,例如 rta:bid:{advertiser_id}:{user_id} 已经是细粒度Key,避免单个Key存储大量数据;
  • 压缩Value:使用更紧凑的序列化格式(如MessagePack、Protobuf)替代JSON,减少内存占用;
  • 避免单Key存储集合:如果需要存储某个广告主的所有用户结果,使用Set或Hash时需分片,例如按用户ID哈希取模分片到不同Key。

5.3 热Key治理

热点Key(如某个热门用户或广告主)可能被频繁访问,导致Redis单节点压力过大。治理方案:

  • 本地缓存:在应用进程内使用Caffeine(Java)或Python的cachetools作为一级缓存,设置极短TTL(如1秒),将热点数据缓存在本地,减少Redis访问;
  • 多级缓存架构:本地缓存 -> Redis -> 后端计算,逐级回退;
  • Key打散与副本 :为热点Key创建多个副本,例如 rta:bid:hot:{user_id}:0、rta:bid:hot:{user_id}:1,客户端随机选择一个副本访问,分散到不同Redis节点(适用于集群模式)。

5.4 本地缓存 + Redis 多级缓存代码片段

Python示例使用 cachetools 实现简单的本地TTL缓存:

python 复制代码
from cachetools import TTLCache, cached

# 本地缓存,容量10000条,TTL 1秒
local_cache = TTLCache(maxsize=10000, ttl=1)

def get_with_local_cache(key: str, ttl: int, loader: Callable[[], Any]) -> Any:
    # 先查本地缓存
    if key in local_cache:
        return local_cache[key]
    # 查Redis
    try:
        result = get_or_compute(key, ttl, loader)
    except redis.exceptions.RedisError:
        # Redis不可用,降级直算
        result = loader()
    # 写入本地缓存(短TTL)
    local_cache[key] = result
    return result

Java中使用Caffeine类似:

java 复制代码
import com.github.benmanes.caffeine.cache.Cache;
import com.github.benmanes.caffeine.cache.Caffeine;

Cache<String, Object> localCache = Caffeine.newBuilder()
    .maximumSize(10_000)
    .expireAfterWrite(1, TimeUnit.SECONDS)
    .build();

5.5 一致性处理

短TTL天然容忍短暂不一致,本地缓存与Redis之间允许秒级延迟。当本地缓存过期后,会重新从Redis获取最新值。无需复杂的一致性协议。

5.6 容量规划与监控告警

  • 估算内存:每个Key约100字节,1000万用户约1GB;布隆过滤器额外12.5MB;加上副本,合理预留2~3倍。
  • 监控指标:缓存命中率、Redis内存使用率、慢查询数量、热Key数量、网络流量。
  • 告警阈值:内存使用率 >80%,慢查询 >100次/分钟,命中率骤降等。

6. 工程边界与取舍

6.1 短TTL缓存的适用边界

短TTL缓存适用于:

  • 状态在短时间内稳定(如RTA参竞决策);
  • 可容忍过期后重新计算带来的延迟;
  • 非强一致场景,允许短暂的数据不一致。

不适用于:

  • 实时性要求极高的数据(如余额、库存);
  • 状态变化频繁且每次变化都需要立即生效。

6.2 缓存与数据库一致性

在RTA场景中,源数据可能来自用户画像库、频控服务等。常见策略:

  • Cache Aside:先读缓存,未命中读源并回写;更新时先更新源,再删除缓存或等待TTL过期。短TTL下,即使更新后缓存未失效,也最多延迟5分钟,可接受。
  • Write Through:写入时同时更新缓存和源,但会降低写性能,对于读多写少的RTA场景不必要。
  • 由于短TTL,最终一致性可以满足业务需求。

6.3 互斥锁的可用性风险

  • 锁超时:如果业务逻辑执行超过锁的TTL,锁可能被自动释放,导致其他线程获取锁并重复执行。需要合理设置锁超时时间,并确保业务逻辑可重入或幂等。
  • 死锁:锁持有者异常退出未释放锁,需要依靠锁超时自动过期;但可能造成短暂阻塞。可以使用分布式锁框架(如Redisson)提供的 watchdog 自动续期。
  • Redis单点故障:如果Redis不可用,锁操作会失败。需要降级方案:直接执行加载(可能并发),或者使用本地锁。在代码中已提供降级路径。

6.4 布隆过滤器的维护成本

  • 用户集合更新:新增用户需要加入布隆过滤器,删除用户无法直接移除(布隆过滤器不支持删除),需要通过重建或使用可布隆过滤器变种(如 Counting Bloom Filter)。
  • 误判:即使不存在的用户也可能被判断为存在,导致缓存穿透防护失效,但概率可控。需要定期评估误判率,必要时重建布隆过滤器。
  • 初始化:需要预先加载所有有效用户ID到布隆过滤器,可以批量导入。

6.5 容量与成本估算

  • 内存占用:假设每个Key的Value约100字节,1000万Key约1GB;加上Redis内部开销,实际约1.5GB。布隆过滤器12.5MB。如果使用本地缓存,每个实例还需额外几十MB。
  • Redis集群规格:根据QPS和内存需求选择,例如3主3从,每个节点8GB内存。
  • 本地缓存内存上限:设置 maxsize,避免占用过多应用内存。

7. 总结与落地清单

7.1 核心价值回顾

RTA高频请求下,5分钟短TTL缓存以极小的新鲜度代价,将重复计算挡在缓存层,显著降低后端压力,提升系统吞吐量。

7.2 三类故障防护要点

  • 缓存穿透:使用布隆过滤器预判不存在的用户;缓存空值。
  • 缓存击穿:互斥锁重建(SET NX PX)保证单线程重建;逻辑过期方案允许返回旧值并异步重建。
  • 缓存雪崩:TTL随机抖动,避免同时过期;批量预热与错峰过期。

7.3 上线检查清单

  • Key设计合理,避免大Key
  • TTL设置合理(如5分钟)并加入随机抖动(±60秒)
  • 布隆过滤器已初始化并包含所有有效用户
  • 互斥锁实现正确,有锁超时和降级逻辑
  • 空值缓存已启用,TTL较短
  • 大Key/热Key监控已配置(bigkeys、hotkeys、slowlog)
  • 本地缓存容量限制已设置
  • Redis故障降级方案已验证
  • 压测验证缓存命中率和后端负载

7.4 后续优化方向

  • 多级缓存:引入本地缓存进一步提升性能。
  • 异步重建:使用消息队列或线程池进行缓存预刷新,避免请求阻塞。
  • 自适应TTL:根据访问频率动态调整TTL,热点数据适当延长,冷数据缩短。
  • 使用RedisBloom模块:更高效的布隆过滤器,减少内存和CPU。

整体总结:本文从RTA业务场景出发,系统阐述了短TTL缓存的设计、实现与故障防护,提供了完整的代码示例和工程实践要点,帮助读者构建高可用的缓存层。

相关推荐
用户0942485680320 分钟前
第19章:库存扣减与秒杀系统:Redis 扛峰值流量
redis·架构·源码
没有气的汽水28 分钟前
redis加了个分片,把线上写挂了一个多小时
redis
xixiaoyunya2 小时前
Redis缓存三大问题实战:穿透、击穿、雪崩,我用一个电商秒杀场景讲清楚
数据库·redis·缓存
鶴哥只手遮天3 小时前
《Redis 8.10.3 深度剖析》系列 · 第 1 章·全景与源码地图:版本谱系、构建体系、全局状态与启动链路
redis·后端·开源
70后写java的老码农3 小时前
docker-compose 部署 Redis Stack Server 6.2.6-v19
redis·docker
小南家的青蛙5 小时前
Redis监控与告警体系化实践
redis·监控方案·告警方案
我不会起名字3225 小时前
Redis 从 0 到 1(二十七):JSON 体积是 Protobuf 的 3.7 倍?3 种序列化的 Go 基准测试
redis·go·序列化·protobuf·messagepack
我不会起名字3225 小时前
Redis 从 0 到 1(二十六):INFO memory 里 8 个关键数字——内存构成与容量规划
redis·容量规划·内存·info·listpack