业务需求:
实现多种方式的登录流程,要求对用户数据采用分库分表来实现水平扩展
难点分析
难点一
用户登录方式需智能匹配,确保根据其输入的数据类型来确定登录方式,查询数据库指定字段,避免无用查询导致资源浪费
难点二
分库分表下,数据散落到多个表中,查询复杂度高,需要在多个登录方式中锁定用户详细数据表的分片键才能定位最终的用户数据结果返回
解决思路
难点一思路
下面是三个常用获取用户选择的登录方式的方法
-
用户自主选择:在登录页面上提供多个登录选项,例如用户名、邮箱、手机号等,用户可以根据需要选择相应的登录方式。你可以使用单选按钮、下拉列表或者切换按钮等方式来呈现选项。
-
输入识别:在用户输入登录凭证时,通过验证凭证的格式来判断用户选择的登录方式。这里可以采用正则表达式匹配的方式,例如,如果输入符合手机号的格式,则可以自动识别为手机号登录;如果输入符合邮箱格式,则可以自动识别为邮箱登录;否则,可以默认为用户名登录。
-
预设默认登录方式:如果用户在注册或设置账号时已经选择了默认的登录方式,可以在登录页面上预设默认的登录方式,让用户直接输入对应的凭证进行登录。
难点二思路
现在的问题是无论我们采用哪种登录方式,最终我们要返回去的都是一样的用户详情数据,而对于用户详情数据,我们也是采用分库分表的方式来进行水平扩展,那么我们想要高效得锁定该用户数据在哪个子表位置上(避免读扩散现象),就要求我们获取到该用户详情表的分片键才行,而多种登录方式就会让分片键数据无法直接被获取(用户一栏明确标出可以使用用户名、邮箱或手机号中的任意一个搭配密码进行登录。需要强调的是,在分库分表中,我们是通过用户名进行分片的。因此,如果在查询用户信息时不带用户名,将会触发读扩散问题。)
为此,引入“路由表”来解决
什么是路由表?
在分库分表中,路由表(Routing Table)是一个用于映射用户数据在分片库和分片表中位置的特殊表。它记录了用户按照特定分片键(例如用户名、邮箱、手机号等)拆分后在哪个分片库的哪张分片表中。
路由表的存在使得系统可以根据不同的登录方式(用户名、邮箱、手机号)来准确地定位到用户数据所在的分片位置,从而能够在查询时快速找到对应的数据。
路由表优点
通过路由表的映射,系统可以快速定位用户数据所在的分片位置,从而实现按照不同的登录方式进行查询。这样,无论用户是通过用户名、邮箱还是手机号登录,系统都能准确地找到对应的数据所在位置,从而提高了查询的效率和准确性。
拿手机号登录方式来说,我们通过手机号定位到路由表中的某一条用户数据,拿到对应的用户名,再根据用户名去查询真实的用户数据。这样就很好避免了读扩散问题。
路由表缺点
当然,路由表也不是万能的,同样存在着一些不好的点,比如:
- 查询性能:在进行用户登录或其他需要根据分片键查找数据的操作时,需要先访问路由表,根据分片键的取值范围定位到对应的分片位置,然后再去实际的分片库和分片表中查询数据。这样的查询过程会引入额外的查询开销,可能影响系统的查询性能。
- 维护成本:随着系统数据量的增加和业务发展,路由表的大小和复杂度可能会不断增加,维护路由表将会变得越来越复杂。对于大规模分库分表系统,路由表的维护成本可能会很高。
总结一下该类业务的简要流程步骤
简要流程步骤
假设用户详情表的分表键为用户名,支持用户名 / 邮箱 / 电话 + 密码的登录场景
- 后端设置匹配规则确定用户的登陆方式,根据登陆方式来调用对应分支逻辑执行登录流程
- 用户名登录
- 直接根据用户名数据 + 密码进行查表查询,返回查表结果
- 非用户名登录
- 首先通过查询路由表,根据邮箱 / 电话号匹配到用户名结果(这一步可以设置一个匹配缓存数据表,减少数据库IO,但是要求注意修改用户名后的缓存一致性保证)
- 结果为空直接失败,结果不为空执行2.1.一样的逻辑
数据库表设计例子
业务代码例子
@Override
public UserLoginRespDTO login(UserLoginReqDTO requestParam) {
String usernameOrMailOrPhone = requestParam.getUsernameOrMailOrPhone();
boolean mailFlag = false;
// 时间复杂度最佳 O(1)。indexOf or contains 时间复杂度为 O(n)
for (char c : usernameOrMailOrPhone.toCharArray()) {
if (c == '@') {
mailFlag = true;
break;
}
}
String username;
if (mailFlag) {
LambdaQueryWrapper<UserMailDO> queryWrapper = Wrappers.lambdaQuery(UserMailDO.class)
.eq(UserMailDO::getMail, usernameOrMailOrPhone);
username = Optional.ofNullable(userMailMapper.selectOne(queryWrapper))
.map(UserMailDO::getUsername)
.orElseThrow(() -> new ClientException("用户名/手机号/邮箱不存在"));
} else {
LambdaQueryWrapper<UserPhoneDO> queryWrapper = Wrappers.lambdaQuery(UserPhoneDO.class)
.eq(UserPhoneDO::getPhone, usernameOrMailOrPhone);
username = Optional.ofNullable(userPhoneMapper.selectOne(queryWrapper))
.map(UserPhoneDO::getUsername)
.orElse(null);
}
username = Optional.ofNullable(username).orElse(requestParam.getUsernameOrMailOrPhone());
LambdaQueryWrapper<UserDO> queryWrapper = Wrappers.lambdaQuery(UserDO.class)
.eq(UserDO::getUsername, username)
.eq(UserDO::getPassword, requestParam.getPassword())
.select(UserDO::getId, UserDO::getUsername, UserDO::getRealName);
UserDO userDO = userMapper.selectOne(queryWrapper);
if (userDO != null) {
UserInfoDTO userInfo = UserInfoDTO.builder()
.userId(String.valueOf(userDO.getId()))
.username(userDO.getUsername())
.realName(userDO.getRealName())
.build();
String accessToken = JWTUtil.generateAccessToken(userInfo);
UserLoginRespDTO actual = new UserLoginRespDTO(userInfo.getUserId(), requestParam.getUsernameOrMailOrPhone(), userDO.getRealName(), accessToken);
distributedCache.put(accessToken, JSON.toJSONString(actual), 30, TimeUnit.MINUTES);
return actual;
}
throw new ServiceException("账号不存在或密码错误");
}