前两天被面试到这个问题,最初回答的不是很合理,登录次数这方面记录还一直往数据库上面想,后来感觉在数据库中加一个登录日志表,来查询一段时间内用户登录的次数,现在看来,自己还是太年轻了,做个登录限制肯定是为了防止数据库高并发,加一个表来记录登录日志,这就把请求都打到数据库了,后来看了前辈们的解决方案,可以用Redis来实现,包括登录次数和账号锁定,我最开始在回答这个问题的时候只回答到了账号锁定用Redis做管理,前者登录次数回答的比较差劲,后来我也及时复盘了这次面试,就标题的内容做出基本实现
Java8、MySQL8、Redis、IDEA,环境支持的同学可以参考这份代码实现以下
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;
<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.0modelVersion>
<parent>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-parentartifactId>
<version>2.2.1.RELEASEversion>
<relativePath/>
parent>
<groupId>com.openallzzzgroupId>
<artifactId>logindemoartifactId>
<version>0.0.1-SNAPSHOTversion>
<properties>
<java.version>1.8java.version>
properties>
<dependencies>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-webartifactId>
dependency>
<dependency>
<groupId>org.mybatis.spring.bootgroupId>
<artifactId>mybatis-spring-boot-starterartifactId>
<version>2.2.0version>
dependency>
<dependency>
<groupId>com.alibabagroupId>
<artifactId>druid-spring-boot-starterartifactId>
<version>1.2.1version>
dependency>
<dependency>
<groupId>mysqlgroupId>
<artifactId>mysql-connector-javaartifactId>
dependency>
<dependency>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-starter-data-redisartifactId>
dependency>
<dependency>
<groupId>org.projectlombokgroupId>
<artifactId>lombokartifactId>
<version>1.18.20version>
dependency>
dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.bootgroupId>
<artifactId>spring-boot-maven-pluginartifactId>
plugin>
plugins>
build>
project>
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
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
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;
}
package com.openallzzz.logindemo.constant;
public class DemoConstant {
// 每分钟限制登录的最大次数
public static final int MAX_LOGIN_TIMRS_PER_MINUTE = 5;
}
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;
}
}
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);
}
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;
}
}
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);
}
}
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;
}
}
package com.openallzzz.logindemo.result;
import lombok.Data;
import java.io.Serializable;
/**
* 后端统一返回结果
* @param
*/
@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;
}
}
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);
}
}
登录业务预先判断了该账号是否被锁定,如果短期内有大量登录请求(用户不断试错、被恶意攻击),压力只会给到Redis,从而避免DB被大量请求打中。