亿级流量下的 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) | 写入幂等、读低延迟、内存压缩极致、自动纠偏 | 秒级最终一致,需后台刷写与键空间管理 |
七、总结与可扩展方向
核心设计理念
- 事实与汇总分离:位图是唯一的"真相来源",SDS 是为读优化的"物化视图"
- 状态驱动而非操作驱动:计数变化源于状态变更,天然幂等
- 异步折叠削峰:Kafka 作为缓冲层,将高频写操作攒批折叠为低频 SDS 更新
- 定长结构极简存储:一个用户的全量计数仅 20 字节,无哈希表开销
可扩展方向
- 段大小平滑升级:当前 4 字节 int32,可在 CounterSchema 中引入版本号,新版本使用 8 字节 int64,读写时按版本选择解析方式
- 冷热分离:热度低的实体计数可下沉到 SSD 存储(如使用 Redis on Flash)
- 多级缓存:本地 Caffeine 缓存 + Redis SDS + 位图,进一步降低读取延迟
- 多维计数:Schema 中增加更多字段(如分享数、评论数),仅增加少量字节即可