跳转至

ThumbUP 并发控制与数据一致性

💡 一句话概述

通过 Lua 脚本保证点赞原子性,10 秒时间片分桶暂存增量,定时任务批量落库,补偿任务每日兜底,构建三层保障的最终一致性体系;单机场景利用字符串常量池特性实现用户级细粒度锁。


🔑 核心概念

  1. Lua 脚本原子性 — Redis 单线程执行,"防重检查 + 增量记录 + 状态标记"三步原子操作
  2. 时间片分桶 — 按 10 秒切片暂存点赞增量,避免单 Key 热点问题
  3. 定时批量落库 — 每 10 秒同步上一个时间片的数据,批量写入 MySQL
  4. 补偿任务兜底 — 每日凌晨扫描残留数据,防止遗漏
  5. 字符串常量池锁 — 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 命令不会被其他客户端命令打断。因此以下三步操作是原子的:

  1. 防重检查(HEXISTS)— 检查用户是否已点赞
  2. 增量记录(HSET tempThumbKey)— 在临时计数中累加
  3. 状态标记(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 批量更新多个博客的点赞计数,减少数据库交互次数。

事务保障

@Transactional(rollbackFor = Exception.class)

整个同步方法在同一个数据库事务中,如果批量插入、删除或更新任何一个失败,全部回滚。

异步清理

使用 Java 21 虚拟线程异步删除已同步的 Redis key:

Thread.startVirtualThread(() -> {
    redisTemplate.delete(tempThumbKey);
});

🛡️ 补偿任务每日兜底

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 隔离)
不同用户之间 完全并行,不互相阻塞
同一用户 串行执行,防止重复点赞

编程式事务

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 原子性 + 定时同步 + 补偿兜底
复杂度 低 高(时间片、定时任务、补偿任务)

⚠️ 常见陷阱

KEYS 命令阻塞风险

补偿任务使用 KEYS temp_thumb:* 扫描残留 Key,在 Redis 数据量大时会阻塞服务。生产环境应替换为 SCAN 命令。

方案一多实例部署风险

synchronized 基于 JVM 字符串常量池,仅在单机内有效。多实例部署时同一用户的并发请求可能在不同机器上同时执行,导致重复点赞。

Lua 脚本中的随机数种子

random.nextDouble() 的随机性依赖于 Java 的随机数生成器,如果种子固定可能导致衰减行为可预测。

时间片边界竞态

在时间片切换的瞬间(如 14:23:09.999 → 14:23:10.000),两个请求可能被分配到不同的时间片,但这不会导致数据错误,只是分散到不同的临时 Key 中。


🔗 相关链接