缓存穿透----用户注册

189 阅读5分钟

用户注册

用户注册支持 用户名+密码 进行注册,且用户名唯一、用户注销后用户名可复用

问题

若用户名已被注册,在数据库中存在数据,并且已经将数据写入到redis缓存,若用户名未注册,则数据库和缓存均无数据,当新用户进行注册,首先调用查询接口,查询用户名是否存在:具体而言是先查缓存,如果未命中,不能说明数据库中就不存在该用户名(因为redis缓存数据有可能过期 或者 可能由于服务问题导致没有将数据库数据写入缓存),因此还需要去查数据库,若数据库也未命中,则最后说明该用户名是可用的。然而,查询用户名是否可用,并不一定就使用该用户名注册(根据业务分析)

那么,如果同一时刻有大量查询请求打到数据库上,就给数据库造成巨大压力。

解决方案

1、 对不存在的key进行缓存,value设置为null,并设置短暂过期时间,如60s

缺点:用户A查询了一次可用的用户名,但没使用,且这时把该用户名作为key,null作为value存入redis缓存,短暂过期时间为60s。则在这60s内该用户名实际上是没人注册的,但因为存入缓存,导致该用户名短暂地不可用,其他用户不能用这个用户名注册

2、将已注册的用户名存入布隆过滤器:

优点:当用户发起查询请求时,先从布隆过滤器中查询,如果未命中,则说明数据库一定不存在该用户名,不会去查数据库,避免造成巨大压力

缺点:布隆过滤器无法删除元素,如果删除,可能影响其他元素的判断

3、将已注册的用户名存入Redis Set里:

缺点:无法存储大量数据。如果Set里未命中,仍然需要查数据库,给数据库造成巨大压力。

4、高并发时,依旧先查缓存,未命中则使用分布式锁限制只能有一个线程查询数据库:

缺点:其他线程阻塞,请求缓慢或超时

最终解决方案

1736578851038.png

将已注册的用户名存入布隆过滤器中,防止未命中时给数据库造成巨大压力

然后每次有用户注销时,将注销的用户名存入Redis Set里,在布隆过滤器命中时,由于布隆过滤器无法删除的特点(无法保证该用户名是否是已注销的,即可能误判),为了解决误判的问题,可以继续查询 注销用户名Set集合,如果命中了,说明前面的布隆过滤器误判了,该用户名是可用的,如果未命中,说明布隆过滤器未误判,该用户名不可用

优化

随着时间推移,注销的用户可能越来越多,Set集合不足以支持大量数据的存储,因此采取两种方案:

1、可以限制一个用户最多只能注销5次

2、即使限制了,又随着时间推移,依旧可能产生大量数据,这就是Redis 大Key问题,因此考虑将Set分片,对用户名hashcode取模,将数据分散存储在1024个Set结构中

实操

用户注册请求体

public class UserRegisterReqDTO {

    /**
     * 用户名
     */
    private String username;

    /**
     * 密码
     */
    private String password;

    /**
     * 真实姓名
     */
    private String realName;

    /**
     * 证件类型
     */
    private Integer idType;

    /**
     * 证件号
     */
    private String idCard;

    /**
     * 手机号
     */
    private String phone;

    /**
     * 邮箱
     */
    private String mail;

    /**
     * 旅客类型
     */
    private Integer userType;

    /**
     * 审核状态
     */
    private Integer verifyState;

    /**
     * 邮编
     */
    private String postCode;

    /**
     * 地址
     */
    private String address;

    /**
     * 国家/地区
     */
    private String region;

    /**
     * 固定电话
     */
    private String telephone;
}

用户信息实体

@Data
@TableName("t_user")
public class UserDO extends BaseDO {

    /**
     * id
     */
    private Long id;

    /**
     * 用户名
     */
    private String username;

    /**
     * 密码
     */
    private String password;

    /**
     * 真实姓名
     */
    private String realName;

    /**
     * 国家/地区
     */
    private String region;

    /**
     * 证件类型
     */
    private Integer idType;

    /**
     * 证件号
     */
    private String idCard;

    /**
     * 手机号
     */
    private String phone;

    /**
     * 固定电话
     */
    private String telephone;

    /**
     * 邮箱
     */
    private String mail;

    /**
     * 旅客类型
     */
    private Integer userType;

    /**
     * 审核状态
     */
    private Integer verifyStatus;

    /**
     * 邮编
     */
    private String postCode;

    /**
     * 地址
     */
    private String address;

    /**
     * 注销时间戳
     */
    private Long deletionTime;
}

用户手机号实体

@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@TableName("t_user_phone")
public class UserPhoneDO extends BaseDO {

    /**
     * id
     */
    private Long id;

    /**
     * 用户名
     */
    private String username;

    /**
     * 手机号
     */
    private String phone;

    /**
     * 注销时间戳
     */
    private Long deletionTime;
}

用户邮箱实体

@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder
@TableName("t_user_mail")
public class UserMailDO extends BaseDO {

    /**
     * id
     */
    private Long id;

    /**
     * 用户名
     */
    private String username;

    /**
     * 手机号
     */
    private String mail;

    /**
     * 注销时间戳
     */
    private Long deletionTime;
}

controller

/**
 * 注册用户
 */
@PostMapping("/api/user-service/register")
public Result<UserRegisterRespDTO> register(@RequestBody @Valid UserRegisterReqDTO requestParam) {
    return Results.success(userLoginService.register(requestParam));
}

serviceImpl

首先使用责任链设计模式进行基础校验

@Transactional(rollbackFor = Exception.class)
@Override
public UserRegisterRespDTO register(UserRegisterReqDTO requestParam) {
    abstractChainContext.handler(UserChainMarkEnum.USER_REGISTER_FILTER.name(), requestParam);
    RLock lock = redissonClient.getLock(LOCK_USER_REGISTER + requestParam.getUsername());
    boolean tryLock = lock.tryLock();
    if (!tryLock) {
        throw new ServiceException(HAS_USERNAME_NOTNULL);
    }
    try {
        try {
            int inserted = userMapper.insert(BeanUtil.convert(requestParam, UserDO.class));
            if (inserted < 1) {
                throw new ServiceException(USER_REGISTER_FAIL);
            }
        } catch (DuplicateKeyException dke) {
            log.error("用户名 [{}] 重复注册", requestParam.getUsername());
            throw new ServiceException(HAS_USERNAME_NOTNULL);
        }
        UserPhoneDO userPhoneDO = UserPhoneDO.builder()
                .phone(requestParam.getPhone())
                .username(requestParam.getUsername())
                .build();
        try {
            userPhoneMapper.insert(userPhoneDO);
        } catch (DuplicateKeyException dke) {
            log.error("用户 [{}] 注册手机号 [{}] 重复", requestParam.getUsername(), requestParam.getPhone());
            throw new ServiceException(PHONE_REGISTERED);
        }
        if (StrUtil.isNotBlank(requestParam.getMail())) {
            UserMailDO userMailDO = UserMailDO.builder()
                    .mail(requestParam.getMail())
                    .username(requestParam.getUsername())
                    .build();
            try {
                userMailMapper.insert(userMailDO);
            } catch (DuplicateKeyException dke) {
                log.error("用户 [{}] 注册邮箱 [{}] 重复", requestParam.getUsername(), requestParam.getMail());
                throw new ServiceException(MAIL_REGISTERED);
            }
        }
        String username = requestParam.getUsername();
        userReuseMapper.delete(Wrappers.update(new UserReuseDO(username)));
        StringRedisTemplate instance = (StringRedisTemplate) distributedCache.getInstance();
        instance.opsForSet().remove(USER_REGISTER_REUSE_SHARDING + hashShardingIdx(username), username);

        userRegisterCachePenetrationBloomFilter.add(username);
    } finally {
        lock.unlock();
    }
    return BeanUtil.convert(requestParam, UserRegisterRespDTO.class);
}

最后的一部分还不是很理解 TODO

image.png

image.png