用户模块闲言碎语
一、异常设计:
1、异常码说明
异常码重点规范。
错误码为字符串类型,共 5 位,分成两个部分:错误产生来源+四位数字编号。
说明:错误产生来源分为 A/B/C。A 表示错误来源于用户,比如参数错误,用户安装版本过低,用户支付 超时等问题;B 表示错误来源于当前系统,往往是业务逻辑出错,或程序健壮性差等问题;C 表示错误来源于第三方服务,比如 CDN 服务出错,消息投递超时等问题。四位数字编号从 0001 到 9999,大类之间的步长间距预留 100。
编号不与公司业务架构,更不与组织架构挂钩,一切与平台先到先申请的原则进行,审批生效,编号即被永久固定。
流程图:
image-20250310104024999
异常码分类:一级宏观错误码、二级宏观错误码、三级详细错误码。
客户端异常
错误码 | 中文描述 | 说明 |
---|---|---|
A0001 | 用户端错误 | 一级宏观错误码 |
A0100 | 用户注册错误 | 二级宏观错误码 |
A0101 | 用户未同意隐私协议 | |
A0102 | 注册国家或地区受限 | |
A0110 | 用户名校验失败 | |
A0111 | 用户名已存在 | |
A0112 | 用户名包含敏感词 | |
xxx | xxx | |
A0200 | 用户登录异常 | 二级宏观错误码 |
A02101 | 用户账户不存在 | |
A02102 | 用户密码错误 | |
A02103 | 用户账户已作废 | |
xxx | xxx |
服务端异常
错误码 | 中文描述 | 说明 |
---|---|---|
B0001 | 系统执行出错 | 一级宏观错误码 |
B0100 | 系统执行超时 | 二级宏观错误码 |
B0101 | 系统订单处理超时 | |
B0200 | 系统容灾功能被触发 | 二级宏观错误码 |
B0210 | 系统限流 | |
B0220 | 系统功能降级 | |
B0300 | 系统资源异常 | 二级宏观错误码 |
B0310 | 系统资源耗尽 | |
B0311 | 系统磁盘空间耗尽 | |
B0312 | 系统内存耗尽 | |
xxx | xxx |
远程调用异常
错误码 | 中文描述 | 说明 |
---|---|---|
C0001 | 调用第三方服务出错 | 一级宏观错误码 |
C0100 | 中间件服务出错 | 二级宏观错误码 |
C0110 | RPC服务出错 | |
C0111 | RPC服务未找到 | |
C0112 | RPC服务未注册 | |
xxx | xxx |
2、异常码设计
规约包,convention/errorcode
/**
* 平台错误码
*/
public interface IErrorCode {
/**
* 错误码
*/
String code();
/**
* 错误信息
*/
String message();
}
/**
* 基础错误码定义
*/
public enum BaseErrorCode implements IErrorCode {
// ========== 一级宏观错误码 客户端错误 ==========
CLIENT_ERROR("A000001", "用户端错误"),
// ========== 二级宏观错误码 用户注册错误 ==========
USER_REGISTER_ERROR("A000100", "用户注册错误"),
USER_NAME_VERIFY_ERROR("A000110", "用户名校验失败"),
USER_NAME_EXIST_ERROR("A000111", "用户名已存在"),
USER_NAME_SENSITIVE_ERROR("A000112", "用户名包含敏感词"),
USER_NAME_SPECIAL_CHARACTER_ERROR("A000113", "用户名包含特殊字符"),
PASSWORD_VERIFY_ERROR("A000120", "密码校验失败"),
PASSWORD_SHORT_ERROR("A000121", "密码长度不够"),
PHONE_VERIFY_ERROR("A000151", "手机格式校验失败"),
// ========== 二级宏观错误码 系统请求缺少幂等Token ==========
IDEMPOTENT_TOKEN_NULL_ERROR("A000200", "幂等Token为空"),
IDEMPOTENT_TOKEN_DELETE_ERROR("A000201", "幂等Token已被使用或失效"),
// ========== 一级宏观错误码 系统执行出错 ==========
SERVICE_ERROR("B000001", "系统执行出错"),
// ========== 二级宏观错误码 系统执行超时 ==========
SERVICE_TIMEOUT_ERROR("B000100", "系统执行超时"),
// ========== 一级宏观错误码 调用第三方服务出错 ==========
REMOTE_ERROR("C000001", "调用第三方服务出错");
private final String code;
private final String message;
BaseErrorCode(String code, String message) {
this.code = code;
this.message = message;
}
@Override
public String code() {
return code;
}
@Override
public String message() {
return message;
}
}
convention/exception
/**
* 抽象项目中三类异常体系,客户端异常、服务端异常以及远程服务调用异常
*
* @see ClientException
* @see ServiceException
* @see RemoteException
*/
@Getter
public abstract class AbstractException extends RuntimeException {
public final String errorCode;
public final String errorMessage;
public AbstractException(String message, Throwable throwable, IErrorCode errorCode) {
super(message, throwable);
this.errorCode = errorCode.code();
this.errorMessage = Optional.ofNullable(StringUtils.hasLength(message) ? message : null).orElse(errorCode.message());
}
}
/**
* 客户端异常
*/
public class ClientException extends AbstractException {
public ClientException(IErrorCode errorCode) {
this(null, null, errorCode);
}
public ClientException(String message) {
this(message, null, BaseErrorCode.CLIENT_ERROR);
}
public ClientException(String message, IErrorCode errorCode) {
this(message, null, errorCode);
}
public ClientException(String message, Throwable throwable, IErrorCode errorCode) {
super(message, throwable, errorCode);
}
@Override
public String toString() {
return "ClientException{" +
"code='" + errorCode + "'," +
"message='" + errorMessage + "'" +
'}';
}
}
3、全局异常拦截器
common/web
/**
* 全局异常处理器
*
*/
@Component
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* 拦截参数验证异常
*/
@SneakyThrows
@ExceptionHandler(value = MethodArgumentNotValidException.class)
public Result validExceptionHandler(HttpServletRequest request, MethodArgumentNotValidException ex) {
BindingResult bindingResult = ex.getBindingResult();
FieldError firstFieldError = CollectionUtil.getFirst(bindingResult.getFieldErrors());
String exceptionStr = Optional.ofNullable(firstFieldError)
.map(FieldError::getDefaultMessage)
.orElse(StrUtil.EMPTY);
log.error("[{}] {} [ex] {}", request.getMethod(), getUrl(request), exceptionStr);
return Results.failure(BaseErrorCode.CLIENT_ERROR.code(), exceptionStr);
}
/**
* 拦截应用内抛出的异常
*/
@ExceptionHandler(value = {AbstractException.class})
public Result abstractException(HttpServletRequest request, AbstractException ex) {
if (ex.getCause() != null) {
log.error("[{}] {} [ex] {}", request.getMethod(), request.getRequestURL().toString(), ex.toString(), ex.getCause());
return Results.failure(ex);
}
log.error("[{}] {} [ex] {}", request.getMethod(), request.getRequestURL().toString(), ex.toString());
return Results.failure(ex);
}
/**
* 拦截未捕获异常
*/
@ExceptionHandler(value = Throwable.class)
public Result defaultErrorHandler(HttpServletRequest request, Throwable throwable) {
log.error("[{}] {} ", request.getMethod(), getUrl(request), throwable);
return Results.failure();
}
private String getUrl(HttpServletRequest request) {
if (StringUtils.isEmpty(request.getQueryString())) {
return request.getRequestURL().toString();
}
return request.getRequestURL().toString() + "?" + request.getQueryString();
}
}
二、用户敏感信息脱敏展示
定义手机号和证件号的 Jackson 自定义序列化器,并在对应需要脱敏的敏感字段上指定自定义序列化器。
1)身份证号序列化器。
/**
* 身份证号脱敏反序列化
*/
public class IdCardDesensitizationSerializer extends JsonSerializer<String> {
@Override
public void serialize(String idCard, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException {
String phoneDesensitization = DesensitizedUtil.idCardNum(idCard, 4, 4);
jsonGenerator.writeString(phoneDesensitization);
}
}
2)手机号序列化器。
/**
* 手机号脱敏反序列化
*/
public class PhoneDesensitizationSerializer extends JsonSerializer<String> {
@Override
public void serialize(String phone, JsonGenerator jsonGenerator, SerializerProvider serializerProvider) throws IOException {
String phoneDesensitization = DesensitizedUtil.mobilePhone(phone);
jsonGenerator.writeString(phoneDesensitization);
}
}
3)敏感字段上自定义序列化器。
/**
* 用户返回参数
*/
@Data
@Accessors(chain = true)
public class UserRespDTO {
/**
* 证件号码
*/
@JsonSerialize(using = IdCardDesensitizationSerializer.class)
private String idCard;
/**
* 手机号
*/
@JsonSerialize(using = PhoneDesensitizationSerializer.class)
private String phone;
}
三、用户注册
1、检查用户名是否存在
直接查询数据库请求用户名是否存在。
流程图

存在什么问题?
- 海量用户如果说查询的用户名存在或不存在,全部请求数据库,会将数据库直接打满。
2、检查用户名是否存在引起的问题
2.1、用户名加载缓存
第一版解决方案,将数据库已有的用户名全部放到缓存里。
流程图:

该方案问题:
- 是否要设置数据的有效期?只能设置为无无有效期,也就是永久数据。
- 如果是永久不过期数据,占用 Redis 内存太高。
2.2、布隆过滤器
第二版解决方案,使用布隆过滤器。
流程图

2.3、什么是布隆过滤器
布隆过滤器是一种数据结构,用于快速判断一个元素是否存在于一个集合中。具体来说,布隆过滤器包含一个位数组和一
组哈希函数。位数组的初始值全部置为 0。在插入一个元素时,将该元素经过多个哈希函数映射到位数组上的多个位置,
并将这些位置的值置为 1。
1字节(Byte)=8位(Bit)
在查询一个元素是否存在时,会将该元素经过多个哈希函数映射到位数组上的多个位置,如果所有位置的值都为 1,则认
为元素存在;如果存在任一位置的值为 0,则认为元素不存在。
优缺点
优点:
- 高效地判断一个元素是否属于一个大规模集合。
- 节省内存。
缺点:
- 可能存在一定的误判。
布隆过滤器误判理解
- 布隆过滤器要设置初始容量。容量设置越大,冲突几率越低。
- 布隆过滤器会设置预期的误判值。
布隆过滤器的误判是否能够接受?
答:可以容忍。为什么?因为用户名不是特别重要的数据,如果说我设置用户名为 aaa,系统返回我不可用,那我大可以在 aaa 的基础上再加一个a,也就是 aaaa。
3、代码中使用布隆过滤器
3.1、引入 Redisson 依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
</dependency>
3.2、配置 Redis 参数
spring:
data:
redis:
host: 127.0.0.1
port: 6379
password: 123456
3.3、创建布隆过滤器实例
/**
* 布隆过滤器配置
*/
@Configuration
public class RBloomFilterConfiguration {
/**
* 防止用户注册查询数据库的布隆过滤器
*/
@Bean
public RBloomFilter<String> userRegisterCachePenetrationBloomFilter(RedissonClient redissonClient) {
RBloomFilter<String> cachePenetrationBloomFilter = redissonClient.getBloomFilter("xxx");
cachePenetrationBloomFilter.tryInit(0, 0);
return cachePenetrationBloomFilter;
}
}
tryInit 有两个核心参数:
expectedInsertions:预估布隆过滤器存储的元素长度。
falseProbability:运行的误判率。
错误率越低,位数组越长,布隆过滤器的内存占用越大。
错误率越低,散列 Hash 函数越多,计算耗时较长。
一个布隆过滤器占用大小的在线网站:
使用布隆过滤器的两种场景:
初始使用:注册用户时就向容器中新增数据,就不需要任务向容器存储数据了。
使用过程中引入:读取数据源将目标数据刷到布隆过滤器。
3.4、代码中使用
private final RBloomFilter<String> userRegisterCachePenetrationBloomFilter;
四、用户注册出现的一些问题:
现在的注册流程图是这样的:

1、如何防止用户名重复?
通过布隆过滤器把所有用户名进行加载。这样该功能就能完全隔离数据库。
数据库层面添加唯一索引。
2、如何防止恶意请求毫秒级触发大量请求去一个未注册的用户名?
因为用户名没注册,所以布隆过滤器不存在,代表着可以触发注册流程插入数据库。但是如果恶意请求短时间海量请求,
这些请求都会落到数据库,造成数据库访问压力。这里通过分布式锁,锁定用户名进行串行执行,防止恶意请求利用未注
册用户名将请求打到数据库。
流程执行图:

3、如果恶意请求全部使用未注册用户名发起注册
结论:系统无法进行完全风控,只有通过类似于限流的功能进行保障系统安全。