文章目录
1. 海量注册的常见问题和解决方案概述
Q: 什么是海量注册?
A: 在短时间内,系统或平台接收到的大量新用户注册行为。
Q: 海量注册要应对什么样的问题?
A:
高并发请求处理:当大量用户同时尝试注册时,系统需要能够快速响应并处理这些请求。如果系统处理能力不足,用户可能会遇到注册失败、页面加载缓慢或超时等问题。
数据重复与唯一性校验: 在注册过程中,需要确保用户提交的信息(如用户名、邮箱、手机号等)是唯一的。在高并发情况下,系统需要高效地进行唯一性校验,避免数据重复。
安全性: 海量注册可能会吸引恶意用户进行自动化注册(如机器人注册、恶意刷号等),因此系统需要采取有效的安全措施,如验证码验证、IP限制、行为分析等,来防止滥用和保障用户数据安全。
用户体验问题:系统需要设计合理的用户体验策略,如提供清晰的注册进度反馈、友好的错误提示和快速的响应速度等。
本文的对上述问题的应对策略:
- 布隆过滤器判断用户唯一性
- 通过分布式锁和快速失败策略对同一时间的同一个账号进行锁定
- 通过创建数据库唯一索引进行兜底, 捕获
DuplicateKeyException
异常 - 通过分表技术 水平分库+水平分表 将一个数据库拆分成多个库, 将一张表拆分成多个表
- 通过自定义线程池和 CompletableFuture 异步初始化用户其他信息或其他操作(发送邮箱/短信/记录注册日志), 提升响应速度
2. 布隆过滤器判断用户唯一性
布隆过滤器是一种数据结构,用于快速判断一个元素是否存在于一个集合中。它以牺牲一定的准确性为代价,换取了存储空间的极大节省和查询速度的显著提升。详细: 布隆(Bloom Filter)过滤器——全面讲解
Q: 为什么要使用布隆过滤器
A:
海量用户如果说查询的用户名存在或不存在,全部请求数据库,会将数据库直接打满。
并且它空间效率高可以显著减少内存或存储空间的消耗, 查询速度快查询操作是常数时间复杂度O(k)
能容忍误判, 布隆过滤器说存在可能数据库中不存在, 而说布布隆过滤器说不存在就肯定不存在
在注册的业务中, 对布隆过滤器的误判是能接受的, 如果用户的账号是
123
系统返回账号已存在那么也无所谓, 就换个1235
也是没问题的
这里使用Redisson
提供布隆过滤器来继承来 SpringBoot 项目中
Redisson官文: Redisson: Easy Redis Java client and Real-Time Data Platform
引入Redisson
依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
</dependency>
在配置文件application.yml|yaml|properties
中配置redis相关参数
# yml
spring:
data:
redis:
host: 127.0.0.1
port: 6379
password: # 有就写
# properties
spring.data.redis.host=127.0.0.1
spring.data.redis.port=6379
spring.data.redis.password= # 有就写
配置布隆过滤器
import org.redisson.api.RBloomFilter;
import org.redisson.api.RedissonClient;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* 布隆过滤器配置
* 错误率越低,位数组越长,布隆过滤器的内存占用越大
* 错误率越低,散列 Hash 函数越多,计算耗时较长
*/
@Configuration(value = "rBloomFilterConfiguration")
public class RBloomFilterConfiguration {
/**
* 防止用户注册查询数据库的布隆过滤器
*/
@Bean
public RBloomFilter<String> userRegisterBloomFilter(RedissonClient redissonClient) {
RBloomFilter<String> cachePenetrationBloomFilter = redissonClient.getBloomFilter("userRegisterCachePenetrationBloomFilter");
// expectedInsertions:预估布隆过滤器存储的元素长度 = 1亿
// falseProbability:运行的误判率 = 设置为千分之一的误判率
cachePenetrationBloomFilter.tryInit(100000000L, 0.001);
return cachePenetrationBloomFilter;
}
}
接收到注册请求后,系统会首先通过布隆过滤器判断该账号是否已存在。若布隆过滤器判定账号已存在,系统将立即反馈账号已被使用
的信息,从而提高注册流程的效率和用户体验。
大致流程如下
具体代码实现
@Autowired
private RBloomFilter<String> userRegisterBloomFilter;
// 判断用户名是可用
public Boolean availableUsername(String username) {
return !userRegisterBloomFilter.contains(username);
}
public void register(RequestParam RequestParam){
if (!availableUsername(requestParam.getUsername())) {
throw new ServiceException("用户名已被占用 ( つ•̀ω•́)つ");
}
}
当然当用户成功完成注册流程后,将其信息纳入布隆过滤器之中,以此作为高效判断用户是否已存在的机制。减轻了数据库的负担,确保了系统在处理用户存在性验证时能够更为流畅与迅速。
public void register(RequestParam RequestParam){
// 注册业务完成...
// 添加到布隆过滤器中
userRegisterBloomFilter.add(requestParam.getUsername());
}
3. 通过分布式锁和快速失败策略对同一时间的同一个账号进行锁定
在处理高并发环境下的账号操作时,尤其需要精细地管理并发问题,以防止数据不一致和潜在的服务冲突。特别是当多个用户尝试同时以同一用户名进行操作时,若缺乏有效的控制机制,可能会引发严重的并发问题。
如果这个正在注册的username
被上锁了, 不管已占用锁的用户是否注册成功则直接返回账号已被使用
, 这样可以不占用主线程走剩下的逻辑, 也不需要阻塞等待锁释放, 具体流程和实现代码如下
private final RedissonClient redissonClient;
public void register(UserRegisterReqDTO requestParam) {
RLock lock = redissonClient.getLock(LOCK_USER_REGISTER_KEY + requestParam.getUsername());
if (!lock.tryLock()) {
// 获取不到锁, 快速失败, 不占用线程阻塞等待
throw new ClientException(USER_NAME_EXIST);
}
// 执行注册逻辑...
// 最后释放锁
lock.unlock();
}
4. 数据库唯一索引兜底
在集群同步过程中, 由于网络延迟或节点故障等原因,可能会出现数据短暂不一致的情况。这种不一致性若未妥善处理,可能导致本应被布隆过滤器拦截的无效请求直接穿透至MySQL数据库层,进而增加了数据库的负担并可能引入数据错误, 所以还需对username字段添加一个唯一索引并在代码层面捕获这个DuplicateKeyException
异常。
public void register(UserRegisterReqDTO requestParam) {
try {
int inserted = userMapper.insert(BeanUtil.toBean(requestParam, UserEntity.class));
if (inserted < 1) {
throw new ClientException(USER_SAVE_ERROR);
}
} catch (DuplicateKeyException ex) {
throw new ClientException(USER_EXIST);
} finally {
lock.unlock();
}
userRegisterCachePenetrationBloomFilter.add(requestParam.getUsername());
}
5. 通过水平分库水平分表
分库分表是数据库架构优化的重要手段,旨在应对高并发访问、海量数据存储的挑战。通过分散存储数据到多个数据库或表中,可以显著提高系统的扩展性、减少单库负载、优化查询性能,并有效支持更大规模的数据存储与处理需求。
现有实现技术:
ShardingSphere-JDBC: 基于AOP原理, 在应用程序中对本地执行的SQL进行拦截, 解析, 改写, 路由处理, 需要自行编码配置实现, 只支持Java语言, 性能较高, 官文: ShardingSphere-JDBC
MyCat: 数据库分库分表中间件, 不用调整代码, 支持多种语言, 性能不及前者, 且社区不活跃, 更新不频繁
官文: MyCat2
老规矩了, SpringBoot 集成某个技术基本就三个步骤
- 引入依赖
- 编写配置
application.yml|yaml|properties
- 跟着官方文档来使用
第一步: pom.xml
<dependency>
<groupId>org.apache.shardingsphere</groupId>
<artifactId>shardingsphere-jdbc-core</artifactId>
<version>5.3.2</version>
</dependency>
第二步: application.yml|yaml|properties
spring:
datasource:
driver-class-name: org.apache.shardingsphere.driver.ShardingSphereDriver
url: jdbc:shardingsphere:classpath:shardingsphere-config.yaml
第三步: shardingsphere-config.yaml
配置数据源和分片规则
了解分库分表的核心概念:
- 分片键: 用于将数据库(表)水平拆分的数据库字段
- 逻辑表: 相同结构的水平拆分数据库(表)的逻辑名称,是 SQL 中表的逻辑标识。 例:用户数据拆分为 32 张表,分别是
t_user_0
到t_user_31
,他们的逻辑表名为t_user
- 真实表: 在水平拆分的数据库中真实存在的物理表。 即下个示例中的
t_user_0
到t_user_31
将一个大型数据库中的
t_user
表根据用户的账号拆分到多个不同的数据库实例中,每个数据库实例中的t_user
表结构相同,但存储的数据子集不同,以分散数据负载和提高查询效率。所有数据库实例中的t_user
表数据的并集构成了原始t_user
表的全量数据。
配置分片规则
根据图上的配置的规则如下
- 分片键为 username
- 两个数据源: ds_0, ds_1
- 每个数据源中水平拆分成16张表
- 分片规则为对 username 进行 HAHS_MOD
dataSources:
# 数据源配置
ds_0:
dataSourceClassName: com.zaxxer.hikari.HikariDataSource
driverClassName: com.mysql.cj.jdbc.Driver
jdbcUrl: jdbc:mysql://127.0.0.1:3306/db_0?useUnicode=true&characterEncoding=UTF-8&rewriteBatchedStatements=true&allowMultiQueries=true&serverTimezone=Asia/Shanghai
username: root
password: root
ds_1:
dataSourceClassName: com.zaxxer.hikari.HikariDataSource
driverClassName: com.mysql.cj.jdbc.Driver
jdbcUrl: jdbc:mysql://127.0.0.1:3306/db_1?useUnicode=true&characterEncoding=UTF-8&rewriteBatchedStatements=true&allowMultiQueries=true&serverTimezone=Asia/Shanghai
username: root
password: root
rules:
- !SHARDING
tables:
t_user: # 逻辑表名称
actualDataNodes: ds_0.t_link_${0..15}, ds_1.t_link_${16..31} # Inline 语法
# 分表策略
tableStrategy:
standard:
# 分片键满足 1.访问频率高 2.数据均匀 3.数据不可变
shardingColumn: username
shardingAlgorithmName: user_table_hash_mod
# 分库策略
defaultDatabaseStrategy:
standard:
shardingColumn: username
shardingAlgorithmName: database_hash_mod
# 分片算法 对应 rules[0].shardingAlgorithms
shardingAlgorithms:
# 数据库表分片算法
user_table_hash_mod: # 分片算法文档: https://shardingsphere.apache.org/document/current/cn/dev-manual/sharding/
# 根据分片键进行Hash分片
type: HASH_MOD
props:
# 分片数量
sharding-count: 16
# 分库算法
database_hash_mod:
type: HASH_MOD
props:
sharding-count: 2
props:
# 输出逻辑SQL和真实SQL
sql-show: true
然后正常使用即可
int inserted = userMapper.insert(BeanUtil.toBean(requestParam, UserEntity.class));
输出的逻辑SQL和真实SQL如下
# Logic SQL:
INSERT INTO t_user ( id,username) VALUES (?,?)
# Actual SQL: ds_0
INSERT INTO t_user_11 ( id,username) VALUES (?, ?) ::: [1, tiantian] # 使用了 ds_0 数据源和 11 号用户表
6. 通过自定义线程池和异步初始化
配置线程池
三个核心参数
corePoolSize
: 任务队列未达到队列容量时,最大可以同时运行的线程数量。maximumPoolSize
: 任务队列中存放的任务达到队列容量的时候,当前可以同时运行的线程数量变为最大线程数。workQueue
: 新任务来的时候会先判断当前运行的线程数量是否达到核心线程数,如果达到的话,新任务就会被存放在队列中。
确定核心线程数:
- 高并发、任务执行时间短 -->( CPU核数+1 ),减少线程上下文的切换
@Bean(name = "registerExecutorService")
public ThreadPoolExecutor registerExecutorService() {
// 计算核心线程数
int corePoolSize = Runtime.getRuntime().availableProcessors() + 1;
// 设置最大线程数
int maximumPoolSize = corePoolSize * 2;
// 其容量可以根据实际情况调整
BlockingQueue<Runnable> workQueue = new ArrayBlockingQueue<>(300);
ThreadPoolExecutor executor = new ThreadPoolExecutor(
corePoolSize, // 核心线程数
maximumPoolSize, // 最大线程数
60L, // 线程空闲时的存活时间
TimeUnit.SECONDS, // 存活时间的时间单位
workQueue // 工作队列
);
return executor;
}
操作异步化
private RegisterExecutorService registerExecutorService;
public void register(RequestParam RequestParam){
// 注册业务完成...
userRegisterBloomFilter.add(requestParam.getUsername());
CompletableFuture.runAsync(() -> {
// 初始化用户信息/发送邮件/短信/记录注册日志
}, registerExecutorService);
}