- 添加项目概览页(index.md),包含技术栈全景和报告索引 - 添加整体架构设计文档(architecture.md),涵盖分层架构、数据库设计、REST API、两套点赞方案对比 - 添加缓存系统设计文档(cache-system.md),详解二级缓存架构、HeavyKeeper 算法、缓存防护策略 - 添加并发控制与数据一致性文档(data-consistency.md),涵盖 Lua 脚本原子性、时间片分桶、定时任务、补偿任务 - 添加简历技术要点文档(resume.md),提炼 5 个核心技术要点 - 更新 mkdocs.yml 导航配置
This commit is contained in:
@@ -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) — 基于源码提炼的面试技术要点
|
||||
@@ -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) — 算法原理与实现细节
|
||||
@@ -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 算法详解
|
||||
@@ -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(分布式会话) │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
```
|
||||
@@ -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`)按需切换。
|
||||
@@ -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
|
||||
- 缓存:
|
||||
|
||||
Reference in New Issue
Block a user