文章目錄
- 背景
- 環境
- 代碼實現
- 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: 8080spring:profiles:active: devmain:allow-circular-references: truedatasource: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=trueusername: ${datasource.username}password: ${datasource.password}redis:host: ${redis.host}port: ${redis.port}database: ${redis.database}mybatis:#mapper配置文件mapper-locations: classpath:mapper/*.xmltype-aliases-package: com.openallzzz.logindemo.entityconfiguration:#開啟駝峰命名map-underscore-to-camel-case: truelogging:level:com:openallzzz:mapper: debugservice: infocontroller: info
4. 配置文件(application-dev.yml)
datasource:driver-class-name: com.mysql.cj.jdbc.Driverhost: localhostport: 3306database: testdbusername: rootpassword: root
redis:host: localhostport: 6379database: 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 {@Autowiredprivate UserMapper userMapper;@Autowiredprivate 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 {@Autowiredprivate 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表示所有范圍,包括privatemapper.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采用Jackson2JsonRedisSerializertemplate.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被大量請求打中。