晚上八点,一部热门剧更新。用户点下播放键时,他只看到一个转圈动画;服务端看到的却是一串必须迅速作答的问题:这个会话还有效吗?用户有播放权限吗?视频是否已经下架?清晰度和字幕有哪些?播放地址该签发多久?这次观看要不要计数?下一集又该推荐什么?
这些问题不能都压在同一条请求里。如果播放接口等着数据库查完、计数写完、观看历史落库、推荐模型跑完才返回,任何一个慢点都会变成首帧延迟。更麻烦的是,热门内容会把同一个视频编号放大成几十万次重复查询。
Redis 在这个场景里的价值,不是笼统地“把所有数据放进内存”,而是把不同性质的工作拆开:短期状态放在离请求最近的地方,重复读取用缓存吸收,排序和计数使用合适的数据结构,观看事件交给异步消费者。真正不能丢、不能错的数据,仍由主数据库或专门的事件系统承担最终责任。

一次播放请求背后的同步响应链路与异步数据处理链路,主数据库保存业务事实,缓存负责加速元数据读取。
设计视频平台时,可以先问一句:用户看到首帧之前,哪些结果非等不可?会话、播放权限、视频状态和播放地址通常在关键路径上;观看计数、历史汇总和推荐特征大多可以异步完成。先划清这条线,Redis 才不会从加速器变成新的耦合中心。
我们先不急着选数据结构,沿着一次真实请求走一遍。
用户携带会话 Cookie 请求 POST /videos/v-2048/play。网关先做基础限流,应用再从 Redis 读取服务端会话,得到用户编号和当前权限版本。随后,播放服务读取视频元数据缓存,确认视频已发布、所在地区可播放,并向媒体服务申请一个短时有效的播放地址。到这里,接口就可以返回了。
“用户开始观看”则是另一个故事。应用把一个尽量小的事件追加到 Streams,计数服务、历史服务和推荐特征服务各自消费。计数稍晚几秒出现通常可以接受;首帧晚几秒,用户已经关掉页面了。
一次播放请求可以按下面的顺序判断:
先验证请求是否合法,包括会话、播放权限和限流。失败就尽早返回,不要先查视频详情,更不要先写观看事件。
再读取播放所需的最小元数据,例如发布状态、地区限制、媒体版本和可选清晰度。评论、完整作者主页等信息不该挤进播放关键路径。
生成短时播放地址并返回,让客户端尽快拿到首帧。播放地址本身要有独立的过期和权限约束,不能因为会话还在就永久有效。
最后追加观看事件,把计数、历史、排行、推荐特征等工作交给异步消费者。追加失败时应有明确策略,不能悄悄假装已经记录成功。
这里有一个常见误区:把“Redis 很快”理解为“所有 Redis 操作都可以放进关键路径”。一次请求依次做十几次 Redis 往返,同样会被网络往返、连接等待和热点键拖慢。关键路径应该短,而且每一次读取都要有明确用途。
视频平台经常同时出现“会话”“访问令牌”“JWT”几个词,概念一混,安全边界就会跟着混。
这一章采用的是服务端会话:浏览器保存一段不可猜测的随机会话编号,Redis 保存这段编号对应的用户状态。浏览器里的编号本身不包含用户编号、角色或会员等级,它只是服务器查找会话的钥匙。退出登录时删除 Redis 键,服务端便能立即让这把钥匙失效。
JWT 是另一种设计。它通常把声明放在令牌内部并由服务器验签,很多判断可以不查询会话存储。它不是“存进 Redis 的随机字符串”的高级叫法。如果系统为了立即撤销 JWT 而在 Redis 维护拒绝列表,那是给自包含令牌补充服务端状态,也不等同于本节的服务端会话。

随机会话编号只是查找服务端会话的索引;登录与权限提升后应轮换编号,并同时执行空闲过期、绝对过期和退出删除策略。
假设键名是 session:{sid},值可以包含以下小字段:
user_id:用户的内部编号。auth_version:权限版本。用户改密码、封禁账号或撤销全部设备时,提升数据库中的版本即可让旧会话失效。created_at:创建时间,用于绝对过期。last_seen_at:最近活动时间,用于审计或风险判断。device_label:经过控制的设备类别,不保存没必要的完整设备指纹。完整个人资料、观看历史和大段权限列表不适合塞进每个会话。会话越大,内存越贵,读取越慢,泄露后的影响面也越大。
下面是一个简化的 Python 例子。它使用 SET 的过期参数原子地创建会话,读取时用 GETEX 同时取值并刷新空闲过期。绝对过期仍由应用单独检查,所以一个持续活跃的会话也不会永久存活。
import json
import secrets
import time
IDLE_TTL_SECONDS = 30 * 60
ABSOLUTE_TTL_SECONDS = 24 * 60 * 60
def create_session(redis_client, user_id: str, auth_version: int) -> str:
sid = secrets.token_urlsafe(32)
now = int(time.time())
payload = {
代码只解释数据流,不是完整的登录框架。实际系统还要处理会话固定攻击、跨站请求伪造、设备撤销和高风险操作的再次认证。
浏览器 Cookie 至少应通过 HTTPS 发送,并根据登录流程配置 HttpOnly、Secure、SameSite 和合适的 Path。登录成功、权限提升和找回密码后要轮换会话编号;退出登录既清 Cookie,也删除服务端会话。日志里不要记录完整会话编号,可以记录不可逆摘要用于排查同一会话的请求链路。
“随机编号里没有敏感字段”不等于“泄露了也不能用”。服务端会话编号通常是持有者凭证,攻击者拿到它就可能直接冒充用户。真正的保护来自高强度随机数、HTTPS、安全 Cookie、短空闲期限、绝对期限、及时轮换和服务端撤销。
会话是关键路径,不能简单地把 Redis 异常当成“用户未登录”,否则基础设施故障会让全站用户集体退出。更稳妥的做法是区分“会话不存在”和“会话存储超时”:前者返回未登录,后者触发短暂重试、故障页或受限的只读降级,并保护后端不被重试风暴打垮。
对于已经签发且仍有效的短时媒体地址,客户端可以继续播放;新建播放、评论或付费操作则应根据风险选择失败关闭。安全边界不能为了可用性被悄悄跳过。
视频标题、封面地址、发布状态、媒体版本、时长和字幕列表会被反复读取,却不会每秒变化。它们很适合使用 cache-aside,也就是常说的旁路缓存。
直觉上,你可以把 Redis 看成前台抽屉,主数据库看成档案室。读请求先拉抽屉:找到就直接用;找不到才去档案室复印一份放回来。更新时先改档案室,再把旧复印件扔掉。下一次读取会重新复印最新版本。

旁路缓存的主线是读时回填、写后主动失效;过期时间负责兜底,热点重建则用短锁限制为单个请求执行。
一个更完整的读路径包含这些判断:
cache:video-meta:v3:{video_id}。模式版本放进键名,能让结构升级更容易隔离。下面的示例保留了最关键的并发边界。释放锁用 Lua 比较唯一值,避免第一个请求执行太久、锁已经过期并被第二个请求取得后,第一个请求又误删第二个人的锁。
import json
import random
import secrets
import time
RELEASE_LOCK = """
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
end
return 0
"""
def get_video_meta(redis_client, database, video_id: str):
cache_key = f"cache:video-meta:v3:{video_id}"
lock_key = f"lock:video-meta:{video_id}"
这个版本仍有权衡。锁等待者最后直接查数据库,是为了控制等待时间;极端高并发下,它仍可能放大数据库压力。生产实现通常还会加入全局并发上限、带截止时间的重试、旧值兜底、指标记录,以及按视频热度调整提前刷新策略。
视频被下架时,如果只等五分钟 TTL 自然过期,五分钟就是不可接受的错误窗口。旁路缓存通常按下面的顺序更新:
先在主数据库完成事务提交,让事实源成为新版本。
再删除对应缓存键,而不是猜测所有字段并覆盖旧值。删除失败必须记录,并通过重试任务、变更日志或消息订阅补偿。
下一次读取未命中后,从主数据库加载新值并回填。TTL 继续存在,但它承担的是故障兜底和内存生命周期,不是唯一的一致性手段。
并发下还有一个细小却真实的窗口:旧读请求先查数据库,更新请求随后提交并删除缓存,旧读请求最后才把旧数据回填。对“标题晚几秒更新”可以接受,对“视频已下架”就不行。处理办法包括缓存值带数据库版本并做条件写、更新后延迟二次删除、由变更日志统一失效,或者让强一致字段在关键路径直接查权威服务。
不要把“最终会过期”当成内容下架、地区禁播或付费权限的安全保证。缓存可以加速权限判断,但真正的播放地址还应短时有效,并由媒体层再次执行必要约束。
下面的实验把几种事故放在同一个请求模型里。先切换场景,再调高流量或数据库耗时,你会看到“一个热键失效”和“大批键同时失效”为什么需要不同的保护手段。
这四个问题在事故复盘里经常被统称为“缓存崩了”,但处理方式并不相同。

缓存穿透、击穿与雪崩看起来都在回源,但触发范围不同,防护手段也不能互换。
有人不断请求随机的视频编号。如果 Redis 不缓存不存在的结果,每个请求都会继续查询数据库。解决方法不是把空值缓存一年,而是分层处理:入口先校验编号格式;确实不存在的记录缓存很短的哨兵;数据规模很大时可用布隆过滤器挡住大部分确定不存在的编号;同时对来源和接口限流。
空值 TTL 要比正常值短。否则一个刚创建的视频可能长时间被旧的“查无此物”遮住。创建流程也要主动删除相应空值键。
热门剧集的元数据键过期时,几万个请求同时发现未命中,一起冲向数据库。这是单个热点的并发重建问题。常见办法是请求合并、带超时的短锁、逻辑过期配合后台提前刷新,或者在业务容忍范围内返回旧值。
锁不是越久越安全。锁时间短于数据库最慢读取,可能出现多名重建者;锁时间过长,重建者崩溃后其他请求又会长时间等候。要根据尾延迟设置,并让等待有上限。
大量键使用完全相同的 TTL,会在整点一起过期;一个缓存分片故障,也会让一大片键同时未命中。对策包括 TTL 抖动、分批预热、容量冗余、故障转移、后端并发闸门和明确的降级页面。单键锁只能处理单键,救不了成千上万个不同键同时失效。
在分片 Redis 中,一个键只落在一个分片。某个超级热视频的计数或元数据每秒被访问几十万次,集群总 CPU 看着不高,单个分片却可能打满。可以用应用内极短缓存吸收重复读,把精确计数改为分桶后异步合并,或将可拆分的数据按时间窗、地区等维度分散。
不要为了“打散”而随意复制需要强一致的数据。复制热点读值需要配套失效;拆分计数则意味着查询时要聚合。优化热点键,本质上是在一致性、读取成本和单分片压力之间重新做选择。
同样是“保存视频相关数据”,元数据、计数、历史和排行的访问方式完全不同。选型应该从要执行的动作出发,而不是从字段长什么样出发。

Redis 负责在线加速与短期状态,主数据库保存业务事实,观看事件持续进入分析仓;观看历史等持久数据不能只保留在 Redis 中。
选型时别只背“排行榜用有序集合”。在下面的沙盘里先选择业务,再判断在线结构和事实源,看看你是否把访问模式、持久性与失败边界一起考虑了。
可以用 history:{user_id} 保存最近观看,成员是视频编号,分数是最近观看时间。再次观看同一视频时,ZADD 会更新分数并把它移到前面;读取最近 50 条使用倒序范围;再用 ZREMRANGEBYRANK 裁掉更早数据。
def touch_history(redis_client, user_id: str, video_id: str, watched_at_ms: int):
key = f"history:{user_id}"
pipe = redis_client.pipeline(transaction=False)
pipe.zadd(key, {video_id: watched_at_ms})
pipe.zremrangebyrank(key, 0, -201) # 只保留最近 200 个不同视频
pipe.expire(key, 90 * 24 * 60 * 60)
这里明确用了非事务 pipeline。它的作用是把三条命令批量发送、减少网络往返,并不保证中间没有其他客户端命令插入,也不保证“全部成功或全部不执行”。短暂并发最多让裁剪或过期稍晚,业务能接受,所以性能优先。
如果你的规则要求“写入、裁剪、设置过期必须作为一个不可穿插的操作”,应使用 MULTI/EXEC 事务或 Lua 脚本。Redis 事务保证队列中的命令连续执行,但不像关系型数据库那样在运行期错误后自动回滚。pipeline 和事务解决的不是同一个问题,只是某些客户端把事务接口包装在 pipeline 对象里,名字看起来容易误导。
收藏夹又不同。用户点了收藏,通常期待几年后仍在、多设备一致,还可能涉及合规导出和删除。正确做法通常是先把收藏关系写入持久数据库,再删除或更新 Redis 在线索引;不能只往一个无持久保证的有序集合里写完就宣布成功。
INCR 和 HINCRBY 可以原子累加,不会因为两个请求同时读旧值而少算一次。但视频平台首先要定义什么叫一次播放:拿到播放地址算不算?播放超过几秒算不算?同一用户刷新十次怎么算?机器人流量怎么办?
一个实用方案是先记录带事件编号的观看事件,消费者做幂等去重,再把短窗口增量累加到 Redis,定时批量汇总进持久存储。页面显示的“实时播放量”允许短暂滞后,结算或创作者收益则使用经过清洗、可追溯的数据。
热门榜可以用 rank:video:hour:2026081320 这样的有序集合。成员是视频编号,分数是当前小时的有效观看增量。ZINCRBY 更新分数,ZRANGE ... REV 取前 N 名,整小时键设置保留期。
只维护一个永不过期的总榜会让老视频永久占优。实际排行常把最近几小时的窗口聚合,并加入时间衰减、完播率或负反馈。Redis 能维护排序,但“什么算热门”仍是产品和数据问题。
下面的 Lua 脚本把“第一次计数时设置过期”和“判断是否超限”放在同一次原子执行中,避免 INCR 成功而 EXPIRE 因连接中断没有执行。
-- KEYS[1]:例如 rate:play:{user_id}:202608132015
-- ARGV[1]:窗口秒数,ARGV[2]:允许次数
local current = redis.call('INCR', KEYS[1])
if current == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
if current > tonumber(ARGV[2]) then
return {0, current}
end
return固定窗口在两个窗口交界处可能允许短时间双倍流量。需要更平滑时,可以使用滑动窗口、令牌桶或专用限流能力,但它们的内存和计算成本也更高。限流键还要选择用户、IP、设备或视频等维度,避免一个出口网络里的正常用户互相误伤。
“用 Redis 做推荐”很容易被说成一句空话。推荐至少包含候选召回、过滤、打分、重排和兜底。Redis 擅长的是在很短时间里拿到一批在线候选和近期特征,不会替你自动决定用户喜欢什么。
可以准备几类候选集合:
candidate:channel:{channel_id}:频道近期优质内容。candidate:tag:{tag_id}:标签相关内容。candidate:trend:{region}:地区热门内容。candidate:continue:{user_id}:用户未看完的内容。feature:recent:{user_id}:经过最小化处理的近期兴趣摘要。请求到来时,召回层取少量候选,排除已下架、年龄不合适、地区不可播和已经明确“不感兴趣”的内容,再由轻量打分或模型服务重排。最终只返回几十条,不要从超大集合一次性取出几万条再在应用里排序。
Sorted Set 适合候选自带在线分数的场景;Set 适合去重和集合运算;需要语义相似召回时,也可以使用向量索引配合结构化过滤。无论采用哪种召回,视频状态和权限过滤都不能因为推荐结果“已经算好了”而省略。
新用户没有历史时,可以使用地区热门、编辑精选和当前频道上下文。推荐服务超时时,直接返回一份短缓存的非个性化候选,不要拖住播放页。用户关闭个性化推荐或撤回相关授权时,系统应停止读取个性化特征,清理或隔离相应在线状态,并让非个性化兜底继续可用。
一个健康的推荐链路,即使拿不到用户特征、模型超时或 Redis 某个候选键丢失,也能返回安全、可播放的通用内容。推荐质量可以下降,播放主链路不能被一起拖垮。
用户开始播放后,接口可以用 XADD 追加一条小事件。Streams 像一条按顺序追加的工作记录:生产者写入,多个消费者组可以独立处理;同一消费者组里的多个消费者分摊工作。

播放主链路只追加最小观看事件;事件流负责异步分发,失败任务可重新领取,系统通过观测指标、隐私保留期和分级降级保障稳定性。
事件字段只保留后续处理真正需要的内容,例如事件编号、内部用户编号或受控的匿名编号、视频编号、事件类型、发生时间、观看会话编号和客户端大类。不要把登录会话编号、完整 IP、User-Agent、播放地址或用户画像整包复制进去。
def append_watch_started(redis_client, event):
return redis_client.xadd(
"stream:watch-events",
{
"event_id": event["event_id"],
"viewer_id": event["viewer_id"],
"video_id": event["video_id"],
"event_type": "watch_started",
"occurred_at_ms": event["occurred_at_ms"],
},
maxlen=1_000_000,
approximate=True,
消费者组读取后,消息会进入待确认清单。只有业务处理成功,消费者才执行 XACK。消费者进程崩溃时,消息不会因为“已经投递过”就自动算完成;巡检任务可以查看长时间未确认的消息,把它重新分配给健康消费者。
def consume_batch(redis_client, consumer_name, handle_event):
batches = redis_client.xreadgroup(
groupname="history-writers",
consumername=consumer_name,
streams={"stream:watch-events": ">"},
count=100,
block=2000,
)
for stream_name, messages in batches:
for message_id, fields in messages:
# handle_event 必须能识别重复 event_id
这类消费通常是“至少一次”的思路:消费者可能在业务写入成功后、执行 XACK 前崩溃,同一事件会再次出现。因此计数和落库端要以 event_id 做幂等,不能假设每条消息只来一次。
Streams 也不是无限硬盘。要设置长度或时间保留策略,监控消费者积压和最老待确认时间,并确认 Redis 的持久化、复制和故障切换能满足丢失预算。若事件关系到结算、版权分成或不可丢审计,常用数据库事务外盒或更强的持久事件平台保存事实,再让 Redis 承担在线分发或加速。
缓存节点能响应 PING,不代表视频平台健康。我们至少要把应用视角和 Redis 视角放在一起看。
观察命令延迟、慢日志、内存使用、淘汰键数量、连接数、复制延迟、持久化状态、分片 CPU 偏斜和热点键。大量 KEYS、一次返回数万成员的范围查询、对超大集合做交集,都可能让其他请求排队。
诊断热点时,生产环境不要长期打开高开销的全量命令监视。先从分片 CPU、客户端指标、慢日志和采样入手,再在受控时段使用针对性工具。采样结果也要避免把完整会话编号和个人数据带进日志。
一个可执行的降级顺序可以是:
下面的演练不会替你自动选方案。触发故障后,先看哪一项指标发生了结构性变化,再决定是保护数据库、清理积压,还是让推荐退出播放主链路。
观看历史能帮助“继续观看”和推荐,也能推断用户兴趣。把它叫作“行为数据”不会让敏感性消失。技术设计需要回答:为什么收集、收集哪些、保留多久、谁能读取、用户删除后如何传播。
播放计数只需要视频编号和去重所需的受控标识,就不要顺手复制完整用户资料。安全排查若只需关联同一会话,可以记录带盐摘要,不记录原始会话编号。精确 IP 只在确有风控需求的受限链路保存,普通推荐事件不必长期携带。
Redis 键也要设置生命周期:会话有空闲和绝对期限;负缓存只有很短 TTL;小时榜按窗口淘汰;近期特征在用户不活跃后过期;Streams 有明确裁剪与落库计划。没有过期策略的“临时数据”,最后往往都会变成永久数据。
用户删除观看历史时,不能只删主数据库,再等 Redis 自然过期。删除流程应发出内部删除任务,覆盖在线历史键、推荐特征、缓存、搜索索引和允许删除的派生副本,并记录完成状态。备份和不可变审计数据要按既定保留规则处理,不能在接口里承诺不可能做到的即时物理抹除。
访问控制上,播放服务只应访问会话和元数据所需的键空间,推荐消费者不应读取原始会话。Redis 连接要经过加密、认证和最小权限控制,管理命令与业务连接分离。
降级不能成为绕过隐私选择的借口。个性化特征不可用时,应回到非个性化热门或编辑精选,而不是悄悄改读一份未授权的旧画像。
假设晚上八点整,新剧集上线。发布系统先提交视频状态,再删除旧元数据缓存;预热任务只加载预计最热的一小批键,并给 TTL 加抖动。用户登录后拿到服务端随机会话,浏览器 Cookie 不含角色信息。播放请求完成会话、权限和元数据检查,拿到短时播放地址就返回。
观看事件进入 Streams。计数组更新分钟分桶,历史组幂等写入持久存储并刷新最近观看索引,推荐组更新短期兴趣摘要。热门榜按小时窗口累加,不拿请求次数直接冒充有效播放。
八点零五分,一个超热视频变成热点键。应用内几十毫秒的小缓存吸收重复元数据读,单飞重建阻止数据库被同时回源。八点十分,推荐消费者积压,系统暂停次要特征计算并切到地区热门榜,播放没有受到影响。值班人员看到最老待确认时间持续上升,而不是只看 Redis 进程仍在运行。
这套方案并不“完美”。它接受元数据短暂陈旧、计数最终一致、推荐质量可以降级,换来播放主链路更短、更稳。内容下架、权限和会话撤销等高风险边界则使用主动失效、短时播放地址和安全失败策略,不把希望寄托在 TTL 碰巧及时到期上。
再做一道开放题:如果 Redis 整体超时,而主数据库也接近连接上限,你会保留哪些功能、关闭哪些功能?
这一章真正要带走的不是一份键名清单,而是一套判断顺序:先分关键路径和异步工作,再确定事实源和可接受陈旧时间,然后选择数据结构,最后把失效、热点、隐私、观测和降级一起设计进去。Redis 擅长把已经想清楚的访问模式跑得很快,却不会替我们补上没有定义的业务边界。