Kafka 灾难回放机制基于事件事实流的计数全量恢复方案一、引言在高并发计数系统中Redis 承载着最终的聚合计数结果。然而Redis 中的数据本质上是派生状态——它是从业务事件经过聚合、折叠后计算出来的结果缓存而非事实本身。一旦 Redis 中的计数 Key 丢失、损坏或者聚合逻辑存在缺陷导致部分事件从未被正确写入仅依赖 Redis 自身的修复手段是不够的。本文以ZhiHub项目中的计数系统为背景详细介绍一套基于 Kafka 事件事实流的灾难回放机制用于在极端场景下实现计数状态的全量重建。二、系统背景计数架构全景知光项目的计数系统采用三层架构事实层 → 聚合层 → 汇总层。用户操作点赞/收藏 │ ▼ ┌──────────────────────┐ │ Redis Bitmap事实层 │ ← 位图原子切换记录谁做了什么 └──────────┬───────────┘ │ 产出 CounterEventdelta±1 ▼ ┌──────────────────────┐ │ Kafka事件流 │ ← 顺序追加、不可篡改的事件事实序列 └──────────┬───────────┘ │ 消费者聚合 ▼ ┌──────────────────────┐ │ Redis Hash聚合桶 │ ← 攒增量HINCRBY 累加 └──────────┬───────────┘ │ 定时任务1s折叠 ▼ ┌──────────────────────┐ │ Redis SDS汇总计数 │ ← 固定结构大端 32 位编码O(1) 读取 └──────────────────────┘核心组件职责如下Bitmap 事实层以分片位图记录每个用户的操作状态点赞/收藏通过 Lua 脚本保证原子切换仅在状态变化时产出增量事件。Kafka 事件流承载所有计数变更的事件事实序列是系统中唯一不可篡改、可追溯的数据源。聚合桶Hash消费者将增量事件写入 Redis Hash起到攒数据的作用避免每次 1 都直接修改最终计数。SDS 汇总计数定时任务将聚合桶中的增量以原子 Lua 脚本折叠到固定结构的 SDS 字符串中提供 O(1) 的高吞吐读取。正常情况下该设计可以很好地支持高并发写入、高吞吐读路径和原子性保证。但当灾难发生时我们需要一条可靠的兜底路径。三、为什么需要灾难回放3.1 Redis 位图重建的局限系统已经内置了一层恢复能力当读取 SDS 发现缺失或结构异常时会触发基于位图分片的 BITCOUNT 重建。核心逻辑如下// CounterServiceImpl.javalongsumbitCountShardsPipelined(m,entityType,entityId);writeInt32BE(newSds,idx*CounterSchema.FIELD_SIZE,sum);这对以下场景是有效的SDS Key 偶发丢失如过期、误删SDS 内容损坏或长度不符。但它有一个根本性前提位图事实层必须是完整、可信的。3.2 位图重建无法覆盖的场景当以下情况发生时位图重建也无能为力位图分片本身丢失Redis 中不仅 SDS 丢失连位图分片也一起丢失或被误删。聚合逻辑缺陷Lua 脚本的折叠逻辑存在 Bug导致部分事件被错误计算——此时基于 Redis 的现有状态重算只会放大既有错误。事件被吞消费成功但 Redis 写失败、Redis 写成功但 Ack 失败、Lua 执行成功但客户端超时……这些小概率事件长时间运行后成为必然。需要跨实体、跨时间窗口全量恢复位图重建是逐实体的无法批量恢复所有实体的计数状态。核心判断在于Redis 中是否还保留了可信的事实来源。如果答案是否定的唯一可靠的数据只剩下 Kafka 中保存的完整事件历史。高并发下不可能完全避免中间失败。即使设计得再好也一定存在消费成功但 Redis 写失败、Redis 写成功但 Ack 失败、Lua 执行成功但客户端超时等问题。单次看是小概率长时间运行后是必然事件。3.3 核心认知派生状态不是事实这个系统的本质是Kafka 事件事实层 → 聚合折叠 → Redis SDS派生状态Kafka事实不可更改顺序追加Redis SDS计算结果可被重建任何只存在于 Redis、却没在事实源里被证明的状态都存在失真的风险。当线上计数出现严重不一致时比如点赞/收藏/浏览数长时间比实际小重启服务、清缓存后无法自动恢复是否启用 Kafka 灾难回放核心判断并不在于Redis 能不能算而在于 Redis 中是否还保留了可信的事实来源。四、设计目标灾难回放不是线上常态链路其核心目标是安全恢复而非高性能目标说明至少一次语义允许重复处理绝不丢失事件与线上链路完全隔离独立消费者组位点互不影响全量历史重放从最早事件开始位点推进可观测、可暂停复用现有折叠逻辑直接复用写入 SDS 的 Lua 脚本避免新逻辑引入一致性风险五、核心架构设计5.1 独立消费者组隔离线上与灾备语义在 Kafka 中消费者组 一条消费语义链路。如果灾难回放与线上聚合使用同一消费者组会产生两个严重问题位点语义冲突线上聚合追求低延迟从latest开始消费新事件灾难回放追求完整性必须从earliest回溯历史。两者的位点推进策略完全不同。运行风险不可控灾难回放是长时间、重 IO 的操作一旦拖慢或阻塞可能直接影响线上消费。因此灾难回放使用单独的消费者组// CounterRebuildConsumer.javaKafkaListener(topicsCounterTopics.EVENTS,groupIdcounter-rebuild,// 独立消费者组properties{auto.offset.resetearliest}// 从最早位点开始)而线上聚合消费者使用的是另一个消费者组// CounterAggregationConsumer.javaKafkaListener(topicsCounterTopics.EVENTS,groupIdcounter-agg)独立消费者组带来的能力位点完全隔离回放进度不影响线上消费线上系统可持续对外服务。可运维性该消费者组可单独启停可在灾备窗口运行可独立监控消费滞后lag。容错友好即使回放失败或中断重启后仍可从上次提交的位点继续。5.2 从最早位点启动以事件事实为唯一真相灾难回放的前提是当前 Redis 中的计数状态已不可信。此时不能依赖任何当前值或增量状态唯一可靠的数据是 Kafka 中保存的完整事件历史。# application.yml - 线上消费者配置consumer:group-id:counter-aggauto-offset-reset:latest# 线上只消费新事件enable-auto-commit:false# 手动提交// 灾难回放消费者properties{auto.offset.resetearliest}// 回放从最早事件开始earliest确保当消费者组首次启动或位点不存在时从 Topic 中最早可用的事件开始消费。这也是 Kafka 能成为灾难回放核心基础的原因——事件以追加方式存储在保留窗口内不会丢失。5.3 手动提交位点语义绑定灾难回放使用手动 Ack形成明确的语义约束Kafka 位点推进 ⇔ Redis SDS 已成功落地publicvoidonMessage(Stringmessage,Acknowledgmentack)throwsException{CounterEventevtobjectMapper.readValue(message,CounterEvent.class);StringcntKeyCounterKeys.sdsKey(evt.getEntityType(),evt.getEntityId());try{// 执行原子折叠redis.execute(incrScript,List.of(cntKey),String.valueOf(CounterSchema.SCHEMA_LEN),String.valueOf(CounterSchema.FIELD_SIZE),String.valueOf(evt.getIdx()),String.valueOf(evt.getDelta()));ack.acknowledge();// ✅ 写入成功后才提交位点}catch(Exceptionex){// ❌ 不提交位点等待重试}}事件成功折叠并写入 Redis SDS 后才提交位点折叠失败则不提交等待重试。这保证了即使发生崩溃重启未成功落地的事件也会被重新消费。5.4 幂等折叠允许重复保证正确灾难回放不追求恰好一次而是通过幂等性消解重复-- INCR_FIELD_LUA原子将增量折叠到 SDS 指定段localcntKeyKEYS[1]localschemaLentonumber(ARGV[1])localfieldSizetonumber(ARGV[2])localidxtonumber(ARGV[3])localdeltatonumber(ARGV[4])localfunctionread32be(s,off)localb{string.byte(s,off1,off4)}localn0fori1,4donn*256b[i]endreturnnendlocalfunctionwrite32be(n)localt{}fori4,1,-1dot[i]n%256;nmath.floor(n/256)endreturnstring.char(unpack(t))endlocalcntredis.call(GET,cntKey)ifnotcntthencntstring.rep(string.char(0),schemaLen*fieldSize)endlocaloffidx*fieldSizelocalvread32be(cnt,off)deltaifv0thenv0endlocalsegwrite32be(v)cntstring.sub(cnt,1,off)..seg..string.sub(cnt,offfieldSize1)redis.call(SET,cntKey,cnt)return1这段 Lua 脚本与线上聚合刷写使用的是完全相同的脚本。关键设计点读取当前值 → 加上增量 → 写回新值整个过程在 Redis 中原子执行。对同一个事件重复执行多次结果与执行一次相同因为每次都是从 SDS 当前值加上固定的 delta。即使消费者重启导致重复消费也能保证最终计数正确。六、线上聚合 vs 灾难回放对比分析维度线上聚合链路灾难回放链路消费者组counter-aggcounter-rebuild位点策略latest只消费新事件earliest回溯全部历史写入目标先写聚合桶Hash再定时折叠到 SDS直接折叠到 SDS刷写方式攒增量 → 定时批量折叠 → 扣减聚合桶每条事件立即折叠设计目标高吞吐、低延迟安全恢复、完整性优先运行时机常驻运行灾备窗口手动开启线上聚合链路之所以采用先攒后刷的策略是为了应对高并发场景下的写入放大问题。而灾难回放链路不需要考虑吞吐性能它的核心诉求是确定性地重建所有派生计数。七、运行与触发机制7.1 配置开关控制灾难回放默认关闭通过配置开关按需启用# application.ymlcounter:rebuild:enabled:false# 默认关闭仅灾备时手动开启对应的 Java 条件装配ServiceConditionalOnProperty(namecounter.rebuild.enabled,havingValuetrue)publicclassCounterRebuildConsumer{// ...}只有当counter.rebuild.enabledtrue时Spring 容器才会实例化灾难回放消费者。这意味着在正常运行期间该消费者根本不存在于应用中不消耗任何资源。7.2 双写风险控制关键灾难回放期间必须暂停常规聚合桶 → SDS的定时刷写链路否则可能导致同一事件被灾难回放直接折叠一次又被线上聚合定时任务折叠一次最终导致计数被放大。推荐操作流程1. 暂停常规聚合刷写调度 2. 清空需要恢复的 SDS Key避免旧数据干扰 3. 开启 counter.rebuild.enabledtrue 4. 启动灾难回放消费者等待回放完成 5. 校验恢复结果 6. 关闭灾难回放恢复常规聚合链路八、SDS 存储结构详解为了更好地理解灾难回放如何重建计数有必要介绍一下 SDS 的存储结构。知光项目使用 Redis SDSSimple Dynamic String作为最终计数的承载结构采用固定长度、大端 32 位编码SDS Key: cnt:v1:{entityType}:{entityId} ┌──────────┬──────────┬──────────┬──────────┬──────────┐ │ read │ like │ fav │ comment │ repost │ │ idx0 │ idx1 │ idx2 │ idx3 │ idx4 │ │ 4 bytes │ 4 bytes │ 4 bytes │ 4 bytes │ 4 bytes │ └──────────┴──────────┴──────────┴──────────┴──────────┘ 总长度 5 × 4 20 bytespublicstaticfinalintFIELD_SIZE4;// 每个字段 4 字节Int32publicstaticfinalintSCHEMA_LEN5;// 预留 5 个指标位O(1) 读取直接通过偏移量定位字段无需解析整个结构。原子写入Lua 脚本内读取 → 修改 → 写回保证单字段更新不影响其他字段。紧凑高效相比 Hash 结构20 字节的 SDS 在内存占用和网络传输上都更轻量。灾难回放时每个事件根据idx定位到 SDS 中对应的 4 字节段执行原子加操作。即使 SDS Key 不存在Lua 脚本也会自动初始化为全零结构。九、一致性与容错分析维度设计选择说明消费语义至少一次At-Least-OnceKafka 手动 Ack确保事件不丢失写入语义Redis Lua 原子写单字段更新在 Lua 中原子执行重复处理允许但不影响最终结果幂等折叠保证多次执行结果一致顺序要求单分区有序即可加法满足交换律无需全局顺序位点绑定语义绑定位点推进 ⇔ SDS 成功落地为什么不需要全局顺序计数操作本质是加法1/-1而加法满足交换律和结合律。即使事件 A 和事件 B 的消费顺序调换最终 SDS 中的计数值也是相同的。因此只需保证同一实体的事件在同一分区内有序即可。为什么允许重复处理灾难回放的 Lua 脚本执行的是读取当前值 → 加上 delta → 写回。如果对同一个事件重复执行由于每次都是从当前值加上相同的 delta结果与只执行一次完全相同。这就是幂等性的本质。十、Kafka 存储边界明确接受的边界条件Kafka 的灾难回放能力只对其存储时间窗口内的数据负责。一旦事件超过保留期被清理就无法再通过 Kafka 恢复。这是系统在设计时明确接受的边界条件。可通过以下方式将不可恢复的风险控制在业务可接受范围内延长 Topic 保留期将计数事件 Topic 的保留时间设置为 7 天或更长。冷存储同步将事件同步到 HDFS / S3 等冷存储保留更长的历史窗口。因此所谓全量恢复指的是在可恢复窗口内基于事件事实的全量重建而不是无限历史的回溯。对于知光项目来说7 天的事件窗口已经足够覆盖灾难恢复的需求。十一、完整代码结构以下是灾难回放涉及的代码文件和职责counter/ ├── config/ │ └── CounterConfig.java # Kafka 生产者工厂、调度启用 ├── event/ │ ├── CounterEvent.java # 事件模型entityType, entityId, idx, delta │ ├── CounterEventProducer.java # 事件生产者发送到 Kafka │ ├── CounterAggregationConsumer # 线上聚合消费者groupcounter-agg │ ├── CounterRebuildConsumer # 灾难回放消费者groupcounter-rebuild │ └── CounterTopics.java # Topic 常量定义 ├── schema/ │ ├── CounterSchema.java # Schema 定义字段数、字段大小、指标映射 │ ├── CounterKeys.java # Redis Key 生成工具 │ └── BitmapShard.java # 位图分片策略 └── service/impl/ └── CounterServiceImpl.java # 计数服务位图切换、SDS 读取、位图重建核心流程1. 用户点赞 → CounterServiceImpl.toggle() → 位图原子切换 → 产出 CounterEvent(delta1) → CounterEventProducer.publish() → Kafka 2. 线上链路 CounterAggregationConsumer.onMessage() → HINCRBY 聚合桶 → 手动 Ack → flush() 定时任务 → Lua 折叠到 SDS → 扣减聚合桶 3. 灾难回放 CounterRebuildConsumer.onMessage() → 直接 Lua 折叠到 SDS → 手动 Ack十二、与 Redis 位图重建的区别维度Redis 位图重建Kafka 灾难回放触发条件SDS 缺失或结构异常时自动触发手动开启灾备窗口执行数据来源Redis Bitmap 分片Kafka 事件事实流恢复粒度单实体、按需触发全量实体、全时间窗口适用场景SDS Key 丢失、结构损坏聚合逻辑缺陷、事件被吞、全量不一致性能特征按需触发毫秒级长时间回放IO 密集局限性依赖位图完整性无法修复缺失事实受限于 Kafka 保留窗口位图重建适用于局部、状态级修复而 Kafka 灾难回放是事实丢失或逻辑错误场景下的唯一全量恢复手段。十三、总结Kafka 灾难回放机制为计数系统提供了一条以事件事实为真相源的最终兜底路径Redis SDS作为最终计数承载结构提供 O(1) 高吞吐读取Kafka 事件作为唯一可追溯事实不可篡改、顺序追加至少一次 幂等折叠确保恢复安全允许重复但不会影响最终结果独立消费者组与线上链路完全隔离避免干扰业务流量配置开关控制默认关闭仅灾备窗口手动启用。它不是高频功能但在真正的异常场景下决定了系统是否可恢复、可信任。正如架构设计中的一个核心认知派生状态不是事实只是结果缓存。当派生状态不可信时唯一能依赖的只有事实层本身。Kafka 灾难回放机制正是基于这一理念构建的最后防线。面试常见问题Q为什么不用 Exactly-Once 语义AKafka 的 Exactly-Once 需要配合事务引入额外的复杂度和性能开销。对于计数场景加法操作天然具备幂等性At-Least-Once 幂等折叠已经能保证最终一致性是更简单、更可靠的选择。Q灾难回放期间线上服务还能用吗A可以。灾难回放使用独立消费者组counter-rebuild与线上聚合消费者组counter-agg完全隔离。线上系统可以继续正常消费新事件、对外提供计数读取服务。但需要注意暂停定时刷写任务避免双写导致计数放大。Q如果 Kafka 中的事件超过了保留期怎么办A这是系统设计时明确接受的边界条件。可以通过延长 Topic 保留期或将事件同步到冷存储来控制风险。对于当前项目7 天的事件窗口已足够覆盖灾难恢复需求。QKafka 分区、消费者组、位点是什么A分区PartitionTopic 的物理分片每个分区内事件有序。分区是并行消费的基本单位。消费者组Consumer Group一组消费者共同消费一个 Topic组内每个消费者负责部分分区。不同消费者组之间独立消费互不影响。位点Offset消费者在分区中的消费进度标记。已提交的位点之前的消息不会被重复消费。Q自动提交位点与手动提交的区别A自动提交enable.auto.committrueKafka 按固定间隔自动提交当前消费位点简单但可能导致消费成功但未处理完就提交的问题。手动提交enable.auto.commitfalseAcknowledgment由应用代码在处理完成后显式提交位点实现处理成功才确认的语义绑定更安全可靠。QKafka 事件存储在哪存储时间可以设置吗A事件存储在 Broker 的磁盘日志文件中Log Segment按分区组织。保留时间通过log.retention.hours默认 168 小时 7 天配置也支持按数据量大小保留。在保留期内所有已提交的消息都可以被重新消费。
Kafka 灾难回放机制:基于事件事实流的计数全量恢复方案
发布时间:2026/6/12 5:38:16
Kafka 灾难回放机制基于事件事实流的计数全量恢复方案一、引言在高并发计数系统中Redis 承载着最终的聚合计数结果。然而Redis 中的数据本质上是派生状态——它是从业务事件经过聚合、折叠后计算出来的结果缓存而非事实本身。一旦 Redis 中的计数 Key 丢失、损坏或者聚合逻辑存在缺陷导致部分事件从未被正确写入仅依赖 Redis 自身的修复手段是不够的。本文以ZhiHub项目中的计数系统为背景详细介绍一套基于 Kafka 事件事实流的灾难回放机制用于在极端场景下实现计数状态的全量重建。二、系统背景计数架构全景知光项目的计数系统采用三层架构事实层 → 聚合层 → 汇总层。用户操作点赞/收藏 │ ▼ ┌──────────────────────┐ │ Redis Bitmap事实层 │ ← 位图原子切换记录谁做了什么 └──────────┬───────────┘ │ 产出 CounterEventdelta±1 ▼ ┌──────────────────────┐ │ Kafka事件流 │ ← 顺序追加、不可篡改的事件事实序列 └──────────┬───────────┘ │ 消费者聚合 ▼ ┌──────────────────────┐ │ Redis Hash聚合桶 │ ← 攒增量HINCRBY 累加 └──────────┬───────────┘ │ 定时任务1s折叠 ▼ ┌──────────────────────┐ │ Redis SDS汇总计数 │ ← 固定结构大端 32 位编码O(1) 读取 └──────────────────────┘核心组件职责如下Bitmap 事实层以分片位图记录每个用户的操作状态点赞/收藏通过 Lua 脚本保证原子切换仅在状态变化时产出增量事件。Kafka 事件流承载所有计数变更的事件事实序列是系统中唯一不可篡改、可追溯的数据源。聚合桶Hash消费者将增量事件写入 Redis Hash起到攒数据的作用避免每次 1 都直接修改最终计数。SDS 汇总计数定时任务将聚合桶中的增量以原子 Lua 脚本折叠到固定结构的 SDS 字符串中提供 O(1) 的高吞吐读取。正常情况下该设计可以很好地支持高并发写入、高吞吐读路径和原子性保证。但当灾难发生时我们需要一条可靠的兜底路径。三、为什么需要灾难回放3.1 Redis 位图重建的局限系统已经内置了一层恢复能力当读取 SDS 发现缺失或结构异常时会触发基于位图分片的 BITCOUNT 重建。核心逻辑如下// CounterServiceImpl.javalongsumbitCountShardsPipelined(m,entityType,entityId);writeInt32BE(newSds,idx*CounterSchema.FIELD_SIZE,sum);这对以下场景是有效的SDS Key 偶发丢失如过期、误删SDS 内容损坏或长度不符。但它有一个根本性前提位图事实层必须是完整、可信的。3.2 位图重建无法覆盖的场景当以下情况发生时位图重建也无能为力位图分片本身丢失Redis 中不仅 SDS 丢失连位图分片也一起丢失或被误删。聚合逻辑缺陷Lua 脚本的折叠逻辑存在 Bug导致部分事件被错误计算——此时基于 Redis 的现有状态重算只会放大既有错误。事件被吞消费成功但 Redis 写失败、Redis 写成功但 Ack 失败、Lua 执行成功但客户端超时……这些小概率事件长时间运行后成为必然。需要跨实体、跨时间窗口全量恢复位图重建是逐实体的无法批量恢复所有实体的计数状态。核心判断在于Redis 中是否还保留了可信的事实来源。如果答案是否定的唯一可靠的数据只剩下 Kafka 中保存的完整事件历史。高并发下不可能完全避免中间失败。即使设计得再好也一定存在消费成功但 Redis 写失败、Redis 写成功但 Ack 失败、Lua 执行成功但客户端超时等问题。单次看是小概率长时间运行后是必然事件。3.3 核心认知派生状态不是事实这个系统的本质是Kafka 事件事实层 → 聚合折叠 → Redis SDS派生状态Kafka事实不可更改顺序追加Redis SDS计算结果可被重建任何只存在于 Redis、却没在事实源里被证明的状态都存在失真的风险。当线上计数出现严重不一致时比如点赞/收藏/浏览数长时间比实际小重启服务、清缓存后无法自动恢复是否启用 Kafka 灾难回放核心判断并不在于Redis 能不能算而在于 Redis 中是否还保留了可信的事实来源。四、设计目标灾难回放不是线上常态链路其核心目标是安全恢复而非高性能目标说明至少一次语义允许重复处理绝不丢失事件与线上链路完全隔离独立消费者组位点互不影响全量历史重放从最早事件开始位点推进可观测、可暂停复用现有折叠逻辑直接复用写入 SDS 的 Lua 脚本避免新逻辑引入一致性风险五、核心架构设计5.1 独立消费者组隔离线上与灾备语义在 Kafka 中消费者组 一条消费语义链路。如果灾难回放与线上聚合使用同一消费者组会产生两个严重问题位点语义冲突线上聚合追求低延迟从latest开始消费新事件灾难回放追求完整性必须从earliest回溯历史。两者的位点推进策略完全不同。运行风险不可控灾难回放是长时间、重 IO 的操作一旦拖慢或阻塞可能直接影响线上消费。因此灾难回放使用单独的消费者组// CounterRebuildConsumer.javaKafkaListener(topicsCounterTopics.EVENTS,groupIdcounter-rebuild,// 独立消费者组properties{auto.offset.resetearliest}// 从最早位点开始)而线上聚合消费者使用的是另一个消费者组// CounterAggregationConsumer.javaKafkaListener(topicsCounterTopics.EVENTS,groupIdcounter-agg)独立消费者组带来的能力位点完全隔离回放进度不影响线上消费线上系统可持续对外服务。可运维性该消费者组可单独启停可在灾备窗口运行可独立监控消费滞后lag。容错友好即使回放失败或中断重启后仍可从上次提交的位点继续。5.2 从最早位点启动以事件事实为唯一真相灾难回放的前提是当前 Redis 中的计数状态已不可信。此时不能依赖任何当前值或增量状态唯一可靠的数据是 Kafka 中保存的完整事件历史。# application.yml - 线上消费者配置consumer:group-id:counter-aggauto-offset-reset:latest# 线上只消费新事件enable-auto-commit:false# 手动提交// 灾难回放消费者properties{auto.offset.resetearliest}// 回放从最早事件开始earliest确保当消费者组首次启动或位点不存在时从 Topic 中最早可用的事件开始消费。这也是 Kafka 能成为灾难回放核心基础的原因——事件以追加方式存储在保留窗口内不会丢失。5.3 手动提交位点语义绑定灾难回放使用手动 Ack形成明确的语义约束Kafka 位点推进 ⇔ Redis SDS 已成功落地publicvoidonMessage(Stringmessage,Acknowledgmentack)throwsException{CounterEventevtobjectMapper.readValue(message,CounterEvent.class);StringcntKeyCounterKeys.sdsKey(evt.getEntityType(),evt.getEntityId());try{// 执行原子折叠redis.execute(incrScript,List.of(cntKey),String.valueOf(CounterSchema.SCHEMA_LEN),String.valueOf(CounterSchema.FIELD_SIZE),String.valueOf(evt.getIdx()),String.valueOf(evt.getDelta()));ack.acknowledge();// ✅ 写入成功后才提交位点}catch(Exceptionex){// ❌ 不提交位点等待重试}}事件成功折叠并写入 Redis SDS 后才提交位点折叠失败则不提交等待重试。这保证了即使发生崩溃重启未成功落地的事件也会被重新消费。5.4 幂等折叠允许重复保证正确灾难回放不追求恰好一次而是通过幂等性消解重复-- INCR_FIELD_LUA原子将增量折叠到 SDS 指定段localcntKeyKEYS[1]localschemaLentonumber(ARGV[1])localfieldSizetonumber(ARGV[2])localidxtonumber(ARGV[3])localdeltatonumber(ARGV[4])localfunctionread32be(s,off)localb{string.byte(s,off1,off4)}localn0fori1,4donn*256b[i]endreturnnendlocalfunctionwrite32be(n)localt{}fori4,1,-1dot[i]n%256;nmath.floor(n/256)endreturnstring.char(unpack(t))endlocalcntredis.call(GET,cntKey)ifnotcntthencntstring.rep(string.char(0),schemaLen*fieldSize)endlocaloffidx*fieldSizelocalvread32be(cnt,off)deltaifv0thenv0endlocalsegwrite32be(v)cntstring.sub(cnt,1,off)..seg..string.sub(cnt,offfieldSize1)redis.call(SET,cntKey,cnt)return1这段 Lua 脚本与线上聚合刷写使用的是完全相同的脚本。关键设计点读取当前值 → 加上增量 → 写回新值整个过程在 Redis 中原子执行。对同一个事件重复执行多次结果与执行一次相同因为每次都是从 SDS 当前值加上固定的 delta。即使消费者重启导致重复消费也能保证最终计数正确。六、线上聚合 vs 灾难回放对比分析维度线上聚合链路灾难回放链路消费者组counter-aggcounter-rebuild位点策略latest只消费新事件earliest回溯全部历史写入目标先写聚合桶Hash再定时折叠到 SDS直接折叠到 SDS刷写方式攒增量 → 定时批量折叠 → 扣减聚合桶每条事件立即折叠设计目标高吞吐、低延迟安全恢复、完整性优先运行时机常驻运行灾备窗口手动开启线上聚合链路之所以采用先攒后刷的策略是为了应对高并发场景下的写入放大问题。而灾难回放链路不需要考虑吞吐性能它的核心诉求是确定性地重建所有派生计数。七、运行与触发机制7.1 配置开关控制灾难回放默认关闭通过配置开关按需启用# application.ymlcounter:rebuild:enabled:false# 默认关闭仅灾备时手动开启对应的 Java 条件装配ServiceConditionalOnProperty(namecounter.rebuild.enabled,havingValuetrue)publicclassCounterRebuildConsumer{// ...}只有当counter.rebuild.enabledtrue时Spring 容器才会实例化灾难回放消费者。这意味着在正常运行期间该消费者根本不存在于应用中不消耗任何资源。7.2 双写风险控制关键灾难回放期间必须暂停常规聚合桶 → SDS的定时刷写链路否则可能导致同一事件被灾难回放直接折叠一次又被线上聚合定时任务折叠一次最终导致计数被放大。推荐操作流程1. 暂停常规聚合刷写调度 2. 清空需要恢复的 SDS Key避免旧数据干扰 3. 开启 counter.rebuild.enabledtrue 4. 启动灾难回放消费者等待回放完成 5. 校验恢复结果 6. 关闭灾难回放恢复常规聚合链路八、SDS 存储结构详解为了更好地理解灾难回放如何重建计数有必要介绍一下 SDS 的存储结构。知光项目使用 Redis SDSSimple Dynamic String作为最终计数的承载结构采用固定长度、大端 32 位编码SDS Key: cnt:v1:{entityType}:{entityId} ┌──────────┬──────────┬──────────┬──────────┬──────────┐ │ read │ like │ fav │ comment │ repost │ │ idx0 │ idx1 │ idx2 │ idx3 │ idx4 │ │ 4 bytes │ 4 bytes │ 4 bytes │ 4 bytes │ 4 bytes │ └──────────┴──────────┴──────────┴──────────┴──────────┘ 总长度 5 × 4 20 bytespublicstaticfinalintFIELD_SIZE4;// 每个字段 4 字节Int32publicstaticfinalintSCHEMA_LEN5;// 预留 5 个指标位O(1) 读取直接通过偏移量定位字段无需解析整个结构。原子写入Lua 脚本内读取 → 修改 → 写回保证单字段更新不影响其他字段。紧凑高效相比 Hash 结构20 字节的 SDS 在内存占用和网络传输上都更轻量。灾难回放时每个事件根据idx定位到 SDS 中对应的 4 字节段执行原子加操作。即使 SDS Key 不存在Lua 脚本也会自动初始化为全零结构。九、一致性与容错分析维度设计选择说明消费语义至少一次At-Least-OnceKafka 手动 Ack确保事件不丢失写入语义Redis Lua 原子写单字段更新在 Lua 中原子执行重复处理允许但不影响最终结果幂等折叠保证多次执行结果一致顺序要求单分区有序即可加法满足交换律无需全局顺序位点绑定语义绑定位点推进 ⇔ SDS 成功落地为什么不需要全局顺序计数操作本质是加法1/-1而加法满足交换律和结合律。即使事件 A 和事件 B 的消费顺序调换最终 SDS 中的计数值也是相同的。因此只需保证同一实体的事件在同一分区内有序即可。为什么允许重复处理灾难回放的 Lua 脚本执行的是读取当前值 → 加上 delta → 写回。如果对同一个事件重复执行由于每次都是从当前值加上相同的 delta结果与只执行一次完全相同。这就是幂等性的本质。十、Kafka 存储边界明确接受的边界条件Kafka 的灾难回放能力只对其存储时间窗口内的数据负责。一旦事件超过保留期被清理就无法再通过 Kafka 恢复。这是系统在设计时明确接受的边界条件。可通过以下方式将不可恢复的风险控制在业务可接受范围内延长 Topic 保留期将计数事件 Topic 的保留时间设置为 7 天或更长。冷存储同步将事件同步到 HDFS / S3 等冷存储保留更长的历史窗口。因此所谓全量恢复指的是在可恢复窗口内基于事件事实的全量重建而不是无限历史的回溯。对于知光项目来说7 天的事件窗口已经足够覆盖灾难恢复的需求。十一、完整代码结构以下是灾难回放涉及的代码文件和职责counter/ ├── config/ │ └── CounterConfig.java # Kafka 生产者工厂、调度启用 ├── event/ │ ├── CounterEvent.java # 事件模型entityType, entityId, idx, delta │ ├── CounterEventProducer.java # 事件生产者发送到 Kafka │ ├── CounterAggregationConsumer # 线上聚合消费者groupcounter-agg │ ├── CounterRebuildConsumer # 灾难回放消费者groupcounter-rebuild │ └── CounterTopics.java # Topic 常量定义 ├── schema/ │ ├── CounterSchema.java # Schema 定义字段数、字段大小、指标映射 │ ├── CounterKeys.java # Redis Key 生成工具 │ └── BitmapShard.java # 位图分片策略 └── service/impl/ └── CounterServiceImpl.java # 计数服务位图切换、SDS 读取、位图重建核心流程1. 用户点赞 → CounterServiceImpl.toggle() → 位图原子切换 → 产出 CounterEvent(delta1) → CounterEventProducer.publish() → Kafka 2. 线上链路 CounterAggregationConsumer.onMessage() → HINCRBY 聚合桶 → 手动 Ack → flush() 定时任务 → Lua 折叠到 SDS → 扣减聚合桶 3. 灾难回放 CounterRebuildConsumer.onMessage() → 直接 Lua 折叠到 SDS → 手动 Ack十二、与 Redis 位图重建的区别维度Redis 位图重建Kafka 灾难回放触发条件SDS 缺失或结构异常时自动触发手动开启灾备窗口执行数据来源Redis Bitmap 分片Kafka 事件事实流恢复粒度单实体、按需触发全量实体、全时间窗口适用场景SDS Key 丢失、结构损坏聚合逻辑缺陷、事件被吞、全量不一致性能特征按需触发毫秒级长时间回放IO 密集局限性依赖位图完整性无法修复缺失事实受限于 Kafka 保留窗口位图重建适用于局部、状态级修复而 Kafka 灾难回放是事实丢失或逻辑错误场景下的唯一全量恢复手段。十三、总结Kafka 灾难回放机制为计数系统提供了一条以事件事实为真相源的最终兜底路径Redis SDS作为最终计数承载结构提供 O(1) 高吞吐读取Kafka 事件作为唯一可追溯事实不可篡改、顺序追加至少一次 幂等折叠确保恢复安全允许重复但不会影响最终结果独立消费者组与线上链路完全隔离避免干扰业务流量配置开关控制默认关闭仅灾备窗口手动启用。它不是高频功能但在真正的异常场景下决定了系统是否可恢复、可信任。正如架构设计中的一个核心认知派生状态不是事实只是结果缓存。当派生状态不可信时唯一能依赖的只有事实层本身。Kafka 灾难回放机制正是基于这一理念构建的最后防线。面试常见问题Q为什么不用 Exactly-Once 语义AKafka 的 Exactly-Once 需要配合事务引入额外的复杂度和性能开销。对于计数场景加法操作天然具备幂等性At-Least-Once 幂等折叠已经能保证最终一致性是更简单、更可靠的选择。Q灾难回放期间线上服务还能用吗A可以。灾难回放使用独立消费者组counter-rebuild与线上聚合消费者组counter-agg完全隔离。线上系统可以继续正常消费新事件、对外提供计数读取服务。但需要注意暂停定时刷写任务避免双写导致计数放大。Q如果 Kafka 中的事件超过了保留期怎么办A这是系统设计时明确接受的边界条件。可以通过延长 Topic 保留期或将事件同步到冷存储来控制风险。对于当前项目7 天的事件窗口已足够覆盖灾难恢复需求。QKafka 分区、消费者组、位点是什么A分区PartitionTopic 的物理分片每个分区内事件有序。分区是并行消费的基本单位。消费者组Consumer Group一组消费者共同消费一个 Topic组内每个消费者负责部分分区。不同消费者组之间独立消费互不影响。位点Offset消费者在分区中的消费进度标记。已提交的位点之前的消息不会被重复消费。Q自动提交位点与手动提交的区别A自动提交enable.auto.committrueKafka 按固定间隔自动提交当前消费位点简单但可能导致消费成功但未处理完就提交的问题。手动提交enable.auto.commitfalseAcknowledgment由应用代码在处理完成后显式提交位点实现处理成功才确认的语义绑定更安全可靠。QKafka 事件存储在哪存储时间可以设置吗A事件存储在 Broker 的磁盘日志文件中Log Segment按分区组织。保留时间通过log.retention.hours默认 168 小时 7 天配置也支持按数据量大小保留。在保留期内所有已提交的消息都可以被重新消费。