文章目录
- 背景
- 环境
- 代码实现
- 0. 项目结构图(供参考)
- 1. 数据库中的表(供参考)
- 2. 依赖(pom.xml)
- 3. 配置文件(application.yml)
- 4. 配置文件(application-dev.yml)
- 5. UserLoginDTO
- 6. DemoConstant
- 7. User(后来才用的lombok,没有统一写法)
- 8. UserMapper
- 9. UserService(供参考)
- 10. UserController
- 11. RedisConfig
- 12. 统一返回结果
- 13. 启动类LoginDemoApplication
- 测试登录接口(一分钟内五次密码全错,触发账号锁定登录权限)
- 第一次测试(300ms)
- 第二次测试(32ms)
- 第三次测试(22ms)
- 第四次测试(12ms)
- 第五次测试(8ms)
- 总结
背景
前两天被面试到这个问题,最初回答的不是很合理,登录次数这方面记录还一直往数据库上面想,后来感觉在数据库中加一个登录日志表,来查询一段时间内用户登录的次数,现在看来,自己还是太年轻了,做个登录限制肯定是为了防止数据库高并发,加一个表来记录登录日志,这就把请求都打到数据库了,后来看了前辈们的解决方案,可以用Redis来实现,包括登录次数和账号锁定,我最开始在回答这个问题的时候只回答到了账号锁定用Redis做管理,前者登录次数回答的比较差劲,后来我也及时复盘了这次面试,就标题的内容做出基本实现
环境
Java8、MySQL8、Redis、IDEA,环境支持的同学可以参考这份代码实现以下
代码实现
0. 项目结构图(供参考)
1. 数据库中的表(供参考)
SET NAMES utf8mb4;
SET FOREIGN_KEY_CHECKS = 0;
-- ----------------------------
-- Table structure for login_demo_user
-- ----------------------------
DROP TABLE IF EXISTS `login_demo_user`;
CREATE TABLE `login_demo_user` (
`id` bigint NOT NULL COMMENT '主键',
`username` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '用户名',
`password` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NULL DEFAULT NULL COMMENT '密码',
PRIMARY KEY (`id`, `username`) USING BTREE
) ENGINE = InnoDB CHARACTER SET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci ROW_FORMAT = Dynamic;
-- ----------------------------
-- Records of login_demo_user
-- ----------------------------
INSERT INTO `login_demo_user` VALUES (1, 'hh', 'hh');
SET FOREIGN_KEY_CHECKS = 1;
2. 依赖(pom.xml)
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.2.1.RELEASE</version>
<relativePath/>
</parent>
<groupId>com.openallzzz</groupId>
<artifactId>logindemo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>2.2.0</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.2.1</version>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.20</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
3. 配置文件(application.yml)
server:
port: 8080
spring:
profiles:
active: dev
main:
allow-circular-references: true
datasource:
druid:
driver-class-name: ${datasource.driver-class-name}
url: jdbc:mysql://${datasource.host}:${datasource.port}/${datasource.database}?serverTimezone=Asia/Shanghai&useUnicode=true&characterEncoding=utf-8&zeroDateTimeBehavior=convertToNull&useSSL=false&allowPublicKeyRetrieval=true
username: ${datasource.username}
password: ${datasource.password}
redis:
host: ${redis.host}
port: ${redis.port}
database: ${redis.database}
mybatis:
#mapper配置文件
mapper-locations: classpath:mapper/*.xml
type-aliases-package: com.openallzzz.logindemo.entity
configuration:
#开启驼峰命名
map-underscore-to-camel-case: true
logging:
level:
com:
openallzzz:
mapper: debug
service: info
controller: info
4. 配置文件(application-dev.yml)
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
host: localhost
port: 3306
database: testdb
username: root
password: root
redis:
host: localhost
port: 6379
database: 1
5. UserLoginDTO
package com.openallzzz.logindemo.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class UserLoginDTO {
private String username;
private String password;
}
6. DemoConstant
package com.openallzzz.logindemo.constant;
public class DemoConstant {
// 每分钟限制登录的最大次数
public static final int MAX_LOGIN_TIMRS_PER_MINUTE = 5;
}
7. User(后来才用的lombok,没有统一写法)
package com.openallzzz.logindemo.entity;
public class User {
private Long id;
private String username;
private String password;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
}
8. UserMapper
package com.openallzzz.logindemo.mapper;
import com.openallzzz.logindemo.entity.User;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface UserMapper {
@Select("select id, username, password from login_demo_user where username = #{username}")
User selectByUsername(String username);
}
9. UserService(供参考)
package com.openallzzz.logindemo.service;
import com.openallzzz.logindemo.constant.DemoConstant;
import com.openallzzz.logindemo.dto.UserLoginDTO;
import com.openallzzz.logindemo.entity.User;
import com.openallzzz.logindemo.mapper.UserMapper;
import com.openallzzz.logindemo.result.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.util.concurrent.TimeUnit;
@Service
@Slf4j
public class UserService {
@Autowired
private UserMapper userMapper;
@Autowired
private RedisTemplate redisTemplate;
public Result<String> login(UserLoginDTO userLoginDTO) {
if (userLoginDTO == null || userLoginDTO.getUsername() == null || userLoginDTO.getPassword() == null) {
return Result.error("用户信息不完整!");
}
String username = userLoginDTO.getUsername();
String password = userLoginDTO.getPassword();
boolean lockStatus = getLockStatus(username);
if (lockStatus) {
return Result.error("失败次数过多,请稍后重试");
}
User user = userMapper.selectByUsername(username);
if (user.getPassword().equals(password)) {
clearLastCount(username);
clearLockStatus(username);
return Result.success();
} else {
int lastCount = getLastCount(username);
if (lastCount + 1 == DemoConstant.MAX_LOGIN_TIMRS_PER_MINUTE) {
setLockStatus(username);
return Result.error("失败次数过多,请稍后重试");
} else {
setLastCount(username);
return Result.error("登录失败,请检查用户名或密码,再重试");
}
}
}
private void clearLockStatus(String username) {
log.info("清除用户锁定状态:{}", username);
String key = "Lock" + ":" + username;
redisTemplate.delete(key);
}
private void clearLastCount(String username) {
log.info("将用户登录次数还原:{}", username);
String key = "Count" + ":" + username;
redisTemplate.delete(key);
}
private int getLastCount(String username) {
log.info("获取用户一分钟内已经失败登录了多少次:{}", username);
String key = "Count" + ":" + username;
Integer count = (Integer) redisTemplate.opsForValue().get(key);
return count == null ? 0 : count;
}
private void setLastCount(String username) {
log.info("设置用户一分钟内已经失败登录了多少次:{}", username);
String key = "Count" + ":" + username;
redisTemplate.opsForValue().set(key, getLastCount(username) + 1, 1, TimeUnit.MINUTES);
}
private void setLockStatus(String username) {
log.info("锁定用户,限制其登录:{}", username);
String key = "Lock" + ":" + username;
redisTemplate.opsForValue().set(key, "lock", 2, TimeUnit.HOURS);
}
private boolean getLockStatus(String username) {
log.info("获取用户是否被限制其登录:{}", username);
String key = "Lock" + ":" + username;
String o = (String) redisTemplate.opsForValue().get(key);
if ("lock".equals(o)) return true;
return false;
}
}
10. UserController
package com.openallzzz.logindemo.controller;
import com.openallzzz.logindemo.dto.UserLoginDTO;
import com.openallzzz.logindemo.result.Result;
import com.openallzzz.logindemo.service.UserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/user")
@Slf4j
public class UserController {
@Autowired
private UserService userService;
@PostMapping("/login")
public Result<String> login(@RequestBody UserLoginDTO userLoginDTO) {
log.info("用户登录:{}", userLoginDTO);
return userService.login(userLoginDTO);
}
}
11. RedisConfig
package com.openallzzz.logindemo.config;
import com.fasterxml.jackson.annotation.JsonAutoDetect;
import com.fasterxml.jackson.annotation.PropertyAccessor;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.serializer.Jackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
@EnableCaching
public class RedisConfig {
@Bean(name = "redisTemplate")
public RedisTemplate<String,Object> redisTemplate(RedisConnectionFactory factory){
RedisTemplate<String,Object> template = new RedisTemplate<>();
//配置连接工厂
template.setConnectionFactory(factory);
//使用jackson序列化和反序列value的值,
Jackson2JsonRedisSerializer jacksonSerializer = new Jackson2JsonRedisSerializer(Object.class);
ObjectMapper mapper = new ObjectMapper();
//指定需要序列化的范围,All表示field、get和set,以及修饰符范围,ANY表示所有范围,包括private
mapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
jacksonSerializer.setObjectMapper(mapper);
//设置template中value使用Jackson2JsonRedisSerializer序列化
template.setValueSerializer(jacksonSerializer);
//设置template中key使用StringRedisSerializer序列化
template.setKeySerializer(new StringRedisSerializer());
//这是hash中key和value的序列化方式,key采用StringRedisSerializer,value采用Jackson2JsonRedisSerializer
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(jacksonSerializer);
//使template属性生效
template.afterPropertiesSet();
return template;
}
}
12. 统一返回结果
package com.openallzzz.logindemo.result;
import lombok.Data;
import java.io.Serializable;
/**
* 后端统一返回结果
* @param <T>
*/
@Data
public class Result<T> implements Serializable {
private Integer code; //编码:1成功,0和其它数字为失败
private String msg; //错误信息
private T data; //数据
public static <T> Result<T> success() {
Result<T> result = new Result<T>();
result.code = 1;
return result;
}
public static <T> Result<T> success(T object) {
Result<T> result = new Result<T>();
result.data = object;
result.code = 1;
return result;
}
public static <T> Result<T> error(String msg) {
Result result = new Result();
result.msg = msg;
result.code = 0;
return result;
}
}
13. 启动类LoginDemoApplication
package com.openallzzz.logindemo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class LoginDemoApplication {
public static void main(String[] args) {
SpringApplication.run(LoginDemoApplication.class, args);
}
}
测试登录接口(一分钟内五次密码全错,触发账号锁定登录权限)
第一次测试(300ms)
第二次测试(32ms)
第三次测试(22ms)
第四次测试(12ms)
第五次测试(8ms)
总结
登录业务预先判断了该账号是否被锁定,如果短期内有大量登录请求(用户不断试错、被恶意攻击),压力只会给到Redis,从而避免DB被大量请求打中。