分布式读写锁实战详解:读多写少场景的性能优化利器
前言
这篇是微服务全家桶系列的学习笔记,这次整理的是分布式读写锁的实战应用。
你想想,如果一个短链接每秒有几千人在访问,但管理员一天可能就改那么一两次配置。用普通的分布式锁会怎样?每个访问请求都得排队等锁,本来可以并发处理的读请求,全变成串行了。这谁顶得住?
这就是读写锁要解决的问题------让读请求可以并发执行,只有写的时候才需要独占。
🏠个人主页:山沐与山
文章目录
- 一、为什么需要读写锁
- 二、读写锁的核心概念
- 三、短链接项目中的问题场景
- 四、Redisson读写锁实战
- 五、写锁场景详解
- 六、读锁场景详解
- 七、演进历程与架构思考
- 八、流程图解
- 九、最佳实践与踩坑记录
- 十、常见问题
- 十一、总结
一、为什么需要读写锁
1.1 从一个生活场景说起
假设你是图书馆的管理员。
场景一:100个学生同时来阅览室看《Java编程思想》的复印件。你会怎么做?让他们排队,一个看完另一个再看?那要等到猴年马月啊!正常做法是,大家一起看,互不影响。
场景二:突然出版社说这本书有个勘误,需要改第128页的一个代码示例。这时候怎么办?你得先把所有正在看这本书的人请出去,改完再让他们进来。不然有人看到的是旧的、有人看到的是新的,岂不是乱套了?
看到没?这就是读写锁的核心思想:
- 读操作:可以多人同时进行(共享)
- 写操作:必须独占,不能有其他人在读或写
1.2 普通互斥锁的问题
如果用普通的分布式锁会怎样?
java
// 普通互斥锁的做法
RLock lock = redissonClient.getLock("short-link:lock:" + fullShortUrl);
lock.lock();
try {
// 不管是读还是写,都得排队
doSomething();
} finally {
lock.unlock();
}
问题在哪?所有操作都串行化了!
假设:
- 每次读操作耗时 10ms
- 每秒 1000 个读请求
- 每秒只能处理 100 个请求
- 剩下 900 个请求全在排队等锁
累不累?明明大家只是来看看数据,又不改,凭什么要排队?
1.3 读写锁的优势
用读写锁的话:
| 场景 | 普通互斥锁 | 读写锁 |
|---|---|---|
| 1000个读请求同时到达 | 串行执行,一个个来 | 并发执行,一起处理 |
| 1个写请求到达 | 等前面的请求完成 | 等当前读完成,然后独占 |
| 吞吐量 | 低 | 高(读多写少场景) |
| 实现复杂度 | 简单 | 稍复杂 |
二、读写锁的核心概念
2.1 两种锁的定义
读写锁把锁分成两种类型:
读锁(共享锁,Shared Lock):
- 多个线程可以同时持有读锁
- 适合读取数据的场景
- 只要没有写锁,就可以获取
写锁(排他锁,Exclusive Lock):
- 同一时刻只能有一个线程持有写锁
- 持有写锁时,其他线程连读锁都拿不到
- 适合修改数据的场景
2.2 兼容性矩阵
这个表格很重要,建议背下来:
| 当前持有的锁 | 请求读锁 | 请求写锁 |
|---|---|---|
| 无锁 | 立即获取 | 立即获取 |
| 读锁 | 立即获取(共享) | 阻塞等待 |
| 写锁 | 阻塞等待 | 阻塞等待 |
用一句话总结:读读共享、读写互斥、写写互斥。
2.3 为什么叫"读写"锁而不是"共享排他"锁
其实从技术角度,叫"共享排他锁"更准确。但"读写锁"更直观------因为在99%的场景下:
- 读操作用共享锁
- 写操作用排他锁
这个命名帮助我们快速理解使用场景。
三、短链接项目中的问题场景
3.1 业务背景
短链接系统有这么一个核心场景:
用户访问短链接 s.link/abc
↓
系统需要记录访问统计(PV、UV、地区、设备等)
↓
统计数据需要关联到正确的分组ID(gid)
问题来了:短链接表 t_link 是按 gid 分片的。如果用户正在访问短链接,系统正在记录统计数据,这时候管理员突然修改了这个短链接的分组...
3.2 问题场景复现
时间线 →
用户A访问短链接 ─────查询gid=A────准备入库────────────入库(gid=A)
↑
│ 这期间gid被改了!
↓
管理员修改分组 ─────────────────gid改成B──────────────完成
看到问题了吗?用户A的访问统计记录到了旧的 gid=A,但实际上短链接已经属于 gid=B 了。数据就这么乱套了。
3.3 为什么不能用普通分布式锁
你可能会说,加个锁不就行了?
java
RLock lock = redissonClient.getLock("short-link:lock:" + fullShortUrl);
lock.lock();
try {
// 记录统计
} finally {
lock.unlock();
}
理论上可以,但问题是:
| 操作类型 | 频率 | 耗时 |
|---|---|---|
| 访问短链接(读) | 每秒几千次 | 10-50ms |
| 修改分组(写) | 每天几次 | 100-300ms |
读写比例可能是 100000:1!用普通互斥锁,每次读都要排队,性能直接崩掉。
3.4 读写锁的解决思路
访问短链接时:获取读锁(共享)
↓
多个访问可以并发执行,互不影响
修改分组时:获取写锁(排他)
↓
等所有读操作完成后,独占执行
↓
修改期间,新的读请求需要等待
这样既保证了数据一致性,又不影响正常访问的性能。
四、Redisson读写锁实战
4.1 Redis Key 设计
java
/**
* 短链接修改分组 ID 锁前缀 Key
*/
public static final String LOCK_GID_UPDATE_KEY = "short-link:lock:update-gid:%s";
最终的锁 Key 格式:short-link:lock:update-gid:{fullShortUrl}
为什么用 fullShortUrl 作为锁的粒度?
| 锁粒度 | 示例 | 优点 | 缺点 |
|---|---|---|---|
| 全局锁 | short-link:lock:global |
简单 | 所有短链接互斥,性能差 |
| 按分组 | short-link:lock:gid:{gid} |
同组互斥 | 修改时gid会变,逻辑复杂 |
| 按短链接 | short-link:lock:update-gid:{fullShortUrl} |
精确控制 | 锁数量较多 |
按短链接粒度是最合适的选择------只有操作同一个短链接时才会互斥。
4.2 Redisson 读写锁 API
Redisson 提供了 RReadWriteLock 接口:
java
// 获取读写锁对象
RReadWriteLock readWriteLock = redissonClient.getReadWriteLock("lockKey");
// 获取读锁
RLock readLock = readWriteLock.readLock();
// 获取写锁
RLock writeLock = readWriteLock.writeLock();
读锁和写锁都是 RLock 类型,用法和普通分布式锁一样:
java
// 阻塞获取
rLock.lock();
// 尝试获取(立即返回)
boolean success = rLock.tryLock();
// 尝试获取(带超时)
boolean success = rLock.tryLock(3, TimeUnit.SECONDS);
// 释放
rLock.unlock();
4.3 底层原理简述
Redisson 的读写锁基于 Redis 的 Hash 数据结构实现:
Key: short-link:lock:update-gid:s.link/abc
Field: mode Value: read(当前是读锁模式)
Field: {clientId}:1 Value: 1(客户端1持有1把读锁)
Field: {clientId}:2 Value: 1(客户端2持有1把读锁)
写锁模式:
Key: short-link:lock:update-gid:s.link/abc
Field: mode Value: write(当前是写锁模式)
Field: {clientId} Value: 1(只有一个客户端持有写锁)
这里就不展开了,有兴趣可以去看 Redisson 源码。
五、写锁场景详解
5.1 业务背景
当管理员修改短链接的分组(gid)时,需要获取写锁。
为什么修改 gid 这么复杂?因为短链接表是分片的:
t_link_0: 存储 gid % 16 = 0 的数据
t_link_1: 存储 gid % 16 = 1 的数据
...
t_link_15: 存储 gid % 16 = 15 的数据
修改 gid 意味着数据要从一个分片迁移到另一个分片,需要:
- 从旧分片删除记录
- 往新分片插入记录
- 更新路由表
这个过程中,绝对不能让其他请求读到不一致的数据。
5.2 完整代码实现
java
@Transactional(rollbackFor = Exception.class)
@Override
public void updateShortLink(ShortLinkUpdateReqDTO requestParam) {
// [Step 1] 校验原链接是否在白名单中
verificationWhitelist(requestParam.getOriginUrl());
// [Step 2] 查询原始短链接信息
LambdaQueryWrapper<ShortLinkDO> queryWrapper = Wrappers.lambdaQuery(ShortLinkDO.class)
.eq(ShortLinkDO::getGid, requestParam.getOriginGid())
.eq(ShortLinkDO::getFullShortUrl, requestParam.getFullShortUrl())
.eq(ShortLinkDO::getDelFlag, 0)
.eq(ShortLinkDO::getEnableStatus, 0);
ShortLinkDO hasShortLinkDO = baseMapper.selectOne(queryWrapper);
if (hasShortLinkDO == null) {
throw new ClientException("短链接记录不存在");
}
// [Step 3] 判断gid是否变更
if (Objects.equals(hasShortLinkDO.getGid(), requestParam.getGid())) {
// gid没变,直接更新,不需要加锁
// 因为不涉及分片迁移,普通update就行
LambdaUpdateWrapper<ShortLinkDO> updateWrapper = Wrappers.lambdaUpdate(ShortLinkDO.class)
.eq(ShortLinkDO::getFullShortUrl, requestParam.getFullShortUrl())
.eq(ShortLinkDO::getGid, requestParam.getGid())
.eq(ShortLinkDO::getDelFlag, 0)
.eq(ShortLinkDO::getEnableStatus, 0)
.set(Objects.equals(requestParam.getValidDateType(),
VailDateTypeEnum.PERMANENT.getType()),
ShortLinkDO::getValidDate, null);
ShortLinkDO shortLinkDO = ShortLinkDO.builder()
.domain(createShortLinkDefaultDomain)
.originUrl(requestParam.getOriginUrl())
.describe(requestParam.getDescribe())
// ... 其他字段
.build();
baseMapper.update(shortLinkDO, updateWrapper);
} else {
// ============ gid变了,必须获取写锁!============
// 获取读写锁
RReadWriteLock readWriteLock = redissonClient.getReadWriteLock(
String.format(LOCK_GID_UPDATE_KEY, requestParam.getFullShortUrl()));
// 获取写锁
RLock rLock = readWriteLock.writeLock();
// 阻塞获取写锁
rLock.lock();
try {
// ===== Step 3.1: 逻辑删除原记录 =====
LambdaUpdateWrapper<ShortLinkDO> linkUpdateWrapper =
Wrappers.lambdaUpdate(ShortLinkDO.class)
.eq(ShortLinkDO::getFullShortUrl, requestParam.getFullShortUrl())
.eq(ShortLinkDO::getGid, hasShortLinkDO.getGid())
.eq(ShortLinkDO::getDelFlag, 0)
.eq(ShortLinkDO::getDelTime, 0L)
.eq(ShortLinkDO::getEnableStatus, 0);
ShortLinkDO delShortLinkDO = ShortLinkDO.builder()
.delTime(System.currentTimeMillis())
.build();
delShortLinkDO.setDelFlag(1);
baseMapper.update(delShortLinkDO, linkUpdateWrapper);
// ===== Step 3.2: 插入新分片记录 =====
ShortLinkDO shortLinkDO = ShortLinkDO.builder()
.domain(createShortLinkDefaultDomain)
.originUrl(requestParam.getOriginUrl())
.gid(requestParam.getGid()) // 新的gid
.createdType(hasShortLinkDO.getCreatedType())
.validDateType(requestParam.getValidDateType())
.validDate(requestParam.getValidDate())
.describe(requestParam.getDescribe())
.shortUri(hasShortLinkDO.getShortUri())
.enableStatus(hasShortLinkDO.getEnableStatus())
.totalPv(hasShortLinkDO.getTotalPv())
.totalUv(hasShortLinkDO.getTotalUv())
.totalUip(hasShortLinkDO.getTotalUip())
.fullShortUrl(hasShortLinkDO.getFullShortUrl())
.favicon(getFavicon(requestParam.getOriginUrl()))
.delTime(0L)
.build();
baseMapper.insert(shortLinkDO);
// ===== Step 3.3: 更新路由表 =====
LambdaQueryWrapper<ShortLinkGotoDO> linkGotoQueryWrapper =
Wrappers.lambdaQuery(ShortLinkGotoDO.class)
.eq(ShortLinkGotoDO::getFullShortUrl, requestParam.getFullShortUrl())
.eq(ShortLinkGotoDO::getGid, hasShortLinkDO.getGid());
ShortLinkGotoDO shortLinkGotoDO = shortLinkGotoMapper.selectOne(linkGotoQueryWrapper);
shortLinkGotoMapper.delete(linkGotoQueryWrapper);
shortLinkGotoDO.setGid(requestParam.getGid()); // 更新为新的gid
shortLinkGotoMapper.insert(shortLinkGotoDO);
} finally {
// ===== 一定要在finally里释放锁!=====
rLock.unlock();
}
}
// 后续的缓存处理逻辑...
}
5.3 关键点解析
为什么用 lock() 而不是 tryLock()?
早期版本我们用的是 tryLock():
java
// 早期版本
if (!rLock.tryLock()) {
throw new ServiceException("短链接正在被访问,请稍后再试");
}
问题是什么?用户体验差!用户点一下"保存",系统告诉他"请稍后再试"。凭什么我点一下就失败了?
引入 RocketMQ 后情况变了:
MQ消费者每次只拉取少量消息- 不会有海量并发的读锁请求
- 写锁等待的时间其实很短
所以改成了 lock() 直接阻塞等待,用户只需要多等几毫秒,体验好多了。
为什么只在 gid 变更时才加锁?
看代码里的判断:
java
if (Objects.equals(hasShortLinkDO.getGid(), requestParam.getGid())) {
// gid没变,直接更新
} else {
// gid变了,加锁
}
如果只是改个描述、改个原链接,gid 没变的话,不涉及分片迁移,直接更新就行,没必要加锁。
六、读锁场景详解
6.1 业务背景
当用户访问短链接时,系统需要异步记录访问统计。统计数据需要关联正确的 gid。
问题:如果在记录统计时,正好有人在修改 gid,会导致统计数据关联到错误的 gid。
解决方案:记录统计时获取读锁 ,确保在记录期间 gid 不会被修改。
6.2 为什么用异步处理
你可能会问,为什么不在用户访问的时候直接同步记录统计?
想想看:
- 用户访问短链接,期望的是快速跳转到目标页面
- 如果同步记录统计,需要等待数据库写入完成
- 还要等待获取读锁
用户:我只是想打开个链接,你让我等 100ms?体验也太差了吧!
所以采用异步处理:
- 用户访问 → 快速返回302跳转
- 同时发送消息到
MQ - 消费者异步处理统计入库
6.3 完整代码实现
java
@Slf4j
@Component
@RequiredArgsConstructor
public class ShortLinkStatsSaveRedisConsumer
implements StreamListener<String, MapRecord<String, String, String>> {
private final ShortLinkMapper shortLinkMapper;
private final ShortLinkGotoMapper shortLinkGotoMapper;
private final RedissonClient redissonClient;
private final LinkAccessStatsMapper linkAccessStatsMapper;
private final LinkLocaleStatsMapper linkLocaleStatsMapper;
private final LinkOsStatsMapper linkOsStatsMapper;
private final LinkBrowserStatsMapper linkBrowserStatsMapper;
private final LinkAccessLogsMapper linkAccessLogsMapper;
private final LinkDeviceStatsMapper linkDeviceStatsMapper;
private final LinkNetworkStatsMapper linkNetworkStatsMapper;
private final LinkStatsTodayMapper linkStatsTodayMapper;
private final StringRedisTemplate stringRedisTemplate;
private final MessageQueueIdempotentHandler messageQueueIdempotentHandler;
@Value("${short-link.stats.locale.amap-key}")
private String statsLocaleAmapKey;
@Override
public void onMessage(MapRecord<String, String, String> message) {
String stream = message.getStream();
RecordId id = message.getId();
// [Step 1] 幂等性检查
if (!messageQueueIdempotentHandler.isMessageProcessed(id.toString())) {
// 消息已处理过
if (messageQueueIdempotentHandler.isAccomplish(id.toString())) {
return; // 已完成,直接返回
}
// 未完成,抛异常触发重试
throw new ServiceException("消息未完成流程,需要消息队列重试");
}
try {
// [Step 2] 解析消息
Map<String, String> producerMap = message.getValue();
String fullShortUrl = producerMap.get("fullShortUrl");
if (StrUtil.isNotBlank(fullShortUrl)) {
String gid = producerMap.get("gid");
ShortLinkStatsRecordDTO statsRecord = JSON.parseObject(
producerMap.get("statsRecord"), ShortLinkStatsRecordDTO.class);
// [Step 3] 调用实际统计方法
actualSaveShortLinkStats(fullShortUrl, gid, statsRecord);
}
// [Step 4] 删除已消费的消息
stringRedisTemplate.opsForStream().delete(
Objects.requireNonNull(stream), id.getValue());
} catch (Throwable ex) {
// 出错了,删除幂等标识,允许重试
messageQueueIdempotentHandler.delMessageProcessed(id.toString());
log.error("记录短链接监控消费异常", ex);
throw ex;
}
// [Step 5] 标记消息处理完成
messageQueueIdempotentHandler.setAccomplish(id.toString());
}
/**
* 实际保存短链接统计数据
*
* 为什么在这里加读锁?
*
* 首先纠正一个概念:读锁不是用来"读取数据"的锁,而是用来"保证数据入库正确性"的锁。
*
* 场景:用户访问短链接,我们要记录统计数据,但如果这期间有人在修改gid,
* 我们可能会把统计数据记录到错误的gid下。
*
* 解决:获取读锁后再查询gid,确保在入库期间gid不会被修改。
*/
public void actualSaveShortLinkStats(String fullShortUrl, String gid,
ShortLinkStatsRecordDTO statsRecord) {
fullShortUrl = Optional.ofNullable(fullShortUrl)
.orElse(statsRecord.getFullShortUrl());
// ===== 获取读写锁 =====
RReadWriteLock readWriteLock = redissonClient.getReadWriteLock(
String.format(LOCK_GID_UPDATE_KEY, fullShortUrl));
// 获取读锁(共享锁)
RLock rLock = readWriteLock.readLock();
rLock.lock();
try {
// ===== 在锁保护下查询最新的gid =====
// 为什么要重新查?因为消息可能在队列里等了一会儿,gid可能已经被修改了
LambdaQueryWrapper<ShortLinkGotoDO> queryWrapper =
Wrappers.lambdaQuery(ShortLinkGotoDO.class)
.eq(ShortLinkGotoDO::getFullShortUrl, fullShortUrl);
ShortLinkGotoDO shortLinkGotoDO = shortLinkGotoMapper.selectOne(queryWrapper);
gid = shortLinkGotoDO.getGid(); // 拿到最新的gid
// 准备统计数据
Date currentDate = statsRecord.getCurrentDate();
int hour = DateUtil.hour(currentDate, true);
Week week = DateUtil.dayOfWeekEnum(currentDate);
int weekValue = week.getIso8601Value();
// ===== 记录访问统计(PV、UV、UIP)=====
LinkAccessStatsDO linkAccessStatsDO = LinkAccessStatsDO.builder()
.pv(1)
.uv(statsRecord.getUvFirstFlag() ? 1 : 0)
.uip(statsRecord.getUipFirstFlag() ? 1 : 0)
.hour(hour)
.weekday(weekValue)
.fullShortUrl(fullShortUrl)
.date(currentDate)
.build();
linkAccessStatsMapper.shortLinkStats(linkAccessStatsDO);
// ===== 记录地区统计 =====
Map<String, Object> localeParamMap = new HashMap<>();
localeParamMap.put("key", statsLocaleAmapKey);
localeParamMap.put("ip", statsRecord.getRemoteAddr());
String localeResultStr = HttpUtil.get(AMAP_REMOTE_URL, localeParamMap);
JSONObject localeResultObj = JSON.parseObject(localeResultStr);
String infoCode = localeResultObj.getString("infocode");
String actualProvince = "未知";
String actualCity = "未知";
if (StrUtil.isNotBlank(infoCode) && StrUtil.equals(infoCode, "10000")) {
String province = localeResultObj.getString("province");
boolean unknownFlag = StrUtil.equals(province, "[]");
LinkLocaleStatsDO linkLocaleStatsDO = LinkLocaleStatsDO.builder()
.province(actualProvince = unknownFlag ? actualProvince : province)
.city(actualCity = unknownFlag ? actualCity :
localeResultObj.getString("city"))
.adcode(unknownFlag ? "未知" : localeResultObj.getString("adcode"))
.cnt(1)
.fullShortUrl(fullShortUrl)
.country("中国")
.date(currentDate)
.build();
linkLocaleStatsMapper.shortLinkLocaleState(linkLocaleStatsDO);
}
// ===== 记录操作系统统计 =====
LinkOsStatsDO linkOsStatsDO = LinkOsStatsDO.builder()
.os(statsRecord.getOs())
.cnt(1)
.fullShortUrl(fullShortUrl)
.date(currentDate)
.build();
linkOsStatsMapper.shortLinkOsState(linkOsStatsDO);
// ===== 记录浏览器统计 =====
LinkBrowserStatsDO linkBrowserStatsDO = LinkBrowserStatsDO.builder()
.browser(statsRecord.getBrowser())
.cnt(1)
.fullShortUrl(fullShortUrl)
.date(currentDate)
.build();
linkBrowserStatsMapper.shortLinkBrowserState(linkBrowserStatsDO);
// ===== 记录设备统计 =====
LinkDeviceStatsDO linkDeviceStatsDO = LinkDeviceStatsDO.builder()
.device(statsRecord.getDevice())
.cnt(1)
.fullShortUrl(fullShortUrl)
.date(currentDate)
.build();
linkDeviceStatsMapper.shortLinkDeviceState(linkDeviceStatsDO);
// ===== 记录网络统计 =====
LinkNetworkStatsDO linkNetworkStatsDO = LinkNetworkStatsDO.builder()
.network(statsRecord.getNetwork())
.cnt(1)
.fullShortUrl(fullShortUrl)
.date(currentDate)
.build();
linkNetworkStatsMapper.shortLinkNetworkState(linkNetworkStatsDO);
// ===== 记录访问日志 =====
LinkAccessLogsDO linkAccessLogsDO = LinkAccessLogsDO.builder()
.user(statsRecord.getUv())
.ip(statsRecord.getRemoteAddr())
.browser(statsRecord.getBrowser())
.os(statsRecord.getOs())
.network(statsRecord.getNetwork())
.device(statsRecord.getDevice())
.locale(StrUtil.join("-", "中国", actualProvince, actualCity))
.fullShortUrl(fullShortUrl)
.build();
linkAccessLogsMapper.insert(linkAccessLogsDO);
// ===== 更新短链接总统计 =====
shortLinkMapper.incrementStats(gid, fullShortUrl, 1,
statsRecord.getUvFirstFlag() ? 1 : 0,
statsRecord.getUipFirstFlag() ? 1 : 0);
// ===== 记录今日统计 =====
LinkStatsTodayDO linkStatsTodayDO = LinkStatsTodayDO.builder()
.todayPv(1)
.todayUv(statsRecord.getUvFirstFlag() ? 1 : 0)
.todayUip(statsRecord.getUipFirstFlag() ? 1 : 0)
.fullShortUrl(fullShortUrl)
.date(currentDate)
.build();
linkStatsTodayMapper.shortLinkTodayState(linkStatsTodayDO);
} catch (Throwable ex) {
log.error("短链接访问量统计异常", ex);
} finally {
// ===== 释放读锁 =====
rLock.unlock();
}
}
}
6.4 关键点解析
为什么要在锁里重新查询 gid?
消息里不是已经带了 gid 吗?为什么还要再查一遍?
java
// 消息里的gid可能已经过时了
String gid = producerMap.get("gid");
// 在锁保护下查询最新的gid
ShortLinkGotoDO shortLinkGotoDO = shortLinkGotoMapper.selectOne(queryWrapper);
gid = shortLinkGotoDO.getGid(); // 用最新的
原因:
- 消息可能在队列里等了一会儿(比如消费者忙不过来)
- 在这期间
gid可能已经被修改了 - 获取读锁后再查询,能确保拿到的是最新的
gid - 读锁保证了在后续入库期间
gid不会被修改
读锁是"读取数据的锁"吗?
这是一个常见的误解。读锁的真正含义是:持有读锁期间,不允许写操作。
在这个场景里:
- "读"操作:记录统计数据(其实是写数据库)
- "写"操作:修改
gid
叫"读锁"是因为它是共享的,多个"读"操作可以并发执行。
七、演进历程与架构思考
7.1 没有 MQ 之前的方案
在引入 RocketMQ 之前,项目使用 Redisson 的延迟队列:
java
/**
* 延迟消费短链接统计发送者
* 在没有MQ之前的解决方案,用来削峰
*/
@Component
@Deprecated
@RequiredArgsConstructor
public class DelayShortLinkStatsProducer {
private final RedissonClient redissonClient;
public void send(ShortLinkStatsRecordDTO statsRecord) {
// 设置消息唯一ID
statsRecord.setKeys(UUID.fastUUID().toString());
// 获取Redis中的阻塞双端队列
RBlockingDeque<ShortLinkStatsRecordDTO> blockingDeque =
redissonClient.getBlockingDeque(DELAY_QUEUE_STATS_KEY);
// 获取延迟队列
RDelayedQueue<ShortLinkStatsRecordDTO> delayedQueue =
redissonClient.getDelayedQueue(blockingDeque);
// 放入延迟队列,5秒后消费
delayedQueue.offer(statsRecord, 5, TimeUnit.SECONDS);
}
}
7.2 旧版本的 tryLock 逻辑
java
public void shortLinkStatsOld(String fullShortUrl, String gid,
ShortLinkStatsRecordDTO statsRecord) {
fullShortUrl = Optional.ofNullable(fullShortUrl)
.orElse(statsRecord.getFullShortUrl());
RReadWriteLock readWriteLock = redissonClient.getReadWriteLock(
String.format(LOCK_GID_UPDATE_KEY, fullShortUrl));
RLock rLock = readWriteLock.readLock();
// 使用tryLock尝试获取锁
if (!rLock.tryLock()) {
// 获取失败,说明有人持有写锁
// 放入延迟队列,5秒后重试
delayShortLinkStatsProducer.send(statsRecord);
return;
}
try {
// 统计逻辑...
} finally {
rLock.unlock();
}
}
为什么要这样设计?
如果有人拿到写锁,所有读锁请求都会阻塞。如果此时让所有读请求都自旋等待:
- 浪费 CPU 资源
- 可能导致大量线程堆积
- 系统可能崩掉
所以用延迟队列兜底:获取不到锁就放队列里,5秒后再试。
7.3 新版本为什么改成 lock()
引入 RocketMQ 后:
| 对比项 | 旧方案(延迟队列) | 新方案(MQ) |
|---|---|---|
| 并发量 | 高,直接从HTTP请求触发 | 低,消费者每次只拉几条 |
| 锁竞争 | 激烈 | 温和 |
| 获取锁策略 | tryLock() + 延迟重试 |
直接 lock() 等待 |
| 代码复杂度 | 高 | 低 |
MQ 消费者每次只拉取少量消息(比如 16 条),不会有海量并发。直接 lock() 等一会儿就能拿到锁,比 tryLock() 失败后重试的体验好多了。
7.4 架构演进总结
阶段1:同步处理
用户访问 → 同步记录统计 → 返回
问题:用户等待时间长
阶段2:异步处理 + 延迟队列
用户访问 → 返回 → 延迟队列消费
问题:tryLock频繁失败,重试逻辑复杂
阶段3:异步处理 + MQ
用户访问 → 返回 → MQ消费者处理
优点:并发可控,直接lock(),代码简洁
八、流程图解
8.1 读写锁协调流程
┌─────────────────────────────────────────────────────────┐
│ 读写锁协调流程 │
└─────────────────────────────────────────────────────────┘
│
┌───────────────────────────────┼───────────────────────────────┐
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ 用户访问短链接 │ │ 用户访问短链接 │ │ 管理员修改gid │
│ 请求 1 │ │ 请求 2 │ │ │
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
│ │ │
▼ ▼ ▼
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ 获取读锁 [OK] │ │ 获取读锁 [OK] │ │ 获取写锁 ... │
│ (共享成功) │ │ (共享成功) │ │ (等待读锁释放) │
└────────┬────────┘ └────────┬────────┘ └────────┬────────┘
│ │ │
▼ ▼ │
┌─────────────────┐ ┌─────────────────┐ │
│ 查询最新gid │ │ 查询最新gid │ │
│ 记录统计数据 │ │ 记录统计数据 │ │
└────────┬────────┘ └────────┬────────┘ │
│ │ │
▼ ▼ │
┌─────────────────┐ ┌─────────────────┐ │
│ 释放读锁 │ │ 释放读锁 │ │
└─────────────────┘ └─────────────────┘ │
│
▼
┌─────────────────┐
│ 获取写锁 [OK] │
│ (所有读锁已释放)│
└────────┬────────┘
│
▼
┌─────────────────┐
│ 执行gid修改 │
│ - 删除旧记录 │
│ - 插入新记录 │
│ - 更新路由表 │
└────────┬────────┘
│
▼
┌─────────────────┐
│ 释放写锁 │
└─────────────────┘
8.2 并发时序图
时间线 →
请求1(读) ──获取读锁──┬────────记录统计────────┬──释放读锁──
│ │
请求2(读) ──获取读锁──┼────────记录统计────────┼──释放读锁──
│ │
请求3(写) ────────────┴──等待...──等待...──────┴──获取写锁──修改gid──释放写锁──
↑ ↑
写锁被阻塞 读锁全部释放
等待读锁释放 写锁获取成功
8.3 MQ消费流程
┌──────────────────────┐
│ MQ消费者收到消息 │
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ 幂等性检查 │
│ 是否已处理过? │
└──────────┬───────────┘
│
┌──────────────┴──────────────┐
│ 是 │ 否
▼ ▼
┌──────────────────┐ ┌──────────────────────┐
│ 直接返回 │ │ 标记为处理中 │
└──────────────────┘ └──────────┬───────────┘
│
▼
┌──────────────────────┐
│ 获取读锁 │
│ readLock.lock() │
└──────────┬───────────┘
│
┌──────────────────┴──────────────────┐
│ 有写锁 │ 无写锁
▼ ▼
┌──────────────────┐ ┌──────────────────┐
│ 阻塞等待 │ │ 立即获取成功 │
│ (写锁释放后) │ │ │
└────────┬─────────┘ └────────┬─────────┘
│ │
└─────────────────┬───────────────────┘
│
▼
┌──────────────────────┐
│ 查询最新gid │
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ 记录各类统计数据 │
│ - 访问统计(PV/UV) │
│ - 地区统计 │
│ - 设备统计 │
│ - 浏览器统计 │
│ - 网络统计 │
│ - 今日统计 │
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ 释放读锁 │
└──────────┬───────────┘
│
▼
┌──────────────────────┐
│ 标记消息处理完成 │
└──────────────────────┘
九、最佳实践与踩坑记录
9.1 锁一定要在 finally 里释放
这个老生常谈了,但还是有人会犯错:
java
// [x] 错误写法 - 千万别这么干
rLock.lock();
doSomething(); // 如果这里抛异常...
rLock.unlock(); // 这行就执行不到,死锁!
// [+] 正确写法
rLock.lock();
try {
doSomething();
} finally {
rLock.unlock(); // 无论如何都会执行
}
9.2 锁的粒度要合适
| 粒度太粗 | 粒度太细 | 粒度合适 |
|---|---|---|
| 全局锁 | 按用户锁 | 按资源锁 |
| 所有请求互斥 | 可能无法保护共享资源 | 只有操作同一资源时互斥 |
| 性能差 | 达不到目的 | 平衡 |
9.3 读写锁不是万能的
读写锁适合读多写少的场景。
| 场景 | 读写比例 | 推荐方案 |
|---|---|---|
| 短链接访问统计 | 100000:1 | 读写锁 |
| 购物车操作 | 1:1 | 普通互斥锁 |
| 秒杀场景 | 1:1000(写多) | 分布式队列 |
如果读写频率差不多,或者写操作很频繁,读写锁的优势就不明显了,反而增加了复杂度。
9.4 可重入性
Redisson 的读写锁是可重入的,同一个线程可以多次获取同一个锁:
java
RLock rLock = readWriteLock.readLock();
rLock.lock(); // 第一次获取,计数=1
rLock.lock(); // 第二次获取,计数=2(可重入)
// ...
rLock.unlock(); // 第一次释放,计数=1
rLock.unlock(); // 第二次释放,计数=0,真正释放
9.5 写锁饥饿问题
如果读请求一直不断,写锁可能一直拿不到。这叫写锁饥饿。
Redisson 的解决方案:当有写锁在等待时,新的读锁请求也会被阻塞,确保写锁最终能获取到。
9.6 锁的看门狗机制
Redisson 的锁有自动续期功能(看门狗):
java
// 不指定超时时间,看门狗会自动续期
rLock.lock();
// 指定超时时间,不会自动续期
rLock.lock(10, TimeUnit.SECONDS);
看门狗默认每 10 秒续期一次,锁的默认超时时间是 30 秒。
十、常见问题
10.1 问题1:读锁和写锁能同时持有吗?
问题描述:同一个线程能同时持有读锁和写锁吗?
答案:可以,但有条件。
java
// 先获取写锁,再获取读锁 - OK(锁降级)
writeLock.lock();
readLock.lock(); // 可以获取
writeLock.unlock();
readLock.unlock();
// 先获取读锁,再获取写锁 - 阻塞(锁升级,不支持)
readLock.lock();
writeLock.lock(); // 会阻塞等待自己释放读锁,造成死锁!
这就是为什么锁降级可以,锁升级不行。
10.2 问题2:tryLock 和 lock 怎么选?
问题描述 :什么时候用 tryLock(),什么时候用 lock()?
解决方案:
| 场景 | 推荐方法 | 原因 |
|---|---|---|
| 用户直接操作 | tryLock(timeout) |
不能让用户等太久 |
| 后台任务 | lock() |
可以等待 |
| 有降级方案 | tryLock() |
失败可以走降级逻辑 |
java
// 场景1:用户操作,限时等待
if (!rLock.tryLock(3, TimeUnit.SECONDS)) {
throw new ServiceException("系统繁忙,请稍后再试");
}
// 场景2:后台任务,可以等
rLock.lock();
// 场景3:有降级方案
if (!rLock.tryLock()) {
// 走降级逻辑,比如放入延迟队列
delayQueue.offer(task);
return;
}
10.3 问题3:读写锁能跨进程吗?
问题描述:不同服务器上的进程能用同一把读写锁吗?
答案:当然可以!这就是"分布式"的意义。
只要不同进程连的是同一个 Redis 实例,就能用同一把锁。Redisson 通过 Redis 的原子操作保证锁的正确性。
10.4 问题4:锁超时了怎么办?
问题描述:如果业务执行时间很长,锁超时释放了怎么办?
解决方案:
- 使用看门狗 (推荐):不指定超时时间,让
Redisson自动续期 - 合理设置超时时间:根据业务预估时间设置
- 拆分任务:把长任务拆成多个短任务
java
// 使用看门狗,自动续期
rLock.lock();
// 不使用看门狗,需要自己确保业务能在10秒内完成
rLock.lock(10, TimeUnit.SECONDS);
10.5 问题5:读锁是用来读数据的吗?
问题描述:为什么记录统计数据(写操作)要用读锁?
答案:读锁的名字确实容易让人误解。
"读锁"的真正含义是共享锁------持有读锁期间,不允许其他人获取写锁,但允许其他人也获取读锁。
在这个项目里:
- "读"操作(用读锁):记录统计数据
- "写"操作(用写锁):修改
gid
叫"读锁"是因为它是共享的,多个"读"操作可以并发执行。
十一、总结
11.1 核心要点回顾
| 知识点 | 说明 |
|---|---|
| 读写锁核心规则 | 读读共享、读写互斥、写写互斥 |
| 适用场景 | 读多写少 |
| 本项目应用 | 写锁:修改 gid;读锁:记录统计 |
| 锁粒度 | 按 fullShortUrl,单个短链接粒度 |
lock() vs tryLock() |
MQ 场景用 lock(),用户直接操作用 tryLock(timeout) |
| 锁释放 | 必须在 finally 里 |
11.2 关键代码位置
| 功能 | 文件 | 方法 |
|---|---|---|
| 写锁使用 | ShortLinkServiceImpl.java |
updateShortLink() |
| 读锁使用 | ShortLinkStatsSaveRedisConsumer.java |
actualSaveShortLinkStats() |
| 锁 Key 定义 | RedisKeyConstant.java |
LOCK_GID_UPDATE_KEY |
11.3 架构思考
分布式读写锁解决了"读多写少"场景下的并发控制问题,但也不是银弹。使用时要考虑:
- 是否真的读多写少:如果不是,用普通互斥锁就行
- 锁粒度是否合适:太粗影响性能,太细达不到目的
- 异常处理是否完善 :锁一定要在
finally里释放 - 是否需要配合其他方案 :比如
MQ削峰
热门专栏推荐
- Agent小册
- 服务器部署
- Java基础合集
- Python基础合集
- Go基础合集
- 大数据合集
- 前端小册
- 数据库合集
- Redis 合集
- Spring 全家桶
- 微服务全家桶
- 数据结构与算法合集
- 设计模式小册
- 消息队列合集
等等等还有许多优秀的合集在主页等着大家的光顾,感谢大家的支持
文章到这里就结束了,如果有什么疑问的地方请指出,诸佬们一起来评论区一起讨论😊
希望能和诸佬们一起努力,今后我们一起观看感谢您的阅读🙏
如果帮助到您不妨3连支持一下,创造不易您们的支持是我的动力🌟