User duplicate registration analysis - bugs caused by locking in multi-threaded transactions
a recurring process
Online client users need to bind a mobile phone number when logging in by scanning QR codes on WeChat. After binding the mobile phone, the user purchases products on the client terminal and logs in again, and finds that the user account ID has been changed. It is no longer the mobile phone number that the user just bound. The ID of the user account that is automatically logged in at the time, query the online database, and find that the same mobile phone has generated multiple account IDs, so the problem has reappeared
Two analysis process
It is found that one mobile phone number in the database has generated multiple user accounts. The first reaction is that the user clicks the binding button multiple times during the process of binding the mobile phone number, causing the binding interface to be called multiple times, causing multiple threads to call the user registration interface concurrently. , and then generate multiple accounts. In order to verify our conjecture, directly check the user registration method after binding the mobile phone
/**
* Register according to the user's mobile phone number
*/
// Start @Transactional transaction annotation
@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();
// Use redis distributed lock
if (lock) {
// Query the database to see if the user's phone number has been successfully inserted, if it exists, exit the operation
MemberDO member = mapper.findByMobile(body.getAccount(), body.getRegRes());
if (Objects. nonNull(member)) {
resp.setResultFail(ReturnCodeEnum.USER_EXIST);
return false;
}
// Execute the user registration operation, including inserting the user table, order table, and whether to be invited
...
}
} catch (Exception e) {
log.error("User registration failed: ", e);
throw new Exception("User registration failed");
} finally {
redisLock.unLock();
}
// Add registration logs and report to the data analysis platform...
return true;
}
Read uncommitted: read uncommitted, as long as other transactions modify the data, even if it is not committed, this transaction can still see the modified data value.
Read committed: The read has been committed. After other transactions have submitted the modification to the data, this transaction can read the modified data value.
Repeatable read: Repeatable read, no matter whether other transactions modify and commit data, the data value seen in this transaction is always not affected by other transactions.
Serializable: Serialization, the execution of a transaction one by one.
MySQL database uses repeatable read (Repeatable read) by default.
When the user performs the registration operation and clicks the registration button repeatedly, assuming that threads A and B are executed at the same
redisLock.lock()
time , assuming that thread A acquires the lock, thread B enters the spin wait, thread A executes themapper.findByMobile(body.getAccount(), body.getRegRes())
operation and finds that the user's mobile phone does not exist in the database, and proceeds After the registration operation (adding user information into the database, etc.), the lock is released after the execution is completed. Perform subsequent operations of adding registration logs and reporting to the data analysis platform. Note that the transaction has not been submitted yet.Thread B finally acquires the lock and performs the
mapper.findByMobile(body.getAccount(), body.getRegRes())
operation. In our initial assumption, we thought it would return that the user already exists, but the actual execution result is not like this. The reason is that the transaction of thread A has not been submitted yet, and thread B cannot read the data of the uncommitted transaction of thread A, which means that the user's registered information cannot be found. So far, we know the reason for the user's repeated registration.
Three solutions:
3.1 Modify the scope of the transaction, minimize the operation code of the transaction, and ensure that the transaction submission is completed before the end of the lock. The code is as follows to start the manual transaction, so that other threads can see the latest data in the locked code block
@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();
// Use redis distributed lock
if (lock) {
// Query the database to see if the user's phone number has been successfully inserted, if it exists, exit the operation
MemberDO member = mapper.findByMobile(body.getAccount(), body.getRegRes());
if (Objects. nonNull(member)) {
resp.setResultFail(ReturnCodeEnum.USER_EXIST);
return false;
}
// start the transaction manually
transaction = platformTransactionManager.getTransaction(transactionDefinition);
// Execute the user registration operation, including inserting the user table, order table, and whether to be invited
...
// Manually commit the transaction
platformTransactionManager.commit(transaction);
...
}
} catch (Exception e) {
log.error("User registration failed: ", e);
if (transaction != null) {
platformTransactionManager.rollback(transaction);
}
return false;
} finally {
redisLock.unLock();
}
// Add registration logs and report to the data analysis platform...
return true;
}
3.2 Add anti-duplication submission processing for the registration interface when the user registers
/**
* Current limit enumeration
*/
public enum LimitType {
// default
CUSTOMER,
// by ip addr
IP
}
/**
* Custom interface current limit
*
* @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;
}
/**
* Limiter slice
*/
@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("The {}th access key is {}, the interface described as [{}]", count, strings, limit.name());
return joinPoint. proceed();
} else {
throw new DragonSparrowException("The number of visits in a short period of time is limited");
}
}
/**
* Limit script
*/
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;";
}
}
0 Comments