亿级流量下的 Redis 计数系统设计:位图事实 + 事件聚合 + SDS 汇总

亿级流量下的 Redis 计数系统设计:位图事实 + 事件聚合 + SDS 汇总

灵感来源:《亿级流量系统架构设计与实战》


一、背景与挑战

在社交平台或内容社区中,点赞数、收藏数、关注数、粉丝数等计数是最基础也最高频的功能。一个热门内容可能在几秒内涌入数十万次操作,这对计数系统提出了极高的要求。

传统方案面临三大痛点:

痛点 表现 根因
写瓶颈 数据库热行锁、Redis 单键过热 每次操作直接 INCR/UPDATE
数据膨胀 Redis Hash 字段无限增长 大量用户对同一实体操作导致哈希表扩容
不可自愈 缓存与 DB 不一致后难以纠偏 缺乏独立的事实层用于回溯重建

本方案提出一套 "位图事实 + Kafka 事件聚合 + SDS 固定结构汇总" 的三层架构,以极低的内存开销和秒级最终一致性解决上述问题。


二、设计目标

  • 统一支撑:内容实体的点赞、收藏等高并发计数;用户维度的关注、粉丝、发文、获赞、获收藏
  • 写入幂等:同一操作重复执行不影响最终结果
  • 读低延迟:单次读取 O(1),批量读取管道化
  • 秒级最终一致:写入后 1 秒内可读到最新值
  • 自动纠偏:异常时具备从事实际自主重建的能力
  • 低成本:内存占用低、CPU 分布均衡,避免数据库热写与 Redis 哈希膨胀

三、架构总览

#mermaid-svg-Tx5alSRNg8SZBqFq{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Tx5alSRNg8SZBqFq .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Tx5alSRNg8SZBqFq .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Tx5alSRNg8SZBqFq .error-icon{fill:#552222;}#mermaid-svg-Tx5alSRNg8SZBqFq .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Tx5alSRNg8SZBqFq .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Tx5alSRNg8SZBqFq .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Tx5alSRNg8SZBqFq .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Tx5alSRNg8SZBqFq .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Tx5alSRNg8SZBqFq .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Tx5alSRNg8SZBqFq .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Tx5alSRNg8SZBqFq .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Tx5alSRNg8SZBqFq .marker.cross{stroke:#333333;}#mermaid-svg-Tx5alSRNg8SZBqFq svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Tx5alSRNg8SZBqFq p{margin:0;}#mermaid-svg-Tx5alSRNg8SZBqFq .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Tx5alSRNg8SZBqFq .cluster-label text{fill:#333;}#mermaid-svg-Tx5alSRNg8SZBqFq .cluster-label span{color:#333;}#mermaid-svg-Tx5alSRNg8SZBqFq .cluster-label span p{background-color:transparent;}#mermaid-svg-Tx5alSRNg8SZBqFq .label text,#mermaid-svg-Tx5alSRNg8SZBqFq span{fill:#333;color:#333;}#mermaid-svg-Tx5alSRNg8SZBqFq .node rect,#mermaid-svg-Tx5alSRNg8SZBqFq .node circle,#mermaid-svg-Tx5alSRNg8SZBqFq .node ellipse,#mermaid-svg-Tx5alSRNg8SZBqFq .node polygon,#mermaid-svg-Tx5alSRNg8SZBqFq .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Tx5alSRNg8SZBqFq .rough-node .label text,#mermaid-svg-Tx5alSRNg8SZBqFq .node .label text,#mermaid-svg-Tx5alSRNg8SZBqFq .image-shape .label,#mermaid-svg-Tx5alSRNg8SZBqFq .icon-shape .label{text-anchor:middle;}#mermaid-svg-Tx5alSRNg8SZBqFq .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Tx5alSRNg8SZBqFq .rough-node .label,#mermaid-svg-Tx5alSRNg8SZBqFq .node .label,#mermaid-svg-Tx5alSRNg8SZBqFq .image-shape .label,#mermaid-svg-Tx5alSRNg8SZBqFq .icon-shape .label{text-align:center;}#mermaid-svg-Tx5alSRNg8SZBqFq .node.clickable{cursor:pointer;}#mermaid-svg-Tx5alSRNg8SZBqFq .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Tx5alSRNg8SZBqFq .arrowheadPath{fill:#333333;}#mermaid-svg-Tx5alSRNg8SZBqFq .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Tx5alSRNg8SZBqFq .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Tx5alSRNg8SZBqFq .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Tx5alSRNg8SZBqFq .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Tx5alSRNg8SZBqFq .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Tx5alSRNg8SZBqFq .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Tx5alSRNg8SZBqFq .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Tx5alSRNg8SZBqFq .cluster text{fill:#333;}#mermaid-svg-Tx5alSRNg8SZBqFq .cluster span{color:#333;}#mermaid-svg-Tx5alSRNg8SZBqFq div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Tx5alSRNg8SZBqFq .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Tx5alSRNg8SZBqFq rect.text{fill:none;stroke-width:0;}#mermaid-svg-Tx5alSRNg8SZBqFq .icon-shape,#mermaid-svg-Tx5alSRNg8SZBqFq .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Tx5alSRNg8SZBqFq .icon-shape p,#mermaid-svg-Tx5alSRNg8SZBqFq .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Tx5alSRNg8SZBqFq .icon-shape .label rect,#mermaid-svg-Tx5alSRNg8SZBqFq .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Tx5alSRNg8SZBqFq .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Tx5alSRNg8SZBqFq .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Tx5alSRNg8SZBqFq :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 仅状态变更时
fixedDelay=1s
常规
异常
用户动作
位图切换

(Lua 原子 SETBIT)
计数事件
Kafka

counter-events
聚合增量桶

(Redis Hash)
SDS 汇总

(固定结构计数)
读取请求
读取策略
位图 BITCOUNT 重建
分布式锁

三层数据层次

复制代码
┌─────────────────────────────────────┐
│           位图事实层 (不可变事实)      │  ← 幂等开关,状态唯一可信来源
│    bm:{metric}:{etype}:{eid}:{chunk} │
├─────────────────────────────────────┤
│          聚合增量层 (过渡态)          │  ← Kafka 消费攒批,秒级窗口
│   agg:{schema}:{etype}:{eid} (Hash) │
├─────────────────────────────────────┤
│          SDS 汇总层 (读优化)          │  ← 定长二进制结构,O(1) 读取
│    cnt:{schema}:{etype}:{eid}       │
└─────────────────────────────────────┘

四、核心设计详解

4.1 数据模型与键设计

实体计数(内容维度)
键类型 Key 格式 存储结构 说明
位图分片 bm:{metric}:{etype}:{eid}:{chunk} Bitmap, 4KB/分片 chunk=userId/32768, bit=userId%32768
SDS 汇总 cnt:{schema}:{etype}:{eid} 定长二进制 schema=v1, 每段 4 字节大端 int32, 共 5 段
聚合增量桶 agg:{schema}:{etype}:{eid} Hash field=idx, value=delta
重建锁 lock:sds-rebuild:{etype}:{eid} String TTL 5s,防并发回写
用户计数(用户维度)
复制代码
ucnt:{userId}  →  5 段 × 4 字节(大端 int32)
 ┌────────────┬───────────┬────────┬──────────────┬──────────────┐
 │ followings │ followers │ posts  │ likesReceived│ favsReceived │
 │   (0-3)    │  (4-7)    │ (8-11) │   (12-15)    │   (16-19)    │
 └────────────┴───────────┴────────┴──────────────┴──────────────┘

设计亮点 :将一组计数编码为定长二进制结构存储在 Redis String 中(类似 Redis 内部的 SDS),一个用户的所有计数仅占用 20 字节,读取时按偏移量直接解出对应字段,真正做到 O(1) + 零膨胀。

4.2 写路径:幂等原子 + 异步聚合

写路径的核心思想是 "状态驱动计数"------计数的变化源于用户状态的变更,而非单纯的加减操作。
FlushScheduler AggConsumer Kafka Redis App Client FlushScheduler AggConsumer Kafka Redis App Client #mermaid-svg-voSMJjKWwFfH8eWy{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-voSMJjKWwFfH8eWy .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-voSMJjKWwFfH8eWy .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-voSMJjKWwFfH8eWy .error-icon{fill:#552222;}#mermaid-svg-voSMJjKWwFfH8eWy .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-voSMJjKWwFfH8eWy .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-voSMJjKWwFfH8eWy .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-voSMJjKWwFfH8eWy .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-voSMJjKWwFfH8eWy .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-voSMJjKWwFfH8eWy .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-voSMJjKWwFfH8eWy .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-voSMJjKWwFfH8eWy .marker{fill:#333333;stroke:#333333;}#mermaid-svg-voSMJjKWwFfH8eWy .marker.cross{stroke:#333333;}#mermaid-svg-voSMJjKWwFfH8eWy svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-voSMJjKWwFfH8eWy p{margin:0;}#mermaid-svg-voSMJjKWwFfH8eWy .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-voSMJjKWwFfH8eWy text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-voSMJjKWwFfH8eWy .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-voSMJjKWwFfH8eWy .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-voSMJjKWwFfH8eWy .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-voSMJjKWwFfH8eWy .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-voSMJjKWwFfH8eWy #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-voSMJjKWwFfH8eWy .sequenceNumber{fill:white;}#mermaid-svg-voSMJjKWwFfH8eWy #sequencenumber{fill:#333;}#mermaid-svg-voSMJjKWwFfH8eWy #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-voSMJjKWwFfH8eWy .messageText{fill:#333;stroke:none;}#mermaid-svg-voSMJjKWwFfH8eWy .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-voSMJjKWwFfH8eWy .labelText,#mermaid-svg-voSMJjKWwFfH8eWy .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-voSMJjKWwFfH8eWy .loopText,#mermaid-svg-voSMJjKWwFfH8eWy .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-voSMJjKWwFfH8eWy .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-voSMJjKWwFfH8eWy .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-voSMJjKWwFfH8eWy .noteText,#mermaid-svg-voSMJjKWwFfH8eWy .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-voSMJjKWwFfH8eWy .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-voSMJjKWwFfH8eWy .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-voSMJjKWwFfH8eWy .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-voSMJjKWwFfH8eWy .actorPopupMenu{position:absolute;}#mermaid-svg-voSMJjKWwFfH8eWy .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-voSMJjKWwFfH8eWy .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-voSMJjKWwFfH8eWy .actor-man circle,#mermaid-svg-voSMJjKWwFfH8eWy line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-voSMJjKWwFfH8eWy :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} alt状态发生变更 点赞 / 取消点赞Lua TOGGLE (SETBIT + 状态判定)1=变更 / 0=无变化发布 CounterEvent(+1/-1)消费事件HINCRBY 聚合桶手动 ACK扫描聚合桶 (每1s)Lua 原子折叠到 SDS + 删除字段

关键代码:位图切换

java 复制代码
private boolean toggle(String etype, String eid, long uid, 
                       String metric, int idx, boolean add) {
    // 固定分片定位,避免单键膨胀
    long chunk = BitmapShard.chunkOf(uid);   // uid / 32768
    long bit   = BitmapShard.bitOf(uid);     // uid % 32768
    String bmKey = CounterKeys.bitmapKey(metric, etype, eid, chunk);
    
    // Lua 原子执行:仅状态变更时置 1/清 0,返回 1 表示变更
    Long changed = redis.execute(toggleScript, 
        List.of(bmKey), 
        String.valueOf(bit), add ? "add" : "remove");
    
    if (changed == 1L) {
        int delta = add ? 1 : -1;
        // 产出计数事件 → Kafka 异步聚合
        eventProducer.publish(CounterEvent.of(etype, eid, metric, idx, uid, delta));
        // 同步触发本地事件 → 缓存失效等
        eventPublisher.publishEvent(CounterEvent.of(etype, eid, metric, idx, uid, delta));
    }
    return changed == 1L;
}

定时刷写到 SDS

java 复制代码
@Scheduled(fixedDelay = 1000L)  // 秒级最终一致
public void flush() {
    Set<String> keys = redis.keys("agg:" + CounterSchema.SCHEMA_ID + ":*");
    for (String aggKey : keys) {
        Map<Object, Object> entries = redis.opsForHash().entries(aggKey);
        if (entries.isEmpty()) continue;
        
        String cntKey = CounterKeys.sdsKey(etype, eid);
        for (Map.Entry<Object, Object> e : entries.entrySet()) {
            // Lua 原子折叠:INCRBY SDS[idx] += delta,成功后删除聚合字段
            redis.execute(incrFieldScript, cntKey, e.getKey(), e.getValue());
        }
    }
}

4.3 读路径:常规快速 + 异常自愈

#mermaid-svg-XQWVwRYbj50rT8sK{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-XQWVwRYbj50rT8sK .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-XQWVwRYbj50rT8sK .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-XQWVwRYbj50rT8sK .error-icon{fill:#552222;}#mermaid-svg-XQWVwRYbj50rT8sK .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-XQWVwRYbj50rT8sK .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-XQWVwRYbj50rT8sK .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-XQWVwRYbj50rT8sK .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-XQWVwRYbj50rT8sK .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-XQWVwRYbj50rT8sK .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-XQWVwRYbj50rT8sK .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-XQWVwRYbj50rT8sK .marker{fill:#333333;stroke:#333333;}#mermaid-svg-XQWVwRYbj50rT8sK .marker.cross{stroke:#333333;}#mermaid-svg-XQWVwRYbj50rT8sK svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-XQWVwRYbj50rT8sK p{margin:0;}#mermaid-svg-XQWVwRYbj50rT8sK .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-XQWVwRYbj50rT8sK .cluster-label text{fill:#333;}#mermaid-svg-XQWVwRYbj50rT8sK .cluster-label span{color:#333;}#mermaid-svg-XQWVwRYbj50rT8sK .cluster-label span p{background-color:transparent;}#mermaid-svg-XQWVwRYbj50rT8sK .label text,#mermaid-svg-XQWVwRYbj50rT8sK span{fill:#333;color:#333;}#mermaid-svg-XQWVwRYbj50rT8sK .node rect,#mermaid-svg-XQWVwRYbj50rT8sK .node circle,#mermaid-svg-XQWVwRYbj50rT8sK .node ellipse,#mermaid-svg-XQWVwRYbj50rT8sK .node polygon,#mermaid-svg-XQWVwRYbj50rT8sK .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-XQWVwRYbj50rT8sK .rough-node .label text,#mermaid-svg-XQWVwRYbj50rT8sK .node .label text,#mermaid-svg-XQWVwRYbj50rT8sK .image-shape .label,#mermaid-svg-XQWVwRYbj50rT8sK .icon-shape .label{text-anchor:middle;}#mermaid-svg-XQWVwRYbj50rT8sK .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-XQWVwRYbj50rT8sK .rough-node .label,#mermaid-svg-XQWVwRYbj50rT8sK .node .label,#mermaid-svg-XQWVwRYbj50rT8sK .image-shape .label,#mermaid-svg-XQWVwRYbj50rT8sK .icon-shape .label{text-align:center;}#mermaid-svg-XQWVwRYbj50rT8sK .node.clickable{cursor:pointer;}#mermaid-svg-XQWVwRYbj50rT8sK .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-XQWVwRYbj50rT8sK .arrowheadPath{fill:#333333;}#mermaid-svg-XQWVwRYbj50rT8sK .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-XQWVwRYbj50rT8sK .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-XQWVwRYbj50rT8sK .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-XQWVwRYbj50rT8sK .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-XQWVwRYbj50rT8sK .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-XQWVwRYbj50rT8sK .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-XQWVwRYbj50rT8sK .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-XQWVwRYbj50rT8sK .cluster text{fill:#333;}#mermaid-svg-XQWVwRYbj50rT8sK .cluster span{color:#333;}#mermaid-svg-XQWVwRYbj50rT8sK div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-XQWVwRYbj50rT8sK .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-XQWVwRYbj50rT8sK rect.text{fill:none;stroke-width:0;}#mermaid-svg-XQWVwRYbj50rT8sK .icon-shape,#mermaid-svg-XQWVwRYbj50rT8sK .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-XQWVwRYbj50rT8sK .icon-shape p,#mermaid-svg-XQWVwRYbj50rT8sK .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-XQWVwRYbj50rT8sK .icon-shape .label rect,#mermaid-svg-XQWVwRYbj50rT8sK .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-XQWVwRYbj50rT8sK .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-XQWVwRYbj50rT8sK .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-XQWVwRYbj50rT8sK :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是

获取成功
获取失败
读取请求
SDS 是否存在

且结构正确?
直接返回

O(1) 读取对应段
尝试获取分布式锁

(TTL 5s)
逐分片 BITCOUNT 管道求和
拼装新 SDS 并回写
清理聚合桶字段

释放锁
等待后重试 / 降级返回 0

常规读取

java 复制代码
// GET cnt:{schema}:{etype}:{eid}
// 按 Schema 偏移直接读取段值(大端 32 位)
public long getCount(String etype, String eid, int metricIdx) {
    byte[] raw = redis.get(CounterKeys.sdsKey(etype, eid));
    if (raw != null && raw.length == CounterSchema.TOTAL_BYTES) {
        return CounterSchema.readField(raw, metricIdx); // O(1) 偏移读取
    }
    return rebuildAndGet(etype, eid, metricIdx); // 异常触发重建
}

批量读取(Feed 场景)

java 复制代码
// 管道批量 GET,缺失时补 0,避免逐条 RTT
List<Object> results = redis.executePipelined((RedisCallback<?>) conn -> {
    for (String key : sdsKeys) {
        conn.stringCommands().get(ByteBuffer.wrap(key.getBytes()));
    }
    return null;
});

4.4 用户维度计数

用户维度的关注数、粉丝数等通过 Outbox 事件处理器 异步维护:

java 复制代码
// 关注事件处理
@EventListener
public void onFollow(FollowEvent evt) {
    if (evt.isCancelled()) {
        // 取关:删除关注关系 + 过期集中缓存
        redis.opsForSet().remove("uf:flws:" + evt.getFromUserId(), 
                                  String.valueOf(evt.getToUserId()));
        redis.opsForSet().remove("uf:fans:" + evt.getToUserId(), 
                                  String.valueOf(evt.getFromUserId()));
        
        userCounterService.incrementFollowings(evt.getFromUserId(), -1);
        userCounterService.incrementFollowers(evt.getToUserId(), -1);
    }
}

并通过 定期抽样校验(每 300s 对关注/粉丝做数据库对比,不一致则全量重建)保证数据质量。


五、一致性、幂等与容错

5.1 幂等保证

环节 幂等策略
位图切换 Lua 脚本仅在状态变化时返回成功,同一用户重复操作自动跳过
事件投递 Kafka 生产端开启 enable.idempotence=true + acks=all
关系事件 Redis 去重键 SETNX,TTL 10 分钟,防止重复处理
增量折叠 Lua 折叠后删除字段,确保不会重复计入 SDS

5.2 最终一致性

复制代码
写入 → 聚合桶 → SDS 刷写   窗口:≤ 1 秒

在窗口内读取可能略滞后,这是 秒级最终一致 的有意取舍------换来了写入的高吞吐和内存的高压缩。

5.3 异常自愈

  • SDS 缺失/损坏:自动触发位图重建 + 分布式锁保护,杜绝并发回写
  • 严重故障 :可切换 CounterRebuildConsumer 做全量 Kafka 事件回放(earliest),直接从历史事件重建 SDS
  • 定期对账:异步任务从业务事实表聚合校正,作为最后一道防线

5.4 并发保护

复制代码
┌──────────────┐     ┌──────────────┐
│  请求 A       │     │  请求 B       │
│  SDS 缺失!    │     │  SDS 缺失!    │
│  SETNX lock  │     │  SETNX lock  │
│  获取成功 ✓   │     │  获取失败 ✗   │
│  BITCOUNT..  │     │  等待/降级    │
│  回写 SDS    │     │              │
│  清理聚合字段 │     │              │
│  DEL lock    │     │              │
└──────────────┘     └──────────────┘

六、方案对比

复制代码
                   直接写 Hash    纯位图+BITCOUNT    数据库计数列     本方案
                   ──────────    ──────────────    ───────────    ──────
写入性能              ★★★           ★★★★              ★★            ★★★★
读取性能              ★★★           ★★                ★★★           ★★★★
幂等性                ★★            ★★★★★             ★★★           ★★★★★
内存占用              ★★            ★★★               ★★★★          ★★★★★
批量友好              ★★            ★★                ★★★           ★★★★★
事实回溯              ★             ★★★★★             ★★★★          ★★★★★
自动纠偏              ★             ★★                ★★★           ★★★★★
实现复杂度            ★★★★★         ★★★               ★★★★          ★★★
方案 优点 缺点
直接写 Redis Hash (HINCRBY) 简单直接 高并发下哈希膨胀严重,无幂等保证,无事实层纠偏
纯位图 + 读时 BITCOUNT 事实唯一可信 多分片 BITCOUNT 开销大,批量场景 RTT 与 CPU 高
数据库计数列 (UPDATE) 事务保证一致 数据库成写热点,扩展性差,缓存回填复杂
本方案 (位图 + 事件聚合 + SDS) 写入幂等、读低延迟、内存压缩极致、自动纠偏 秒级最终一致,需后台刷写与键空间管理

七、总结与可扩展方向

核心设计理念

  1. 事实与汇总分离:位图是唯一的"真相来源",SDS 是为读优化的"物化视图"
  2. 状态驱动而非操作驱动:计数变化源于状态变更,天然幂等
  3. 异步折叠削峰:Kafka 作为缓冲层,将高频写操作攒批折叠为低频 SDS 更新
  4. 定长结构极简存储:一个用户的全量计数仅 20 字节,无哈希表开销

可扩展方向

  • 段大小平滑升级:当前 4 字节 int32,可在 CounterSchema 中引入版本号,新版本使用 8 字节 int64,读写时按版本选择解析方式
  • 冷热分离:热度低的实体计数可下沉到 SSD 存储(如使用 Redis on Flash)
  • 多级缓存:本地 Caffeine 缓存 + Redis SDS + 位图,进一步降低读取延迟
  • 多维计数:Schema 中增加更多字段(如分享数、评论数),仅增加少量字节即可

相关推荐
专注VB编程开发20年1 小时前
C#,VB.NET 生成debug日志文件
服务器·数据库·c#
basketball6161 小时前
Redis基础:4. 事务
数据库·redis·缓存
zzz_23681 小时前
【Redis】缓存策略与三大经典问题
数据库·redis·缓存
zzz_23681 小时前
【Redis】Redis 数据结构与 Spring Boot 集成
数据结构·spring boot·redis
菠萝猫yena1 小时前
【数据库软件】beekeeper-studio安装方式(Mac)
数据库
Dovis(誓平步青云)1 小时前
《指标中转站:Pushgateway 如何把监控覆盖到这些原本看不见的角落》
数据库·生成对抗网络·oracle·内网穿透·飞牛nas
yurenpai(27届找实习中)1 小时前
Elasticsearch 核心总结 + 面试题实战(黑马点评项目)
redis·es
YJlio1 小时前
OpenClaw v2026.5.26-beta.1 / beta.2 预发布解读:Gateway 加速、transcript 路径统一、多通道修复、语音增强与安装更新链路加固
人工智能·windows·python·ui·缓存·gateway·outlook
IT龟苓膏10 小时前
Redis 数据类型底层原理:SDS、quicklist、intset、skiplist、Bitmap、HyperLogLog 一篇讲清
数据库·redis·skiplist