前言
如果说 Spring Boot 基础篇解决的是"项目怎么起起来",那么进阶篇更关心的是:
- 数据访问怎么做得更可控
- 登录态怎么设计得更适合前后端分离
- 缓存怎么落地
- 代码结构怎么撑得住真实业务
这篇文章用一个偏典型的用户认证 + 商品查询场景,把下面四块内容串起来:
- Spring Boot
- MyBatis
- Redis
- JWT
目标是给出一套更像真实业务系统的实现骨架,而不是只给你几个零散知识点。
1. 场景设计
假设我们现在要做一个简单的后端服务,支持这些能力:
- 用户登录
- 登录成功后签发 JWT
- JWT 中携带用户基础身份信息
- 用户会话信息放 Redis
- 查询商品列表时,先查 Redis 缓存,缓存没有再查数据库
- 所有接口统一返回格式
一个合理的技术职责拆分大致是:
- Spring Boot:项目骨架、Web、依赖管理
- MyBatis:数据库访问
- Redis:缓存、会话辅助
- JWT:无状态认证令牌
2. 依赖清单
先看 Maven 依赖。
xml
<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 http://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>3.3.2</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>advanced-demo</artifactId>
<version>1.0.0</version>
<properties>
<java.version>17</java.version>
<mybatis-spring-boot.version>3.0.3</mybatis-spring-boot.version>
<jjwt.version>0.12.6</jjwt.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>${mybatis-spring-boot.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jjwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>
3. 项目结构建议
建议按下面这种方式组织:
text
src/main/java/com/example/advanced
├─ AdvancedApplication.java
├─ common
│ ├─ exception
│ ├─ response
│ └─ util
├─ config
│ ├─ RedisConfig.java
│ └─ WebMvcConfig.java
├─ security
│ ├─ JwtAuthenticationInterceptor.java
│ ├─ JwtProperties.java
│ └─ JwtTokenProvider.java
├─ user
│ ├─ controller
│ ├─ service
│ ├─ mapper
│ ├─ entity
│ └─ dto
└─ product
├─ controller
├─ service
├─ mapper
├─ entity
└─ dto
这么分的好处是:
- 认证相关能力集中在
security - 业务模块按领域分组
- MyBatis Mapper 不和 Controller/Service 混在一起
- 通用基础设施集中在
common和config
4. 配置文件
yaml
server:
port: 8080
spring:
application:
name: advanced-demo
datasource:
url: jdbc:mysql://localhost:3306/advanced_demo?useSSL=false&serverTimezone=UTC
username: root
password: 123456
driver-class-name: com.mysql.cj.jdbc.Driver
data:
redis:
host: localhost
port: 6379
database: 0
timeout: 3000ms
mybatis:
mapper-locations: classpath:/mapper/**/*.xml
type-aliases-package: com.example.advanced.user.entity,com.example.advanced.product.entity
configuration:
map-underscore-to-camel-case: true
jwt:
secret: 01234567890123456789012345678901
expire-seconds: 7200
issuer: advanced-demo
logging:
level:
root: info
com.example.advanced: debug
说明:
map-underscore-to-camel-case=true可以让数据库下划线字段自动映射成 Java 驼峰属性jwt.secret至少应足够长,生产环境不要写死在配置文件里- Redis 这里既可以做缓存,也可以做会话辅助存储
5. 统一返回结构和异常处理
5.1 统一返回体
java
package com.example.advanced.common.response;
public class ApiResponse<T> {
private boolean success;
private String code;
private String message;
private T data;
public static <T> ApiResponse<T> success(T data) {
ApiResponse<T> response = new ApiResponse<>();
response.success = true;
response.code = "OK";
response.message = "success";
response.data = data;
return response;
}
public static <T> ApiResponse<T> fail(String code, String message) {
ApiResponse<T> response = new ApiResponse<>();
response.success = false;
response.code = code;
response.message = message;
return response;
}
public boolean isSuccess() {
return success;
}
public String getCode() {
return code;
}
public String getMessage() {
return message;
}
public T getData() {
return data;
}
}
5.2 业务异常
java
package com.example.advanced.common.exception;
public class BizException extends RuntimeException {
private final String code;
public BizException(String code, String message) {
super(message);
this.code = code;
}
public String getCode() {
return code;
}
}
5.3 全局异常处理
java
package com.example.advanced.common.exception;
import com.example.advanced.common.response.ApiResponse;
import java.util.stream.Collectors;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BizException.class)
public ApiResponse<Void> handleBizException(BizException ex) {
return ApiResponse.fail(ex.getCode(), ex.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ApiResponse<Void> handleValidationException(MethodArgumentNotValidException ex) {
String message = ex.getBindingResult()
.getFieldErrors()
.stream()
.map(error -> error.getField() + ": " + error.getDefaultMessage())
.collect(Collectors.joining("; "));
return ApiResponse.fail("PARAM_INVALID", message);
}
@ExceptionHandler(Exception.class)
public ApiResponse<Void> handleException(Exception ex) {
return ApiResponse.fail("INTERNAL_ERROR", ex.getMessage());
}
}
6. JWT:签发、解析、校验
6.1 配置类
java
package com.example.advanced.security;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "jwt")
public class JwtProperties {
private String secret;
private Long expireSeconds;
private String issuer;
public String getSecret() {
return secret;
}
public void setSecret(String secret) {
this.secret = secret;
}
public Long getExpireSeconds() {
return expireSeconds;
}
public void setExpireSeconds(Long expireSeconds) {
this.expireSeconds = expireSeconds;
}
public String getIssuer() {
return issuer;
}
public void setIssuer(String issuer) {
this.issuer = issuer;
}
}
6.2 Token Provider
java
package com.example.advanced.security;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import java.nio.charset.StandardCharsets;
import java.security.Key;
import java.time.Instant;
import java.util.Date;
import java.util.Map;
import org.springframework.stereotype.Component;
@Component
public class JwtTokenProvider {
private final JwtProperties jwtProperties;
public JwtTokenProvider(JwtProperties jwtProperties) {
this.jwtProperties = jwtProperties;
}
public String generateToken(Long userId, String username) {
Instant now = Instant.now();
Instant expireAt = now.plusSeconds(jwtProperties.getExpireSeconds());
return Jwts.builder()
.issuer(jwtProperties.getIssuer())
.subject(String.valueOf(userId))
.claims(Map.of("username", username))
.issuedAt(Date.from(now))
.expiration(Date.from(expireAt))
.signWith(getSigningKey())
.compact();
}
public Claims parseToken(String token) {
return Jwts.parser()
.verifyWith((javax.crypto.SecretKey) getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload();
}
public boolean validateToken(String token) {
try {
parseToken(token);
return true;
} catch (Exception ex) {
return false;
}
}
private Key getSigningKey() {
return Keys.hmacShaKeyFor(jwtProperties.getSecret().getBytes(StandardCharsets.UTF_8));
}
}
JWT 做的事情很明确:
- 登录成功后签发 Token
- 后续请求携带 Token
- 服务端解析 Token,拿到用户身份信息
但要记住一件事:
JWT 适合表达身份,不适合承载太多会频繁变化的业务状态。
7. Redis:会话辅助与缓存
7.1 RedisTemplate 配置
java
package com.example.advanced.config;
import com.fasterxml.jackson.databind.ObjectMapper;
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.GenericJackson2JsonRedisSerializer;
import org.springframework.data.redis.serializer.StringRedisSerializer;
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(
RedisConnectionFactory connectionFactory,
ObjectMapper objectMapper) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
GenericJackson2JsonRedisSerializer serializer =
new GenericJackson2JsonRedisSerializer(objectMapper);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(serializer);
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(serializer);
template.afterPropertiesSet();
return template;
}
}
7.2 用户会话写入 Redis
登录成功后,可以把会话信息写进去:
java
package com.example.advanced.user.service;
import java.time.Duration;
import java.util.Map;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
@Service
public class UserSessionService {
private final RedisTemplate<String, Object> redisTemplate;
public UserSessionService(RedisTemplate<String, Object> redisTemplate) {
this.redisTemplate = redisTemplate;
}
public void saveLoginSession(Long userId, String username, String token, long expireSeconds) {
String key = "login:token:" + token;
Map<String, Object> session = Map.of(
"userId", userId,
"username", username
);
redisTemplate.opsForValue().set(key, session, Duration.ofSeconds(expireSeconds));
}
public Object getSessionByToken(String token) {
return redisTemplate.opsForValue().get("login:token:" + token);
}
public void removeSession(String token) {
redisTemplate.delete("login:token:" + token);
}
}
这样做有几个好处:
- 支持手动踢下线
- 支持注销立即失效
- 可以在 Redis 层附加更多会话控制能力
8. MyBatis:数据库访问层
8.1 用户实体
java
package com.example.advanced.user.entity;
public class UserEntity {
private Long id;
private String username;
private String passwordHash;
private Integer status;
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 getPasswordHash() {
return passwordHash;
}
public void setPasswordHash(String passwordHash) {
this.passwordHash = passwordHash;
}
public Integer getStatus() {
return status;
}
public void setStatus(Integer status) {
this.status = status;
}
}
8.2 Mapper 接口
java
package com.example.advanced.user.mapper;
import com.example.advanced.user.entity.UserEntity;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@Mapper
public interface UserMapper {
UserEntity findByUsername(@Param("username") String username);
}
8.3 Mapper XML
resources/mapper/user/UserMapper.xml
xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.advanced.user.mapper.UserMapper">
<select id="findByUsername" resultType="com.example.advanced.user.entity.UserEntity">
SELECT
id,
username,
password_hash,
status
FROM t_user
WHERE username = #{username}
LIMIT 1
</select>
</mapper>
MyBatis 的优势在于:
- SQL 明确可控
- 复杂查询更自然
- 对性能调优更友好
它特别适合:
- SQL 复杂度高
- 需要细粒度优化
- 查询结果结构灵活多变
9. 登录流程实现
9.1 登录请求 DTO
java
package com.example.advanced.user.dto;
import jakarta.validation.constraints.NotBlank;
public class LoginRequest {
@NotBlank(message = "username 不能为空")
private String username;
@NotBlank(message = "password 不能为空")
private String password;
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;
}
}
9.2 登录响应 VO
java
package com.example.advanced.user.dto;
public class LoginResponse {
private String token;
private Long userId;
private String username;
public String getToken() {
return token;
}
public void setToken(String token) {
this.token = token;
}
public Long getUserId() {
return userId;
}
public void setUserId(Long userId) {
this.userId = userId;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
}
9.3 AuthService
java
package com.example.advanced.user.service;
import com.example.advanced.common.exception.BizException;
import com.example.advanced.security.JwtProperties;
import com.example.advanced.security.JwtTokenProvider;
import com.example.advanced.user.dto.LoginRequest;
import com.example.advanced.user.dto.LoginResponse;
import com.example.advanced.user.entity.UserEntity;
import com.example.advanced.user.mapper.UserMapper;
import org.springframework.stereotype.Service;
@Service
public class AuthService {
private final UserMapper userMapper;
private final JwtTokenProvider jwtTokenProvider;
private final UserSessionService userSessionService;
private final JwtProperties jwtProperties;
public AuthService(
UserMapper userMapper,
JwtTokenProvider jwtTokenProvider,
UserSessionService userSessionService,
JwtProperties jwtProperties) {
this.userMapper = userMapper;
this.jwtTokenProvider = jwtTokenProvider;
this.userSessionService = userSessionService;
this.jwtProperties = jwtProperties;
}
public LoginResponse login(LoginRequest request) {
UserEntity user = userMapper.findByUsername(request.getUsername());
if (user == null) {
throw new BizException("USER_NOT_FOUND", "用户不存在");
}
if (!mockPasswordMatch(request.getPassword(), user.getPasswordHash())) {
throw new BizException("PASSWORD_INVALID", "用户名或密码错误");
}
if (user.getStatus() == null || user.getStatus() != 1) {
throw new BizException("USER_DISABLED", "用户已禁用");
}
String token = jwtTokenProvider.generateToken(user.getId(), user.getUsername());
userSessionService.saveLoginSession(
user.getId(),
user.getUsername(),
token,
jwtProperties.getExpireSeconds()
);
LoginResponse response = new LoginResponse();
response.setToken(token);
response.setUserId(user.getId());
response.setUsername(user.getUsername());
return response;
}
private boolean mockPasswordMatch(String rawPassword, String passwordHash) {
return rawPassword != null && rawPassword.equals(passwordHash);
}
}
生产环境里,密码当然不能明文比较,应该用 BCryptPasswordEncoder 之类的安全方案。
10. 登录接口
java
package com.example.advanced.user.controller;
import com.example.advanced.common.response.ApiResponse;
import com.example.advanced.user.dto.LoginRequest;
import com.example.advanced.user.dto.LoginResponse;
import com.example.advanced.user.service.AuthService;
import jakarta.validation.Valid;
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("/api/auth")
public class AuthController {
private final AuthService authService;
public AuthController(AuthService authService) {
this.authService = authService;
}
@PostMapping("/login")
public ApiResponse<LoginResponse> login(@Valid @RequestBody LoginRequest request) {
return ApiResponse.success(authService.login(request));
}
}
请求示例:
http
POST /api/auth/login
Content-Type: application/json
{
"username": "alice",
"password": "123456"
}
返回示例:
json
{
"success": true,
"code": "OK",
"message": "success",
"data": {
"token": "eyJhbGciOiJIUzI1NiJ9...",
"userId": 1,
"username": "alice"
}
}
11. JWT 拦截器:保护需要登录的接口
11.1 拦截器实现
java
package com.example.advanced.security;
import com.example.advanced.common.exception.BizException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
@Component
public class JwtAuthenticationInterceptor implements HandlerInterceptor {
private final JwtTokenProvider jwtTokenProvider;
public JwtAuthenticationInterceptor(JwtTokenProvider jwtTokenProvider) {
this.jwtTokenProvider = jwtTokenProvider;
}
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
String authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
throw new BizException("UNAUTHORIZED", "缺少有效 Authorization 头");
}
String token = authHeader.substring(7);
if (!jwtTokenProvider.validateToken(token)) {
throw new BizException("TOKEN_INVALID", "Token 非法或已过期");
}
return true;
}
}
11.2 注册拦截器
java
package com.example.advanced.config;
import com.example.advanced.security.JwtAuthenticationInterceptor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
private final JwtAuthenticationInterceptor jwtAuthenticationInterceptor;
public WebMvcConfig(JwtAuthenticationInterceptor jwtAuthenticationInterceptor) {
this.jwtAuthenticationInterceptor = jwtAuthenticationInterceptor;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(jwtAuthenticationInterceptor)
.addPathPatterns("/api/**")
.excludePathPatterns("/api/auth/login");
}
}
这样登录接口放行,其他接口默认需要带 JWT。
12. 商品查询:先查 Redis,查不到再查 MyBatis
12.1 商品实体
java
package com.example.advanced.product.entity;
import java.math.BigDecimal;
public class ProductEntity {
private Long id;
private String productCode;
private String productName;
private BigDecimal price;
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getProductCode() {
return productCode;
}
public void setProductCode(String productCode) {
this.productCode = productCode;
}
public String getProductName() {
return productName;
}
public void setProductName(String productName) {
this.productName = productName;
}
public BigDecimal getPrice() {
return price;
}
public void setPrice(BigDecimal price) {
this.price = price;
}
}
12.2 ProductMapper
java
package com.example.advanced.product.mapper;
import com.example.advanced.product.entity.ProductEntity;
import java.util.List;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ProductMapper {
List<ProductEntity> findAll();
}
12.3 ProductMapper.xml
xml
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.example.advanced.product.mapper.ProductMapper">
<select id="findAll" resultType="com.example.advanced.product.entity.ProductEntity">
SELECT
id,
product_code,
product_name,
price
FROM t_product
ORDER BY id DESC
</select>
</mapper>
12.4 ProductService:带缓存
java
package com.example.advanced.product.service;
import com.example.advanced.product.entity.ProductEntity;
import com.example.advanced.product.mapper.ProductMapper;
import java.time.Duration;
import java.util.List;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
@Service
public class ProductService {
private static final String PRODUCT_LIST_CACHE_KEY = "product:list:all";
private final ProductMapper productMapper;
private final RedisTemplate<String, Object> redisTemplate;
public ProductService(ProductMapper productMapper, RedisTemplate<String, Object> redisTemplate) {
this.productMapper = productMapper;
this.redisTemplate = redisTemplate;
}
@SuppressWarnings("unchecked")
public List<ProductEntity> findAllProducts() {
Object cached = redisTemplate.opsForValue().get(PRODUCT_LIST_CACHE_KEY);
if (cached != null) {
return (List<ProductEntity>) cached;
}
List<ProductEntity> products = productMapper.findAll();
redisTemplate.opsForValue().set(PRODUCT_LIST_CACHE_KEY, products, Duration.ofMinutes(10));
return products;
}
public void clearProductCache() {
redisTemplate.delete(PRODUCT_LIST_CACHE_KEY);
}
}
这里是最基础的 Cache Aside 模式:
- 先读缓存
- 没命中再查数据库
- 查到结果后回写缓存
这是业务系统里非常高频的一种缓存方式。
13. 商品接口
java
package com.example.advanced.product.controller;
import com.example.advanced.common.response.ApiResponse;
import com.example.advanced.product.entity.ProductEntity;
import com.example.advanced.product.service.ProductService;
import java.util.List;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/products")
public class ProductController {
private final ProductService productService;
public ProductController(ProductService productService) {
this.productService = productService;
}
@GetMapping
public ApiResponse<List<ProductEntity>> list() {
return ApiResponse.success(productService.findAllProducts());
}
}
调用这个接口时,请求头要带上:
text
Authorization: Bearer <token>
14. 一次完整请求会怎么流转
#mermaid-svg-GaYHe0oMO0IE7oFd{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-GaYHe0oMO0IE7oFd .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-GaYHe0oMO0IE7oFd .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-GaYHe0oMO0IE7oFd .error-icon{fill:#552222;}#mermaid-svg-GaYHe0oMO0IE7oFd .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-GaYHe0oMO0IE7oFd .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-GaYHe0oMO0IE7oFd .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-GaYHe0oMO0IE7oFd .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-GaYHe0oMO0IE7oFd .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-GaYHe0oMO0IE7oFd .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-GaYHe0oMO0IE7oFd .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-GaYHe0oMO0IE7oFd .marker{fill:#333333;stroke:#333333;}#mermaid-svg-GaYHe0oMO0IE7oFd .marker.cross{stroke:#333333;}#mermaid-svg-GaYHe0oMO0IE7oFd svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-GaYHe0oMO0IE7oFd p{margin:0;}#mermaid-svg-GaYHe0oMO0IE7oFd .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-GaYHe0oMO0IE7oFd .cluster-label text{fill:#333;}#mermaid-svg-GaYHe0oMO0IE7oFd .cluster-label span{color:#333;}#mermaid-svg-GaYHe0oMO0IE7oFd .cluster-label span p{background-color:transparent;}#mermaid-svg-GaYHe0oMO0IE7oFd .label text,#mermaid-svg-GaYHe0oMO0IE7oFd span{fill:#333;color:#333;}#mermaid-svg-GaYHe0oMO0IE7oFd .node rect,#mermaid-svg-GaYHe0oMO0IE7oFd .node circle,#mermaid-svg-GaYHe0oMO0IE7oFd .node ellipse,#mermaid-svg-GaYHe0oMO0IE7oFd .node polygon,#mermaid-svg-GaYHe0oMO0IE7oFd .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-GaYHe0oMO0IE7oFd .rough-node .label text,#mermaid-svg-GaYHe0oMO0IE7oFd .node .label text,#mermaid-svg-GaYHe0oMO0IE7oFd .image-shape .label,#mermaid-svg-GaYHe0oMO0IE7oFd .icon-shape .label{text-anchor:middle;}#mermaid-svg-GaYHe0oMO0IE7oFd .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-GaYHe0oMO0IE7oFd .rough-node .label,#mermaid-svg-GaYHe0oMO0IE7oFd .node .label,#mermaid-svg-GaYHe0oMO0IE7oFd .image-shape .label,#mermaid-svg-GaYHe0oMO0IE7oFd .icon-shape .label{text-align:center;}#mermaid-svg-GaYHe0oMO0IE7oFd .node.clickable{cursor:pointer;}#mermaid-svg-GaYHe0oMO0IE7oFd .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-GaYHe0oMO0IE7oFd .arrowheadPath{fill:#333333;}#mermaid-svg-GaYHe0oMO0IE7oFd .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-GaYHe0oMO0IE7oFd .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-GaYHe0oMO0IE7oFd .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-GaYHe0oMO0IE7oFd .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-GaYHe0oMO0IE7oFd .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-GaYHe0oMO0IE7oFd .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-GaYHe0oMO0IE7oFd .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-GaYHe0oMO0IE7oFd .cluster text{fill:#333;}#mermaid-svg-GaYHe0oMO0IE7oFd .cluster span{color:#333;}#mermaid-svg-GaYHe0oMO0IE7oFd div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-GaYHe0oMO0IE7oFd .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-GaYHe0oMO0IE7oFd rect.text{fill:none;stroke-width:0;}#mermaid-svg-GaYHe0oMO0IE7oFd .icon-shape,#mermaid-svg-GaYHe0oMO0IE7oFd .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-GaYHe0oMO0IE7oFd .icon-shape p,#mermaid-svg-GaYHe0oMO0IE7oFd .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-GaYHe0oMO0IE7oFd .icon-shape .label rect,#mermaid-svg-GaYHe0oMO0IE7oFd .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-GaYHe0oMO0IE7oFd .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-GaYHe0oMO0IE7oFd .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-GaYHe0oMO0IE7oFd :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} miss
Client Login Request
AuthController
AuthService
UserMapper
MySQL
JwtTokenProvider
Redis Session
JWT Token
Client
Protected API Request
JwtAuthenticationInterceptor
ProductController
ProductService
Redis Cache
ProductMapper
这个链路里每个组件各司其职:
- Controller 处理 HTTP
- Service 处理业务逻辑
- MyBatis 负责 SQL
- JWT 负责身份令牌
- Redis 负责缓存和会话辅助
这就是一个很典型的前后端分离后端结构。
15. 生产环境里要注意的几个关键点
1. JWT Secret 不要写死在仓库里
开发环境可以临时写配置,生产环境应该走:
- 环境变量
- 配置中心
- 密钥平台
2. 密码必须加密存储
示例里为了突出主线逻辑,使用了简化比对。真实项目必须使用:
- BCrypt
- Argon2
- PBKDF2
3. Redis 缓存要考虑一致性
商品数据更新后,应主动清缓存,或者使用更稳妥的缓存策略。
4. 不要把太多业务信息塞进 JWT
JWT 最适合放:
- userId
- username
- role
不适合放:
- 频繁变化的用户状态
- 大量业务对象
- 敏感明文信息
5. 登录态失效不能只依赖 JWT 自身过期
如果只依赖过期时间,那就很难做"立即下线"。
所以更实用的方式通常是:
- JWT 负责身份声明
- Redis 负责会话有效性控制
16. 为什么这个组合很常见
Spring Boot + MyBatis + Redis + JWT 这组技术经常一起出现,不是偶然。
因为它们分别解决了后端最常见的几类问题:
- Spring Boot:让项目快速启动并具备工程基础设施
- MyBatis:让 SQL 可控
- Redis:让缓存和高频数据访问更高效
- JWT:让认证适配前后端分离场景
它们不是一套"高级炫技组合",而是非常务实的一套工程组合。
17. 总结
如果只学 Spring Boot 基础,你能写接口。
如果把 MyBatis、Redis、JWT 也整合起来,你才开始接近一个真实后端服务的基本形态。
这篇文章最值得带走的几个点是:
- MyBatis 适合复杂 SQL 和可控数据访问
- Redis 适合做缓存和会话辅助
- JWT 适合前后端分离身份认证
- Spring Boot 负责把这些能力组织成一套项目骨架
一个真正能上线的后端系统,往往不是靠某一个技术点取胜,而是靠它们之间的边界设计是否清晰。
如果你下一步还想继续往真实项目靠近,那么自然会继续进入这些主题:
- Spring Security 接管权限体系
- Refresh Token 设计
- Redis 分布式锁
- 多级缓存
- 接口幂等
- 审计日志
- Docker 和 Kubernetes 部署
但不管后面再叠多少能力,今天这套组合已经足够构成很多业务系统的核心骨架了。