From 1fbeae7c44aaf5c5d80be65ba5f2511b2c8bd2d5 Mon Sep 17 00:00:00 2001 From: wonder Date: Tue, 1 Sep 2026 10:41:33 +0800 Subject: [PATCH] =?UTF-8?q?docs:=20=E6=B7=BB=E5=8A=A0=20ThumbUP=20?= =?UTF-8?q?=E9=AB=98=E5=B9=B6=E5=8F=91=E7=82=B9=E8=B5=9E=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E6=96=87=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加项目概览页(index.md),包含技术栈全景和报告索引 - 添加整体架构设计文档(architecture.md),涵盖分层架构、数据库设计、REST API、两套点赞方案对比 - 添加缓存系统设计文档(cache-system.md),详解二级缓存架构、HeavyKeeper 算法、缓存防护策略 - 添加并发控制与数据一致性文档(data-consistency.md),涵盖 Lua 脚本原子性、时间片分桶、定时任务、补偿任务 - 添加简历技术要点文档(resume.md),提炼 5 个核心技术要点 - 更新 mkdocs.yml 导航配置 --- docs/project/thumbup/architecture.md | 343 +++++++++++++++++ docs/project/thumbup/cache-system.md | 453 +++++++++++++++++++++++ docs/project/thumbup/data-consistency.md | 419 +++++++++++++++++++++ docs/project/thumbup/index.md | 66 ++++ docs/project/thumbup/resume.md | 33 ++ mkdocs.yml | 6 + 6 files changed, 1320 insertions(+) create mode 100644 docs/project/thumbup/architecture.md create mode 100644 docs/project/thumbup/cache-system.md create mode 100644 docs/project/thumbup/data-consistency.md create mode 100644 docs/project/thumbup/index.md create mode 100644 docs/project/thumbup/resume.md diff --git a/docs/project/thumbup/architecture.md b/docs/project/thumbup/architecture.md new file mode 100644 index 0000000..6516900 --- /dev/null +++ b/docs/project/thumbup/architecture.md @@ -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`: + +```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) — 基于源码提炼的面试技术要点 diff --git a/docs/project/thumbup/cache-system.md b/docs/project/thumbup/cache-system.md new file mode 100644 index 0000000..a10f5d2 --- /dev/null +++ b/docs/project/thumbup/cache-system.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 localCache; // Caffeine 本地缓存 + private final TopK hotKeyDetector; // HeavyKeeper 热点探测器 + private final RedisTemplate 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 minHeap; // 最小堆,维护 Top K +private final BlockingQueue expelledQueue; // 被挤出的 Key 队列 +``` + +在 `add` 方法的后半部分: + +```java +synchronized (minHeap) { + Optional 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 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` 加速查找。 + +!!! warning "String.intern() 内存风险" + 大量不同 userId 会导致字符串常量池膨胀,可改用 `ConcurrentHashMap` 管理锁对象。 + +!!! warning "Redis Key 永不过期" + `thumb:{userId}` 没有 TTL,长期未活跃用户的点赞数据会永久占用内存,可考虑设置过期时间或定期清理。 + +!!! warning "LaissezFaireSubTypeValidator" + Redis 序列化使用宽松的子类型验证器,生产环境应替换为白名单验证器,防止反序列化漏洞。 + +--- + +## 🔗 相关链接 + +- [整体架构设计](architecture.md) — 分层架构、数据库设计、两套方案对比 +- [并发控制与数据一致性](data-consistency.md) — 时间片分桶、定时任务、最终一致性保障 +- [HeavyKeeper 算法详解](../algorithm/heavykeeper.md) — 算法原理与实现细节 diff --git a/docs/project/thumbup/data-consistency.md b/docs/project/thumbup/data-consistency.md new file mode 100644 index 0000000..7bf84c2 --- /dev/null +++ b/docs/project/thumbup/data-consistency.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 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 算法详解 diff --git a/docs/project/thumbup/index.md b/docs/project/thumbup/index.md new file mode 100644 index 0000000..76984f5 --- /dev/null +++ b/docs/project/thumbup/index.md @@ -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(分布式会话) │ +└─────────────────────────────────────────────────────────┘ +``` diff --git a/docs/project/thumbup/resume.md b/docs/project/thumbup/resume.md new file mode 100644 index 0000000..23b07f9 --- /dev/null +++ b/docs/project/thumbup/resume.md @@ -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`)维护 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`)按需切换。 diff --git a/mkdocs.yml b/mkdocs.yml index 3b8123e..7bf57bc 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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 - 缓存: