短视频点赞与计数设计:关系事实与热点投影
短视频系统里,点赞、取消点赞和点赞计数如何设计?
阅读说明与事实边界
点赞看起来只是一个按钮:没点过时按一下变红,再按一下取消,旁边的数字加一或减一。真正进入后端以后,它至少包含两类不同事实:某个用户当前是否点赞了某个视频,以及某个视频当前被多少用户点赞。前者直接决定用户看到的按钮状态和个人点赞列表,后者主要用于展示、排序与推荐特征,两者对一致性的要求并不相同。
本文把“用户小林在刷视频时点赞,紧接着又取消;该视频突然成为热点;期间 Redis 和 MQ 分别发生故障”作为贯穿案例,从业务动作一直讲到关系表、缓存、消息、计数投影、分页查询、对账和人工修复。
本文是一份可落地的目标设计,不对应某个已经提交源码、完成压测或运行在生产环境的项目。文中出现的容量和时延只能这样理解:
| 标记 | 本文含义 | 能否当成项目成绩 |
|---|---|---|
| 设计目标 | 用于选择分片、容量和降级策略的验收标尺 | 不能 |
| 功能测试 | 未来可在测试环境重复执行的正确性检查 | 只有报告存在后才能写成已验证 |
| 压测 | 需要注明机器、数据分布、持续时间和瓶颈的容量实验 | 本文尚未执行 |
| 故障演练 | 主动注入缓存、消息或数据库故障验证恢复路径 | 不等于线上事故 |
| 生产监控 | 真实系统运行指标 | 本文没有此类数据 |
后文使用的 50,000 次/秒点赞命令、200,000 QPS 计数读取、单个热视频 10,000 次/秒关系变化 都是容量推演用的设计目标。真正实施时必须根据用户分布、取消率、热点集中度、数据库规格和允许的计数延迟重新测量,不能把这些数字写进简历当作生产成绩。
一、项目基础:点赞系统到底负责什么
1.1 点赞不是一个数字,而是一段关系
核心业务对象不是 video.like_count,而是“用户与视频之间的点赞关系”。一条关系至少要回答:
- 哪个用户对哪个视频操作;
- 当前状态是已点赞还是未点赞;
- 最近一次有效状态版本是多少;
- 哪个业务操作导致了这次变化;
- 状态何时变化,是否因为风控或内容下架变得不可见;
- 这次变化是否已经传播到点赞数、个人列表和推荐特征。
如果系统只在视频表上执行 like_count = like_count + 1,就无法可靠回答“这个用户是否已经点过”“重复点击是否重复加数”“取消时该不该减”“消息重放会不会再加一次”。
1.2 本文覆盖的业务范围
系统内包含四类主要能力:
- 用户对可见视频执行点赞或取消点赞。
- 信息流、详情页批量查询“我的点赞状态”和展示点赞数。
- 用户分页查看自己的点赞列表;创作者在权限允许时查看点赞用户摘要。
- 关系变化异步更新计数、反向索引、推荐特征,并能在故障后对账修复。
明确不放进当前边界的内容包括:
- 视频推荐模型本身如何训练;
- 评论点赞、直播点赞和表情反应的完整实现;
- 跨平台社交关系同步;
- 结算型激励金额计算;
- 内容审核模型的算法细节。
评论点赞可以复用部分思想,但评论树、楼中楼和删除语义不同,不能只换一个资源 ID 就宣称完全等价。直播间“飘心”通常是互动事件而非持久关系,也不能和短视频点赞共用同一套强事实模型。
1.3 参与者与系统边界
| 参与者或系统 | 发起或承担的动作 | 点赞服务需要确认的边界 |
|---|---|---|
| 普通用户 | 点赞、取消、查看个人点赞列表 | 只能操作自己的关系,不能相信客户端传入 user_id |
| 视频服务 | 提供视频存在性、作者、可见性和下架状态 | 点赞服务不自行修改视频审核结论 |
| 信息流服务 | 批量读取计数和当前用户关系 | 不能逐条串行调用造成 N+1 请求 |
| 计数投影服务 | 将关系事件收敛成公开视频点赞数 | 计数可以短暂滞后,但最终要与关系事实收敛 |
| 推荐系统 | 消费点赞关系事件作为特征 | 消费成功不代表模型已经使用该特征 |
| 风控系统 | 识别刷赞、设备异常和批量脚本 | 风控处置必须保留审计,不能悄悄篡改原始事实 |
| 运营后台 | 查询异常、冻结操作、触发重算 | 人工修复必须可审批、可回滚、可追踪 |
1.4 一分钟项目介绍
可以把整体方案概括为:点赞命令写入分库后的用户视频关系表,关系状态使用唯一约束、操作幂等键和单调版本保护;同一事务写入 Outbox。事务提交后,用户自己的点赞状态立即以关系事实为准,点赞数、视频点赞者列表和推荐特征通过消息异步投影。热视频不直接更新一个全局计数 Key,而是按用户哈希写入生命周期内不变的虚拟桶,再把虚拟桶路由到可扩缩的物理分区并合并为展示快照。消费者按照“更高版本的绝对状态”收敛,能够忽略重复事件并处理点赞后快速取消导致的乱序。Redis 是加速层,数据库关系记录、持久投影版本和可回放事件才是恢复依据。
二、业务闭环:一次点赞如何真正结束
2.1 从页面动作到异步消费者
同步闭环到“关系事务已提交”为止。接口返回 LIKED,只表示点赞关系事实已经成立,不表示所有下游投影都已经更新,更不表示推荐系统已经使用该行为。
异步闭环要继续追踪:Outbox 是否发布、消费者是否处理、计数版本是否推进、缓存是否刷新、异常是否进入补偿。只有这些环节可观测,系统才不是“发完消息就不管了”。
2.2 贯穿全文的典型场景
假设小林打开视频 V9001,页面显示 8,721 个赞,自己的按钮是未点赞:
- 小林点击点赞,客户端立即把按钮变红,但标记为本地待确认。
- 客户端发送目标状态
LIKED、操作 IDop-101和当前关系版本。 - 服务端事务提交关系版本 11,同时保存状态事件版本 11。
- 接口响应丢失,小林很快又点了一次取消。
- 客户端不能直接发送一个无身份的“切换”请求,而要先确认
op-101的结果,再用新操作 IDop-102提交目标状态UNLIKED。 - 关系版本最终变为 12,数据库中的当前状态是未点赞。
- 计数消费者可能先收到版本 12,再收到重复的版本 11;它必须以版本和绝对状态收敛,不能机械执行事件中的
+1、-1。 - 视频成为热点后,大量用户同时操作,关系行分散,但该视频的总计数会成为写热点,需要拆成计数分桶。
这个场景同时暴露四个问题:客户端顺序、服务端幂等、异步事件乱序、热门资源热写。四者不能用一个“加分布式锁”笼统解决。
2.3 必须守住的业务不变量
- 同一用户与同一视频在任意时刻只有一个当前关系事实。
- 同一操作 ID 重试不能再次改变关系版本或重复影响计数。
- 已经处理过更高关系版本的投影,不能被更低版本覆盖。
- 从
UNLIKED到UNLIKED的重复取消不产生-1。 - 从
LIKED到LIKED的重复点赞不产生+1。 - 点赞数不能长期小于零,也不能依赖客户端上报的旧数字计算。
- 视频不可见、已删除或用户无权访问时,新增点赞必须被拒绝。
- Redis 丢失不能抹掉已提交的点赞关系。
- MQ 重复、延迟或短暂不可用不能让关系写入回滚成未知状态。
- 人工改数必须对应修复依据,不能直接在缓存里“调到看起来正常”。
- 同一用户视频关系在点赞、取消、重新点赞和扩缩容期间始终属于同一个稳定虚拟桶。
- 虚拟桶迁移期间任一时刻只有一个物理分区能接受当前 route epoch 的写入。
- 用于恢复的计数快照必须包含关系投影、桶计数、路由清单和完整消息 offset 向量。
- 反向索引已处理高版本取消后,低版本点赞不能重新暴露用户身份。
- 虚拟桶的哈希算法、种子、键编码和
V必须作为不可变版本一起保存,代码升级不能偷偷改变关系所属桶。 - 恢复快照只有在两份全量投影制品、不可变路由清单、行数、校验和与 offset 向量全部验完后才能标记为
SEALED。
2.4 两类事实采用不同一致性
| 数据 | 用户感知 | 推荐一致性 | 原因 |
|---|---|---|---|
| 我的点赞状态 | 提交成功后应读到最新结果 | 关系事务提交后立即确认 | 决定按钮、个人列表和重复操作语义 |
| 单个视频展示点赞数 | 可允许短暂延迟 | 异步最终收敛 | 数字高频读取,热点写入集中 |
| 我的点赞列表 | 与关系事务同步可见 | 直接查询关系事实表的事务内二级索引 | 用户会直接核对刚刚操作的视频;只有另建搜索投影时才允许延迟 |
| 谁赞过该视频 | 通常允许秒级投影延迟 | 反向索引最终一致 | 不是关系写入的主交易路径 |
| 推荐特征 | 允许消费延迟 | 以事件水位为准 | 推荐系统有自己的摄取和训练边界 |
“最终一致”不是不设期限。产品需要定义可接受窗口,例如设计目标是常态下计数投影在数秒内收敛,超过阈值触发告警;具体数值要由真实压测和产品体验共同确定。
三、总体技术架构与模块所有权
3.1 总体架构
架构里的关键分界不是“用了多少中间件”,而是谁拥有事实:
- 关系事实分片库拥有用户当前是否点赞的最终判定;
- Outbox 拥有关系变更尚待发布的可靠证据;
- 稳定虚拟桶与投影库拥有可重建的计数投影,桶路由账本决定物理落点;
- 持久缓存版本投影拥有 Redis 能接受的关系版本下限,Redis 清空不会清掉这份下限;
- Redis 只负责加速关系批量读取和计数展示;
- MQ 负责传播,不负责替代关系数据库成为唯一事实;
- 反向索引用于“谁赞过”“按时间分页”,不是命令裁决依据。
3.2 模块责任表
| 模块 | 负责 | 不负责 |
|---|---|---|
| API 网关 | 身份透传、粗粒度限流、请求大小与协议检查 | 判断点赞关系是否已存在 |
| 点赞命令服务 | 鉴权、目标状态命令、事务、版本、幂等与 Outbox | 同步计算全站展示总数 |
| 视频可见性快照 | 快速判断视频是否存在、下架、仅好友可见 | 替代视频服务的审核事实 |
| 关系缓存投影 | 按关系版本维护持久版本下限,再更新 Redis | 从无版本的读回填覆盖更高关系事实 |
| Outbox 发布器 | 可靠扫描、发布、确认与重试 | 把发布确认当成所有消费者完成 |
| 计数投影服务 | 按关系版本收敛、更新稳定虚拟桶、生成带 offset 向量的快照 | 改写原始点赞关系 |
| 反向索引服务 | 维护关系键版本墓碑和按视频排序的查询索引 | 参与点赞成功裁决 |
| 对账服务 | 发现水位停滞、抽查恒等式、生成修复任务 | 无审计地直接改缓存数字 |
3.3 同步与异步边界
同步链路只做用户必须立即知道的裁决:
- 从网关取得可信用户身份。
- 校验视频允许当前用户操作。
- 按操作 ID 判断是否重复。
- 更新关系状态并递增版本。
- 同事务写入 Outbox。
- 返回已提交的关系状态和版本。
异步链路承担可延后、可重放的投影:
- 视频点赞数;
- 视频点赞者反向索引;
- 创作者通知;
- 推荐和风控行为流;
- 运营统计与离线报表。
不要把“通知作者”塞进点赞事务。通知平台抖动不应该让用户无法点赞。也不要在命令事务中同步调用推荐系统,否则推荐延迟会扩大为点赞接口延迟。
四、关系状态机与核心数据模型
4.1 点赞关系状态机
数据库里第一次操作前可以没有关系行,但业务上应视为 UNLIKED。一旦发生过关系变化,建议保留 UNLIKED 墓碑及版本一段受控生命周期,而不是取消时立即物理删除。否则迟到的低版本点赞事件无法与更高版本取消事件比较,对账也失去明确依据。
内容下架、账号封禁、隐私隐藏和风控排除不建议硬塞进这台二态关系机。它们分别属于视频可见性、账号状态和公开计数策略,可以用独立状态与调整账目叠加在原始关系上。这样既保留“用户确实执行过点赞”的审计事实,也能解释为什么这条关系当前不对外展示或不进入公开计数。
4.2 为什么接口是设置状态,不是切换状态
客户端不应该调用:
POST /videos/V9001/like/toggle
因为请求超时后重试一次,toggle 会把已经成功的点赞反向取消。服务端也无法知道第二次请求是用户的新动作还是第一次的网络重试。
推荐命令表达为绝对目标状态:
PUT /v1/videos/V9001/like-relation
Idempotency-Key: op-101
{
"desiredState": "LIKED",
"baseVersion": 10,
"deviceId": "device-a",
"clientSequence": 81
}
响应至少包含:
{
"state": "LIKED",
"relationVersion": 11,
"operationId": "op-101",
"countConsistency": "EVENTUAL"
}
baseVersion 用于识别客户端基于旧页面发出的操作;clientSequence 用于同一设备队列排查和风控,不能单独承担跨设备全局顺序。最终关系版本由服务端事务生成。
4.3 实体关系图
这张 ER 图里有三类“投影状态”,不能只保留最终查询索引:
LIKE_COUNT_PROJECTION保存每段用户视频关系最后处理到的版本和稳定虚拟桶;LIKE_RELATION_CACHE_PROJECTION是 Redis 关系缓存之前、按缓存代次隔离的持久版本下限,Redis 清空后仍然存在;LIKE_REVERSE_PROJECTION保存视频点赞者列表最后处理到的关系版本、当前状态和原始liked_at,排序索引只负责分页。
COUNT_BUCKET_SPACE 冻结一套虚拟桶契约:哈希算法、种子、规范化键编码和 V 缺一不可。COUNT_PROJECTION_GENERATION 只能绑定一个桶空间版本;关系投影、虚拟桶行、消费回执和水位都属于这个代次。代码升级若要换算法、种子、编码或 V,必须创建新桶空间和影子投影代次,完成重建、追增量、校验后再切换,不能对在线行原地重新取模。
COUNT_BUCKET_ROUTE 是便于在线解析的当前路由物化表,不能单独充当裁决或历史恢复依据。每次切换先生成候选 COUNT_ROUTE_MANIFEST + COUNT_ROUTE_MANIFEST_ITEM;清单必须覆盖该桶空间的全部虚拟桶,保存每个桶的唯一物理分区与 route epoch,并以行数和校验和封存。随后在一个元数据事务里 CAS COUNT_PROJECTION_GENERATION.active_manifest_id 并更新发生变化的当前路由行。宕机后以激活指针为裁决,路由物化表不一致时从该清单重建。恢复、聚合和对账同样引用激活过的不可变 manifest_id,不是事后可能已经变化的当前路由表。
COUNT_EVENT_RECEIPT 与关系投影、虚拟桶计数写在同一个本地事务里,保存这次状态变化来自哪个消息位置。事务成功后,协调器才能把 COUNT_CONSUMER_CHECKPOINT.next_offset 推进到连续完成的位置;如果数据库已提交而 MQ 位点尚未提交,重放仍会被事件回执和关系版本安全吸收。
COUNT_SNAPSHOT_ITEM 只适合保存视频级展示汇总。一个可用于恢复或精确对账的 RECOVERY_CONSISTENT 快照,必须另外保存完整 LIKE_COUNT_PROJECTION 制品、完整 VIDEO_LIKE_COUNT_VBUCKET 制品、不可变路由清单和 topic + partition + next_offset 向量。两份制品都要记录 checkpoint URI、行数和内容校验和;路由清单也要处于 SEALED 且覆盖全部虚拟桶。任何制品缺失、行数不符、校验失败或分区向量不完整时,COUNT_SNAPSHOT.snapshot_status 只能是 BUILDING/FAILED,不能进入 SEALED。任意一条关系自己的 relation_version 也不能冒充全局水位。
4.4 关系表的约束与索引
VIDEO_LIKE_RELATION 至少需要:
- 主键或唯一约束:
user_id + video_id; - 用户点赞列表索引:
user_id + state + updated_at + video_id; - 单行 CAS 字段:
relation_version; - 最近操作标识:
last_operation_id,用于快速响应最近一次重试; - 创建与更新时间,支持归档、审计和时间窗口对账。
LIKE_COMMAND 以 operation_id 唯一,保存这次命令得到的确定结果。只在关系行保存 last_operation_id 不足以处理旧操作在新操作之后重试:旧操作已经不再是“last”,仍可能重新进入业务。因此需要一个有保留期的幂等记录,或者用等价的持久请求日志保证操作 ID 唯一。
幂等记录并非必须永久在线保存。可以按照最大客户端重试窗口、客服追溯窗口和审计要求分层归档,但不能在请求仍可能重试时提前删除。
4.5 分片键的选择
关系事实优先按 user_id 分片:
- 信息流要一次查询“当前用户对 20 个视频的状态”,可以路由到同一个用户分片并批量读取;
- 我的点赞列表天然按用户分页;
- 热门视频的点赞关系不会全部压在一条视频分片上;
- 同一用户的限速和异常行为更容易聚合观察。
代价是“查询某视频所有点赞用户”不能直接在关系事实库高效完成,需要异步构建按 video_id 分区的反向索引。这个代价应明确接受,不能既说按用户分片,又在同一张表上承诺任意视频的全量高效分页。
五、核心技术实现:点赞与取消点赞的端到端命令链路
5.1 正常点赞时序
响应发生在关系与 Outbox 一起提交之后,命令服务不直接双写关系缓存。异步缓存投影失败不撤销关系事务,MQ 暂时不可用也不让用户的已提交关系变回失败;它们分别由缓存代次重建和 Outbox 重试恢复。
视频可见性校验可以使用带版本和短 TTL 的本地或 Redis 快照,避免每次点赞同步调用视频服务。但对删除、封禁等高风险状态要有主动失效消息,必要时在关系落库前使用强校验。可见性策略由业务风险决定,不能只靠一个永久缓存值。
5.2 事务内的状态迁移
一次命令可以按下面的顺序处理:
- 先查
LIKE_COMMAND(operation_id);存在则复用当时操作裁决,并同时读取当前关系快照。 - 锁定或 CAS 读取
VIDEO_LIKE_RELATION(user_id, video_id)。 - 判断命令是否基于明显过期版本;根据产品策略返回当前状态或拒绝旧命令。
- 若目标状态与当前状态相同,记录幂等结果,但不递增业务版本、不产生计数变化事件。
- 若状态变化,关系版本递增一,写入绝对新状态。
- 保存命令结果和同版本 Outbox 事件。
- 提交事务后才向客户端确认。
“原样返回当时结果”指的是这次操作曾经得到的裁决,不代表它仍是关系的当前状态。若 op-101 得到 LIKED v11,之后 op-102 已经提交 UNLIKED v12,旧 op-101 再重试时,响应应区分:
operationResult: LIKED v11
currentRelation: UNLIKED v12
客户端也只能用不低于本地已确认版本的 currentRelation 更新按钮,不能让迟到的低版本响应覆盖 v12。这样既保持操作幂等,又不会把历史操作结果误当成当前事实。
常见条件更新可以表达为:
UPDATE video_like_relation
SET state = :desired_state,
relation_version = relation_version + 1,
last_operation_id = :operation_id,
updated_at = :now
WHERE user_id = :user_id
AND video_id = :video_id
AND relation_version = :expected_version;
SQL 只是说明 CAS 机制,不代表已有真实表结构。首次点赞需要处理无行插入与并发唯一键冲突;失败后重新读取当前版本,再决定返回冲突还是按同一操作重试。
5.3 同一关系是否需要分布式锁
通常不需要为每次点赞申请 Redis 分布式锁。user_id + video_id 唯一约束、行锁或版本 CAS 已经能串行化同一关系的有效变化,而且锁的真相与关系数据位于同一事务系统中。
分布式锁会额外引入超时、续租、锁丢失和跨系统一致性问题。只有当一次状态变化必须协调多个无法通过事件解耦的资源时才考虑更高层的协调,但仍不能用锁替代数据库约束。
5.4 快速点赞又取消:真正的顺序问题
客户端需要为同一 userId + videoId 维护一个很小的命令队列:
- UI 可以立即乐观变化;
- 网络层一次只让一个状态命令处于未决;
- 用户继续点击时只更新本地最终目标,不把无序
toggle全部并发发出; - 前一命令超时则用相同操作 ID 查询或重试;
- 确认服务端版本后,再发送尚未满足的最终目标状态。
这样做不是把正确性全部推给客户端。服务端仍要做操作幂等、状态版本和数据库约束。客户端队列解决“用户点击意图顺序”,服务端机制解决“请求重复、并发和持久事实”。
5.5 多设备并发时谁算最后
同一账号可能在手机和平板同时操作。不同设备没有天然共享的点击顺序,不能相信两个设备的本地时钟决定最终结果。合理口径是:
- 每个设备内部保证自己的命令顺序;
- 服务端以成功提交的关系版本定义全局顺序;
- 较旧
baseVersion的命令可以被拒绝,并返回当前状态供客户端刷新; - 若产品选择接受旧基线命令,则要明确采用“服务端最后提交获胜”,并让客户端用返回版本校正 UI。
对点赞这种可逆、低风险交互,返回当前状态并提示客户端静默收敛通常比强行覆盖更稳。涉及付费、投票或不可逆动作时则需要更严格的命令语义,不能照搬点赞策略。
5.6 幂等和顺序不是一回事
| 问题 | 例子 | 解决机制 |
|---|---|---|
| 重复 | op-101 因超时发送三次 | 操作 ID 唯一与结果复用 |
| 并发 | 两个服务实例同时写同一关系 | 唯一约束、行锁或 CAS |
| 乱序 | 取消事件 v12 先于点赞事件 v11 到达 | 单调关系版本与绝对状态投影 |
| 旧页面 | 用户基于 v7 页面操作,服务端已经 v12 | baseVersion 冲突策略 |
| 多设备 | 两台设备分别基于不同版本操作 | 服务端提交版本定义全局顺序 |
只说“接口做了幂等”无法回答乱序;只说“MQ 分区有序”也无法处理旧 HTTP 请求和发布器并发。每个问题需要自己的证据。
六、Redis 与数据库的真相边界
6.1 哪些数据以数据库为准
关系事实库保存:
- 当前关系状态;
- 服务端关系版本;
- 命令幂等结果;
- 变更时间与审计字段;
- 待发布的 Outbox 事件。
接口在数据库事务提交后返回成功,因此即使随后 Redis 整个实例组不可用,用户的关系也能从数据库恢复。数据库不是因为“更慢”就只能退居备份;它承担的是约束、事务和恢复依据。
6.2 Redis 保存什么
关系缓存可以按用户维度分桶,例如:
like:relation:g<generation>:{userId|relationBucket}
-> Hash(videoId => state|relationVersion|generation)
计数缓存分为两层:
like:count:g<generation>:{videoId|vb-<virtualBucket>}
-> activeCount|bucketVersion|routeEpoch|generation
like:count:view:g<generation>:{videoId}
-> displayCount|snapshotId|generation
第一类 Key 分散写入,第二类 Key 主要承担读取,由聚合任务周期性刷新。热门视频的所有变化不能直接 INCR 同一个 like:count:view:{videoId}。
尖括号表示变量,花括号是实际的 Redis Cluster hash tag。关系缓存按用户与关系桶落槽,计数缓存按视频与虚拟桶落槽;不要把 generation 或单独的 videoId 放进第一个花括号,否则会把大量写重新挤到同一个槽。
缓存值必须携带关系版本和缓存代次。更新脚本先确认目标 generation 是持久表登记的 ACTIVE 或 REBUILDING 合法代次,再只允许该代次内更高或相等版本覆盖旧值。仅在 Redis 值里比较版本还不够:Key 被淘汰或 Redis 整组清空后,空 Key 会错误接受迟到的 v11。因此 Redis 之前还要有一份持久的 LIKE_RELATION_CACHE_PROJECTION,以 cache_generation + user_id + video_id 为复合主键,分别保存每个代次内已经投影到的版本下限;同一代次、同一关系的缓存任务按聚合键串行,只能从对应代次的持久状态生成 Redis 写入。
6.3 Cache Aside 下的读后写窗口
关系读取使用批量 Cache Aside,但“读数据库后的返回”和“回填缓存”要分开:
- 信息流传入一组视频 ID。
- 查询服务读取当前已激活的缓存代次;代次处于
REBUILDING时直接绕过 Redis。 - Redis 批量读取当前用户的关系状态,只接受当前代次的值。
- 缺失项按用户分片一次查询数据库,并立即把数据库结果返回给本次请求。
- 查询线程不把刚读到的值直接写回 Redis,而是提交带
user_id + video_id + observed_version的修复提示。 - 缓存投影服务重新读取或 CAS 推进当前
active_generation的持久版本投影,再从该代次投影状态写 Redis。 - 返回结果按原视频顺序组装。
这条限制处理一个经典窗口:读请求先从数据库读到 v11,随后取消事务提交 v12;v12 关系事件先推进持久缓存投影并输出新缓存,最后早先读请求产生的 v11 修复提示才到达。若查询线程能直接写缓存,v11 仍可能在新值之后复活;改由持久版本投影串行裁决后,v11 不高于已经保存的 v12,只能被拒绝。这里没有“命令服务同步删除 Redis”这一步。
命令提交后不直接承担“数据库 + Redis 双写成功”的承诺。同版本关系事件会推进缓存持久投影;当前设备则使用命令响应中的 state + relationVersion 提供即时体验。这样缓存链路延迟不会改变点赞成功语义,也不会让不同实例发出的无序缓存写互相覆盖。
为了提供当前设备的读后写体验,点赞响应本身就是最可靠的即时结果。客户端应以响应中的 state + relationVersion 更新本地会话覆盖层,直到下一次服务端批量查询返回不低于该版本的数据。
6.4 Redis 整组丢失时怎样阻止旧状态复活
整组缓存重建使用持久代次和双阶段切换:
协议必须满足:
CACHE_GENERATION位于持久存储,Redis 清空不会清掉当前代次和重建状态。- 发现整组丢失后先创建新代次并把查询切到回源,不能一边让旧任务写空 Key、一边宣布缓存可用。
- 所有 Redis 写任务携带生成时取得的代次;Lua 脚本只接受持久表当前激活或正在构建的指定代次。旧代次任务即使迟到也不能写入新命名空间。
- 新代次的基线来源是关系事实分片库在 checkpoint 向量
W下的状态,不是旧的持久缓存投影;G与G+1使用不同复合主键空间,所以旧代次 v15 不会拒绝新代次在W时刻写入的 v12。扫描前先启动 CDC 或 Outbox 尾流捕获,再在G+1内重放各分片W之后的版本事件,直到它追上当前源版本。 - 只有基线、增量和抽样核对都通过后才原子激活新代次。失败时保留
REBUILDING/FAILED证据并继续回源,不返回半成品缓存。 - 单个 Key 被淘汰时也不允许读线程直接回填;缓存投影服务从当前活跃代次的持久版本下限重新生成它,因此空 Key 不会成为低版本事件的入口。
- 持久缓存投影负责比较版本、保存版本下限并输出 Redis,不负责凭自己还原源事实;若关系事实 checkpoint 或后续变更流不可用,就不能把这次重建标记为完整。
代次解决“缓存清空后旧任务失去比较对象”,关系投影版本解决“同一代次内事件重复和乱序”。两者缺一不可。
6.5 异步落库:什么可以异步,什么不应该异步
推荐基线是“关系同步落库,计数异步落库”。原因是:
- 用户是否点赞是需要立即确认的核心关系事实;
- 每个点赞写入不同的用户视频关系行,天然比单个视频计数行分散;
- 同事务 Outbox 能留下异步传播证据;
- 点赞数允许短暂延迟,适合削峰与批量落库。
如果写入规模超过关系库容量,可以演进为“可靠受理后异步写关系”,但接口只能返回 ACCEPTED/PROCESSING,不能返回已经点赞成功。该模式还必须补齐:
- 可持久化的接入日志,不只把请求放在进程内存;
- 同一关系的分区顺序与绝对状态版本;
- 用户查询未决请求结果的接口;
- 消费失败后的补偿和客服入口;
- 接入成功但关系最终拒绝时的 UI 回滚语义;
- Redis 丢失后的请求重放来源。
这套复杂度只有在同步分片库经过真实压测仍不满足目标时才值得承担。不能因为“高并发”三个字就默认先写 Redis 再慢慢落库。
七、关系事实与点赞计数必须分离
7.1 为什么不能每次直接更新视频表
若每个点赞事务都执行:
UPDATE video SET like_count = like_count + 1 WHERE video_id = ?;
同一个热门视频会让所有用户竞争同一行。即使关系表已经分散,计数行仍会成为数据库锁热点。取消、重复消息和补偿还会使计数更新语义变得复杂。
另一个错误是每次页面查询都执行 COUNT(*)。关系数据达到大规模后,按视频跨用户分片聚合既昂贵又无法支撑信息流高频读取。
因此需要把两件事分开:
- 关系表回答“用户 U 当前是否点赞视频 V”;
- 计数投影回答“根据已处理到的关系版本,目前可展示多少赞”。
7.2 关系事件为什么携带绝对状态
Outbox 事件建议包含:
{
"eventId": "evt-9008",
"aggregateKey": "user-7:video-9001",
"relationVersion": 12,
"absoluteState": "UNLIKED",
"userId": "user-7",
"videoId": "video-9001",
"sourceShard": "relation-shard-03",
"sourceCommitSeq": 9280017,
"stateChangedAt": "2026-08-19T10:20:30Z"
}
不要让消费者只看到 delta = -1。绝对状态与关系版本使消费者能够从自己的投影状态计算变化,即使中间事件暂时缺失或顺序颠倒,也能直接向更高版本的当前状态收敛。
stateChangedAt 由关系事务在真正发生状态迁移时生成并与新版本一同提交。对 UNLIKED -> LIKED,它就是反向索引使用的 serverLikedAt;对取消,它记录进入 UNLIKED 的事务时间。重复点赞或重复取消不产生新版本,也不伪造新的变化时间。事件生产时间、MQ 投递时间和消费者处理时间都不能替代它,否则重试或积压会把点赞者顺序改写。事件中可以附带旧状态和理论增量用于审计,但消费者不能无条件执行增量。真正的增量应由“消费者已投影状态”和“更高版本绝对状态”比较得到。sourceShard + sourceCommitSeq 用于证明用户分片事实已经传播到哪里;它与 MQ 的 topic + partition + offset 是两套不同水位,不能互相冒充。
7.3 计数投影如何处理重复与乱序
设消费者当前保存 projectedState=UNLIKED, projectedVersion=10:
- 收到 v11
LIKED,计算+1,保存 v11; - v11 重复到达,版本不高,直接忽略;
- v12
UNLIKED先到,计算-1,保存 v12; - 随后迟到的 v11 因版本更低被忽略;
- 若消费者直接从 v10 收到 v12
UNLIKED,状态没有变化,只推进版本,不做加减;这正好折叠了中间“点赞又取消”。
这套逻辑要求 LIKE_COUNT_PROJECTION、VIDEO_LIKE_COUNT_VBUCKET 和 COUNT_EVENT_RECEIPT 处在同一个物理分区、同一个本地事务中。若先改投影状态再改计数,中间宕机会让重试认为版本已处理,却永远少一次加减;若数据库已提交但消息位点提交前宕机,回执和关系版本又能让重放安全变成空操作。
投影第一次见到某段关系时,把旧状态视作 UNLIKED v0。因此若 v12 UNLIKED 在 v11 LIKED 之前到达,只会建立 v12 墓碑并把消费证据写下,不会执行 -1;后到的 v11 版本更低,也不会让关系复活。
7.4 计数投影表是不是重复数据
它确实是重复数据,但重复有明确价值:
- 记录消费者已经看到哪个关系版本;
- 从绝对状态推导安全增量;
- 让消息重放不重复加数;
- 支持审计“关系事实与计数投影卡在哪个版本”;
- 在计数重算时提供分段水位。
如果选择流处理引擎的持久状态库承担这一职责,也必须说明状态如何备份、恢复和重放。不能把进程内 Map 当成可靠投影状态。
逻辑归属不能只把一行取模公式写死在代码里,而要引用持久化的桶空间契约:
contract = COUNT_BUCKET_SPACE[bucketSpaceVersion]
canonicalBytes = contract.canonicalKeyEncoding(videoId, userId)
virtualBucket = unsignedHash(contract.hashAlgorithm, contract.hashSeed, canonicalBytes) mod contract.V
physicalPartition = routeTable[bucketSpaceVersion, virtualBucket]
规范化编码必须消除拼接歧义,例如使用“长度前缀 + UTF-8 字节”编码两个 ID;仅写 videoId + ':' + userId 而不冻结转义规则并不稳定。哈希算法名称与版本、种子、键编码版本、V 和 bucketSpaceVersion 一起成为不可变合同。
LIKE_COUNT_PROJECTION 和它影响的 (projection_generation, video_id, virtual_bucket) 计数行保存同一个 bucket_space_version,并始终按该版本算出的虚拟桶路由,才能在当前物理分区完成本地事务。扩容只改变同一桶空间内的物理路由,不会改变某段关系的虚拟桶。若必须改变哈希合同或 V,要创建新桶空间和影子投影代次,从源事实重建、追平增量、对账后再 CAS 切代;不能让新代码在旧表上原地重算。原始关系事实仍按 user_id 分片,两套存储服务于不同访问路径;这里的重复是受事件版本和对账控制的投影,不是两个都能独立裁决的“双真相源”。
7.5 计数异步批量落库
单条事件更新一个计数分桶行,热点时仍可能产生高频事务。计数消费者可以在很短的窗口内合并变化,但一个消息批次可能跨多个物理分区,不能假装存在一个覆盖所有分区的本地事务。可执行流程是:
- 逐事件校验关系投影版本。
- 按当前
routeEpoch + physicalPartition拆成本地批次,再累计各虚拟桶的净变化。 - 每个本地事务批量更新关系投影、虚拟桶计数和消息应用回执。
- 所有本地批次成功后,才把该 MQ 分区的
next_offset推进到连续完成的位置。 - 若只成功了一部分,整批可以重放;已提交分组被事件回执和关系版本吸收,未提交分组继续执行。
- Redis 分桶和展示快照再由带版本任务更新。
如果同一 MQ 分区内并行处理多个 offset,协调器必须保存未完成缺口,只能推进“没有空洞的连续前缀”,不能把最大已见 offset 当成消费水位。每个事件应携带 source_topic + source_partition + source_offset,这样消息位点、投影事务和恢复快照之间才有可核对的映射。
批量窗口越大,写放大越小,但展示延迟与失败重做范围越大。窗口大小必须通过设计目标和压测确定,不应把固定的 100 毫秒当作任何系统都适用的答案。
7.6 展示快照和恢复快照不是一回事
展示快照服务于高频读取,可以按视频独立刷新。它允许不同视频来自略有差异的时刻,记录 generatedAt 和观测水位即可,不承担精确日志恢复。
可恢复快照则必须给出一个没有混入边界外事件的一致切面。对每个消息分区 p,定义:
nextOffset[p] = 快照尚未包含的第一条消息
也就是说,所有 offset < nextOffset[p] 的影响都已进入快照,所有 offset >= nextOffset[p] 的影响都没有进入。不能只保存一个最大 offset,也不能遗漏“暂时没有新消息”的分区。
并行消费时,这个定义需要调度栅栏才能成立。每个 MQ 分区的 dispatcher 必须按连续 offset 顺序分发,worker 可以并行完成;创建快照时按以下顺序执行:
- 先停止分发新 offset,再把
B[p]记为该分区第一条从未分发的 offset。 - 排空所有已经分发且
offset < B[p]的在途事务,直到事件回执无空洞、持久next_offset恰好等于B[p]。 - 校验快照视图中不存在任何
offset >= B[p]的COUNT_EVENT_RECEIPT。若 dispatcher 曾跳号分发、边界外事务已经提交,当前切面直接作废,不能靠调大B[p]掩盖。 - 从停止分发到制品封存期间一旦发生 consumer-group rebalance、分区增减或所有权变化,整次快照中止并重新协调。
基线方案可以在 barrier 期间短暂停止计数投影写入;规模更大时可使用流处理引擎的对齐 checkpoint,或数据库提供的跨分片快照令牌,但仍要证明同一边界,不能只借用“checkpoint”这个名称。创建期间还要暂停路由切换,并把已经封存的 bucket_space_version + route_manifest_id 写入快照;单纯记录当前可变路由表的版本号不够。
恢复快照必须同时包含完整 LIKE_COUNT_PROJECTION 和完整 VIDEO_LIKE_COUNT_VBUCKET。只保存桶数字而不保存同一水位的旧关系状态,重放一条取消事件时就不知道是否应该 -1。协调器只有在两份 checkpoint 可读取、行数与校验和一致、路由清单完整且不可变、offset 向量覆盖当时全部分区、边界外无回执时才允许 SEALED。任何一项缺失、offset 倒退,或日志保留期已经越过 nextOffset[p],该快照都不能用于日志恢复,只能从关系事实重新构建。
7.7 计数允许延迟,但不能无期限漂移
对外可以展示近实时整数、格式化数字如 8.7万,也可以在极热点时采用较低刷新频率。无论产品如何展示,内部需要保存:
- 当前各分桶计数;
- 聚合快照版本或更新时间;
- 消费者消息水位;
- 最老未处理事件年龄;
- 对账最近成功时间;
- 修复批次和修复原因。
“用户不在意差一两个”不能成为不做对账的理由。推荐排序、创作者分析和反作弊都会依赖计数质量。
八、热门视频、单 Key 热写与热用户治理
8.1 热点到底落在哪里
热门视频出现时,关系事实按 user_id 分片,因此不同用户不会竞争同一关系行。真正的集中点通常是:
- 单个
videoId的计数 Key; - 单个视频计数数据库行;
- 按视频维护的点赞用户反向索引分区;
- 该视频的详情页计数读取 Key;
- 下游推荐或通知消费者的同一分区。
如果设计只展示 Redis Cluster 有很多节点,却让所有点赞都 INCR like:count:{videoId},该 Key 仍然只落在一个主节点。集群扩容不会自动把一个 Key 拆开。
8.2 计数分桶
V 是预先规划的逻辑虚拟桶总数,例如可以在容量验证后选择 1024 或 4096,但示例值不是通用答案。在同一个 bucketSpaceVersion 内,哈希算法、种子、规范化键编码和 V 都保持不变:
canonicalBytes = lengthPrefixedUtf8(videoId, userId)
virtualBucket = unsignedHash(hashAlgorithm, hashSeed, canonicalBytes) mod V
因此同一投影代次里,一段关系的点赞、取消、重新点赞和事件重放永远落在同一个虚拟桶,不需要记住“点赞当时用了几只桶”,也不会因扩容重新取模。消费者必须读取代次绑定的桶空间合同,不能使用部署包里的“最新默认值”。冷门视频只创建实际触达的桶行,不需要预先为每个视频生成 V 行;热门视频则会自然铺到更多虚拟桶。
Redis Cluster 中还要检查实际槽位。Key 可以写成:
like:count:{videoId|virtualBucket}:value
花括号里同时包含视频和虚拟桶,才能让一个热视频的不同桶进入不同槽。若写成 like:count:{videoId}:vb-37,所有桶仍会被强制放到同一个槽。聚合器不依赖跨槽原子操作,而是按 Redis 节点分组并行读取,再生成一个只读展示快照。
8.3 桶数怎样确定
稳定虚拟桶总数不是越多越好。需要在首次建模时从单个热视频峰值反推:
V >= 单个热点视频关系变化峰值 / 单个虚拟桶可持续写能力 x 安全余量
还要单独验证每个物理分区承载多个虚拟桶后的总吞吐,以及单可用区故障时的剩余容量。V 增加会带来更多桶行、缓存 Key、聚合和巡检成本;V 太小则会让未来增加机器也无法继续拆散一个逻辑桶。改变 V、哈希算法、种子或编码中的任意一项,都要新建 bucketSpaceVersion + projectionGeneration,在影子命名空间全量重建并追增量,核对无误后切换活跃代次;不能直接更新配置让旧数据重新取模。
日常扩容和缩容只迁移“虚拟桶到物理分区”的路由,状态机如下:
| 状态 | 唯一权威与动作 | 宕机后的幂等恢复 |
|---|---|---|
PREPARE | 激活指针仍指向源清单、源分区 epoch E;迁移账本冻结 bucket_space_version、源/目标 route epoch 和源清单,创建目标影子命名空间 | 按 migration_id 重建或复用目标;合同版本、激活指针或源清单不匹配时拒绝恢复 |
COPYING | 源仍是唯一读写点;在一致基线 C 复制该虚拟桶的关系投影和计数行,源事务同时留下 CDC 或迁移 Outbox | 从持久主键游标续传,目标按行版本幂等覆盖,绝不把源和目标同时求和 |
CATCHING_UP | 目标从 C 后重放源端变更,账本保存 replay_next_position;查询仍只认源 | 从已保存位置继续,重复变化由关系版本和批次 ID 吸收 |
CUTOVER | 先把源端 (bucketSpaceVersion,virtualBucket,E) 设为 DRAINING 并排空在途事务,记录最终水位 H;目标追到 H 后先封存覆盖全部桶的候选清单,再在一个元数据事务中 CAS 激活指针并把当前路由更新为目标 epoch E+1 | 以 active_manifest_id 为最终裁决:CAS 前可继续完成或确认后解除源栅栏;CAS 后目标已是唯一权威,绝不能自动重开源写入;物化路由异常可从激活清单重建 |
VERIFY | 查询和聚合只认激活清单中的目标,源保留只读副本;按 H 的摘要核对投影状态和虚拟桶恒等式 | 校验可重复;切换后若目标已有新写,回退必须走反向迁移或版本化修复,不能直接翻回旧清单 |
RETIRED | 等待消息重试、路由缓存和修复任务都越过旧 epoch,再清理源副本与迁移日志 | 清理按账本幂等执行,失败不会改变当前权威路由 |
关系事件不固化物理分区;计数投影消费者从当前 projectionGeneration 取得不可变 bucketSpaceVersion 后计算虚拟桶,再解析该桶的当前 routeEpoch。目标分区同时检查桶空间版本和本地栅栏。切换后仍投递到源端的旧路由请求会得到 ROUTE_EPOCH_MISMATCH,此时不能提交 MQ 位点,只能刷新路由并去目标重试。目标已经复制截至 H 的投影版本,所以迟到低版本会被忽略,尚未处理的高版本则正常生效。
迁移账本的 cutover_checkpoint_ref 引用源分区内该虚拟桶的变更日志水位,不等于随便选一个 MQ offset:一个虚拟桶可能接收多个 MQ 分区的数据,两者需要分别记录。账本还要保存源/目标 manifest ID 与 route epoch,宕机恢复时才能判断走到 CAS 前还是 CAS 后。扩容是把部分虚拟桶迁到新增节点,缩容是把待下线节点的虚拟桶迁出,二者使用完全相同的协议。迁移期间聚合器在一个确定的不可变路由清单下,每个虚拟桶只读取唯一权威副本,绝不能把源和目标相加。
虚拟桶和路由层次增加会带来:
- 聚合读取成本增加;
- Key 和数据库行数量增加;
- 路由迁移与栅栏协议更复杂;
- 对账范围变大;
- 运营排查需要同时理解虚拟桶、物理路由和 route epoch。
这些成本只有在单 Key、单行或固定少量分桶已经被真实热点压测证明不足时才值得承担。
8.4 展示计数的读热点
写入分桶后,展示不应让每个信息流请求都读取几十个桶。投影服务定期生成 displayCount 快照,信息流批量读取快照。热视频的单个快照 Key 仍可能是读热点,但读热点比原子热写更容易治理:
- 应用本地短 TTL 缓存;
- Redis 只读副本或热点只读副本;
- 信息流服务批量请求而非逐视频 RPC;
- 允许极热点视频降低刷新频率;
- CDN 或页面静态数据只能用于非个性化计数,不能缓存“我的点赞状态”。
8.5 热用户与刷赞用户
单个用户在短时间内对大量视频操作,会集中到一个用户关系分片,也可能是脚本刷赞。治理分为三层:
- 网关按账号、设备和 IP 做粗粒度令牌桶限速。
- 点赞服务按用户行为窗口判断异常切换频率、设备数量和视频分布。
- 风控异步结合账号年龄、播放时长、设备指纹和关系图谱判定是否降权或冻结。
限流不能只按 IP,否则校园、公司或运营商出口会误伤大量正常用户。也不能只按 userId,否则脚本可以批量注册账号绕过。
同一用户反复点赞取消同一视频时,服务端可以限制单位时间内的有效状态迁移次数,但仍需返回当前真实状态,不能简单丢弃请求导致客户端永远不知道结局。
8.6 热点降级顺序
当系统接近容量上限时,建议依次考虑:
- 降低展示计数刷新频率。
- 暂停低优先级创作者通知和离线特征消费。
- 对重复点击、异常设备和超额用户更严格限流。
- 关系查询缓存失败时限制大批量回源,保护数据库。
- 保留关系命令与 Outbox 主链路,延后计数和反向索引。
- 主链路也无法可靠提交时明确返回失败或稍后重试,不能假装成功。
九、缓存与消息故障下怎样恢复
9.1 失败窗口全景
图中的每一条失败分支都要有可查询证据。只配置重试次数而没有最老积压年龄、失败原因和人工入口,等于没有闭环。
9.2 数据库成功、接口响应丢失
客户端用相同 operation_id 重试。服务端从命令幂等记录返回第一次事务的 result_state + result_version,不再次修改关系。若客户端想表达后续取消,必须使用新的操作 ID 和绝对目标状态。
9.3 关系缓存投影或 Redis 写入失败
命令服务不承担“关系数据库提交后必须同步写 Redis”的双写承诺。关系事务和 Outbox 已成功时,用户的关系事实已经成立;关系缓存消费者稍后用同版本事件推进 LIKE_RELATION_CACHE_PROJECTION,再写当前 Redis 代次。
若投影或 Redis 写入失败:
- 当前设备继续使用命令响应中的
state + relationVersion作为会话版本下限; - 查询命中低于客户端所带
minRelationVersion的值时绕过缓存并回源; - 普通缓存缺失也批量回源,但查询线程只提交修复提示,不直接回填;
- 持久缓存投影保留已经处理到的版本,重试只能写相同或更高版本;
- 整组故障按 6.4 节创建新代次,旧代次任务不能进入新命名空间。
不能因为 Redis 失败就反向取消已经提交的关系,也不能让读线程无版本地“删完再写”。数据库、持久投影和 Redis 分别承担事实、版本下限和读取加速,故障时按这个顺序退化。
9.4 Outbox 发布器停机或 MQ 不可用
关系事务已经保存了事件,发布器恢复后继续扫描 NEW/RETRY 记录。需要关注:
- 最老未发布事件年龄;
- 每分钟新增与发布数量差;
- 单个聚合版本是否出现空洞;
- 发布重试次数和最近错误;
- Outbox 表增长和归档水位。
MQ 发送成功但发布确认丢失时,发布器可能重复发送。消费者必须依靠关系版本收敛,不能假设消息只来一次。
9.5 消费到一半宕机
投影关系状态、虚拟桶计数和 COUNT_EVENT_RECEIPT 必须在同一本地事务里提交,消息位点在事务成功后再确认:
- 事务提交前宕机,没有状态和回执,消息重放后正常执行;
- 数据库提交后、MQ 位点提交前宕机,重放命中回执或不高于当前关系版本,不会重复加减;
- 一个 MQ 批次拆到多个物理分区时,只成功的分组保留幂等证据,恢复后重做整个批次并补齐失败分组;
- 路由切换导致旧物理分区拒绝时,不得确认消息,刷新
routeEpoch后去新分区重试。
若状态、计数与回执确实使用不同存储系统,就失去了上述本地事务保证,必须增加可重放的事务日志或明确由对账修复哪个窗口,不能只写一句“最终一致”。
不能用“MQ 支持至少一次,所以不会丢”代替应用设计。至少一次意味着消息可能重复;发布前宕机、消费后确认前宕机、死信积压都需要应用层证据。
9.6 Redis 计数数据丢失
要先区分“Redis 加速层丢失”和“持久计数投影损坏”,两者不是同一种恢复。
只有 Redis 丢失时,LIKE_COUNT_PROJECTION、VIDEO_LIKE_COUNT_VBUCKET 和消费者水位仍是当前状态,不应该把持久库回滚到旧快照,也不需要重新消费历史 MQ。正确流程是:
- 在持久
COUNT_CACHE_GENERATION创建G+1 / REBUILDING,计数查询暂时读取持久展示快照或降级值。 - 重建期间暂停 route cutover,固定激活的
route_manifest_id;普通投影写仍可继续。按清单中每个虚拟桶的唯一权威物理分区读取计数行,写入G+1命名空间。 - 构建期间的刷新任务同时携带
generation + routeEpoch + bucketVersion;Lua 只接受目标代次、当前路由纪元和不低于现值的桶版本。 - 追平构建起点后的更新,核对分桶和、热点视频抽样与展示快照。
- CAS 把
G+1改为ACTIVE;旧代次和旧路由任务即使迟到也无法污染新 Key。
只有持久计数投影也损坏时,才使用 7.6 节的 SEALED 恢复快照:先校验两份全量制品的 URI、行数和校验和,再同时还原关系投影与虚拟桶,加载快照绑定的桶空间和不可变路由清单,最后从每个分区各自的 nextOffset[p] 开始重放。若某个 offset 已超出日志保留期、向量缺分区、manifest 缺桶或任一制品校验不通过,该快照不可用,必须走 11.4 节的源事实重建协议。
若系统只把分桶存在 Redis,又没有同时包含关系投影状态、完整 offset 向量和路由清单的持久快照,那么取消事件重放时无法判断旧状态,Redis 故障后的计数就不可证明地恢复。
9.7 消息积压时的背压
积压时先保护关系主链路,再按业务价值分配消费者资源:
- 计数投影优先于作者通知;
- 当前热点视频优先于冷门历史数据,但不能让冷分区永久饥饿;
- 对展示快照降低刷新频率,避免消费者同时做大量缓存写;
- 限制补偿任务并发,防止与实时消费争抢数据库;
- 超过产品可接受延迟时,展示可以标记近似值,但后台必须持续报告真实积压。
十、查询、批量接口与分页设计
10.1 信息流为什么必须批量查
一页信息流可能包含二十个视频。若每个视频分别调用:
- 一次查询点赞数;
- 一次查询我的点赞状态;
- 一次查询作者关系;
就会把一个页面放大成大量 RPC。推荐让信息流聚合层发起批量查询:
POST /v1/like-relations/batch-query
{
"videoIds": ["V1", "V2", "V3"]
}
用户身份来自登录上下文,接口一次返回:
videoId -> myState, relationVersion, displayCount, countSnapshotAt
myState 与 displayCount 的一致性标签不同,前端不能因为数字尚未加一就把已经确认的红心恢复成未点赞。
10.2 批量查询时序
查询服务应限制批量大小,避免一次请求携带几万个视频 ID 变成数据库扫描工具。输入去重后再查,输出仍按原始列表顺序组装。
10.3 我的点赞列表使用游标分页
推荐索引:
(user_id, state, updated_at DESC, video_id DESC)
游标包含上页最后扫描到的 updated_at + video_id,下一页使用“小于该游标”的条件继续读取。不要使用深 OFFSET,因为用户点赞数增长后,数据库需要跳过大量记录,且前面插入或删除数据会整体移动偏移量。
取消点赞后,关系行变为 UNLIKED,自然从 state=LIKED 的列表查询中消失。若产品要求记录历史,可以把历史变更放在审计流,不要让当前列表查询混入所有过去状态。
这里的普通游标定义的是实时列表遍历,不承诺一个固定历史快照:
- 翻页期间产生的新点赞或重新点赞会移动到当前游标之前,本轮继续向后翻页可能看不到,刷新第一页后才能看到;
- 翻页期间取消的项目会消失,当前页可能不足请求条数;
- 内容下架、隐私变化和风控过滤也会让可见集合变化;
- 这些变化不代表数据库丢数据,只代表查询的是不断变化的当前集合。
若运营导出要求“同一批次一条不多、一条不少”,就要创建带 asOf 的导出任务,使用数据库快照、时间旅行表或版本化离线快照,并在任务生命周期内保留该版本。实时 UI 游标不能冒充精确导出协议。
查询层通常还要过滤下架视频。为避免一页原始索引全部被过滤后游标原地不动,服务端应以“最后扫描的原始索引键”推进游标,循环扫描直到填满页面、到达末尾或触发扫描上限,而不是用最后一条返回给用户的可见记录作为游标。游标应签名并做成不透明字符串,防止客户端篡改排序键和过滤范围。
10.4 谁赞过该视频需要反向索引
关系事实按用户分片,不适合按视频全量扫描。反向投影不能只有一张排序表,还需要一张按 (video_id, user_id) 保存版本和当前状态的墓碑表。基线处理如下:
几个顺序例子能说明墓碑为什么必要:
- v12 取消先到且本地没有行:写
UNLIKED v12墓碑,不创建排序项;迟到的 v11 点赞不能复活它。 - v11 点赞后 v12 取消:取消事务从投影取得 v11 的原始
currentLikedAt,删除精确的(video, shard, likedAt, user)排序项,再写 v12 墓碑。 - v13 重新点赞:使用关系事务提交的
stateChangedAt作为这次serverLikedAt创建全新的排序项,不能复用第一次点赞时间,也不能使用事件生产、投递或消费者处理时间。 - 若 v13 点赞跨过尚未到达的 v12 直接出现,即使本地仍是
LIKED v11,也要用 v13 的时间替换旧排序项;在“只有有效状态变化才产生新版本”的事件契约下,更高版本的LIKED代表最新一次进入点赞状态。
反向投影状态和排序项最好按 video_id + reverse_shard 共置并在一个本地事务更新。这里的 R 也不能是随部署变化的配置:REVERSE_INDEX_GENERATION 要冻结哈希算法、种子、规范化用户 ID 编码和 R,投影行、排序项与外部游标都携带 reverse_generation。reverse_shard = generationHash(userId) mod R 只在该代次内计算。修改 R 或哈希合同要建立影子代次、重放并追平关系变化、校验后切换;不能原地重算,否则同一用户的墓碑与排序项会落到不同分片。热点视频查询时并行读取该代次的 R 个分片,按 liked_at + user_id 做 k 路归并。若两类数据不得不跨存储,就要增加版本化索引 Outbox,并在查询时校验排序项版本,不能声称跨库删除和插入天然原子。
排序索引可以按:
(video_id, reverse_shard, liked_at DESC, user_id)
分页,但它仍有几个边界:
- 索引可能比关系事实延迟;
- 用户取消、隐私变更或账号封禁后需要删除或隐藏索引项;
- 同一关系的低版本事件不能复活已经取消的索引;
- 公开视频也不代表所有点赞者身份都能对外展示;
- 用户隐私、账号状态和黑名单必须在返回时重新检查,异步清理只负责缩短脏数据停留时间。
隐私过滤与多分片归并时,“已扫描”必须精确定义为:某条原始候选已经从 k 路最小堆弹出,并且已经返回给用户或被隐私规则过滤。只有这时,该候选所在分片的游标才能推进到它的原始键。批量预取到内存、但尚未从堆弹出的候选不能推进游标;页面结束后可以丢弃预取缓冲,下一页从该分片最后已弹出的键继续查询,重新读到未消费候选并按稳定主键去重。若某分片本页一条都没有弹出,就保留它进入本页前的 frontier。这样既不会因整页隐藏用户而死循环,也不会因预取确认过早而漏项。
外部游标应签名并保存 reverse_generation 与每个分片的已弹出 frontier。代次切换后,可以让旧游标在保留期内继续读旧代次,或明确返回“游标已过期”要求刷新,不能拿旧 R 的向量解释新代次。普通点赞者列表仍是实时视图;取消和重新点赞期间,记录允许消失或移动。需要精确历史名单时,另建带 asOf 的审核导出,让数据快照、隐私规则版本和反向索引代次共同固定,不复用实时 UI 接口。
如果产品只展示头像摘要,不提供全量点赞用户列表,可以维护最近若干可见点赞者的有界集合,显著降低存储和分页复杂度。
10.5 计数与列表短暂不一致怎么解释
计数投影和反向索引是两个独立消费者,可能出现“数字已加一,头像列表稍后才出现”,或相反。接口应分别携带快照时间或水位,后台监控两条链路的延迟。
不要尝试在读接口中临时把反向索引 COUNT(*) 作为点赞数,也不要为了让两者一瞬间相等而把所有查询串到一个全局事务。产品可以接受短暂差异,系统负责在明确窗口内收敛。
十一、对账、修复与人工恢复
11.1 先区分投影内部恒等式和源完整性
第一层检查的是计数投影内部是否自洽。在同一个 SEALED 计数水位和同一份路由清单上,对一个视频应满足:
count(LIKE_COUNT_PROJECTION where projected_state = LIKED)
= sum(VIDEO_LIKE_COUNT_VBUCKET.active_count)
= raw_active_count
还可以按 (video_id, virtual_bucket) 分组逐桶比较,这比只看视频总和更容易定位一边多一、一边少一的抵消错误。
如果业务允许对外隐藏疑似刷赞,需要把原始数、调整量和最终展示数分字段保存:
display_count = raw_active_count - risk_hidden_count - policy_hidden_count
不要直接改 display_count 而丢失调整原因,否则之后无法解释为什么关系数与展示数不同。
但内部恒等式成立,并不能证明上游关系事件没有整体漏掉。关系投影和计数桶可能同时少了一条事件,二者仍然相等。因此第二层要检查关系事实到投影的源完整性:
- 为每个按用户分片的关系事实库选择源水位
sourceCommitSeq[shard]。 - 通过数据库历史快照、CDC 审计投影或可重放 Outbox,得到该源水位下的关系状态。
- 验证每个有效关系变化都有同事务 Outbox,并能映射到消息
topic + partition + offset。 - 等待计数消费者的完整 offset 向量至少覆盖这些源事件,再执行“版本覆盖检查”,不能直接要求投影等于旧源快照。
在线消费者可能已经吸收源水位之后的合法变化,所以逐关系判断规则是:
projectedVersion缺失或小于sourceVersion:投影滞后、漏发布或存在消费空洞,检查失败;- 两个版本相等:
projectedState必须与该源快照状态相同; projectedVersion更高:不能当作错误,也不能无证据放行;必须从源 CDC/Outbox 找到水位之后的有效关系变化,证明这个更高版本确由源事务产生,并确认投影状态等于已证明的最新绝对状态。
这叫在线覆盖检查,不是同一时刻的精确相等。如果确实需要逐行版本和状态完全相等,必须针对目标范围协调一个新切面:先阻止新的关系写入并排空在途事务,记录源分片水位;再把这些事务的 Outbox 全部发布并记录源提交序号到 MQ offset 的映射;最后按 7.6 节停止计数消费者分发、排空到对应 B[p]。只有源端、发布端和消费端三道 barrier 都成立,且期间没有 rebalance,才能比较精确相等,完成后再解除写栅栏。
若关系库只保留当前行,又没有历史快照、CDC 或足够长的 Outbox,就无法把“现在的关系表”精确还原到几分钟前的计数水位。此时只能使用上述协调 barrier 获得新切面,或承认本次检查只是近似抽样,不能把不同时间的数据硬比成精确结论。
11.2 对账流程
对账报告必须同时写明源分片水位、计数消息 offset 向量、桶空间版本、投影代次和不可变路由清单。还要标明执行的是“在线覆盖”还是“协调 barrier 下的精确相等”。relationVersion 只在一段 userId + videoId 关系内单调,不能当作全局水位;迁移账本的源端日志序号也只服务于虚拟桶搬迁,这些水位不要混用。
11.3 不要每天全库 COUNT(*)
全量跨分片聚合成本很高,可以分层执行:
- 实时检查投影内部恒等式、负数、版本倒退、桶突变和水位停滞。
- 按源分片检查“关系事务数、Outbox 数、已发布数、消费者覆盖数”的连续水位和缺口。
- 对热点视频、异常视频和发生过路由迁移的虚拟桶优先做精确对账。
- 通过 CDC 审计投影按源提交序号增量核对,冷数据按分片轮转抽样或离线聚合。
- 持久投影恢复、消费逻辑升级和大规模路由迁移后执行专项全量重算;仅 Redis 缓存丢失通常只需核对重建代次,不必扫描关系全库。
对账策略要能回答“覆盖了哪些源分片、哪个源提交序号、完整 MQ offset 向量是什么、使用哪套路由清单、哪些视频被跳过”。只有一个定时任务成功日志不足以证明数据一致。
11.4 修复优先重算,不优先补增量
如果不知道缺了哪一条 +1 或 -1,手工再补一个增量可能把错误扩大。修复前先判断错误层次:展示快照错误只重建展示层;桶与关系投影不等则从可靠投影重算虚拟桶;关系投影本身漏事件则从源事实或 Outbox/CDC 重建两者。
允许实时写继续进行时,推荐采用影子修复,但要把两种基线协议分开。
从 SEALED 恢复快照恢复:
- 创建新的
repairGeneration,加载快照绑定的桶空间与不可变路由清单。 - 校验并还原完整关系投影 checkpoint 和完整虚拟桶 checkpoint;任何 URI、行数或校验和不符都停止恢复。
- 以快照自己的完整
nextOffset[p]向量启动影子消费者,按事件回执和关系版本重放,直到新的消费 barrierT。 - 核对投影内部恒等式、源在线覆盖、路由清单和制品摘要,短暂栅栏目标范围并追平到
T后 CAS 激活新代次。
从关系事实重建:
- 选择关系分片 checkpoint 向量
S[shard],先启动对应的 CDC/Outbox 尾流捕获,再在新桶空间和固定路由清单下扫描S时刻的关系事实。 - 由源状态直接构造影子关系投影与虚拟桶,不读取损坏的旧计数投影;随后按
sourceCommitSeq应用S之后的绝对状态变化。 - 源水位
S本身没有 MQnextOffset[p]。切换前必须短暂设置源写 barrier,排空 Outbox 与尾流,通过已保存的源提交序号到 MQ offset 映射建立消费 barrierT;映射不完整时不能臆造 offset 交给正常 MQ 消费者。 - 在同一个协调切面执行精确源对账与投影内部恒等式,CAS 激活新代次后再解除源写 barrier。旧代次保留观察期,记录修复原因、审批人、前后差异和两套水位。
事件完整且消费逻辑已经修复时,优先使用 SEALED 快照和其 offset 向量重放;历史投影或快照不可信时,从事实重建。无论哪种方式,旧基线计算出的数都不能无版本地覆盖已经吸收新事件的在线桶。若修复范围正处于虚拟桶迁移,先固定不可变路由清单或等待迁移结束,避免修复任务和路由切换互相覆盖。
11.5 运营后台需要的恢复入口
后台至少提供:
- 按用户和视频查询当前关系、版本和最近操作;
- 按事件 ID 查询 Outbox、MQ 和各消费者状态;
- 查看视频各虚拟桶、route epoch、迁移账本、缓存 generation、展示快照和完整 offset 向量;
- 创建范围明确的重放或重算任务;
- 暂停某视频的展示快照更新;
- 审批风控隐藏与恢复;
- 查看修复任务执行人、参数、差异和结果。
后台不应提供一个没有审计的“点赞数改成任意值”输入框。
十二、隐私、安全与风控边界
12.1 鉴权与越权
点赞接口从认证令牌取得 user_id,拒绝客户端在请求体指定另一个用户。视频 ID 也要经过存在性和可见性校验:
- 私密视频仅作者可见时,其他用户不能点赞;
- 仅好友可见时,需要可信关系判断;
- 视频已下架或删除时,拒绝新增互动;
- 被拉黑双方是否允许互动,由产品规则明确;
- 管理员代操作必须走独立后台权限与审计接口。
12.2 点赞关系是否公开
“点赞数公开”不等于“点赞者身份公开”。需要分别定义:
- 其他用户能否查看我赞过的视频;
- 作者能否查看完整点赞用户列表;
- 私密账号是否出现在公开视频的点赞者列表;
- 注销、封禁或未成年人账户如何展示;
- 推荐系统能使用哪些行为字段以及保留多久。
查询反向索引时必须再次应用当前隐私规则,不能因为用户点赞当时是公开状态,就永久暴露其身份。
12.3 防刷不是简单不计数
风控有三种处置层次:
- 请求前拒绝:明显自动化或超限请求不进入关系事务。
- 关系保留、展示降权:保存原始行为供审计,但从公开计数中排除。
- 关系冻结或撤销:高风险处置,需要原因、证据和可恢复状态。
若直接删除疑似刷赞关系,对账会看到数量下降,却不知道是用户取消还是风控删除。推荐使用独立风险状态和调整账目保留原因。
12.4 重放、伪造与接口滥用
- 操作 ID 由客户端生成时仍要绑定用户和视频,不能跨关系复用。
- 高风险环境可对请求时间、设备标识和签名做校验,但服务端版本仍是最终顺序依据。
- 限制批量查询视频数量,防止枚举关系状态。
- 结构化日志不记录完整访问令牌、设备敏感标识或不必要的个人资料。
- 运营导出点赞者列表需要权限、用途和下载审计。
12.5 删除与数据生命周期
视频删除后可以停止展示计数并禁止新关系,但历史关系与审计是否物理删除要遵循数据保留规则。用户注销同样需要处理:
- 当前关系的匿名化或删除;
- 公开反向索引移除;
- 计数是否随关系删除而调整;
- 推荐特征与离线数据的删除传播;
- Tombstone 保留时间与异步事件最大延迟的协调。
删除流程本身也应产生带版本的状态事件,避免旧点赞消息在删除之后重新建立公开投影。
十三、指标、容量、压测与故障演练
13.1 分层可观测指标
命令入口:
- 按点赞、取消、重复、冲突、风控拒绝分类的请求量;
- 关系事务成功率和延迟分位;
- CAS 冲突率、唯一键冲突率;
- 单用户、单视频、单分片热点分布;
- 客户端旧版本命令比例。
可靠传播:
- Outbox 新增、已发布、重试和最老年龄;
- 各消息分区生产与连续消费水位,offset 向量缺失、倒退和空洞数量;
- 重复事件、低版本事件、版本跳跃数量;
- 死信或隔离事件数量与最老年龄。
计数投影:
- 关系事件到计数快照的端到端延迟;
- 各视频稳定虚拟桶写入分布、物理分区负载和最大偏斜;
- 负数桶、异常跳变、快照 offset 倒退或缺分区;
ROUTE_EPOCH_MISMATCH、各迁移状态停留时间和源目标追赶差;- 对账差异视频数与修复时长;
- 关系与计数缓存命中率、代次重建进度、旧代次拒绝量和数据库回源量;
- 反向投影墓碑数、低版本拒绝量、孤儿排序项和隐私过滤扫描放大。
13.2 设计目标必须拆成流量模型
容量推演示例统一标为设计目标:
| 维度 | 设计目标示例 | 用途 |
|---|---|---|
| 点赞与取消命令峰值 | 50,000 次/秒 | 关系库、服务实例和消息写入容量 |
| 展示计数读取峰值 | 200,000 QPS | 本地缓存、Redis 只读与批量接口容量 |
| 单个热点视频关系变化 | 10,000 次/秒 | 计数桶数量和反向索引分区 |
| 单个用户异常操作 | 100 次/秒 | 限流与风控阈值推演,不代表正常行为 |
| 常态计数收敛窗口 | 秒级目标 | 消费能力与告警阈值 |
| 故障恢复目标 | 由业务确认 | Redis 重建、MQ 积压追平和数据重算 |
50,000 次命令不等于 50,000 个有效 +1。其中可能包含重复点击、目标状态未变化、风控拒绝和取消操作。压测数据必须包含真实比例,否则只测全新用户持续点赞,会高估数据库新增写、低估幂等查询和取消路径。
13.3 容量估算的基本方法
对关系服务,至少计算:
有效事务 TPS
= 命令 QPS × 通过鉴权比例 × 非幂等重试比例 × 需要状态变化比例
对消息系统,计算状态变化事件大小、峰值事件量、分区偏斜和保留时长。对计数系统,分别计算普通视频均匀流量与单个热点视频流量,不能只看全站平均。
实例数量要以单实例在目标数据分布下的稳定吞吐为依据,并预留滚动发布、单可用区故障和积压追平空间。本文没有实际单机压测结果,因此不提供虚假的机器数量。
13.4 正确性测试矩阵
| 测试场景 | 应验证的不变量 | 证据状态 |
|---|---|---|
| 同一操作 ID 连续重试 | 关系只变化一次,返回同一版本 | 设计用例,未执行 |
| 已点赞再次点赞 | 不产生新计数变化 | 设计用例,未执行 |
| 未点赞再次取消 | 不出现负增量 | 设计用例,未执行 |
| 点赞事务提交后响应丢失 | 重试得到原结果 | 设计用例,未执行 |
| 点赞 v11 与取消 v12 乱序消费 | 投影最终为未点赞,净计数不变 | 设计用例,未执行 |
| 关系 Redis 整组清空后 v11 迟到 | 持久版本下限与新代次阻止 v11 覆盖 v12 | 设计演练,未执行 |
| Redis 重建完成后旧代次任务迟到 | 旧 generation 不能写入当前命名空间 | 设计用例,未执行 |
| Outbox 重复发布 | 投影版本不重复加数 | 设计用例,未执行 |
| 投影事务提交后、MQ 位点提交前宕机 | 重放命中回执,不重复加减 | 设计用例,未执行 |
| 一个 MQ 批次只提交部分物理分组 | 重放补齐失败分组,连续水位不越过空洞 | 设计用例,未执行 |
| 并行 worker 在快照 barrier 前后乱序完成 | 先停止分发,以第一条未分发 offset 定义 B[p];边界外回执使快照失败 | 设计用例,未执行 |
| 建快照期间发生 consumer-group rebalance | 当前快照中止,重新取得完整分区所有权后再建 | 设计演练,未执行 |
| v12 取消先于 v11 到达反向投影 | 建立 v12 墓碑,v11 不创建排序项 | 设计用例,未执行 |
| v13 重新点赞跨过未到的 v12 | 删除旧 likedAt,使用 v13 时间重新排序 | 设计用例,未执行 |
| 消费积压后重放点赞事件 | 排序使用关系事务的 stateChangedAt,不使用生产、投递或消费时间 | 设计用例,未执行 |
| 计数 Redis 清空 | 从当前持久虚拟桶构建新代次,不回滚持久投影 | 设计演练,未执行 |
| 恢复快照 offset 向量缺分区或倒退 | 快照拒绝 SEALED 或拒绝恢复 | 设计用例,未执行 |
| 恢复快照只包含桶、不含关系投影 | 快照判定不完整,避免重放取消时漏减 | 设计用例,未执行 |
| 恢复制品行数、校验和或路由 manifest 不匹配 | 快照保持 FAILED,不得用于重放 | 设计用例,未执行 |
代码升级改变哈希算法、种子、编码或 V | 旧桶空间结果保持不变,只允许影子代次重建切换 | 设计用例,未执行 |
| 点赞、取消、重新点赞跨物理扩容 | 关系始终使用同一稳定虚拟桶 | 设计用例,未执行 |
| 迁移各阶段进程宕机 | 按账本游标和路由 CAS 幂等恢复,始终只有一个权威副本 | 设计演练,未执行 |
| 路由切换后旧 epoch 请求迟到 | 源拒绝写入,刷新路由后目标按关系版本处理 | 设计用例,未执行 |
| 物理缩容迁出虚拟桶 | 与扩容使用同一协议,源节点可安全退役 | 设计演练,未执行 |
| 热门视频槽位压测 | 不同虚拟桶分布到多个 Redis 槽和物理分区 | 设计压测,未执行 |
| 源关系有变更但 Outbox 存在缺口 | 内部恒等式可能通过,源完整性检查必须失败 | 设计用例,未执行 |
| 在线对账时投影版本高于源快照 | 有后续源事件证据才通过;不能误判为状态不等,也不能无证据放行 | 设计用例,未执行 |
| 精确源对账缺少写、Outbox 或消费 barrier | 拒绝“精确相等”结论,只能标记在线覆盖或近似抽样 | 设计用例,未执行 |
| 对账影子重算与实时写并发 | 旧基线不能覆盖新版本,追平后才 CAS 切换 | 设计用例,未执行 |
| 实时分页期间取消和重新点赞 | 项目允许消失或移动,刷新后收敛 | 设计用例,未执行 |
| 一页原始点赞者都被隐私过滤 | 游标按最后扫描原始键推进,不循环、不漏后续项 | 设计用例,未执行 |
| 多分片查询预取后提前结束页面 | 只有已从归并堆弹出的项推进分片 frontier,预取未消费项下一页仍可读 | 设计用例,未执行 |
反向索引修改 R 或哈希合同 | 新 generation 影子重建;旧游标不解释为新分片向量 | 设计用例,未执行 |
| 视频删除与迟到点赞事件 | 旧事件不恢复公开关系 | 设计用例,未执行 |
每个高风险不变量既要有正常例,也要有反例。例如只测 v11 后 v12 的正常顺序不够,还要主动让 v12 先到、v11 重复到,并检查投影版本和最终桶值。
13.5 压测需要模拟真实热点
至少设置四组流量:
- 大量用户点赞大量不同视频,验证均匀分片吞吐。
- 大量用户集中点赞一个视频,验证计数分桶和反向索引热点。
- 少量异常用户快速切换大量视频,验证用户分片和风控。
- 同一批关系连续点赞取消,验证幂等、版本和净计数。
测试报告必须记录:数据准备方式、用户与视频基数、热点比例、点赞取消比例、消息大小、机器规格、持续时间、预热方式、错误定义和资源瓶颈。只给一个峰值 QPS 数字没有复现价值。
13.6 故障演练清单
可以按以下顺序注入故障:
- 关闭关系 Redis,观察数据库回源保护和当前设备读后写体验;
- 暂停 Outbox 发布器,观察关系命令是否仍成功、积压告警是否触发;
- 让 MQ 某分区不可用,观察重试和分区偏斜;
- 在投影事务提交前后分别杀进程,验证消息位点与计数不重复;
- 清空关系 Redis 后注入旧代次、低版本回填,验证查询回源和版本下限;
- 清空计数 Redis,验证从当前持久虚拟桶构建新代次,而不是回滚投影;
- 人为制造 v12 先于 v11、v13 跨过 v12,分别验证计数和反向排序投影;
- 在并行 worker 尚有在途任务时创建快照,分别制造边界外回执、consumer rebalance、offset 向量缺分区、倒退和日志过期,验证快照中止或恢复被拒绝;
- 删除一份恢复制品、篡改行数或校验和、让路由 manifest 缺桶,验证快照永远不能
SEALED; - 在新版本代码中故意改变哈希种子、键编码、
V或反向索引R,验证旧代次结果不变且只能通过影子代次切换; - 在
PREPARE、COPYING、CATCHING_UP、CUTOVER、VERIFY、RETIRED每个阶段杀进程,分别演练扩容和缩容; - 路由 CAS 后向源端发送旧 epoch 请求,验证源栅栏和目标重试;
- 将某热点视频虚拟桶流量倾斜,验证物理迁移或降级方案;
- 让影子对账修复与实时消费并发,验证追增量和代次 CAS 不互相覆盖;分别用“投影等于、落后和领先源快照”验证在线覆盖规则;
- 在实时分页期间持续取消、重新点赞和切换隐私,并让各分片批量预取后提前填满页面,验证只有已弹出项推进游标。
这些只能称为计划中的故障演练。完成后还要保存脚本、环境、时间、预期、实际结果和遗留问题,才能写成已验证。
13.7 一条典型故障排查路径
现象:热视频展示点赞数十分钟不变,但用户自己的红心操作正常。
排查顺序:
- 查询命令成功率和关系版本,确认关系主链路是否健康。
- 检查 Outbox 最老年龄,判断事件是否卡在发布前。
- 比较 MQ 生产与计数消费者水位,定位具体积压分区。
- 查看该视频各桶更新时间,判断是全局消费者问题还是单视频偏斜。
- 检查计数投影事务错误、负数保护和版本跳跃日志。
- 若分桶库已更新而展示不变,检查快照聚合与 Redis 写入。
- 恢复后执行该视频带水位对账,确认没有只恢复速度却留下错数。
十四、可靠性保证与不保证
| 场景 | 系统应保证 | 明确不保证 | 恢复证据 |
|---|---|---|---|
点赞接口返回 LIKED v11 | 关系事务和同版本 Outbox 已提交 | 展示计数已经加一 | 关系行、命令记录、Outbox |
| 客户端请求超时 | 相同操作 ID可查询或重试 | 超时等于失败 | 幂等结果记录 |
| MQ 重复消息 | 更低或相同版本不重复改计数 | 消息只出现一次 | 投影版本与消费日志 |
| MQ 延迟 | 关系状态仍可读 | 点赞数实时精确 | 消费水位、最老事件年龄 |
| Redis 故障 | 关系事实、持久缓存版本下限和当前计数投影仍可用于新代次重建 | 故障期间所有读取无延迟上升 | generation 账本、重建批次与回源指标 |
| 反向索引延迟 | 关系事实不会因此回滚 | 点赞者列表与计数瞬时相等 | 两条投影各自水位 |
| 虚拟桶迁移中断 | 激活 manifest 指针 CAS 和物理分区栅栏保持单一权威,可按迁移账本恢复 | 源目标双写天然原子 | manifest ID、route epoch、迁移水位与校验摘要 |
| 持久计数投影损坏 | 仅从带两份全量制品、校验和、不可变 manifest 与各分区 next offset 的 SEALED 快照恢复 | 任意展示快照都能用于重放 | 快照制品清单、offset 向量与恢复报告 |
| 风控隐藏 | 原始关系与调整原因可审计 | 公开计数等于全部原始关系数 | 风控调整账目 |
| 人工修复 | 有范围、版本、审批和前后差异 | 直接改一个缓存值就永久正确 | 修复任务与复核记录 |
系统内部采用“至少一次传播 + 版本化幂等收敛”,不承诺消息天然只投递一次。推荐系统、通知系统等下游是否最终处理,还受各自消费能力和业务规则约束,点赞服务只能提供事件发布与消费水位证据。
十五、设计取舍与演进条件
15.1 三种写入方案比较
| 方案 | 优点 | 风险与代价 | 适用条件 |
|---|---|---|---|
| 数据库关系同步提交 + Outbox | 语义清楚、可立即确认、约束完整 | 需要关系库分片和容量规划 | 推荐基线 |
| Redis 先写 + 异步关系落库 | 入口延迟低、可削峰 | 受理与成功语义复杂,缓存丢失和乱序恢复成本高 | 同步库经过验证确实不足,且产品接受处理中 |
| MQ 先受理 + 消费者落关系 | 天然削峰、入口轻 | 查询未决、分区顺序、拒绝补偿和积压体验复杂 | 极端突发、允许异步确认的业务 |
点赞不是支付,允许一定体验折中;但这不意味着可以用模糊响应掩盖结果未知。选择异步方案时,接口状态和用户页面必须真实表达 PROCESSING。
15.2 计数方案比较
| 方案 | 优点 | 主要问题 | 演进判断 |
|---|---|---|---|
| 视频表单行计数 | 实现简单、读取直接 | 热门视频行锁争用 | 普通规模且压测满足时可用 |
Redis 单 Key INCR | 延迟低、开发快 | 热门视频单节点热写,恢复和对账复杂 | 热点可控、写量有明确上限时可用 |
| 每视频固定少量桶 | 分散一部分热点、易理解 | 桶数不足时改取模会破坏取消与重放 | 热点上限稳定且迁移需求低时使用 |
| 稳定稀疏虚拟桶 + 物理路由 | 关系逻辑桶不变,物理节点可扩缩 | 路由栅栏、迁移账本和聚合成本较高 | 极端热点已被压测证明,且具备迁移运维能力时使用 |
| 只在离线批处理统计 | 成本低 | 用户看不到近实时变化 | 仅适合低实时性的分析报表 |
15.3 为什么不用分布式事务包住所有投影
关系库、Redis、MQ、推荐系统和反向索引跨越多个存储与团队。把它们放进一个同步分布式事务会放大延迟和故障范围,而且通知、推荐本来就不需要成为点赞成功的前置条件。
更合理的边界是:关系事实和 Outbox 在本地事务中原子提交;下游以版本事件异步收敛;每个投影有幂等、监控、重放和对账。复杂度没有消失,但被放在可观测、可恢复的位置。
15.4 V1、V2、V3 的演进触发器
V1 可以采用:关系库按用户分片、单个计数行或少量固定桶、Outbox、批量查询和基本对账。前提是实际压测证明单行计数和缓存写入满足当前目标,并且事件已经使用绝对状态和关系版本,为后续重建保留可能。
V2 在出现这些可观察条件时,引入冻结完整哈希合同的稳定虚拟桶空间、投影代次和不可变路由 manifest;这是一次需要回填与校验的计数投影升级,不是在运行中直接修改取模:
- 单个热视频计数写延迟显著高于普通视频;
- Redis 单 Key 或数据库计数行达到持续容量上限;
- 计数消费积压主要由少数视频引起;
- 扩机器后热点仍集中在同一节点;
- 预计的单热视频上限能够据此确定并压测
V。
V3 在物理计数分区确实需要频繁扩容、缩容或重平衡时,再实现完整的 PREPARE -> COPYING -> CATCHING_UP -> CUTOVER -> VERIFY -> RETIRED 在线迁移、恢复快照和影子修复。更高阶段可以评估流式状态库或专用计数服务,但触发条件必须来自持续监控和故障复盘,不是为了让架构图显得复杂。
十六、团队职责与个人职责边界
16.1 团队方案与个人成果要分开
本文描述的是完整团队方案。真实项目中,点赞命令、关系库、消息平台、计数投影、风控和信息流往往由不同成员或团队共同完成。面试时不能把所有模块说成个人独立设计和实现。
16.2 可选择的三条职责主线
职责主线一:点赞命令与关系事实。
- 可负责内容:目标状态接口、幂等记录、CAS 版本、关系表索引、Outbox 与集成测试。
- 必须拿得出:接口契约、迁移脚本、关键代码、并发测试和评审记录。
- 不可越界:没有参与计数平台时,不能说自己独立搭建整套热点计数系统。
职责主线二:计数投影与热点治理。
- 可负责内容:绝对状态事件、投影版本、分桶计数、快照聚合、乱序测试和对账工具。
- 必须拿得出:事件契约、投影表、消费测试、压测报告和修复记录。
- 不可越界:没有真实容量报告时,不能把设计目标说成已经扛住的生产峰值。
职责主线三:查询、可观测与恢复。
- 可负责内容:批量查询、游标分页、缓存版本、指标告警、运营后台与故障演练。
- 必须拿得出:查询计划、缓存测试、Dashboard、告警规则和演练报告。
- 不可越界:协助排查不等于拥有整个关系数据库或消息平台。
16.3 负责、参与、协作、了解 的使用
| 表述 | 合理证据 |
|---|---|
| 负责 | 主要设计、实现、测试与验收记录 |
| 参与 | 明确子模块提交、联调和缺陷修复 |
| 协作 | 上下游评审、接口联调或故障排查记录 |
| 了解 | 能解释团队方案,但不作为个人成果认领 |
在没有代码和报告的当前文档阶段,只能说“目标方案可以这样设计”,不能写“我优化后接口达到某个时延”。
十七、面试主回答与继续追问
17.1 三分钟主回答
面试时可以这样回答:
短视频点赞要先把“用户与视频的关系”和“视频展示点赞数”拆开。关系是核心事实,用户点击时发送目标状态 LIKED/UNLIKED,而不是 toggle。服务端用 user_id + video_id 唯一关系、操作 ID 幂等和关系版本处理重复与并发,并在关系事务里一起写 Outbox。接口成功只代表关系事实提交,当前用户按钮可以立即按返回版本更新。
点赞数不在关系事务里直接改视频单行,而是消费关系事件异步投影。事件携带关系版本和绝对状态,消费者保存自己的 projectedState + projectedVersion,只接受更高版本,并根据旧投影状态与新绝对状态计算 +1/0/-1。这样点赞后马上取消、事件重复或 v12 先于 v11 到达时都能收敛。
热点计数按不可变 bucketSpaceVersion 中的哈希算法、种子、规范化键编码和 V 得到稳定虚拟桶,关系投影、虚拟桶计数和事件回执在当前物理分区本地事务提交,再周期聚合成只读展示快照。扩缩容只通过不可变路由清单和迁移账本搬动物理承载,不改变关系所属虚拟桶;哈希合同变化必须影子重建切代。关系库是事实源,Redis 是带代次的加速层;MQ 或缓存故障由 Outbox、版本幂等、带完整制品与 offset 向量的快照和分层对账恢复。我的点赞列表直接按用户关系事实做实时游标分页,谁赞过视频则用带版本墓碑的反向投影,并在查询时重新应用隐私规则。
最后会补一句事实边界:如果 5 万 QPS 只是设计目标,就说明需要怎样压测和演练,不能说成已经验证的生产成绩。
17.2 高频追问与回答要点
追问一:为什么不直接存 Redis Set?
Redis Set 很适合加速“用户是否点赞”和批量成员判断,但如果它是唯一事实,持久化恢复、操作审计、版本乱序和复杂分页都会变难。推荐以关系库为事实,Redis Set 或 Hash 做版本化缓存;只有经过明确可靠性设计的异步受理模式才把 Redis 放到写前面。
追问二:点赞和取消怎么保证幂等?
接口使用绝对目标状态,每个业务动作有唯一操作 ID。重复操作 ID返回第一次结果;目标状态与当前状态相同不产生新版本和计数变化。数据库唯一约束和 CAS 处理并发。
追问三:为什么不能用 toggle?
因为网络超时重试会再次翻转状态,服务端无法区分重试和新动作。SET LIKED/UNLIKED 即使重复也能收敛到同一目标。
追问四:用户快速点赞又取消,消息乱序怎么办?
客户端同一关系串行发送并保留最终意图;服务端生成单调关系版本;事件携带绝对状态。消费者只接受更高版本,并根据自身投影状态计算变化,低版本迟到不会覆盖高版本。
追问五:MQ 按 key 分区有序,还需要版本吗?
需要。HTTP 请求可能乱序,Outbox 发布器可能重试,扩分区或消费者恢复也会引入边界。版本既能拒绝低版本,又能在对账时说明处理水位,不能只依赖传输层顺序。
追问六:为什么不让事件只带 delta?
+1/-1 在重复或跳版本时缺少最终目标。绝对状态加版本能折叠中间点赞再取消;消费者从自己已投影状态计算净变化。
追问七:单个热视频 Redis Key 太热怎么办?
预先规划稳定虚拟桶空间,把哈希算法、种子、规范化键编码和 V 冻结成 bucketSpaceVersion,用它把一个热视频拆到多个逻辑桶和 Redis 槽,再聚合展示快照。V 由单热点目标写量、单桶实测能力和安全余量确定;机器扩缩容只迁移虚拟桶的物理路由,并通过 route epoch、不可变 manifest 和源端栅栏保证单一权威。合同变化只能新建投影代次,不能原地取模。
追问八:计数暂时不准确,用户刚点赞数字没变怎么办?
红心状态以关系响应为准,计数明确是异步快照。客户端可以做仅用于当前会话的乐观 +1/-1,但下一次服务端快照要按版本和产品策略收敛,不能把客户端数字写回服务端事实。
追问九:如何防止计数变成负数?
消费者从投影状态变化计算增量,重复取消不会减数;关系投影、虚拟桶和事件回执在同一本地事务更新,桶行再加非负约束和异常隔离。出现负桶要停止该桶继续传播,按 SEALED 水位在影子代次从关系投影重算并追增量,而不是简单改成零。
追问十:关系库和计数不一致听谁的?
用户关系裁决听关系库;展示计数是可重建投影。先在同一 SEALED 切面检查计数关系投影与虚拟桶的内部恒等式,再用各关系分片 sourceCommitSeq、源事件证据和完整 MQ offset 向量做在线覆盖检查:投影落后失败,相等时状态必须一致,投影领先则要证明后续源变化。只有写、Outbox 和消费者三道 barrier 都排空,才能声称精确相等。
追问十一:按 userId 分库,怎么查某视频谁点赞?
通过关系事件构建按 (videoId,userId) 保存状态版本墓碑的反向投影,以及按 videoId + reverseShard + likedAt 排序的索引。取消用投影保存的原 likedAt 删除精确排序项,重新点赞使用关系事务的 stateChangedAt 生成新时间;低版本不能越过取消墓碑复活。R 和哈希合同冻结在反向索引 generation 内,查询仍要重新应用隐私、封禁和黑名单规则。
追问十二:Redis 和数据库双写失败怎么办?
命令路径只原子提交关系事实和 Outbox,不承诺同步双写 Redis。缓存消费者先推进持久版本下限,再写当前 generation;失败时查询批量回源并提交修复提示,当前设备以命令响应版本兜底。不能为缓存失败反向取消已提交关系。
追问十三:Outbox 会不会把表撑大?
发布器按状态和主键扫描,监控最老年龄;确认发布并超过追溯窗口后分区归档。归档前要保证事件重放和审计要求,不是发布成功就立即物理删除。
追问十四:什么时候才做异步关系落库?
同步关系分片库在真实热点压测和故障容量下仍不满足目标,且产品接受 PROCESSING 语义时。演进前要补齐可靠接入日志、查询未决结果、顺序、补偿和缓存丢失恢复。
追问十五:如何证明方案真的有效?
用重复、乱序、响应丢失、Redis 代次清空、并行 offset barrier、快照制品损坏、迁移六阶段宕机、旧 route epoch、哈希合同切代、预取分页和热点偏斜测试验证不变量;压测报告记录环境与分布;两层对账报告证明源关系、投影、虚拟桶和展示快照重新收敛。
追问十六:Redis 整组清空后,迟到的低版本为什么不会复活?
关系缓存的版本下限和当前 generation 保存在持久存储,不随 Redis 一起消失。重建时先创建新代次并让查询回源,基线与增量校验完成后再激活;旧代次任务进不了新命名空间,同代次低版本也过不了持久投影的版本判断。
追问十七:虚拟桶迁移到一半宕机怎么办?
按持久迁移账本恢复。复制和追增量阶段从游标继续;切换阶段以 active_manifest_id 的 CAS 结果为裁决,源清单仍激活就继续完成或确认后解除源栅栏,目标清单已激活就只认目标,绝不自动重开源写入。当前路由物化表若不一致,从激活清单重建;源副本要等校验和保留期结束后才清理。
追问十八:游标分页能保证翻页期间一条不漏吗?
普通 UI 接口是实时列表,不承诺固定历史集合。新点赞和重新点赞可能移动到游标之前,取消会消失;刷新后看到当前状态。精确导出必须使用 asOf 快照。多分片归并时,只有原始候选从堆中弹出并被返回或过滤,才推进对应分片 frontier;预取但未弹出的项下一页必须能重新读到。
十八、复盘自检清单
- 接口表达的是目标状态还是无语义的切换?
- 相同操作 ID 在新操作之后迟到,是否仍能返回原结果而不改状态?
- 关系版本由谁生成,能否在同一关系上单调递增?
- 关系事务和 Outbox 是否原子提交?
- 数据库提交后 Redis 失败,用户关系能否恢复?
- v12 取消事件先于 v11 点赞事件时,计数最终是多少?
- 投影状态与计数桶更新中间宕机,会不会永久少加或多加?
- 热门视频是否仍然写一个 Redis Key 或数据库行?
- 点赞、取消和重新点赞是否始终落在同一个稳定虚拟桶?
- 扩容和缩容是否只改变物理路由,并有迁移账本、源端栅栏、route epoch 与不可变 manifest?
CUTOVER中途宕机时,能否仅凭active_manifest_id判断唯一权威并重建当前路由?- 可恢复快照是否同时包含两份全量制品的 URI、行数、校验和、不可变路由清单和完整 next offset 向量?
- Redis 单独丢失与持久计数投影损坏是否使用不同恢复流程?
- 信息流查询是否避免逐视频 RPC、跨分片 N+1 和读线程直接回填缓存?
- 我的点赞列表是否明确是实时游标,精确导出是否另有
asOf协议? - 反向点赞者列表是否有版本墓碑,并用原
likedAt精确删除排序项? - 多分片游标是否只在候选被堆弹出后推进,预取未消费项会不会被跳过?
- 反向索引的
stateChangedAt、R、哈希合同和 generation 是否都已冻结? - 对账是否区分投影内部恒等式、在线源覆盖和协调 barrier 下的精确相等?
- 投影版本高于源快照时,是否要求后续 CDC/Outbox 证据而非直接判错或放行?
SEALED快照恢复和源事实重建是否使用各自真实的水位协议?- 影子修复是否追平实时增量后才 CAS 切换,避免旧基线覆盖新写?
- 修复任务是否记录范围、两套水位、桶空间、清单、原因、审批和前后结果?
- 每个容量数字是否明确标为设计目标、压测或生产监控?
- 故障演练是否同时验证故障期间表现和恢复后数据?
- 面试叙述能否区分团队架构、个人负责和仅仅了解的部分?
总结
短视频点赞系统最重要的不是 INCR,而是先建立清晰的事实层次:用户视频关系是需要版本、幂等和事务保护的核心事实;展示点赞数是从关系事件构建、允许短暂延迟但必须可重建和可对账的投影。
在这条主线上,快速点赞又取消由“客户端单关系队列 + 绝对目标状态 + 服务端单调版本”处理;消息重复和乱序由“更高版本的绝对状态投影”收敛;热门视频由版本化稳定虚拟桶消除单 Key 热写,物理扩缩容通过不可变路由清单、迁移账本和栅栏完成;Redis 与持久投影故障分别由缓存代次重建、带完整制品的 offset 向量快照、源事实尾流和影子修复兜底。
一个可信的回答还必须说清限制:关系成功不代表计数立即变化,消息发布不代表所有下游完成,设计目标不代表生产成绩,团队完整方案也不等于个人全部负责。把这些边界讲透,才算真正回答了“点赞、取消点赞和点赞计数如何设计”。