ThumbUP 并发控制与数据一致性¶
💡 一句话概述
通过 Lua 脚本保证点赞原子性,10 秒时间片分桶暂存增量,定时任务批量落库,补偿任务每日兜底,构建三层保障的最终一致性体系;单机场景利用字符串常量池特性实现用户级细粒度锁。
🔑 核心概念¶
- Lua 脚本原子性 — Redis 单线程执行,"防重检查 + 增量记录 + 状态标记"三步原子操作
- 时间片分桶 — 按 10 秒切片暂存点赞增量,避免单 Key 热点问题
- 定时批量落库 — 每 10 秒同步上一个时间片的数据,批量写入 MySQL
- 补偿任务兜底 — 每日凌晨扫描残留数据,防止遗漏
- 字符串常量池锁 —
String.intern()实现用户级细粒度 synchronized 锁
📝 最终一致性三层保障¶
方案二(ThumbServiceRedisImpl)通过三层保障实现最终一致性:
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)¶
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)¶
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 命令不会被其他客户端命令打断。因此以下三步操作是原子的:
- 防重检查(
HEXISTS)— 检查用户是否已点赞 - 增量记录(
HSET tempThumbKey)— 在临时计数中累加 - 状态标记(
HSET/HDEL userThumbKey)— 更新用户点赞状态
返回值语义¶
| 返回值 | 枚举 | 含义 |
|---|---|---|
1 |
LuaStatusEnum.SUCCESS |
操作成功 |
-1 |
LuaStatusEnum.FAIL |
重复点赞 / 未点赞却取消 |
⏱️ 10 秒时间片分桶暂存¶
时间片计算¶
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(主同步任务)¶
@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)¶
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¶
UPDATE blog
SET thumbCount = thumbCount + CASE id
WHEN #{key1} THEN #{value1}
WHEN #{key2} THEN #{value2}
...
END
WHERE id IN (...)
使用 CASE WHEN 实现单条 SQL 批量更新多个博客的点赞计数,减少数据库交互次数。
事务保障¶
整个同步方法在同一个数据库事务中,如果批量插入、删除或更新任何一个失败,全部回滚。
异步清理¶
使用 Java 21 虚拟线程异步删除已同步的 Redis key:
🛡️ 补偿任务每日兜底¶
SyncThumb2DBCompensatoryJob¶
@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 命令。
🔒 单机锁实现(方案一)¶
字符串常量池锁¶
synchronized (("LOCK-USERID-" + loginUser.getId().toString()).intern()) {
return transactionTemplate.execute(status -> {
// 检查是否已点赞
// 更新博客点赞计数
// 插入/删除点赞记录
// 更新 Redis 和本地缓存
});
}
原理:Java 中 String.intern() 方法返回字符串常量池中的唯一引用。相同内容的字符串调用 intern() 后返回的是同一个对象引用。
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 隔离) |
| 不同用户之间 | 完全并行,不互相阻塞 |
| 同一用户 | 串行执行,防止重复点赞 |
编程式事务¶
使用 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:同一用户并发点赞(方案一)¶
风险:多实例部署时方案一存在重复点赞风险。
场景 3:点赞和取消点赞并发(方案二)¶
结果正确,无副作用。
场景 4:定时任务与写入并发(方案二)¶
结果正确,时间片设计天然避免了读写冲突。
场景 5:定时任务执行失败¶
数据不会丢失,只是延迟同步。
场景 6:应用重启¶
📋 两种方案并发控制对比¶
| 维度 | 方案一(ThumbServiceImpl) | 方案二(ThumbServiceRedisImpl) |
|---|---|---|
| 并发控制 | synchronized 单机锁 | Redis Lua 原子脚本 |
| 数据落库 | 实时写库 | 异步批量写库(10 秒延迟) |
| 一致性模型 | 强一致性(单机内) | 最终一致性 |
| 多实例支持 | 不支持(单机锁失效) | 支持(Redis 天然分布式) |
| 性能 | 低(每次请求都写库) | 高(写 Redis 内存,批量落库) |
| 数据安全 | 事务保证 | Lua 原子性 + 定时同步 + 补偿兜底 |
| 复杂度 | 低 | 高(时间片、定时任务、补偿任务) |
⚠️ 常见陷阱¶
KEYS 命令阻塞风险
补偿任务使用 KEYS temp_thumb:* 扫描残留 Key,在 Redis 数据量大时会阻塞服务。生产环境应替换为 SCAN 命令。
方案一多实例部署风险
synchronized 基于 JVM 字符串常量池,仅在单机内有效。多实例部署时同一用户的并发请求可能在不同机器上同时执行,导致重复点赞。
Lua 脚本中的随机数种子
random.nextDouble() 的随机性依赖于 Java 的随机数生成器,如果种子固定可能导致衰减行为可预测。
时间片边界竞态
在时间片切换的瞬间(如 14:23:09.999 → 14:23:10.000),两个请求可能被分配到不同的时间片,但这不会导致数据错误,只是分散到不同的临时 Key 中。