短链接核心1:用户注册

前提:数据库用户表分表

用户名全局唯一

分片键为用户名
登录时需要通过用户名查询用户信息,所以需要将用户表分片,以用户名为分片键,将用户名的hash值对分片数取模。
而如果用用户ID作为分片键,那么在通过用户名登录时查询没有带上分片键,数据库会用union all的形式查所有的表,造成非常大的性能深渊。

分片键的关键因素:
访问频率:选择分片键应考虑数据访问频率。将经常访问的数据放在一个分片上,可以提高查询性能和降低跨分片查询的开销。
数据均匀性:分片键应该保证数据的均匀分布在各个分片上,避免出现热点数据集中在某个分片上的情况。
数据不可变:一旦选择了分片键,它应该是不可变的,不能随着业务的变化而频繁修改。

业务流程

短链接核心1:用户注册

完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@Override
public Boolean hasUsername(String username) {
return !userRegisterCachePenetrationBloomFilter.contains(username);
}

@Transactional(rollbackFor = Exception.class)
@Override
public void register(UserRegisterReqDTO requestParam) {
if (!hasUsername(requestParam.getUsername())) {
throw new ClientException(USER_NAME_EXIST);
}
RLock lock = redissonClient.getLock(LOCK_USER_REGISTER_KEY + requestParam.getUsername());
if (!lock.tryLock()) {
throw new ClientException(USER_NAME_EXIST);
}
try {
int inserted = baseMapper.insert(BeanUtil.toBean(requestParam, UserDO.class));
if (inserted < 1) {
throw new ClientException(USER_SAVE_ERROR);
}
groupService.saveGroup(requestParam.getUsername(), "默认分组");
userRegisterCachePenetrationBloomFilter.add(requestParam.getUsername());
} catch (DuplicateKeyException ex) {
throw new ClientException(USER_EXIST);
} finally {
lock.unlock();
}
}

检查用户名是否已存在

如果有海量用户查询的用户名存在或不存在,全部请求数据库,会将数据库直接打满。

需要将用户名存储在缓存中

本项目通过布隆过滤器检查用户名是否已存在

为什么使用布隆过滤器?

  1. 布隆过滤器是一个很长的二进制向量和一系列随机映射函数。
  2. 布隆过滤器可以用于检索一个元素是否在一个集合中。
  3. 布隆过滤器的优点是空间效率和查询时间都远远超过一般的算法。
  4. 布隆过滤器的缺点是有一定的误识别率和删除困难。
  5. 布隆过滤器的应用场景:缓存穿透、用户注册、垃圾邮件过滤、爬虫URL去重等。
  6. 布隆过滤器的原理:将元素通过多个 Hash 函数映射到位数组中,当查询元素时,通过多个 Hash 函数映射到位数组中,只有当所有位都为 1 时,才表示元素存在。
  7. 布隆过滤器的误判率:当位数组的长度和 Hash 函数的个数确定时,误判率是固定的。

如果不采用布隆过滤器而直接用缓存,需要考虑缓存过期时间,如果永不过期,占用 Redis 内存太高。

  1. 配置布隆过滤器
1
2
3
4
5
6
7
8
9
10
11
12
13
@Configuration(value = "rBloomFilterConfigurationByAdmin")
public class RBloomFilterConfiguration {

/
* 防止用户注册查询数据库的布隆过滤器
*/
@Bean
public RBloomFilter<String> userRegisterCachePenetrationBloomFilter(RedissonClient redissonClient) {
RBloomFilter<String> cachePenetrationBloomFilter = redissonClient.getBloomFilter("userRegisterCachePenetrationBloomFilter");
cachePenetrationBloomFilter.tryInit(100000000L, 0.001);
return cachePenetrationBloomFilter;
}
}

tryInit 有两个核心参数:

  • expectedInsertions:预估布隆过滤器存储的元素长度。
  • falseProbability:运行的误判率。
    错误率越低,位数组越长,布隆过滤器的内存占用越大。
    错误率越低,散列 Hash 函数越多,计算耗时较长。
  1. 使用布隆过滤器
1
private final RBloomFilter<String> userRegisterCachePenetrationBloomFilter;
  1. 检查用户名是否已存在
1
2
3
4
@Override
public Boolean hasUsername(String username) {
return !userRegisterCachePenetrationBloomFilter.contains(username);
}

用户注册

如何防止恶意请求毫秒级触发大量请求去一个未注册的用户名?

因为用户名没注册,所以布隆过滤器不存在,代表着可以触发注册流程插入数据库。但是如果恶意请求短时间海量请求,这些请求都会落到数据库,造成数据库访问压力。
这里通过分布式锁,锁定用户名进行串行执行,防止恶意请求利用未注册用户名将请求打到数据库。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
RLock lock = redissonClient.getLock(LOCK_USER_REGISTER_KEY + requestParam.getUsername());
if (!lock.tryLock()) {
throw new ClientException(USER_NAME_EXIST);
}
try {
int inserted = baseMapper.insert(BeanUtil.toBean(requestParam, UserDO.class));
if (inserted < 1) {
throw new ClientException(USER_SAVE_ERROR);
}
groupService.saveGroup(requestParam.getUsername(), "默认分组");
userRegisterCachePenetrationBloomFilter.add(requestParam.getUsername());
} catch (DuplicateKeyException ex) {
throw new ClientException(USER_EXIST);
} finally {
lock.unlock();
}
tryLock() 和 lock() 区别

RLockRedissonLock)是 Redisson 提供的分布式锁,它基于 Redis 实现。lock()tryLock() 都可以用于加锁,但它们的工作方式有所不同。


1. lock() 方法

1
2
RLock lock = redissonClient.getLock("myLock");
lock.lock();

特点

  • 阻塞式:如果锁已被其他线程或进程持有,lock() 会一直等待,直到获取到锁。
  • 默认支持 WatchDog 机制(避免死锁):Redisson 默认会给锁设置 30 秒的过期时间,并且如果业务逻辑没有执行完,Redisson 会每隔 10 秒自动续期,直到 unlock() 释放锁。
  • 适用于长时间的任务,或者必须保证成功获取锁的场景。

2. tryLock() 方法

1
2
3
4
5
6
7
8
9
10
11
RLock lock = redissonClient.getLock("myLock");
boolean isLocked = lock.tryLock();
if (isLocked) {
try {
// 执行业务逻辑
} finally {
lock.unlock();
}
} else {
// 处理获取锁失败的情况
}

特点

  • 非阻塞:如果锁已经被占用,它不会等待,而是立即返回 false
  • 可以设置超时时间:
    1
    boolean isLocked = lock.tryLock(5, 10, TimeUnit.SECONDS);
    • 5 秒是等待时间(如果锁已被占用,它会最多等待 5 秒)。
    • 10 秒是锁的持有时间(如果获取到锁,它最多会持有 10 秒,之后自动释放)。
  • 适用于尝试获取锁但不想长时间等待的场景,如限时任务或高并发场景。

3. lock() vs tryLock() 区别总结

lock()tryLock()
是否阻塞阻塞(一直等待获取锁)非阻塞(立即返回)
等待锁的方式获取不到锁会一直等待立即返回 false,可设定最大等待时间
是否可以设置超时支持(默认 30 秒 WatchDog 续期)支持(可手动指定锁的持有时间)
适用场景必须确保拿到锁的任务不能等待太久的任务,如限时任务

4. 什么时候用 lock(),什么时候用 tryLock()

✅ 用 lock() 的情况:

  • 需要一定要成功获取锁才能执行任务,例如分布式事务。
  • 任务执行时间不确定,依赖Redisson WatchDog 续期机制。

✅ 用 tryLock() 的情况:

  • 不想等待太久,如果锁不可用,可以立即做其他事情(如返回错误或稍后重试)。
  • 需要设置最大等待时间和锁的持有时间,如秒杀、限时抢购等高并发场景。

示例:

  • 订单支付并发处理(lock()):

    1
    lock.lock();
    • 确保订单不会被多个进程同时处理。
  • 限时秒杀抢购(tryLock(3, 5, TimeUnit.SECONDS)):

    1
    if (lock.tryLock(3, 5, TimeUnit.SECONDS)) {
    • 如果 3 秒内没拿到锁,直接返回 “抢购失败”。

🚀 结论:

  • 如果需要一定要获取锁再执行任务,用 lock()
  • 如果获取不到锁就直接返回或限时等待,用 tryLock()