本文记录博主线上项目一次用户重复注册问题的分析过程与解决方案
线上客户端用户使用微信扫码登陆时需要再绑定一个手机号,在绑定手机后,用户购买客户端商品下线再登录,发现用户账号 ID 被变更,已经不是用户刚绑定手机号时自动登录的用户账号 ID ,查询线上数据库,发现同一个手机生成了多个账号 id ,至此问题复现
发现数据库中一个手机号生成了多个用户账号,第一反应是用户在绑定手机号过程中,多次点击绑定按钮,导致绑定接口被调用多次,造成多线程并发调用用户注册接口,进而生成多个账号。为了验证我们的猜想,直接查看绑定手机后的用户注册方法
/**
* 根据用户手机号进行注册操作
*/
// 启动 @Transactional 事务注解
@Transactional(rollbackFor = Exception.class)
public boolean userRegister(LoginReqBody body, BaseReqHeader header, BaseResp<BaseRespHeader, LoginRespBody> resp) {
RedisLock redisLock = redisCache.getRedisLock(RedisNameEnum.USER_REGISTER_LOCK.get(""), 10);
boolean lock;
try {
lock = redisLock.lock();
// 使用 redis 分布式锁
if (lock) {
// 查询数据库该用户手机号是否插入成功,已存在则退出操作
MemberDO member = mapper.findByMobile(body.getAccount(), body.getRegRes());
if (Objects.nonNull(member)) {
resp.setResultFail(ReturnCodeEnum.USER_EXIST);
return false;
}
// 执行用户注册操作,包含插入用户表、订单表、是否被邀请
...
}
} catch (Exception e) {
log.error("用户注册失败:", e);
throw new Exception("用户注册失败");
} finally {
redisLock.unLock();
}
// 添加注册日志,上报到数据分析平台...
return true;
}
初看代码,在分布式环境中,先加分布式锁保证同时只能被一个线程执行,然后判断数据库中是否存在用户手机信息,已存在则退出,不存在则执行用户注册操作,咋以为逻辑上没有问题,但是线上环境确实就是出现了相同手机号重复注册的问题,首先代码被 @Transactional
注解包含,就是在自动事务中执行注册逻辑
现在博主带大家回忆一下,MySQL
事务的隔离级别有 4 个
隔离级别越高,越能保证数据的完整性和一致性,但是对并发性能的影响也越大,MySQL 的默认隔离级别是读可重复读。在上述场景里,也就是说,无论其他线程事务是否提交了数据,当前线程所在事务中看到的数据值始终不受其他事务影响
说人话(划重点):就是在 MySQL
中一个线程所在事务是读不到另一个线程事务未提交的数据的
下面结合上述代码给出分析过程:上述注册逻辑都包含在 Spring
提供的自动事务中,整个方法都在事务中。而加锁也在事务中执行。最终导致我们注册 线程 B
在当前事物中查询不到另一个注册 线程 A
所在事物未提交的数据, 举个例子
eg:
redisLock.lock()
时,假设线程 A 获取到锁,线程 B 进入自旋等待,线程 A 执行mapper.findByMobile(body.getAccount(), body.getRegRes())
操作,发现用户手机不存在数据库中,进行注册操作(添加用户信息入库等),执行完毕,释放锁。执行后续添加注册日志,上报到数据分析平台操作,注意此时事务还未提交。mapper.findByMobile(body.getAccount(), body.getRegRes())
操作,在我们一开始的假设中,以为这里会返回用户已存在,但是实际执行结果并不是这样的。原因就是线程 A 的事务还未提交,线程 B 读不到线程 A 未提交事务的数据也就是说查不到用户已注册信息,至此,我们知道了用户重复注册的原因。给出三种解决方案
@Autowired
private PlatformTransactionManager platformTransactionManager;
@Autowired
private TransactionDefinition transactionDefinition;
private boolean userRegister(LoginReqBody body, BaseReqHeader header, BaseResp<BaseRespHeader, LoginRespBody> resp) {
RedisLock redisLock = redisCache.getRedisLock(RedisNameEnum.USER_REGISTER_LOCK.get(""), 10);
boolean lock;
TransactionStatus transaction = null;
try {
lock = redisLock.lock();
// 使用 redis 分布式锁
if (lock) {
// 查询数据库该用户手机号是否插入成功,已存在则退出操作
MemberDO member = mapper.findByMobile(body.getAccount(), body.getRegRes());
if (Objects.nonNull(member)) {
resp.setResultFail(ReturnCodeEnum.USER_EXIST);
return false;
}
// 手动开启事务
transaction = platformTransactionManager.getTransaction(transactionDefinition);
// 执行用户注册操作,包含插入用户表、订单表、是否被邀请
...
// 手动提交事务
platformTransactionManager.commit(transaction);
...
}
} catch (Exception e) {
log.error("用户注册失败:", e);
if (transaction != null) {
platformTransactionManager.rollback(transaction);
}
return false;
} finally {
redisLock.unLock();
}
// 添加注册日志,上报到数据分析平台...
return true;
}
下面给出一个基于 AOP
切面 + 注解实现的限流逻辑
/**
* 限流枚举
*/
public enum LimitType {
// 默认
CUSTOMER,
// by ip addr
IP
}
/**
* 自定义接口限流
*
* @author jacky
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Limit {
boolean useAccount() default true;
String name() default "";
String key() default "";
String prefix() default "";
int period();
int count();
LimitType limitType() default LimitType.CUSTOMER;
}
/**
* 限制器切面
*/
@Slf4j
@Aspect
@Component
public class LimitAspect {
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Pointcut("@annotation(com.dogame.dragon.sparrow.framework.common.annotation.Limit)")
public void pointcut() {
}
@Around("pointcut()")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
HttpServletRequest request = attrs.getRequest();
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
Method signatureMethod = signature.getMethod();
Limit limit = signatureMethod.getAnnotation(Limit.class);
boolean useAccount = limit.useAccount();
LimitType limitType = limit.limitType();
String key = limit.key();
if (StringUtils.isEmpty(key)) {
if (limitType == LimitType.IP) {
key = IpUtils.getIpAddress(request);
} else {
key = signatureMethod.getName();
}
}
if (useAccount) {
LoginMember loginMember = LocalContext.getLoginMember();
if (loginMember != null) {
key = key + "_" + loginMember.getAccount();
}
}
String join = StringUtils.join(limit.prefix(), key, "_", request.getRequestURI().replaceAll("/", "_"));
List<String> strings = Collections.singletonList(join);
String luaScript = buildLuaScript();
RedisScript<Long> redisScript = new DefaultRedisScript<>(luaScript, Long.class);
Long count = stringRedisTemplate.execute(redisScript, strings, limit.count() + "", limit.period() + "");
if (null != count && count.intValue() <= limit.count()) {
log.info("第{}次访问 key 为 {},描述为 [{}] 的接口", count, strings, limit.name());
return joinPoint.proceed();
} else {
throw new DragonSparrowException("短时间内访问次数受限制");
}
}
/**
* 限流脚本
*/
private String buildLuaScript() {
return "local c" +
"\nc = redis.call('get',KEYS[1])" +
"\nif c and tonumber(c) > tonumber(ARGV[1]) then" +
"\nreturn c;" +
"\nend" +
"\nc = redis.call('incr',KEYS[1])" +
"\nif tonumber(c) == 1 then" +
"\nredis.call('expire',KEYS[1],ARGV[2])" +
"\nend" +
"\nreturn c;";
}
}
线上项目对于 Spring
提供的自动事务注解使用要多加思考,尽可能减少事务影响范围,针对注册等按钮要在前后端添加防重复点击处理
1
git00ll 2022-12-11 13:45:22 +08:00
1. 数据库添加唯一索引 兜底可以避免这种问题
2.事务内不建议存在除数据库以外的 io 操作,这里的加锁过程在事务内访问 redis 是不合理的 |
2
chenjau 2022-12-11 15:32:28 +08:00
数据库手机号字段唯一不就完了吗? 前端提交加 disabled.
这种东西又是 redis 又是多线程的, 复杂了. |