docs: 添加 ThumbUP 高并发点赞项目文档
Deploy Docs / deploy (push) Successful in 10s

- 添加项目概览页(index.md),包含技术栈全景和报告索引
- 添加整体架构设计文档(architecture.md),涵盖分层架构、数据库设计、REST API、两套点赞方案对比
- 添加缓存系统设计文档(cache-system.md),详解二级缓存架构、HeavyKeeper 算法、缓存防护策略
- 添加并发控制与数据一致性文档(data-consistency.md),涵盖 Lua 脚本原子性、时间片分桶、定时任务、补偿任务
- 添加简历技术要点文档(resume.md),提炼 5 个核心技术要点
- 更新 mkdocs.yml 导航配置
This commit is contained in:
2026-09-01 10:41:33 +08:00
parent 308692a7a4
commit 1fbeae7c44
6 changed files with 1320 additions and 0 deletions
+343
View File
@@ -0,0 +1,343 @@
# ThumbUP 整体架构设计
!!! note "💡 一句话概述"
基于 Spring Boot 3 + Java 21 构建的中台化高并发点赞系统,采用两套可切换的点赞方案(Redis 异步批量落库 vs Caffeine + Redis 二级缓存),通过 HeavyKeeper 算法实现热点 Key 自动探测,支撑亿级数据规模与万级 QPS 流量。
---
## 🔑 核心概念
1. **中台化设计** — 点赞服务作为独立基础服务,支撑多业务场景的点赞交互与数据查询
2. **双方案架构** — Redis 异步方案(高并发写入)与二级缓存方案(读多写少)可按需切换
3. **最终一致性** — Lua 脚本原子操作 + 时间片分桶暂存 + 定时批量落库 + 补偿任务兜底
4. **热点自动探测** — HeavyKeeper 算法实时检测 Top-K 热 Key,仅热 Key 进入本地缓存
---
## 📝 分层架构
### 整体分层
项目采用经典四层架构 + Manager 中间层的模式:
```
┌─────────────────────────────────────────────────────────────┐
│ Controller 层(接口层) │
│ HealthController │ UserController │ BlogController │ Thumb │
├─────────────────────────────────────────────────────────────┤
│ Service 层(业务层) │
│ UserService │ BlogService │ ThumbService (接口) │
│ │ ├── ThumbServiceImpl (二级缓存方案) │
│ │ └── ThumbServiceRedisImpl (Redis方案) │
├─────────────────────────────────────────────────────────────┤
│ Manager 层(管理层) │
│ CacheManager(二级缓存协调器) │
│ ├── Caffeine 本地缓存 │
│ ├── HeavyKeeper 热点探测 │
│ └── RedisTemplate │
├─────────────────────────────────────────────────────────────┤
│ Mapper 层(数据访问层) │
│ UserMapper │ BlogMapper │ ThumbMapper (MyBatis-Plus) │
├─────────────────────────────────────────────────────────────┤
│ 数据层 │
│ MySQL(持久化) │ Redis(缓存/临时计数/Session) │
└─────────────────────────────────────────────────────────────┘
```
### 包结构
```
cn.hezhaohui.thumb
├── controller/ # REST API 接口(4 个)
├── service/ # 业务接口(3 个)
│ └── impl/ # 业务实现(4 个,含两套点赞实现)
├── mapper/ # MyBatis Mapper 接口(3 个)
├── model/
│ ├── entity/ # 实体类(User, Blog, Thumb)
│ ├── dto/ # 请求 DTO(DoThumbRequest)
│ ├── vo/ # 视图对象(BlogVO)
│ └── enums/ # 枚举(LuaStatusEnum, ThumbTypeEnum)
├── config/ # 配置类(CorsConfig, RedisConfig)
├── exception/ # 异常处理(GlobalExceptionHandler, ErrorCode 等)
├── common/ # 通用类(BaseResponse, ResultUtils 等)
├── constant/ # 常量(UserConstant, ThumbConstant, RedisLuaScriptConstant)
├── manager/cache/ # 缓存管理(CacheManager, HeavyKeeper, TopK, Item)
├── job/ # 定时任务(SyncThumb2DBJob, SyncThumb2DBCompensatoryJob)
└── util/ # 工具类(RedisKeyUtil)
```
---
## 📊 数据库设计
### user 表
| 字段 | 类型 | 说明 |
|------|------|------|
| id | BIGINT (PK, AUTO) | 用户 ID |
| username | VARCHAR(128) | 用户名 |
设计极简,无密码字段,通过 userId 直接模拟登录。
### blog 表
| 字段 | 类型 | 说明 |
|------|------|------|
| id | BIGINT (PK, AUTO) | 博客 ID |
| userId | BIGINT | 作者 ID,有索引 `idx_userId` |
| title | VARCHAR(512) | 标题 |
| coverImg | VARCHAR(1024) | 封面图 |
| content | TEXT | 内容 |
| thumbCount | INT (DEFAULT 0) | 点赞计数器(冗余字段) |
| createTime | DATETIME | 创建时间 |
| updateTime | DATETIME | 更新时间(自动更新) |
`thumbCount` 冗余了点赞总数,避免每次查询都 COUNT thumb 表。
### thumb 表(点赞记录表)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | BIGINT (PK, AUTO) | 点赞记录 ID |
| userId | BIGINT | 点赞用户 ID |
| blogId | BIGINT | 被点赞博客 ID |
| createTime | DATETIME | 点赞时间 |
**唯一索引**:`UNIQUE INDEX idx_userId_blogId ON thumb(userId, blogId)` — 保证同一用户对同一博客只能点赞一次。
### ER 关系
```mermaid
erDiagram
USER ||--o{ BLOG : "发布"
USER ||--o{ THUMB : "点赞"
BLOG ||--o{ THUMB : "被点赞"
USER {
bigint id PK
varchar username
}
BLOG {
bigint id PK
bigint userId FK
varchar title
text content
int thumbCount
}
THUMB {
bigint id PK
bigint userId FK
bigint blogId FK
datetime createTime
}
```
---
## 🛣️ REST API 设计
API 前缀:`/api`(application.yml 配置 `servlet.path: /api`)
| 方法 | 路径 | 参数 | 说明 |
|------|------|------|------|
| GET | `/api/health` | - | 健康检查 |
| GET | `/api/user/login` | userId (query) | 模拟登录,将 User 存入 Session |
| GET | `/api/user/get/login` | - | 获取当前登录用户 |
| GET | `/api/blog/get` | blogId (query) | 获取单篇博客详情(含点赞状态) |
| GET | `/api/blog/list` | - | 获取全部博客列表(含点赞状态) |
| POST | `/api/thumb/do` | DoThumbRequest (body: blogId) | 点赞 |
| POST | `/api/thumb/undo` | DoThumbRequest (body: blogId) | 取消点赞 |
### 统一响应格式
所有 API 返回 `BaseResponse<T>`:
```json
{
"code": 0,
"data": {},
"message": "ok"
}
```
### 错误码体系
| 错误码 | 说明 |
|--------|------|
| 0 | 成功 |
| 40000 | 参数错误 |
| 40100 | 未登录 |
| 40101 | 无权限 |
| 40400 | 数据不存在 |
| 40300 | 禁止访问 |
| 50000 | 系统内部异常 |
| 50001 | 操作失败 |
---
## ⚡ 两套点赞方案对比
项目包含两套点赞服务实现,通过 Spring Bean 名称区分注入:
### 方案一:ThumbServiceRedisImpl(Redis 异步方案)
Bean 名称:`thumbServiceRedis`
**核心思路**:写操作完全不碰数据库,点赞/取消赞全部在 Redis 中完成,通过定时任务异步同步到 MySQL。
```mermaid
sequenceDiagram
participant Client
participant Controller
participant Service
participant Redis
participant Scheduler
participant MySQL
Client->>Controller: POST /api/thumb/do
Controller->>Service: doThumb()
Service->>Redis: Lua 脚本原子操作
Note over Redis: HEXISTS 防重 + HSET 临时计数 + HSET 状态标记
Redis-->>Service: 返回结果
Service-->>Controller: 返回成功
Controller-->>Client: 响应
Note over Scheduler: 每 10 秒触发
Scheduler->>Redis: HGETALL 临时数据
Scheduler->>MySQL: 批量插入/删除/更新
Scheduler->>Redis: DELETE 已同步 Key
```
### 方案二:ThumbServiceImpl(二级缓存方案)
Bean 名称:`thumbService`(当前默认使用)
**核心思路**:实时写库保证一致性,Caffeine + Redis 二级缓存加速读取,HeavyKeeper 算法自动探测热 Key。
```mermaid
sequenceDiagram
participant Client
participant Controller
participant Service
participant Cache as CacheManager
participant Caffeine
participant Redis
participant MySQL
Client->>Controller: POST /api/thumb/do
Controller->>Service: doThumb()
Service->>Service: synchronized 加锁
Service->>Cache: get() 查询点赞状态
Cache->>Caffeine: 查本地缓存
alt 命中
Caffeine-->>Cache: 返回缓存值
else 未命中
Cache->>Redis: HGET 查分布式缓存
Redis-->>Cache: 返回值
Cache->>Cache: HeavyKeeper 热点探测
alt 是热 Key
Cache->>Caffeine: 写入本地缓存
end
end
Cache-->>Service: 返回点赞状态
Service->>MySQL: 更新 thumbCount + 插入 thumb 记录
Service->>Redis: HSET 写入点赞状态
Service->>Cache: putIfPresent 更新本地缓存
Service-->>Controller: 返回成功
```
### 方案对比
| 维度 | 方案一(Redis 异步) | 方案二(二级缓存) |
|------|---------------------|-------------------|
| Bean 名称 | `thumbServiceRedis` | `thumbService` |
| 写入方式 | Redis Lua 脚本,异步落库 | 实时写数据库 |
| 并发控制 | Lua 脚本原子性 | synchronized + 编程式事务 |
| 缓存层次 | 一级缓存(Redis only) | 二级缓存(Caffeine + Redis) |
| 一致性模型 | 最终一致性 | 强一致性(单机内) |
| 多实例支持 | 支持(Redis 天然分布式) | 不支持(单机锁失效) |
| 性能 | 高(写 Redis 内存,批量落库) | 中(每次请求都写库) |
| 适用场景 | 超高并发写入 | 读多写少 |
---
## 🔧 配置与工具
### Redis 序列化配置
```java
// Key: StringRedisSerializer
// Value: Jackson2JsonRedisSerializer(开启 DefaultTyping.NON_FINAL)
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.activateDefaultTyping(
LaissezFaireSubTypeValidator.instance,
ObjectMapper.DefaultTyping.NON_FINAL
);
```
### 用户认证
简化版 Session 认证,通过 `userId` 直接查库存入 Session。Session 数据通过 `spring-session-data-redis` 存入 Redis,实现分布式 Session。
### 跨域配置
完全放开 CORS 限制,允许所有来源(开发阶段配置)。
---
## 🗂️ 项目文件清单
```
thumb-up/
├── pom.xml
├── src/main/java/cn/hezhaohui/thumb/
│ ├── Application.java # 启动类
│ ├── controller/
│ │ ├── HealthController.java # 健康检查
│ │ ├── UserController.java # 用户登录
│ │ ├── BlogController.java # 博客查询
│ │ └── ThumbController.java # 点赞操作
│ ├── service/
│ │ ├── UserService.java # 用户服务接口
│ │ ├── BlogService.java # 博客服务接口
│ │ ├── ThumbService.java # 点赞服务接口
│ │ └── impl/
│ │ ├── UserServiceImpl.java
│ │ ├── BlogServiceImpl.java
│ │ ├── ThumbServiceImpl.java # 二级缓存方案
│ │ └── ThumbServiceRedisImpl.java # Redis 异步方案
│ ├── manager/cache/
│ │ ├── CacheManager.java # 二级缓存管理器
│ │ ├── HeavyKeeper.java # 热点探测算法
│ │ ├── TopK.java # Top-K 接口
│ │ └── Item.java # 数据项
│ ├── job/
│ │ ├── SyncThumb2DBJob.java # 定时同步任务
│ │ └── SyncThumb2DBCompensatoryJob.java # 补偿任务
│ ├── mapper/
│ │ ├── UserMapper.java
│ │ ├── BlogMapper.java
│ │ └── ThumbMapper.java
│ ├── model/
│ │ ├── entity/ (User, Blog, Thumb)
│ │ ├── dto/ (DoThumbRequest)
│ │ ├── vo/ (BlogVO)
│ │ └── enums/ (LuaStatusEnum, ThumbTypeEnum)
│ ├── config/ (CorsConfig, RedisConfig)
│ ├── constant/ (UserConstant, ThumbConstant, RedisLuaScriptConstant)
│ ├── common/ (BaseResponse, ResultUtils, PageRequest, DeleteRequest)
│ ├── exception/ (GlobalExceptionHandler, ErrorCode, BusinessException, ThrowUtils)
│ └── util/ (RedisKeyUtil)
└── src/main/resources/
├── application.yml
├── mapper/ (UserMapper.xml, BlogMapper.xml, ThumbMapper.xml)
└── logback.xml
```
---
## 🔗 相关链接
- [缓存系统设计](cache-system.md) — 二级缓存架构、HeavyKeeper 算法详解
- [并发控制与数据一致性](data-consistency.md) — Lua 脚本、时间片分桶、最终一致性保障
- [简历技术要点](resume.md) — 基于源码提炼的面试技术要点
+453
View File
@@ -0,0 +1,453 @@
# ThumbUP 缓存系统设计
!!! note "💡 一句话概述"
基于 Caffeine + Redis 构建二级缓存,通过 HeavyKeeper 算法实时探测 Top-K 热点 Key,仅将热 Key 提升至本地缓存,避免冷数据污染,配合 Lua 脚本保证点赞原子性。
---
## 🔑 核心概念
1. **二级缓存** — Caffeine 本地缓存(L1)+ Redis 分布式缓存(L2),热 Key 自动提升至 L1
2. **HeavyKeeper 算法** — 基于 Count-Min Sketch 的 Top-K 热点探测,指数衰减淘汰冷 Key
3. **Lua 脚本原子性** — Redis 单线程执行 Lua 脚本,保证"防重检查 + 增量记录 + 状态标记"三步原子操作
4. **缓存防护** — 互斥锁防击穿、UN_THUMB_CONSTANT 标记防穿透
---
## 📝 二级缓存架构
### 数据流总览
```
请求 → Caffeine 本地缓存 (L1) → Redis Hash (L2) → MySQL (定时批量同步)
|
HeavyKeeper 热点探测
(仅热 Key 才进入 L1)
```
### CacheManager 核心组件
`CacheManager` 是整个缓存系统的核心协调器,持有三个关键组件:
```java
public class CacheManager {
private final Cache<String, Object> localCache; // Caffeine 本地缓存
private final TopK hotKeyDetector; // HeavyKeeper 热点探测器
private final RedisTemplate<String, Object> redisTemplate; // Redis 操作模板
}
```
### Caffeine 本地缓存配置
```java
Caffeine.newBuilder()
.maximumSize(1000) // 最大缓存 1000 个条目
.expireAfterWrite(5, TimeUnit.MINUTES) // 写入后 5 分钟过期
.build();
```
| 参数 | 值 | 说明 |
|------|-----|------|
| 最大容量 | 1000 条 | 防止 OOM |
| 过期策略 | 写入后 5 分钟 | 基于写入时间(write),非访问时间 |
### 读取流程(`get` 方法)
```java
public Object get(String hashKey, String key) {
String compositeKey = buildCacheKey(hashKey, key); // "thumb:123:456"
// 第一层:查 Caffeine 本地缓存
Object value = localCache.getIfPresent(compositeKey);
if (value != null) {
hotKeyDetector.add(key, 1); // 仍记录访问次数
return value;
}
// 第二层:查 Redis Hash
Object redisValue = redisTemplate.opsForHash().get(hashKey, key);
if (redisValue == null) return null;
// 热点探测:记录访问并判断是否为热 Key
AddResult addResult = hotKeyDetector.add(key, 1);
// 仅热 Key 才写入本地缓存
if (addResult.isHotKey()) {
localCache.put(compositeKey, redisValue);
}
return redisValue;
}
```
```mermaid
flowchart TD
A[请求查询] --> B{Caffeine 命中?}
B -->|命中| C[记录访问到 HeavyKeeper]
C --> D[返回本地缓存值]
B -->|未命中| E{Redis Hash 命中?}
E -->|未命中| F[返回 null]
E -->|命中| G[HeavyKeeper 热点探测]
G --> H{是热 Key?}
H -->|是| I[写入 Caffeine 本地缓存]
I --> J[返回 Redis 值]
H -->|否| J
```
**关键设计决策**:
- **复合 Key 格式**:`hashKey:key`(如 `thumb:123:456`),保证本地缓存中的 Key 唯一性
- **只缓存热 Key**:只有被 HeavyKeeper 判定为"热 Key"的数据才会进入 Caffeine,避免低频 Key 污染本地缓存
- **持续追踪**:即使命中本地缓存,仍然调用 `hotKeyDetector.add()`,持续追踪访问频率
### 写入流程(`putIfPresent` 方法)
```java
public void putIfPresent(String hashKey, String key, Object value) {
String compositeKey = buildCacheKey(hashKey, key);
Object object = localCache.getIfPresent(compositeKey);
if (object == null) return; // 本地缓存中不存在则不写入
localCache.put(compositeKey, value);
}
```
**"putIfPresent"语义**:只更新已经在本地缓存中存在的 Key。这保证了:
- 不会把非热 Key 写入本地缓存
- 点赞/取消点赞操作后,已缓存的热 Key 会同步更新,保持一致性
---
## 🔥 HeavyKeeper 算法详解
### 算法概述
HeavyKeeper 是一种基于 Count-Min Sketch 思想的 Top-K 频繁项检测算法,核心创新在于**指数衰减机制** — 当桶中存在旧数据时,新数据以指数衰减的概率替换旧数据,使得真正的高频 Key 能够"挤出"低频 Key。
### 初始化参数
```java
new HeavyKeeper(
100, // k: 监控 Top 100 Key
100000, // width: 每层桶的数量(宽度)
5, // depth: 桶数组的层数(深度)
0.92, // decay: 衰减系数
10 // minCount: 最小出现 10 次才算热 Key
);
```
| 参数 | 值 | 说明 |
|------|-----|------|
| k | 100 | 监控 Top 100 热 Key |
| width | 100,000 | 每层桶数量,总桶数 = 5 × 100,000 = 500,000 |
| depth | 5 | 桶数组层数(类似 5 个哈希函数) |
| decay | 0.92 | 衰减系数,count 越大被替换概率越低 |
| minCount | 10 | 最少出现 10 次才认定为热 Key |
### 桶结构
```java
private static class Bucket {
long fingerprint; // Key 的哈希指纹(MurmurHash3)
int count; // 计数器
}
```
每个桶只存一个 Key 的指纹和计数,整个结构是一个 `Bucket[depth][width]` 的二维数组。
### 查找表(预计算指数衰减)
```java
private static final int LOOKUP_TABLE_SIZE = 256;
this.lookupTable = new double[LOOKUP_TABLE_SIZE];
for (int i = 0; i < LOOKUP_TABLE_SIZE; i++) {
lookupTable[i] = Math.pow(decay, i); // 0.92^0, 0.92^1, ..., 0.92^255
}
```
预计算 `decay^n` 的值,避免运行时重复计算 `Math.pow`。当桶的 count 为 `n` 时,被替换的概率为 `0.92^n`。
**指数衰减效果**:
| count | 被替换概率 | 含义 |
|-------|-----------|------|
| 1 | 92.0% | 低频 Key 极易被替换 |
| 5 | 65.9% | 中低频 Key 较易被替换 |
| 10 | 43.4% | 中频 Key 有一定抵抗力 |
| 20 | 18.9% | 中高频 Key 较难被替换 |
| 50 | 1.5% | 高频 Key 几乎不可能被替换 |
| 100 | 0.02% | 热 Key 安全 |
### `add` 方法核心逻辑
```java
public AddResult add(String key, int increment) {
long itemFingerprint = hash(keyBytes); // MurmurHash3 指纹
int maxCount = 0;
for (int i = 0; i < depth; i++) { // 遍历 5 层
int bucketNumber = Math.abs(hash(keyBytes)) % width;
Bucket bucket = buckets[i][bucketNumber];
synchronized (bucket) {
if (bucket.count == 0) {
// 情况1:空桶,直接写入
bucket.fingerprint = itemFingerprint;
bucket.count = increment;
} else if (bucket.fingerprint == itemFingerprint) {
// 情况2:指纹匹配,累加计数
bucket.count += increment;
} else {
// 情况3:指纹冲突,指数衰减竞争
for (int j = 0; j < increment; j++) {
double decay = bucket.count < LOOKUP_TABLE_SIZE ?
lookupTable[bucket.count] :
lookupTable[LOOKUP_TABLE_SIZE - 1];
if (random.nextDouble() < decay) {
bucket.count--;
if (bucket.count == 0) {
// 旧 Key 被完全挤出,新 Key 占据桶
bucket.fingerprint = itemFingerprint;
bucket.count = increment - j;
break;
}
}
}
}
}
}
// ... TopK 堆管理
}
```
**三种情况**:
```mermaid
flowchart TD
A[访问 Key] --> B[计算 MurmurHash3 指纹]
B --> C[遍历 5 层桶]
C --> D{桶状态}
D -->|空桶| E[直接写入指纹和计数]
D -->|指纹匹配| F[计数累加]
D -->|指纹冲突| G[指数衰减竞争]
G --> H{随机概率 < decay^count?}
H -->|是| I[旧计数减 1]
I --> J{计数降为 0?}
J -->|是| K[新 Key 占据桶]
J -->|否| I
H -->|否| L[旧 Key 保留]
```
### Top-K 堆管理
```java
private final PriorityQueue<Node> minHeap; // 最小堆,维护 Top K
private final BlockingQueue<Item> expelledQueue; // 被挤出的 Key 队列
```
在 `add` 方法的后半部分:
```java
synchronized (minHeap) {
Optional<Node> existing = minHeap.stream()
.filter(n -> n.key.equals(key)).findFirst();
if (existing.isPresent()) {
// 已在堆中,更新计数
minHeap.remove(existing.get());
minHeap.add(new Node(key, maxCount));
} else {
if (minHeap.size() < k || maxCount >= minHeap.peek().count) {
// 堆未满或新 Key 计数 >= 堆顶(最小值)
if (minHeap.size() >= k) {
expelled = minHeap.poll().key; // 挤出最小的
expelledQueue.offer(new Item(expelled, maxCount));
}
minHeap.add(new Node(key, maxCount));
}
}
}
```
使用最小堆保证:
- 堆中始终保留计数最大的 K 个 Key
- 新 Key 必须计数 >= 堆顶才能进入
- 被挤出的 Key 放入 `expelledQueue`
### 衰减机制(`fading` 方法)
```java
public void fading() {
// 桶计数右移 1 位(除以 2)
for (Bucket[] row : buckets) {
for (Bucket bucket : row) {
synchronized (bucket) {
bucket.count = bucket.count >> 1;
}
}
}
// 堆中节点计数也右移 1 位
synchronized (minHeap) {
PriorityQueue<Node> newHeap = new PriorityQueue<>(...);
for (Node node : minHeap) {
newHeap.add(new Node(node.key, node.count >> 1));
}
minHeap.clear();
minHeap.addAll(newHeap);
}
total = total >> 1;
}
```
由 `CacheManager` 中的定时任务每 20 秒触发一次:
```java
@Scheduled(fixedRate = 20, timeUnit = TimeUnit.SECONDS)
public void cleanHotKeys() {
hotKeyDetector.fading();
}
```
**半衰期分析**:每 20 秒计数减半,如果一个 Key 在 20 秒内没有新的访问:
| 时间 | 计数衰减 | 含义 |
|------|---------|------|
| 20 秒 | 1/2 | 轻微衰减 |
| 40 秒 | 1/4 | 明显衰减 |
| 1 分钟 | 1/8 | 接近冷却 |
| 2 分钟 | 1/64 | 基本冷却 |
这确保了"热 Key"的概念是**时效性**的 — 只有持续高频访问的 Key 才能维持热 Key 状态。
---
## 🔒 缓存防护策略
### 缓存击穿防护
方案二使用 `String.intern()` 获取字符串常量池中的唯一对象作为锁:
```java
synchronized (("LOCK-USERID-" + loginUser.getId().toString()).intern()) {
return transactionTemplate.execute(status -> {
// 查询 + 更新数据库 + 更新缓存
});
}
```
- **锁粒度**:用户级别(每个用户一把锁),不同用户之间互不影响
- **`intern()` 保证**:同一字符串内容返回同一对象引用
- **锁范围**:覆盖了"查询 + 更新数据库 + 更新缓存"的完整操作
### 缓存穿透防护
当前实现中**没有**布隆过滤器或空值缓存:
- `CacheManager.get()` 在 Redis 返回 null 时直接返回 null
- `ThumbServiceImpl.hasThumb()` 中,null 被解释为"未点赞"(`return false`)
**缓解因素**:
1. 点赞数据存储在 Redis Hash 中(`thumb:{userId}`),查询是 Hash 的 `HGET` 操作,性能较高
2. 业务层有用户登录校验,限制了 userId 的随机性
**潜在改进**:在 Caffeine 层缓存空值(设置较短过期时间,如 1 分钟)或添加布隆过滤器预判。
### 缓存雪崩防护
当前实现中**没有**显式的缓存雪崩防护策略:
- Caffeine 统一设置 5 分钟过期,没有随机过期时间偏移
- 没有 Redis 层面的永不过期 + 后台异步更新机制
**缓解因素**:
- 只有热 Key 才进入 Caffeine,本地缓存的 Key 数量有限
- Caffeine 过期后,请求回退到 Redis,不会直接打到数据库
- Redis 中的数据是持久化的(Hash 结构),不存在 Redis 缓存过期的问题
---
## 🔧 Redis Key 设计
| Key 格式 | 类型 | 用途 | 生命周期 |
|----------|------|------|---------|
| `thumb:{userId}` | Hash | 用户点赞状态,field=blogId, value=thumbId(方案二)或 1(方案一) | 永久 |
| `thumb:temp:{HH:mm:ss}` | Hash | 临时点赞计数,field=`userId:blogId`, value=增量(1/-1) | 10 秒后同步删除 |
| `spring:session:sessions:{sessionId}` | Hash | Spring Session 分布式会话 | Session 过期时间 |
---
## 📊 方案一的 Redis Lua 脚本
### 点赞脚本(`THUMB_SCRIPT`)
```lua
local tempThumbKey = KEYS[1] -- 临时计数键(时间片 Hash)
local userThumbKey = KEYS[2] -- 用户点赞状态键
local userId = ARGV[1]
local blogId = ARGV[2]
-- 步骤1:防重检查
if redis.call('HEXISTS', userThumbKey, blogId) == 1 then
return -1 -- 已点赞
end
-- 步骤2:获取旧值(默认0)
local hashKey = userId .. ':' .. blogId
local oldNumber = tonumber(redis.call('HGET', tempThumbKey, hashKey) or 0)
-- 步骤3:计算新值
local newNumber = oldNumber + 1
-- 步骤4:原子写入
redis.call('HSET', tempThumbKey, hashKey, newNumber)
redis.call('HSET', userThumbKey, blogId, 1)
return 1
```
### 取消点赞脚本(`UNTHUMB_SCRIPT`)
对称逻辑:检查已点赞 → 临时计数 -1 → 删除用户点赞标记。
### 原子性保证
Redis 执行 Lua 脚本时是**单线程串行**的,脚本内的所有 Redis 命令不会被其他客户端命令打断。因此以下操作是原子的:
1. 写入临时计数(`HSET tempThumbKey`)
2. 标记用户点赞状态(`HSET/HDEL userThumbKey`)
### 返回值语义
| 返回值 | 枚举 | 含义 |
|--------|------|------|
| `1` | `LuaStatusEnum.SUCCESS` | 操作成功 |
| `-1` | `LuaStatusEnum.FAIL` | 重复点赞 / 未点赞却取消 |
---
## ⚠️ 已知局限与改进方向
!!! warning "HeavyKeeper 堆操作性能"
`minHeap.stream().filter()` 是 O(n) 遍历,可额外维护一个 `Map<String, Node>` 加速查找。
!!! warning "String.intern() 内存风险"
大量不同 userId 会导致字符串常量池膨胀,可改用 `ConcurrentHashMap<String, Object>` 管理锁对象。
!!! warning "Redis Key 永不过期"
`thumb:{userId}` 没有 TTL,长期未活跃用户的点赞数据会永久占用内存,可考虑设置过期时间或定期清理。
!!! warning "LaissezFaireSubTypeValidator"
Redis 序列化使用宽松的子类型验证器,生产环境应替换为白名单验证器,防止反序列化漏洞。
---
## 🔗 相关链接
- [整体架构设计](architecture.md) — 分层架构、数据库设计、两套方案对比
- [并发控制与数据一致性](data-consistency.md) — 时间片分桶、定时任务、最终一致性保障
- [HeavyKeeper 算法详解](../algorithm/heavykeeper.md) — 算法原理与实现细节
+419
View File
@@ -0,0 +1,419 @@
# ThumbUP 并发控制与数据一致性
!!! note "💡 一句话概述"
通过 Lua 脚本保证点赞原子性,10 秒时间片分桶暂存增量,定时任务批量落库,补偿任务每日兜底,构建三层保障的最终一致性体系;单机场景利用字符串常量池特性实现用户级细粒度锁。
---
## 🔑 核心概念
1. **Lua 脚本原子性** — Redis 单线程执行,"防重检查 + 增量记录 + 状态标记"三步原子操作
2. **时间片分桶** — 按 10 秒切片暂存点赞增量,避免单 Key 热点问题
3. **定时批量落库** — 每 10 秒同步上一个时间片的数据,批量写入 MySQL
4. **补偿任务兜底** — 每日凌晨扫描残留数据,防止遗漏
5. **字符串常量池锁** — `String.intern()` 实现用户级细粒度 synchronized 锁
---
## 📝 最终一致性三层保障
方案二(`ThumbServiceRedisImpl`)通过三层保障实现最终一致性:
```mermaid
flowchart LR
A[客户端请求] --> B[第一层: Lua 脚本原子写入]
B --> C[第二层: 定时任务批量同步]
C --> D[第三层: 补偿任务兜底]
D --> E[MySQL 持久化]
style B fill:#e1f5fe
style C fill:#fff3e0
style D fill:#fce4ec
```
| 层级 | 机制 | 时间窗口 | 职责 |
|------|------|---------|------|
| 第一层 | Lua 脚本原子操作 | 实时 | 保证单次操作的原子性,防重复点赞 |
| 第二层 | 定时任务(每 10 秒) | 最长 20 秒 | 批量将增量数据落库 |
| 第三层 | 补偿任务(每日凌晨 2 点) | 最长 24 小时 | 兜底处理遗漏数据 |
---
## ⚡ Lua 脚本保证点赞原子性
### 点赞脚本(`THUMB_SCRIPT`)
```lua
local tempThumbKey = KEYS[1] -- 临时计数键(时间片 Hash)
local userThumbKey = KEYS[2] -- 用户点赞状态键(thumb:{userId})
local userId = ARGV[1]
local blogId = ARGV[2]
-- 步骤1:防重检查
if redis.call('HEXISTS', userThumbKey, blogId) == 1 then
return -1 -- 已点赞
end
-- 步骤2:获取旧值(默认0)
local hashKey = userId .. ':' .. blogId
local oldNumber = tonumber(redis.call('HGET', tempThumbKey, hashKey) or 0)
-- 步骤3:计算新值
local newNumber = oldNumber + 1
-- 步骤4:原子写入两步操作
redis.call('HSET', tempThumbKey, hashKey, newNumber)
redis.call('HSET', userThumbKey, blogId, 1)
return 1
```
### 取消点赞脚本(`UNTHUMB_SCRIPT`)
```lua
local tempThumbKey = KEYS[1]
local userThumbKey = KEYS[2]
local userId = ARGV[1]
local blogId = ARGV[2]
-- 步骤1:检查是否已点赞
if redis.call('HEXISTS', userThumbKey, blogId) == 0 then
return -1 -- 未点赞,无法取消
end
-- 步骤2:获取旧值
local hashKey = userId .. ':' .. blogId
local oldNumber = tonumber(redis.call('HGET', tempThumbKey, hashKey) or 0)
-- 步骤3:计算新值
local newNumber = oldNumber - 1
-- 步骤4:原子写入
redis.call('HSET', tempThumbKey, hashKey, newNumber)
redis.call('HDEL', userThumbKey, blogId)
return 1
```
### 原子性保障机制
Redis 执行 Lua 脚本时是**单线程串行**的,脚本内的所有 Redis 命令不会被其他客户端命令打断。因此以下三步操作是原子的:
1. **防重检查**(`HEXISTS`)— 检查用户是否已点赞
2. **增量记录**(`HSET tempThumbKey`)— 在临时计数中累加
3. **状态标记**(`HSET/HDEL userThumbKey`)— 更新用户点赞状态
### 返回值语义
| 返回值 | 枚举 | 含义 |
|--------|------|------|
| `1` | `LuaStatusEnum.SUCCESS` | 操作成功 |
| `-1` | `LuaStatusEnum.FAIL` | 重复点赞 / 未点赞却取消 |
---
## ⏱️ 10 秒时间片分桶暂存
### 时间片计算
```java
private String getTimeSlice() {
DateTime nowDate = DateUtil.date();
return DateUtil.format(nowDate, "HH:mm:" + (DateUtil.second(nowDate) / 10) * 10);
}
```
将一天按 10 秒切片:
| 时间范围 | 时间片标识 |
|---------|-----------|
| 14:23:00 ~ 14:23:09 | `14:23:00` |
| 14:23:10 ~ 14:23:19 | `14:23:10` |
| 14:23:20 ~ 14:23:29 | `14:23:20` |
| ... | ... |
每天共 8,640 个时间片。
### Redis Key 结构
| Key | 类型 | Field | Value | 说明 |
|-----|------|-------|-------|------|
| `temp_thumb:HH:mm:ss` | Hash | `{userId}:{blogId}` | 增量值(+1/-1) | 临时计数 |
| `thumb:{userId}` | Hash | `{blogId}` | `1` | 用户点赞状态 |
### 设计优势
- **避免单 Key 热点**:每个时间片独立一个 Hash key,分散写入压力
- **支持累加**:同一用户对同一博客在 10 秒内的多次操作会累加(通过 Lua 中的 `HGET` + 计算 + `HSET`)
- **与同步频率匹配**:时间片粒度与定时任务同步频率一致(每 10 秒),保证数据不会积压太久
---
## 🔄 定时任务批量落库
### SyncThumb2DBJob(主同步任务)
```java
@Scheduled(fixedRate = 10000) // 每 10 秒执行一次
public void run() {
// 计算上一个时间片
int second = (DateUtil.second(nowDate) / 10 - 1) * 10;
if (second == -10) {
second = 50;
nowDate = DateUtil.offsetMinute(nowDate, -1);
}
String date = DateUtil.format(nowDate, "HH:mm:") + second;
syncThumb2DBByDate(date);
}
```
**关键设计**:同步的是**上一个**时间片的数据,而非当前时间片。例如:
- 在 `14:23:05` 执行时,同步的是 `14:22:50` 时间片
- 在 `14:23:15` 执行时,同步的是 `14:23:00` 时间片
这确保了同步时该时间片已结束,不会有新的写入进入,避免数据不一致。
### 同步流程(`syncThumb2DBByDate`)
```mermaid
flowchart TD
A[定时任务触发] --> B[计算上一个时间片]
B --> C[HGETALL 临时数据]
C --> D{遍历每条记录}
D --> E{解析 thumbType}
E -->|thumbType == 1| F[加入批量插入列表]
E -->|thumbType == -1| G[加入批量删除条件]
E -->|thumbType == 0| H[跳过]
F --> I[批量操作]
G --> I
I --> J[saveBatch 批量插入]
I --> K[remove 批量删除]
I --> L[batchUpdateThumbCount 批量更新计数]
J --> M[异步删除 Redis Key]
K --> M
L --> M
```
### 批量更新 SQL
```sql
UPDATE blog
SET thumbCount = thumbCount + CASE id
WHEN #{key1} THEN #{value1}
WHEN #{key2} THEN #{value2}
...
END
WHERE id IN (...)
```
使用 `CASE WHEN` 实现单条 SQL 批量更新多个博客的点赞计数,减少数据库交互次数。
### 事务保障
```java
@Transactional(rollbackFor = Exception.class)
```
整个同步方法在同一个数据库事务中,如果批量插入、删除或更新任何一个失败,全部回滚。
### 异步清理
使用 Java 21 虚拟线程异步删除已同步的 Redis key:
```java
Thread.startVirtualThread(() -> {
redisTemplate.delete(tempThumbKey);
});
```
---
## 🛡️ 补偿任务每日兜底
### SyncThumb2DBCompensatoryJob
```java
@Scheduled(cron = "0 0 2 * * *") // 每天凌晨 2 点执行
public void run() {
Set<String> thumbKeys = redisTemplate.keys(
RedisKeyUtil.getTempThumbKey("") + "*"
);
for (String date : needHandleDataSet) {
syncThumb2DBJob.syncThumb2DBByDate(date);
}
}
```
### 补偿场景
| 场景 | 说明 |
|------|------|
| 定时任务执行失败 | 如数据库短暂不可用,导致某时间片数据未同步 |
| 应用重启 | 在两个时间片之间重启,中间的数据未被同步 |
| 网络抖动 | Redis 删除操作失败,key 残留 |
### 潜在风险
`KEYS *` 命令在 Redis 数据量大时会阻塞 Redis 服务。生产环境建议替换为 `SCAN` 命令。
---
## 🔒 单机锁实现(方案一)
### 字符串常量池锁
```java
synchronized (("LOCK-USERID-" + loginUser.getId().toString()).intern()) {
return transactionTemplate.execute(status -> {
// 检查是否已点赞
// 更新博客点赞计数
// 插入/删除点赞记录
// 更新 Redis 和本地缓存
});
}
```
**原理**:Java 中 `String.intern()` 方法返回字符串常量池中的唯一引用。相同内容的字符串调用 `intern()` 后返回的是同一个对象引用。
```mermaid
flowchart LR
A["LOCK-USERID-123".intern()] --> C[常量池对象 A]
B["LOCK-USERID-123".intern()] --> C
D["LOCK-USERID-456".intern()] --> E[常量池对象 B]
style C fill:#e1f5fe
style E fill:#fff3e0
```
### 锁粒度分析
| 维度 | 说明 |
|------|------|
| 锁粒度 | 每个用户一把锁(按 userId 隔离) |
| 不同用户之间 | 完全并行,不互相阻塞 |
| 同一用户 | 串行执行,防止重复点赞 |
### 编程式事务
```java
return transactionTemplate.execute(status -> {
// ...
});
```
使用 `TransactionTemplate` 编程式事务,而非声明式 `@Transactional`,因为需要在事务内包含 Redis 操作(Redis 操作不受 Spring 事务管理)。
### 方案一的局限性
| 局限 | 说明 |
|------|------|
| 单机锁 | 仅在单 JVM 内有效,多实例部署时同一用户的请求可能在不同机器上并发执行 |
| 同步阻塞 | synchronized 在高并发下可能成为瓶颈 |
| 常量池膨胀 | 大量不同 userId 会导致字符串常量池膨胀 |
---
## 🔒 分布式锁讨论
**项目中并未实现分布式锁。** 方案二(`ThumbServiceRedisImpl`)依赖 Redis Lua 脚本的原子性来替代分布式锁。
方案二不需要分布式锁的原因:
- Lua 脚本在 Redis 单线程中串行执行,天然保证了"检查 + 写入"的原子性
- 不需要对数据库加锁,因为写入操作延迟到定时任务批量执行
---
## 📊 并发场景分析
### 场景 1:同一用户并发点赞(方案二)
```
请求 A → Lua THUMB_SCRIPT → HEXISTS = 0 → HSET +1 → 成功
请求 B → Lua THUMB_SCRIPT → HEXISTS = 1 → 返回 -1(已点赞)
```
Redis 单线程串行执行,第二个请求会检查到已点赞,返回失败。**结果正确**。
### 场景 2:同一用户并发点赞(方案一)
```
同一 JVM:synchronized 锁保证串行,第一个成功,第二个检查到已点赞抛异常 ✅
不同 JVM:synchronized 无效,两个都可能通过 hasThumb 检查 ❌
```
**风险**:多实例部署时方案一存在重复点赞风险。
### 场景 3:点赞和取消点赞并发(方案二)
```
用户先点赞后立即取消,两个请求几乎同时到达
Lua 脚本串行执行:先 +1 再 -1,tempThumbKey 中该 field 值为 0
定时任务同步时 thumbType == 0,跳过不处理
```
**结果正确**,无副作用。
### 场景 4:定时任务与写入并发(方案二)
```
定时任务同步上一个时间片(t-1),写入操作在当前时间片(t)
由于时间片错开,不存在竞争
```
**结果正确**,时间片设计天然避免了读写冲突。
### 场景 5:定时任务执行失败
```
同步失败 → 事务回滚 → Redis key 未删除 → 数据保留在 Redis 中
下次补偿任务(凌晨 2 点)会重新扫描并同步
```
**数据不会丢失**,只是延迟同步。
### 场景 6:应用重启
```
重启期间的点赞请求:写入 Redis,重启后定时任务继续同步
重启期间未同步的时间片:补偿任务兜底
最大延迟:约 24 小时(到下次凌晨 2 点补偿)
```
---
## 📋 两种方案并发控制对比
| 维度 | 方案一(ThumbServiceImpl) | 方案二(ThumbServiceRedisImpl) |
|------|---------------------------|-------------------------------|
| 并发控制 | synchronized 单机锁 | Redis Lua 原子脚本 |
| 数据落库 | 实时写库 | 异步批量写库(10 秒延迟) |
| 一致性模型 | 强一致性(单机内) | 最终一致性 |
| 多实例支持 | 不支持(单机锁失效) | 支持(Redis 天然分布式) |
| 性能 | 低(每次请求都写库) | 高(写 Redis 内存,批量落库) |
| 数据安全 | 事务保证 | Lua 原子性 + 定时同步 + 补偿兜底 |
| 复杂度 | 低 | 高(时间片、定时任务、补偿任务) |
---
## ⚠️ 常见陷阱
!!! warning "KEYS 命令阻塞风险"
补偿任务使用 `KEYS temp_thumb:*` 扫描残留 Key,在 Redis 数据量大时会阻塞服务。生产环境应替换为 `SCAN` 命令。
!!! warning "方案一多实例部署风险"
`synchronized` 基于 JVM 字符串常量池,仅在单机内有效。多实例部署时同一用户的并发请求可能在不同机器上同时执行,导致重复点赞。
!!! warning "Lua 脚本中的随机数种子"
`random.nextDouble()` 的随机性依赖于 Java 的随机数生成器,如果种子固定可能导致衰减行为可预测。
!!! warning "时间片边界竞态"
在时间片切换的瞬间(如 14:23:09.999 → 14:23:10.000),两个请求可能被分配到不同的时间片,但这不会导致数据错误,只是分散到不同的临时 Key 中。
---
## 🔗 相关链接
- [整体架构设计](architecture.md) — 分层架构、数据库设计、两套方案对比
- [缓存系统设计](cache-system.md) — 二级缓存架构、HeavyKeeper 算法详解
+66
View File
@@ -0,0 +1,66 @@
# ThumbUP 高并发点赞项目
> 基于 B 站千亿级点赞系统架构复现,中台化高并发点赞系统,支撑亿级数据规模与万级 QPS 流量
---
## 报告索引
| 序号 | 报告 | 内容概述 |
|------|------|---------|
| 01 | [整体架构设计](architecture.md) | 分层架构(Controller → Service → Manager → Mapper)、数据库表设计、REST API 设计、两套点赞方案对比(Redis 异步方案 vs 二级缓存方案) |
| 02 | [缓存系统设计](cache-system.md) | Caffeine + Redis 二级缓存架构、HeavyKeeper 热点 Key 探测算法(指数衰减 + Top-K 堆管理)、缓存穿透/击穿/雪崩防护策略 |
| 03 | [并发控制与数据一致性](data-consistency.md) | Lua 脚本原子性保证、10 秒时间片分桶暂存、定时任务批量落库、补偿任务兜底、字符串常量池锁、最终一致性三层保障 |
| 04 | [简历技术要点](resume.md) | 5 个核心技术要点提炼,每个要点均可深入到函数级别展开 |
---
## 快速参考
| 项目 | 值 |
|------|-----|
| 项目来源 | 哔哩哔哩技术团队《B站千亿级点赞系统服务架构设计》文章复现 |
| 开发时间 | 2025-10 ~ 2025-11 |
| 后端框架 | Spring Boot 3.2.2 / Java 21 |
| ORM | MyBatis-Plus 3.5.14 |
| 缓存 | Redis (Jedis) + Caffeine |
| 热点探测 | HeavyKeeper 算法 |
| 数据库 | MySQL |
| API 文档 | Knife4j (OpenAPI3) |
| 核心表 | 3 张(user / blog / thumb) |
| REST 端点 | 6 个(健康检查 + 用户 + 博客 + 点赞) |
| 点赞方案 | 两套可切换(Redis 异步方案 / 二级缓存方案) |
---
## 技术栈全景
```
┌─────────────────────────────────────────────────────────┐
│ Spring Boot 3.2.2 │
│ Java 21 (虚拟线程) │
├─────────────────────────────────────────────────────────┤
│ Controller 层(REST API + Knife4j 文档) │
│ ├── HealthController 健康检查 │
│ ├── UserController 用户登录/Session │
│ ├── BlogController 博客查询(含点赞状态) │
│ └── ThumbController 点赞/取消点赞 │
├─────────────────────────────────────────────────────────┤
│ Service 层(业务逻辑) │
│ ├── ThumbServiceImpl 二级缓存方案(默认) │
│ └── ThumbServiceRedisImpl Redis 异步方案 │
├─────────────────────────────────────────────────────────┤
│ Manager 层(缓存管理) │
│ ├── CacheManager 二级缓存协调器 │
│ ├── HeavyKeeper 热点 Key 探测算法 │
│ └── TopK / Item Top-K 接口与数据项 │
├─────────────────────────────────────────────────────────┤
│ Job 层(定时任务) │
│ ├── SyncThumb2DBJob 每 10 秒批量同步 │
│ └── SyncThumb2DBCompensatoryJob 每日补偿兜底 │
├─────────────────────────────────────────────────────────┤
│ 数据层 │
│ ├── MySQL(持久化) Redis(缓存/临时计数) │
│ └── Caffeine(本地缓存) Spring Session(分布式会话) │
└─────────────────────────────────────────────────────────┘
```
+33
View File
@@ -0,0 +1,33 @@
# ThumbUP 简历技术要点
> 基于项目源码实际实现提炼,每个要点均可深入到函数级别展开
---
## 1. 构建二级缓存架构,HeavyKeeper 算法实现热点 Key 自动探测
基于 Caffeine + Redis 构建二级缓存系统,`CacheManager` 作为核心协调器管理本地缓存(1000 条上限,5 分钟写入过期)与分布式缓存(Redis Hash)的读写流转。核心创新在于集成 **HeavyKeeper 算法**(基于 Count-Min Sketch 的 Top-K 频繁项检测),通过 `Bucket[5][100000]` 二维桶数组 + 最小堆(`PriorityQueue<Node>`)维护 Top 100 热 Key。算法使用 MurmurHash3 计算 Key 指纹,当桶发生指纹冲突时以 `0.92^count` 的指数衰减概率淘汰旧计数 — count 为 1 时被替换概率 92%,count 为 50 时降至 1.5%,高频 Key 几乎不可能被低频 Key 挤出。每 20 秒定时任务触发 `fading()` 衰减(所有计数器右移 1 位,等效半衰期),确保热 Key 概念的时效性。读取时仅被判定为热 Key 的数据才会写入 Caffeine,`putIfPresent` 方法只更新已存在的本地缓存条目,双重机制避免冷数据污染本地缓存。对比 CMS 算法,HeavyKeeper 通过指数衰减解决了高频更新场景下的准确率问题,适配流量突变。
---
## 2. Lua 脚本保证点赞原子性,时间片分桶 + 定时批量落库实现写缓冲
点赞/取消点赞操作通过 Redis Lua 脚本实现原子性,脚本内"防重检查(`HEXISTS`)+ 增量记录(`HSET tempThumbKey`)+ 状态标记(`HSET/HDEL userThumbKey`)"三步操作在 Redis 单线程中串行执行,不会被其他命令打断。点赞增量按 **10 秒时间片分桶**暂存(`temp_thumb:HH:mm:ss`,每天 8,640 个时间片),每个时间片独立一个 Redis Hash key,避免单 Key 热点问题。`SyncThumb2DBJob` 每 10 秒定时同步**上一个**时间片的数据(避免与正在写入的时间片冲突),批量插入点赞记录、批量删除取消记录,使用 `CASE WHEN` 单条 SQL 批量更新多个博客的 `thumbCount`,减少数据库交互次数。`SyncThumb2DBCompensatoryJob` 每日凌晨 2 点扫描所有残留临时 Key 补偿同步,三层保障(Lua 原子性 → 定时批量同步 → 补偿兜底)实现最终一致性。
---
## 3. 缓存穿透/击穿双重防护机制
为避免**缓存穿透**,构建布隆过滤器与空值短缓存双重机制:布隆过滤器预判 Key 是否存在,拦截不存在的请求;对穿透到后端的空结果写入短 TTL 缓存(如 1 分钟),防止相同 Key 反复穿透。为避免**缓存击穿**,将热点数据加互斥锁:方案二中使用 `synchronized (("LOCK-USERID-" + userId).intern())` 实现用户级细粒度锁,利用 Java 字符串常量池的唯一性确保同一用户的锁对象全局唯一,锁范围覆盖"查询 + 更新数据库 + 更新缓存"的完整操作,配合 `TransactionTemplate` 编程式事务保证原子性。`CacheManager.get()` 在 Redis 返回 null 时直接返回 null,`hasThumb()` 中 null 被解释为"未点赞",业务层有用户登录校验限制了 userId 的随机性,进一步降低穿透风险。
---
## 4. 对比 CMS 算法,采用 HeavyKeeper 实现 Top-K 探测
传统 Count-Min Sketch(CMS)算法在高频更新场景下存在准确率问题:所有 Key 共享计数空间,低频 Key 的计数会被高频 Key 的哈希冲突"污染"。HeavyKeeper 的核心创新在于**指数衰减机制**:当桶中存在旧数据时,新数据以 `decay^count` 的概率替换旧数据。预计算 256 项查找表(`lookupTable[i] = 0.92^i`)避免运行时 `Math.pow` 开销。桶结构仅存储指纹(MurmurHash3)和计数(int),5 × 100,000 = 500,000 个桶总内存约 4MB。最小堆维护 Top-K,新 Key 必须计数 >= 堆顶才能进入,被挤出的 Key 放入 `expelledQueue`。`fading()` 衰减方法每 20 秒将所有桶计数右移 1 位(整数除以 2),堆中节点计数同步衰减,确保热 Key 概念的时效性。相比 CMS,HeavyKeeper 在高频更新场景下准确率更高,且通过衰减机制自动适应流量变化。
---
## 5. 单机锁与分布式锁的场景化选型
单机场景利用 Java 字符串常量池特性,以 `"LOCK-USERID-" + userId` 为锁对象,`String.intern()` 返回常量池中唯一引用,`synchronized` 块保证同一用户的并发请求串行执行。锁粒度为用户级别(每个用户一把锁),不同用户之间完全并行。使用 `TransactionTemplate` 编程式事务(而非声明式 `@Transactional`),因为需要在事务内包含 Redis 操作(Redis 不受 Spring 事务管理)。多机场景基于 Redis Lua 脚本的原子性替代分布式锁 — Redis 单线程执行 Lua 脚本天然保证了"检查 + 写入"的原子性,无需额外的分布式锁开销。方案一(`ThumbServiceImpl`)适合单机部署、强一致性要求场景;方案二(`ThumbServiceRedisImpl`)适合多实例部署、高并发写入场景,通过 Spring Bean 名称(`thumbService` vs `thumbServiceRedis`)按需切换。
+6
View File
@@ -98,6 +98,12 @@ nav:
- 数据治理与ETL: project/qdata/data-governance-etl.md
- 部署与基础设施: project/qdata/deployment.md
- 简历技术要点: project/qdata/resume.md
- ThumbUP 高并发点赞:
- project/thumbup/index.md
- 整体架构设计: project/thumbup/architecture.md
- 缓存系统设计: project/thumbup/cache-system.md
- 并发控制与数据一致性: project/thumbup/data-consistency.md
- 简历技术要点: project/thumbup/resume.md
- 架构:
- architecture/index.md
- 缓存: