构建高并发注册系统:Redisson 与分库分表策略实践

1. 海量注册的常见问题和解决方案概述

Q: 什么是海量注册?

A: 在短时间内,系统或平台接收到的大量新用户注册行为。

Q: 海量注册要应对什么样的问题?

A:

高并发请求处理:当大量用户同时尝试注册时,系统需要能够快速响应并处理这些请求。如果系统处理能力不足,用户可能会遇到注册失败、页面加载缓慢或超时等问题。

数据重复与唯一性校验: 在注册过程中,需要确保用户提交的信息(如用户名、邮箱、手机号等)是唯一的。在高并发情况下,系统需要高效地进行唯一性校验,避免数据重复。

安全性: 海量注册可能会吸引恶意用户进行自动化注册(如机器人注册、恶意刷号等),因此系统需要采取有效的安全措施,如验证码验证、IP限制、行为分析等,来防止滥用和保障用户数据安全。

用户体验问题:系统需要设计合理的用户体验策略,如提供清晰的注册进度反馈、友好的错误提示和快速的响应速度等。

本文的对上述问题的应对策略:

  1. 布隆过滤器判断用户唯一性
  2. 通过分布式锁和快速失败策略对同一时间的同一个账号进行锁定
  3. 通过创建数据库唯一索引进行兜底, 捕获DuplicateKeyException异常
  4. 通过分表技术 水平分库+水平分表 将一个数据库拆分成多个库, 将一张表拆分成多个表
  5. 通过自定义线程池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 集成某个技术基本就三个步骤

  1. 引入依赖
  2. 编写配置application.yml|yaml|properties
  3. 跟着官方文档来使用

第一步: 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_0t_user_31,他们的逻辑表名为 t_user
  • 真实表: 在水平拆分的数据库中真实存在的物理表。 即下个示例中的 t_user_0t_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);
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

tiantian17)

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值