凌晨一点,一位拥有千万粉丝的作者发了一条动态。发布接口很快返回了成功,几秒后却出现了三种互相矛盾的现象:有些粉丝已经刷到了新帖,有些人的首页还没有;作者删除动态后,搜索结果里仍能看见标题;通知服务重启后,同一条提醒又发了一遍。
如果我们只画一张“用户发帖 → Redis → 粉丝收到”的图,这些问题都像是代码里漏了几个命令。真正做过这类系统后,你会发现,难点恰恰不在 HSET、ZADD 会不会写,而在于四件事如何配合:事实存在哪里,派生数据如何传播,失败后如何续跑,以及每次读取如何重新判断权限。
这章会搭一个教学版社交平台。我们会用 Redis 保存热点资料、关系索引、时间线候选集、通知事件和幂等状态,但不会把它包装成一套可直接上线的完整系统。用户名合规、内容审核、账号安全、反滥用、跨地域容灾、容量规划和法律合规都需要单独设计。

关系数据库适合保留可审计的业务事实,Redis 更像高速工作台:它保存缓存、索引、时间线和短期协调状态。
本章代码是为了讲清数据结构、竞态和恢复路径。它省略了认证、审计、内容安全、跨机房一致性、容量压测和完整错误处理,不能原样当成生产系统。
我们先别急着选数据结构。假设小雨把显示名从“雨天散步”改成“雨天写代码”,这次修改到底要改多少份数据?如果每条帖子里都复制了一份显示名,那么几千条历史帖子都可能要改;如果帖子只保存作者 ID,读取时又必须额外查用户资料。
两种做法都能工作,区别在于你是否明确知道哪一份是事实。
一个比较稳妥的起点,是把数据分成三类:
Redis 可以承担其中任何一类,但承担“事实库”与承担“缓存”的运行要求完全不同。只把 Redis 当缓存时,缓存丢失会让请求变慢;把 Redis 当唯一事实库时,淘汰策略、持久化、备份、故障转移和写入确认都会直接关系到数据是否还在。
一个很好用的自检问题是:“如果这个 Redis 键突然消失,我能从哪里重建?”能从事实库和事件日志重建的是投影;不能重建的就是事实,必须按事实数据的标准保护。
教学实现可以先约定下面这些键。冒号只是命名分隔符,不代表 Redis 自动理解对象关系。
user:profile:{user_id} 用户资料缓存 Hash
handle:owner:{normalized_handle} 规范化用户名 -> 用户 ID String
post:cache:{post_id} 帖子读取缓存 Hash / JSON
rel:following:{user_id} 正在关注的用户 ZSET
rel:followers:{user_id} 粉丝 ZSET
timeline:profile:{user_id} 个人帖子索引 ZSET
timeline:home:{user_id} 首页候选帖子 ZSET
visibility:post:{post_id} 删除状态与可见性版本
idem:{operation}:{request_id} 幂等结果 String / Hash
stream:social-events 社交事件 Stream
stream:notifications 通知事件 Stream键名里保留对象类型和 ID,运维时能一眼看出归属。不过,进入 Redis Cluster 后还要考虑哈希槽。事务、Lua 脚本或 Redis Function 涉及的多个键通常必须位于同一个槽,只有确实需要原子操作的键才应使用相同的 {...} 哈希标签。把所有社交键都塞进同一个标签虽然省事,却会把压力重新集中到一个分片。
本章采用“关系数据库保存事实,Redis 保存读取投影与事件工作状态”的主线。原因不是关系数据库永远更可靠,而是社交业务常见的唯一约束、外键、审计、复杂后台查询和长期备份更容易在事实库里统一处理。
这条边界也不是铁律。如果你的团队决定让 Redis 成为主存储,就必须把下面的问题写进设计,而不是留给运气:
noeviction 还是允许业务数据被淘汰;换句话说,“Redis 很快”回答的是延迟问题,不会自动回答持久性和一致性问题。
注册接口最容易写成下面这样:先查用户名是否存在,不存在就创建。单请求测试全绿,一开并发就出现两个相同用户名,因为两个请求可能同时完成“检查”,再分别执行“写入”。
这不是 Redis 特有的问题,而是典型的检查与写入分离竞态。

原子占位解决“谁获得用户名”,幂等键解决“同一个请求是否重复执行”;这两个问题看起来相似,实际上不能互相替代。
如果只调用 lower(),RedisFan 和 redisfan 能归一,但全角字符、Unicode 组合字符、前后空白、不可见字符和形似字符仍可能绕过规则。应用层应固定一套规范化流程,并且版本化:
normalized_handle,把唯一约束建在这个值上。规范化规则升级时不能静默改变已有账号的归属,否则原本不同的两个用户名可能突然冲突。实践中常保留规范化版本,并为迁移准备冲突处理流程。
如果 Redis 是用户名注册表,可以用 SET key value NX 原子争抢一个名字;如果关系数据库是事实库,更直接的做法是在 normalized_handle 上建唯一索引,让数据库事务决定唯一赢家。Redis 的占位可以挡掉大部分重复请求,但不能替代事实库的最终约束。
下面这段伪代码把两层职责分开。request_id 必须由客户端在一次逻辑注册中保持不变,网络超时后重试也沿用同一个值。
def register_user(request_id, raw_handle, profile):
handle = normalize_and_validate(raw_handle)
# 快速返回同一次请求的既有结果;这里只是优化,事实仍在数据库。
cached = redis.get(f"idem:register:{request_id}")
if cached:
return decode_result(cached)
try:
with database.transaction() as tx:
previous = tx.find_request_result(request_id, for_update=True)
if previous:
return
这里故意没有使用“先拿分布式锁,再写数据库”的组合来承诺强一致。锁可能过期,持锁进程可能暂停,Redis 与数据库之间也没有天然的跨系统事务。事实库唯一约束才是最后一道门。
如果系统完全以 Redis 为事实库,可把“用户名占位、用户 Hash、幂等结果”放进一个短小的 Redis Function 中原子执行。但要同时满足两个条件:所有相关键能在集群中落到同一槽,并且函数在修改前完成类型与参数校验。Redis 脚本执行期间不会被其他命令插入,却不是关系数据库那种遇到运行时错误自动回滚已执行命令的事务。
帖子事实至少需要这些字段:
post_id 全局唯一 ID
author_id 作者 ID
body 正文或正文对象地址
created_at 服务端创建时间
visibility 公开 / 关注者可见 / 私密
visibility_ver 可见性版本
deleted_at 删除时间,可为空
content_ver 内容版本时间线 ZSET 只保存帖子 ID 和排序分数,不把整段正文塞进成员。读取时先批量拿候选 ID,再用 pipeline 批量获取 post:cache:{post_id},缓存未命中则回源事实库。这样删除、编辑和审核只需失效正文缓存,不必重写每个粉丝的时间线成员。
显示名和头像可以在通知里保留发布时快照,也可以读取当前资料。前者保留历史语境但会陈旧,后者总是最新却增加查询。重要的是明确选择,而不是一部分页面读快照、一部分页面读当前值,最后谁都解释不清。
两次发送相同文字可能是用户真的想发两次,不能用正文哈希直接判重。更稳妥的规则是:同一个 request_id 重试只能创建同一个 post_id,不同请求即使正文相同也允许创建。反垃圾系统可以另行检测短时间重复内容,但那是风控判断,不是写接口幂等。
不要用 Bloom Filter 作为用户名唯一性的最终判断。它可能误判“存在”,适合挡掉明显重复或减少后端查询,不适合决定一个合法用户名永远不能注册。
用户点一下“关注”,页面至少会问四个问题:我是否关注了对方,我最近关注了谁,对方有哪些粉丝,对方的粉丝数是多少。只保存一条 A → B 关系,后两项查询就会变得昂贵,所以常见做法是维护两份索引:
rel:following:A 保存 A 关注的用户;rel:followers:B 保存 B 的粉丝。若只关心成员关系,可以使用 Set。若要展示最近关注或按关注时间回填内容,可用 ZSET,成员是用户 ID,分数是服务端确认的关注时间。

双向索引服务不同读法;真正返回内容前,还要检查拉黑、私密账号、关注状态和帖子删除状态。
假设客户端因为超时把“关注 B”重试了三次。最终关系仍应只有一条,粉丝数也只能加一次。ZADD 的成员天然唯一,但如果代码无条件执行 HINCRBY followers 1,计数仍会变成三次。
在单实例或相关键同槽时,可以把“条件添加 + 双向索引 + 计数变化”放进 Redis Function,让函数根据 ZADD NX 的返回值决定是否改计数。进入多分片后,A 的 following 和 B 的 followers 通常落在不同槽,跨槽原子函数不可用。此时更稳妥的方案是:
(follower_id, followee_id) 唯一约束提交关注事实;这意味着用户刚点完关注时,某个列表可能晚一点更新。产品可以在当前会话里采用“读己之写”覆盖,或者同步更新一个局部缓存,但不能假装跨系统双写天然原子。
一个直觉方案是取消关注 B 后,遍历 B 的所有历史帖子,再从 A 的首页逐条 ZREM。如果 B 发过几十万条内容,这个操作会把一个轻量按钮变成大任务。
更实用的处理是:
ZREM 没有额外副作用;这体现了一个重要区别:索引可以暂时脏,访问控制不能暂时松。
粉丝数可以由关系表精确计算,也可以由 Redis 计数器快速展示。高并发下,异步事件重放、人工修复和历史迁移都可能让计数偏离,所以不要用 followers=0 推断“绝对没有粉丝”,也不要只凭计数决定权限。
可以把计数事件设计成带版本的绝对值投影,或者用事件 ID 去重后再增减。无论选哪种,都要有对账任务:按事实关系重新计算抽样账号或异常账号,并记录修正幅度。
小雨打开首页时,系统不是简单读取一个永不变化的列表。她关注的人还在发帖,有人刚删除内容,有人把公开账号改成私密,排序服务也可能加入热度和多样性。首页更准确的名字其实是“候选集的一个快照窗口”。

写时扇出把工作放在发布时,读时扇入把工作放在打开首页时;混合方案按作者规模和读写模式分流。
写时扇出会在作者发布后,把帖子 ID 写入活跃粉丝的 timeline:home:{viewer_id}。它像提前把报纸塞进每个人的信箱:首页读取只需拿一个 ZSET,延迟稳定。代价是写放大。作者有一百万活跃粉丝,一条帖子就可能产生近一百万次候选写入。
读时扇入只维护每位作者的 timeline:profile:{author_id}。用户打开首页时,再从所关注作者的个人流中取出一批最新内容并归并。发布很轻,但一个关注几千人的用户会在每次读取时触发大量分散查询和合并。
混合方案通常更符合真实流量:普通作者走写时扇出,热点作者只写个人流;读取首页时,把预计算候选与少量热点作者的新帖归并。分流阈值不该是拍脑袋的“粉丝超过十万”,而应同时看活跃粉丝数、作者发帖频率、首页读取频率、队列水位和可接受的新鲜度。
调节粉丝量、发帖频率和活跃比例,观察三种策略的相对成本。实验中的数字用于理解趋势,不是生产容量承诺。
一个可恢复的发布流程可以这样组织:
客户端生成请求号。发布服务在事实库事务中检查请求号,创建唯一帖子,并写入一条 post.created outbox 事件。事务提交后,重复请求返回同一个帖子 ID。
事件转发器把 outbox 写入 Redis Stream。重复写入时沿用同一个事件 ID;消费者也不能假设生产端永远不会重复。
个人流投影器用 ZADD 把帖子加入作者个人时间线,并写入或失效帖子缓存。相同成员重复 ZADD 不会产生两条时间线记录。
扇出调度器根据作者类别创建分批任务。每个任务记录粉丝分页游标、帖子 ID、可见性版本和事件 ID,失败后从检查点继续。
注意“发布成功”的含义。接口返回成功可以表示帖子事实已经提交,不必等待所有粉丝首页都写完。页面如果承诺“所有粉丝立刻可见”,就必须为同步等待、超时和部分失败定义行为;绝大多数系统会接受秒级的最终一致,并展示作者自己的新帖以满足读己之写。
下面是简化后的批处理伪代码。关键不是语法,而是重试后仍得到相同结果。
def fanout_batch(job):
if is_deleted_or_hidden(job.post_id, job.visibility_ver):
mark_job_cancelled(job.id)
return
followers, next_cursor = load_active_followers(
author_id=job.author_id,
after=job.follower_cursor,
limit=500,
)
pipe = redis.pipeline(transaction=False)
for viewer_id in followers:
key = f"timeline:home:{
pipeline 只是减少网络往返,transaction=False 时不提供跨命令原子性。即使使用事务,任务检查点通常仍在另一个系统里,所以消费者必须能容忍“Redis 已写成功,但检查点还没保存”的重复执行。
每个用户都保存无限历史首页,会迅速消耗内存。常见做法是只保留最近几百到几千条候选,久远内容走读时合并、冷存储查询或直接不支持无限回滚。窗口大小取决于用户实际会翻多深、帖子对象大小、活跃用户数和内存预算。
裁剪时要测试空集合、小集合和刚好超过上限的情况。ZREMRANGEBYRANK key 0 -(limit+1) 这类负排名表达式很紧凑,也很容易因为一个符号写错而删掉整条时间线。
普通作者发帖时,扇出任务可能只需处理几十个活跃粉丝。热点作者发帖时,同一个作者的粉丝索引会被持续扫描,大量首页键被修改,任务队列也会突然堆高。单次 ZADD 再快,乘上一千万仍然是一千万份工作。

大 V 问题是写放大、热点键与队列背压叠加,不是把批大小从 500 调成 5000 就能根治。
扇出任务应切成有界小批,每批只处理一段粉丝游标。调度器在批次之间重新排队,让其他作者的事件也能获得执行机会。常见控制手段包括:
一个 Redis 键只属于一个分片。rel:followers:hot_author 即使放在大集群里,也仍由某个主分片处理。给集群增加节点能分散其他键,不能自动拆开这个热点键。
可以把超大粉丝索引按稳定规则拆成多个桶,例如 followers:{author_id}:00 到 followers:{author_id}:63,但分桶会让计数、分页和原子操作更复杂。哈希标签也要谨慎:相同标签能让相关键同槽,却可能反过来制造热点槽。
发布接口快速返回,并不代表时间线系统健康。至少要观察:
如果系统只报警“HTTP 99 线变慢”,很多异步积压要等用户投诉时才会被发现。
第一页读取第 1 到 20 条,第二页读取第 21 到 40 条,这种 offset 分页在静态后台表里很好懂。时间线却一直有新帖子插到顶部:用户读完第一页后新增了 5 条,原来的第 16 到 20 条就被挤到第二页,用户会重复看见;如果中间删除了内容,还可能漏项。

稳定游标记录最后一条的排序分数和帖子 ID。新帖插到顶部后,下一页仍从这个锚点向旧内容移动。
只用毫秒时间戳当 score,会遇到同一毫秒发布多条帖子的情况。ZSET 对相同 score 的成员按字典序排序,所以我们需要把帖子 ID 作为稳定的第二排序键,并把游标设计成 (last_score, last_post_id)。
工程上有三种常见办法:
不要把任意 64 位雪花 ID 直接转成 Redis ZSET 的双精度 score。超过精确整数范围后,相邻 ID 可能被舍入成同一个数。
下面的伪代码展示第二种方式。实际命令封装会随客户端变化,重点是“同分桶先按 ID 续读,再读取更小分数”。
def next_page(feed_key, cursor=None, limit=20):
if cursor is None:
snapshot_max = "+inf"
same_score_tail = []
else:
snapshot_max = cursor.score
# 只取 score == cursor.score 的桶,并跳过已经读过的成员。
same_score_tail = load_same_score_members_after_id(
feed_key,
score=cursor.score,
last_id=cursor.post_id,
limit=
如果同一 score 下可能堆积大量成员,扫描整个同分桶也会变慢。这时应提升排序键粒度、加入受控序列,或改用能稳定表达复合排序的索引方案。游标不是魔法,它依赖排序模型本身稳定。
在两次翻页之间插入或删除帖子,对比 offset 与复合游标的重复、漏项和同分破平。
帖子可能已经被复制到作者个人流、成千上万条首页、搜索索引、推荐候选、通知和第三方流式接口。执行一次 DEL post:{id} 只删除了正文缓存,其他索引仍会继续指向这个 ID。
如果读取逻辑看到缓存缺失就回源事实库,而事实库里又只是软删除,那么一段写得不严谨的回源代码甚至可能把已删除内容重新缓存回来。

删除事件负责逐步清理投影,墓碑和读时权限检查负责立刻阻断内容返回。
第一阶段是让内容不可返回:事实库写入 deleted_at 或提升可见性版本,Redis 写入短小的墓碑键,正文缓存立即失效。所有读路径在补全内容前检查墓碑和当前权限。
第二阶段是异步清理派生索引:从个人流、首页窗口、搜索、推荐和通知投影中删除帖子 ID。清理允许延迟、重试和重复执行,因为第一阶段已经封住了数据泄露路径。
一个删除处理器可以按下面的结构工作:
def apply_post_deleted(event):
post_id = event.post_id
# 先写墓碑;重复写入安全。TTL 应覆盖所有投影的最大修复时间。
redis.hset(
f"visibility:post:{post_id}",
mapping={"deleted": 1, "version": event.visibility_ver},
)
redis.expire(f"visibility:post:{post_id}", TOMBSTONE_TTL)
redis.delete(f"post:cache:{post_id
墓碑 TTL 不能随手设成五分钟。如果冷缓存、搜索索引或离线通知可能在数小时后才重放旧数据,墓碑过早消失就会发生“删帖复活”。更稳妥的办法是让投影事件携带 visibility_ver,任何旧版本写入都被拒绝;墓碑则承担加速和兜底。
作者从公开改为私密,不一定要删除帖子,但可见人群变了。拉黑关系也可能只影响两个人。把“当时允许看”永久烙进首页索引,会让后来权限变更难以收回。
因此首页 ZSET 只应被视为候选索引。真正返回前至少检查:
为了避免每条候选都单独访问关系库,可以批量取权限缓存,或者维护带短 TTL 的可见性投影。但缓存失效时必须选择安全的降级策略:敏感内容宁可暂时不展示,也不要在权限状态未知时默认放行。
如果删除事件和大 V 的百万级扇出共用同一个先进先出队列,删除可能要等很久。可以为安全相关事件设独立 Stream 或高优先级消费组,并监控“从事实提交到读路径不可见”的时延。
索引里暂时残留一个已删除的帖子 ID通常可以接受;接口继续返回标题、摘要、图片或通知预览则不可接受。清理最终一致,不代表隐私也可以最终一致。
小雨关注小林后,小林应该收到通知。通知 worker 刚拿到事件就崩溃,是应该丢掉、重发,还是交给另一台机器?这三个答案对应完全不同的消息语义。
Redis Pub/Sub 的订阅者断线后,不会补发断线期间的消息。它适合刷新在线状态、告诉前端“可能有新内容,请重新拉取”等短暂提示。把删帖传播、计费、邮件发送或持久通知只放在 Pub/Sub 上,一次网络抖动就可能永久丢任务。
Streams 会保留条目,并允许消费者从指定 ID 继续读取。消费组把新事件分给组内 worker;worker 使用 XREADGROUP 获取事件,处理成功后 XACK。未确认事件会留在待处理列表,健康消费者可以通过 XAUTOCLAIM 认领超时任务。
设想 worker 已经把通知写进数据库,却在 XACK 前崩溃。事件会再次投递。如果第二次又插入通知,用户就看到重复提醒。
更稳妥的做法是在通知事实库中为 (consumer_name, event_id) 建唯一约束,并在同一个数据库事务里完成“记录已处理事件”和“创建通知”。事务成功后再确认 Stream:
def consume_notification(stream_entry):
event_id, event = stream_entry
try:
with database.transaction() as tx:
if tx.has_processed("notification-projector", event_id):
pass
else:
tx.insert_notification(
recipient_id=event.recipient_id,
actor_id=event.actor_id,
kind=event.kind,
object_id=event.object_id,
)
tx.mark_processed(
如果副作用完全在同一个 Redis 分片中,也可以用 Redis Function 原子执行“检查幂等标记 + 写通知投影”。但发送短信、推送或邮件是外部副作用,不能靠 Redis 键自动获得 exactly-once。常见做法是下游继续使用 outbox、供应商幂等键或可查询的发送记录。
Redis 较新版本为 Streams 提供了生产端幂等写入能力,能在配置的保留窗口内识别同一生产者的重复消息;新版本也增强了裁剪与多个消费组待处理引用的协作。但这不会消除业务消费者的幂等要求:事件可能在处理成功、确认失败后再次到达,外部副作用仍需自己防重。
某条格式损坏的事件可能每次都让消费者报错。利用待处理列表的投递次数,可以在超过阈值后把事件放入失败隔离 Stream,记录错误类型、原始事件 ID 和最后一次堆栈摘要,并触发告警。修复代码后再人工或自动重放。
裁剪 Stream 时也要理解消费组语义。按长度粗略裁剪能控制内存,却可能移除仍被某个消费组引用的旧条目。保留窗口应覆盖最大故障恢复时间,并结合版本支持选择如何处理待处理引用;不能只看到 MAXLEN 很方便就随手设一个很小的值。
模拟消费、宕机、超时认领、重复投递和失败隔离,观察待处理列表为什么是恢复链路的关键。
对浏览器单向推送,可用 Server-Sent Events;需要双向高频交互时可考虑 WebSocket。无论选哪种,Redis Stream ID 都可以成为恢复游标:客户端重连时携带最后成功收到的事件 ID,网关从它之后继续读。
下面是 SSE 网关的结构化伪代码:
async def stream_events(request, viewer_id):
authorize_connection(request, viewer_id)
cursor = validate_last_event_id(request.headers.get("Last-Event-ID"))
while not request.is_disconnected():
batches = await redis.xread(
{"stream:social-events": cursor},
count=100,
block=15000,
)
if not batches:
yield sse_comment("heartbeat")
真实实现还要限制每个账号的连接数和过滤器复杂度,处理代理缓冲、心跳、慢客户端、发送队列上限、认证过期和部署滚动重启。客户端来不及消费时,不能无限缓存;应断开并让它用游标重连,或降级为“有新内容”提示后重新拉取。
不要把全站原始事件直接交给第三方,再指望对方只看自己有权看的部分。网关应先把内部事件转换成公开投影,再结合调用方权限执行用户、关键词、话题或位置过滤。
删除事件也要进入流,否则客户端会一直保留旧内容。对关键词过滤还有一个容易忽略的细节:如果客户端曾经收到一条后来删除的帖子,删除事件不一定包含原正文,因此客户端需要按帖子 ID 撤回,而不是重新跑关键词匹配。
到这里,我们已经有用户、帖子、关系、时间线和通知。真正决定系统能否维护的,是每个边界处失败后会发生什么。
最危险的写法是:先提交数据库,再 XADD;或者先 XADD,再提交数据库。两个系统之间任一步失败,都会留下“有帖子没事件”或“有事件没帖子”。
Outbox 的思路很朴素:在创建业务事实的同一个数据库事务里插入一条待发送事件。独立转发器不断扫描 outbox,把事件写入 Stream,成功后标记已发送。转发器可能重复发送,因此事件 ID 固定,消费者必须幂等。
这条链路允许 Redis 暂时不可用:发帖事实仍在,outbox 会积压;Redis 恢复后,投影器从事件补齐个人流和首页。系统需要暴露积压量和最老事件年龄,超过阈值时可以暂停部分写入或明确告诉用户时间线正在延迟。
异步系统中,有些动作已经被外部用户看到,简单反向执行未必正确。例如通知已推送到手机后再删 Redis 键,并不能把手机通知收回来。
补偿应针对业务效果设计:
post.created,不要重新创建帖子;WATCH 可以实现乐观条件更新。它不提供任意跨系统事务,也不会像传统数据库一样自动回滚运行时已经执行的命令。Redis 主从复制默认是异步的。WAIT 可以降低主节点故障时丢写的概率,让客户端等待一定数量副本确认,但不会把部署自动变成强一致系统;故障转移、持久化配置和网络分区仍会影响结果。对必须持久的事实,应明确可接受的数据丢失目标,并通过故障演练验证,而不是只在配置文件里写一个数字。
AOF 每秒同步通常意味着灾难情况下仍可能丢失一个短窗口的写入;RDB 是时间点快照,窗口通常更大。两者组合、备份和副本能改善恢复能力,但仍要测试磁盘满、AOF 重写、主从切换、进程误重启和恢复耗时。
现在再看一次小雨发布动态,系统里的每一步就不再是模糊的“写 Redis”。
发布请求(request_id)
↓
事实库事务:帖子 + 请求结果 + outbox
↓
事件转发:outbox → Redis Stream
↓
个人流投影(幂等 ZADD)
├─ 普通作者:分批扇出到活跃粉丝首页
└─ 热点作者:保留个人流,读取时合并
↓
首页读取:稳定游标取候选
↓
批量补全正文缓存 / 回源
↓
删除、拉黑、私密、审核过滤
↓
不足一页则继续补候选
↓
返回内容与下一游标def read_home(viewer_id, cursor, page_size=20):
visible = []
scan_cursor = cursor
scanned = 0
while len(visible) < page_size + 1 and scanned < MAX_CANDIDATES_PER_PAGE:
refs, scan_cursor = load_feed_candidates(
viewer_id,
scan_cursor,
batch_size=50,
)
if not
这里还需要定义一个失败条件:如果候选里绝大多数都被过滤,扫描达到上限仍凑不满一页,接口应返回较短页面,并记录过滤率和扫描量。无限向后扫描会让一次普通读取变成慢命令放大器。
XACK 前杀掉消费者,确认只出现一条业务通知;一个社交系统是否稳,不取决于图里用了多少种 Redis 数据结构,而取决于你能否说清:哪份是事实、每个投影如何重建、重复事件是否安全、权限变化多久生效,以及任一步失败后从哪里继续。
某作者有 800 万粉丝,每天发 30 条内容,但真正每天打开首页的粉丝只有 3%。如果把每条帖子同步写入所有粉丝首页,会发生什么?你会怎样改造?
通知 worker 先执行 SET processed:{event_id} 1 NX,再调用短信供应商。如果 SET 成功后进程崩溃,会怎样?如果先发短信再 SET,又会怎样?
删除 worker 已经从作者个人流移除了帖子 ID,为什么首页接口仍要查墓碑?
这一章的终点不是“Redis 能做社交网络”,而是你已经能把一句模糊的需求拆成具体承诺:注册如何判唯一,关注如何去重,首页允许延迟多久,热点作者怎样降级,删除多久不可见,通知重放是否重复,以及事实库与 Redis 之间如何补偿。把这些承诺写清楚,数据结构的选择反而会自然很多。
首页读取器获取候选 ID,批量补全帖子,再执行删除、拉黑、私密账号和审核过滤。如果过滤后不足一页,就继续向后取候选,而不是返回半页或放松权限。