一、项目基础架构与公共模块
1.0项目分层架构设计
本项目采用经典的三层架构 + 领域对象分层,职责清晰、便于维护,是企业级 Java 项目的标准范式。
|------------------|-------------------------------------|-----------------------------------|
| 层级/模块 | 职责说明 | 示例 |
| controller 控制层 | 接收 HTTP 请求、参数校验、调用 Service、封装统一结果返回 | ArticleController、LoginController |
| service 业务层 | 核心业务逻辑处理、事务控制、组装数据 | ArticleService、UserService + 实现类 |
| mapper/dao 数据访问层 | 直接与数据库交互,执行 SQL 查询 / 更新 | ArticleMapper、UserMapper |
| entity/DO 数据库实体 | 与数据库表一一对应,字段与表字段映射 | Article、SysUser |
| DTO 数据传输对象 | 前端传入后端的参数载体,与业务绑定 | LoginParam、ArticleDTO |
| VO 视图对象 | 返回给前端的数据载体,裁剪敏感字段、组装展示数据 | ArticleVO、CommentVO |
| common 公共模块 | 统一返回结果、全局异常、工具类、常量、注解 | Result、ErrorCode、JWTUtils |
设计原则:
- 前端请求 → DTO → Controller → Service → Mapper → 数据库;
- 数据库返回 → DO → Service 组装 → VO → Controller → 前端;
- 禁止数据库实体直接返回前端,避免字段泄露、前后端耦合。
1.1核心 Maven 依赖
XML
<!-- SpringWeb -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- MyBatis-Plus -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>3.5.2</version>
</dependency>
<!-- MySQL驱动 -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<!-- Redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<!-- JWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt</artifactId>
<version>0.9.1</version>
</dependency>
<!-- AOP -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
1.2统一返回结果封装
作用:所有接口返回格式统一,前端可统一拦截处理,降低前后端联调成本。
java
@Data
@AllArgsConstructor
@NoArgsConstructor
public class Result<T> {
private int code; // 状态码 200成功 其他失败
private String msg; // 提示信息
private T data; // 返回数据
// 成功响应(带数据)
public static <T> Result<T> success(T data) {
return new Result<>(200, "操作成功", data);
}
// 成功响应(无数据)
public static <T> Result<T> success() {
return success(null);
}
// 失败响应
public static <T> Result<T> fail(int code, String msg) {
return new Result<>(code, msg, null);
}
// 基于错误枚举的失败
public static <T> Result<T> fail(ErrorCode errorCode) {
return fail(errorCode.getCode(), errorCode.getMsg());
}
}
代码解析:
- 使用泛型兼容任意返回数据类型;
- 静态工厂方法简化调用,业务代码只需
Result.success(data)即可; - 配合错误码枚举统一管理所有异常信息,避免魔法值。
配套错误码枚举:
java
@Getter
@AllArgsConstructor
public enum ErrorCode {
PARAMS_ERROR(40000, "请求参数错误"),
NOT_LOGIN(40100, "未登录"),
NO_PERMISSION(40300, "无权限"),
SYSTEM_ERROR(50000, "系统内部异常"),
ACCOUNT_EXIST(40001, "账号已存在");
private final int code;
private final String msg;
}
1.3全局统一异常处理
作用:集中捕获所有异常,避免堆栈信息泄露给前端,统一返回错误格式。
java
@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
// 捕获自定义业务异常
@ExceptionHandler(BusinessException.class)
public Result<?> businessExceptionHandler(BusinessException e) {
log.error("业务异常:{}", e.getMessage());
return Result.fail(e.getCode(), e.getMessage());
}
// 捕获系统运行时异常
@ExceptionHandler(RuntimeException.class)
public Result<?> runtimeExceptionHandler(RuntimeException e) {
log.error("系统异常:", e);
return Result.fail(ErrorCode.SYSTEM_ERROR);
}
}
自定义业务异常类:
java
@Getter
public class BusinessException extends RuntimeException {
private final int code;
public BusinessException(int code, String message) {
super(message);
this.code = code;
}
public BusinessException(ErrorCode errorCode) {
super(errorCode.getMsg());
this.code = errorCode.getCode();
}
}
代码解析:
@RestControllerAdvice=@ControllerAdvice+@ResponseBody,自动将返回值转为 JSON;- 新增参数校验异常捕获,配合
@Valid注解自动返回友好提示,无需手动判断字段; - 业务层校验失败时直接
throw new BusinessException(ErrorCode.NOT_LOGIN)即可中断流程。
1.4统一参数校验
替代手动if判空,通过注解实现参数校验,代码更简洁规范。
使用示例:登录参数 DTO
java
@Data
public class LoginParam {
@NotBlank(message = "账号不能为空")
private String account;
@NotBlank(message = "密码不能为空")
@Size(min = 6, max = 20, message = "密码长度6-20位")
private String password;
}
Controller 中启用校验
java
@PostMapping("/login")
public Result login(@Valid @RequestBody LoginParam param) {
return userService.login(param);
}
1.5全局跨域配置
前后端分离项目必配,解决浏览器同源策略限制。推荐使用全局 CORS 配置 ,替代每个 Controller 加@CrossOrigin。
java
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**") // 所有接口都支持跨域
.allowedOriginPatterns("*") // 允许所有来源地址
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") // 允许的请求方法
.allowedHeaders("*") // 允许所有请求头
.allowCredentials(true) // 允许携带凭证(cookie、token)
.maxAge(3600); // 预检请求OPTIONS的有效期,1小时内不用重复发预检
}
}
代码解析:
allowCredentials=true时,allowedOrigins不能写*,需用allowedOriginPatterns("*");- 生产环境建议限制
allowedOriginPatterns为指定域名,提升安全性; - 若项目有拦截器,拦截器直接返回错误时会丢失跨域头,需在拦截器中手动添加响应头,或使用过滤器级别的跨域配置。
1.6MyBatis-Plus 配置
java
@Configuration
@MapperScan("com.mszlu.blog.mapper")
public class MybatisPlusConfig {
// 分页插件
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
return interceptor;
}
}
代码解析:
@MapperScan一次性扫描所有 Mapper 接口,无需每个 Mapper 加@Mapper;- 分页插件是 MP 分页查询的前提,不配置则
Page对象不生效,查询结果为全部数据; - 可额外配置乐观锁插件、逻辑删除插件,满足更多业务场景。
1.7常量类设计(代码规范)
将 Redis key、过期时间、通用常量抽为常量类,避免魔法值、便于统一维护。
java
public interface RedisConstants {
String ARTICLE_HOT_KEY = "article:hot";
String ARTICLE_DETAIL_KEY = "article:detail:";
String TOKEN_BLACK_KEY = "token:black:";
long ARTICLE_CACHE_TIMEOUT = 300; // 文章缓存过期时间 5分钟
long TOKEN_EXPIRE = 86400; // token有效期 1天
}
二、数据库设计与优化
2.1 用户表 ms_user
sql
CREATE TABLE `ms_user` (
`id` bigint NOT NULL AUTO_INCREMENT,
`account` varchar(64) NOT NULL COMMENT '账号',
`password` varchar(64) NOT NULL COMMENT '密码(加盐MD5)',
`nickname` varchar(64) NOT NULL COMMENT '昵称',
`avatar` varchar(255) DEFAULT NULL COMMENT '头像',
`create_date` datetime DEFAULT NULL COMMENT '创建时间',
`last_login` datetime DEFAULT NULL COMMENT '最后登录时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_account` (`account`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
2.2 文章表 ms_article
sql
CREATE TABLE `ms_article` (
`id` bigint NOT NULL AUTO_INCREMENT,
`title` varchar(128) NOT NULL COMMENT '标题',
`summary` varchar(256) DEFAULT NULL COMMENT '摘要',
`comment_counts` int DEFAULT 0 COMMENT '评论数',
`view_counts` int DEFAULT 0 COMMENT '浏览量',
`author_id` bigint NOT NULL COMMENT '作者id',
`body_id` bigint NOT NULL COMMENT '文章内容id',
`category_id` bigint DEFAULT NULL COMMENT '分类id',
`create_date` bigint NOT NULL COMMENT '创建时间戳',
`weight` int DEFAULT 0 COMMENT '置顶权重',
PRIMARY KEY (`id`),
KEY `idx_category_id` (`category_id`),
KEY `idx_author_id` (`author_id`),
KEY `idx_create_date` (`create_date`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
2.3 其他核心表
ms_article_body:存储文章正文 Markdown/HTML 内容,与文章一对一分离;ms_category:文章分类表;ms_tag:标签表 +ms_article_tag文章标签中间表(多对多);ms_comment:评论表,含parent_id实现多级评论;ms_sys_user、ms_role、ms_permission、ms_role_permission:后台 RBAC 权限四表。
2.4 表设计优化思路
- 文章内容分离:正文内容大、列表页无需查询,单独建表减少单表数据量,提升列表查询性能。
- 索引设计原则 :
- 主键默认聚簇索引;
- 常用查询条件(分类 id、作者 id、创建时间)建立普通索引;
- 唯一字段(账号)建立唯一索引;
- 避免索引过多:索引会占用磁盘空间,降低写入性能。
- 字段类型选择 :
- 金额用
decimal,不用 float/double; - 状态、类型用
tinyint,不用字符串; - 时间可用
datetime或bigint时间戳,本项目用时间戳避免时区问题。
- 金额用
- 默认值设置:浏览量、评论数默认 0,避免 null 值引发的计算异常。
三、登录认证体系(核心重点)
3.1 MD5 加盐加密工具
java
public class MD5Utils {
// 固定盐值,混入密码一起加密,防止彩虹表反解
private static final String SALT = "mszlu_blog_!@#";
public static String md5(String password) {
String saltPassword = password + SALT;
return DigestUtils.md5Hex(saltPassword);
}
}
代码解析:
- 数据库绝不存储明文密码,加密后即使数据库泄露也无法直接获取原始密码;
- 加盐是为了对抗彩虹表攻击,相同密码加盐后哈希值不同;
- 生产环境推荐每个用户独立盐值,进一步提升安全性。
3.2 JWT 工具类
java
public class JWTUtils {
// 密钥,生产环境务必配置化,不可硬编码
private static final String SECRET = "mszlu!@#$%^&*()";
// 生成token,载荷存入userId
public static String createToken(Long userId) {
JwtBuilder builder = Jwts.builder();
return builder
.setSubject("blog-user")
.claim("userId", userId)
.setExpiration(new Date(System.currentTimeMillis() + 1000 * 60 * 60 * 24)) // 1天过期
.signWith(SignatureAlgorithm.HS256, SECRET)
.compact();
}
// 解析token,获取userId;失败返回null
public static Long getUserId(String token) {
try {
Claims claims = Jwts.parser()
.setSigningKey(SECRET)
.parseClaimsJws(token)
.getBody();
return claims.get("userId", Long.class);
} catch (Exception e) {
return null;
}
}
// 校验token是否有效
public static boolean checkToken(String token) {
try {
Jwts.parser().setSigningKey(SECRET).parseClaimsJws(token);
return true;
} catch (Exception e) {
return false;
}
}
}
代码解析:
- JWT 由头部、载荷、签名三部分组成,载荷存放非敏感信息(userId),不可存密码;
- 密钥是签发和校验的核心,泄露后任何人都能伪造 token,生产环境必须配置在环境变量中;
- 解析失败直接返回 null,由拦截器判断是否登录。
3.3 ThreadLocal 用户上下文
java
public class UserThreadLocal {
private static final ThreadLocal<SysUser> LOCAL = new ThreadLocal<>();
public static void set(SysUser user) {
LOCAL.set(user);
}
public static SysUser get() {
return LOCAL.get();
}
public static void remove() {
LOCAL.remove();
}
}
代码解析:
- ThreadLocal 利用线程隔离特性,在同一条请求线程内共享用户信息,Service/Controller 任意位置都能直接获取登录用户;
- 必须在请求结束时调用 remove (),否则 Tomcat 线程池复用线程时,旧用户数据会残留,造成数据错乱与内存泄漏。
3.4 登录拦截器
java
@Component
public class LoginInterceptor implements HandlerInterceptor {
@Autowired
private RedisTemplate<String, String> redisTemplate;
// 请求进入Controller之前执行
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// 放行OPTIONS预检请求
if ("OPTIONS".equals(request.getMethod())) {
return true;
}
// 1. 从请求头获取token
String token = request.getHeader("Authorization");
// 2. 校验token非空、格式合法
if (StringUtils.isBlank(token) || !JWTUtils.checkToken(token)) {
return writeError(response, ErrorCode.NOT_LOGIN);
}
// 3. 校验token是否在黑名单(退出登录时加入)
if (Boolean.TRUE.equals(redisTemplate.hasKey(RedisConstants.TOKEN_BLACK_KEY + token))) {
return writeError(response, ErrorCode.NOT_LOGIN);
}
// 4. 解析出userId,查询用户信息
Long userId = JWTUtils.getUserId(token);
SysUser user = userService.getById(userId);
if (user == null) {
return writeError(response, ErrorCode.NOT_LOGIN);
}
// 5. 存入ThreadLocal
UserThreadLocal.set(user);
return true;
}
// 请求完成后执行,无论是否异常都会执行
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
// 关键:清除ThreadLocal,防止内存泄漏
UserThreadLocal.remove();
}
// 响应JSON错误信息
private boolean writeError(HttpServletResponse response, ErrorCode errorCode) throws IOException {
response.setContentType("application/json;charset=UTF-8");
// 手动添加跨域头,避免拦截器返回时跨域失效
response.setHeader("Access-Control-Allow-Origin", "*");
response.getWriter().print(JSON.toJSONString(Result.fail(errorCode)));
return false;
}
}
3.5 拦截器注册配置
java
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Autowired
private LoginInterceptor loginInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginInterceptor)
.addPathPatterns("/**")
.excludePathPatterns(
"/user/login",
"/user/register",
"/articles/**",
"/tags/**",
"/categorys/**"
);
}
}
代码解析:
- 放行登录、注册、公开文章列表等无需登录的接口;
- 写文章、发评论、用户中心等接口必须经过拦截器校验;
- 注意放行 OPTIONS 请求,否则跨域预检失败。
3.6 登录业务逻辑
java
@Override
public Result login(LoginParam param) {
String account = param.getAccount();
String password = param.getPassword();
// 1. 参数已通过@Valid校验,此处可省略基础判空
// 2. 密码加密后查询用户
String pwd = MD5Utils.md5(password);
LambdaQueryWrapper<SysUser> wrapper = Wrappers.lambdaQuery();
wrapper.eq(SysUser::getAccount, account).eq(SysUser::getPassword, pwd);
SysUser user = this.getOne(wrapper);
if (user == null) {
throw new BusinessException(400, "账号或密码错误");
}
// 3. 生成token返回
String token = JWTUtils.createToken(user.getId());
// 4. 更新最后登录时间
user.setLastLogin(new Date());
this.updateById(user);
return Result.success(token);
}
3.7 退出登录实现
将 token 加入 Redis 黑名单,实现 JWT 主动失效。
java
@Override
public Result logout(String token) {
// 计算token剩余过期时间,黑名单key与token同生命周期
long expire = JWTUtils.getExpire(token);
redisTemplate.opsForValue().set(
RedisConstants.TOKEN_BLACK_KEY + token,
"1",
expire,
TimeUnit.SECONDS
);
return Result.success();
}
代码解析:
- JWT 本身无法主动作废,通过黑名单机制实现退出登录功能;
- 黑名单 key 设置与 token 一致的过期时间,过期后自动删除,节省 Redis 内存。
四、核心业务模块实现
4.1 首页文章分页列表
java
@Override
public Result listArticle(PageParams pageParams) {
Page<Article> page = new Page<>(pageParams.getPage(), pageParams.getPageSize());
LambdaQueryWrapper<Article> wrapper = Wrappers.lambdaQuery();
// 按权重置顶 + 创建时间倒序
wrapper.orderByDesc(Article::getWeight, Article::getCreateDate);
page(page, wrapper);
// 组装VO:只返回前端需要的字段,屏蔽敏感信息
List<ArticleVO> voList = copyList(page.getRecords());
return Result.success(new PageResult<>(voList, page.getTotal()));
}
代码解析:
- 数据库实体
Article不直接返回前端,通过 VO(视图对象)做字段裁剪,避免泄露内部字段; - 分页 + 排序是列表类接口的标准写法,权重高的文章优先置顶
4.2 文章详情 + 异步阅读量更新
线程池配置
java
@Configuration
@EnableAsync
public class ThreadPoolConfig {
@Bean("taskExecutor")
public ThreadPoolTaskExecutor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(5);
executor.setMaxPoolSize(10);
executor.setQueueCapacity(100);
executor.setThreadNamePrefix("blog-task-");
// 拒绝策略:调用者执行,避免任务丢失
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
}
异步更新阅读量
java
@Service
@Slf4j
public class ArticleServiceImpl implements ArticleService {
@Autowired
private RedisTemplate<String, Integer> redisTemplate;
@Override
public Result findArticleById(Long id) {
Article article = this.getById(id);
if (article == null) {
throw new BusinessException(ErrorCode.PARAMS_ERROR);
}
// 1. Redis中阅读量+1(内存操作,性能极高)
redisTemplate.opsForValue().increment("article:view:" + id);
// 2. 异步同步到MySQL
updateViewCountAsync(id);
// 3. 组装详情VO返回
return Result.success(copyDetail(article));
}
@Async("taskExecutor")
public void updateViewCountAsync(Long articleId) {
try {
// 简化版:异步更新数据库;生产环境配合定时任务批量同步
Article article = new Article();
article.setId(articleId);
article.setViewCounts(this.getById(articleId).getViewCounts() + 1);
this.updateById(article);
} catch (Exception e) {
log.error("更新阅读量失败", e);
}
}
}
代码解析:
- 热点文章访问量高,每次直接 update 数据库会造成大量写压力;
- 先写 Redis 保证计数实时性,再由线程池异步落库,实现读写分离、削峰填谷;
- 拒绝策略设置为调用者执行,避免线程池满了之后任务直接丢弃。
4.3 树形评论结构构建
java
@Override
public Result commentsByArticleId(Long articleId) {
// 1. 查询该文章所有一级评论(parentId = 0)
List<Comment> commentList = commentMapper.selectList(
Wrappers.lambdaQuery(Comment.class)
.eq(Comment::getArticleId, articleId)
.eq(Comment::getParentId, 0)
.orderByDesc(Comment::getCreateDate)
);
// 2. 递归填充子评论
List<CommentVO> voList = commentList.stream().map(comment -> {
CommentVO vo = copy(comment);
vo.setChildren(findChildren(comment.getId()));
return vo;
}).collect(Collectors.toList());
return Result.success(voList);
}
// 递归查询子评论
private List<CommentVO> findChildren(Long parentId) {
List<Comment> children = commentMapper.selectList(
Wrappers.lambdaQuery(Comment.class).eq(Comment::getParentId, parentId)
);
return children.stream().map(c -> {
CommentVO vo = copy(c);
vo.setChildren(findChildren(c.getId()));
return vo;
}).collect(Collectors.toList());
}
代码解析:
- 采用自关联表设计,通过
parent_id实现无限级评论; - 递归构建树形结构,适合评论层级不深的场景;层级过深时建议限制最大层级或扁平化处理;
- 评论量较大时,可一次性查询所有评论再在内存中组装树,减少数据库查询次数。
4.4 发布文章(事务控制)
发布文章需要同时操作文章表、文章内容表、文章标签中间表,必须保证事务一致性。
java
@Override
@Transactional(rollbackFor = Exception.class)
public Result publishArticle(ArticleDTO dto) {
// 1. 获取当前登录用户
SysUser user = UserThreadLocal.get();
// 2. 保存文章正文
ArticleBody body = new ArticleBody();
body.setContent(dto.getContent());
body.setContentHtml(dto.getContentHtml());
articleBodyService.save(body);
// 3. 保存文章主表
Article article = new Article();
BeanUtils.copyProperties(dto, article);
article.setAuthorId(user.getId());
article.setBodyId(body.getId());
article.setCreateDate(System.currentTimeMillis());
article.setViewCounts(0);
article.setCommentCounts(0);
this.save(article);
// 4. 保存文章-标签关联
List<Long> tagIds = dto.getTagIds();
if (CollectionUtils.isNotEmpty(tagIds)) {
List<ArticleTag> articleTagList = tagIds.stream().map(tagId -> {
ArticleTag articleTag = new ArticleTag();
articleTag.setArticleId(article.getId());
articleTag.setTagId(tagId);
return articleTag;
}).collect(Collectors.toList());
articleTagService.saveBatch(articleTagList);
}
// 5. 发布成功后清除相关缓存
redisTemplate.delete(RedisConstants.ARTICLE_HOT_KEY);
return Result.success(article.getId());
}
代码解析:
@Transactional(rollbackFor = Exception.class)声明事务,任何异常都回滚;默认只回滚 RuntimeException;- 事务生效前提:方法必须是 public、不能被同类调用、异常不能被内部捕获不抛出;
- 多表操作必须加事务,防止部分成功部分失败导致数据错乱。
4.5 图片上传实现
本地上传方案(开发测试用)
java
@Service
public class UploadServiceImpl implements UploadService {
@Value("${upload.path}")
private String uploadPath;
@Value("${upload.url-prefix}")
private String urlPrefix;
@Override
public Result uploadImage(MultipartFile file) throws IOException {
// 1. 校验文件类型
String contentType = file.getContentType();
if (!"image/jpeg".equals(contentType) && !"image/png".equals(contentType)) {
throw new BusinessException(400, "仅支持jpg/png图片");
}
// 2. 生成唯一文件名,防止重名覆盖
String originalFilename = file.getOriginalFilename();
String suffix = originalFilename.substring(originalFilename.lastIndexOf("."));
String fileName = UUID.randomUUID().toString().replace("-", "") + suffix;
// 3. 保存文件
File dest = new File(uploadPath + fileName);
if (!dest.getParentFile().exists()) {
dest.getParentFile().mkdirs();
}
file.transferTo(dest);
// 4. 返回访问url
String url = urlPrefix + fileName;
return Result.success(url);
}
}
云存储方案(生产环境推荐)
接入阿里云 OSS / 七牛云 / 腾讯云 COS,步骤:
- 引入对应云存储 SDK;
- 配置 accessKey、secretKey、bucket、域名;
- 调用 SDK 上传文件,返回外网访问 URL。
优势:不占用服务器磁盘、自带 CDN 加速、访问速度快、支持图片处理(压缩、水印)。
4.6 文章搜索功能
基础版使用 MySQL 模糊查询,满足简单搜索需求。
java
@Override
public Result searchArticle(String keyword) {
LambdaQueryWrapper<Article> wrapper = Wrappers.lambdaQuery();
wrapper.like(Article::getTitle, keyword)
.or().like(Article::getSummary, keyword)
.orderByDesc(Article::getCreateDate);
List<Article> list = this.list(wrapper);
return Result.success(copyList(list));
}
代码解析:
- 默认 JDK 序列化存入 Redis 是二进制乱码,可读性差且序列化体积大;
- JSON 序列化后 Redis 客户端可直观查看数据,便于调试与排查。
5.2 通用缓存工具封装
java
@Component
public class CacheUtils {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
/**
* 通用缓存查询:先查缓存,命中返回;未命中查库并写入缓存
* @param key 缓存key
* @param timeout 过期时间 秒
* @param dbQuery 数据库查询函数(函数式编程)
*/
public Object getCache(String key, long timeout, Supplier<Object> dbQuery) {
// 1. 查缓存
Object cache = redisTemplate.opsForValue().get(key);
if (cache != null) {
return cache;
}
// 2. 查数据库
Object data = dbQuery.get();
if (data != null) {
// 3. 写入缓存并设置过期时间
redisTemplate.opsForValue().set(key, data, timeout, TimeUnit.SECONDS);
} else {
// 缓存空值,防止缓存穿透
redisTemplate.opsForValue().set(key, "", 60, TimeUnit.SECONDS);
}
return data;
}
}
代码解析:
- 使用 Supplier 函数式接口,将数据库查询逻辑作为参数传入,实现缓存逻辑与业务解耦;
- 统一设置过期时间,避免永久缓存导致的数据不一致;
- 新增空值缓存,解决缓存穿透问题。
5.3 业务层使用示例
java
@Override
public Result hotArticle() {
Object cache = cacheUtils.getCache(
RedisConstants.ARTICLE_HOT_KEY,
RedisConstants.ARTICLE_CACHE_TIMEOUT,
() -> {
// 查询浏览量最高的前5篇文章
List<Article> list = this.list(
Wrappers.lambdaQuery(Article.class)
.orderByDesc(Article::getViewCounts)
.last("limit 5")
);
return copyList(list);
}
);
return Result.success(cache);
}
5.4 缓存三大经典问题及解决方案
1. 缓存穿透
问题 :查询不存在的数据,缓存和数据库都没有,请求直接打到数据库,大量恶意请求会压垮数据库。 本项目解决方案:
- 缓存空值:查询结果为 null 时,也在 Redis 中存入空值,设置较短过期时间;
- 进阶方案:布隆过滤器,将所有存在的 id 存入布隆过滤器,不存在的直接拦截。
2. 缓存击穿
问题 :某个热点 key 过期瞬间,大量并发请求同时涌入数据库,导致数据库压力骤增。 解决方案:
- 互斥锁:缓存失效时,只有一个线程去查数据库并重建缓存,其他线程等待;
- 热点数据永不过期:物理上不设过期时间,更新数据时主动删除缓存;
- 本项目热门文章可采用永不过期 + 更新时主动删除的方案。
3. 缓存雪崩
问题 :大量 key 同一时间集中过期,或者 Redis 宕机,导致所有请求转发到数据库,数据库崩溃。 解决方案:
- 过期时间加随机值:
过期时间 = 基础时间 + 随机数(0~300秒),避免同时过期; - Redis 集群部署,保证高可用;
- 服务降级:非核心接口直接返回默认数据,保护核心业务。
5.5 定时任务:阅读量批量同步
替代单条异步更新,定时批量将 Redis 中的阅读量同步到 MySQL,降低数据库写入频率。
java
@Component
@EnableScheduling
@Slf4j
public class ViewCountTask {
@Autowired
private RedisTemplate<String, Integer> redisTemplate;
@Autowired
private ArticleService articleService;
// 每5分钟执行一次
@Scheduled(cron = "0 */5 * * * ?")
public void syncViewCount() {
log.info("开始同步阅读量...");
// 1. 匹配所有阅读量key
Set<String> keys = redisTemplate.keys("article:view:*");
if (CollectionUtils.isEmpty(keys)) {
return;
}
// 2. 批量更新数据库
for (String key : keys) {
Long articleId = Long.valueOf(key.split(":")[2]);
Integer count = redisTemplate.opsForValue().get(key);
if (count != null && count > 0) {
Article article = new Article();
article.setId(articleId);
article.setViewCounts(count);
articleService.updateById(article);
}
}
// 3. 删除已同步的key
redisTemplate.delete(keys);
log.info("阅读量同步完成,共{}篇", keys.size());
}
}
代码解析:
- 定时任务批量写入,将多次数据库操作合并为周期性操作,大幅降低数据库压力;
@EnableScheduling开启定时任务,cron表达式定义执行时机;- 生产环境注意加分布式锁,防止集群部署时重复执行。
六、AOP 切面:操作日志实现
6.1 自定义日志注解
java
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface LogAnnotation {
String module() default ""; // 模块名
String operation() default ""; // 操作描述
}
6.2 日志切面
java
@Aspect
@Component
@Slf4j
public class LogAspect {
// 切入点:所有加了@LogAnnotation的方法
@Pointcut("@annotation(com.mszlu.blog.common.aop.LogAnnotation)")
public void logPointCut() {}
// 环绕通知
@Around("logPointCut()")
public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
long beginTime = System.currentTimeMillis();
// 执行原方法
Object result = joinPoint.proceed();
// 计算耗时
long cost = System.currentTimeMillis() - beginTime;
// 记录日志
recordLog(joinPoint, cost);
return result;
}
private void recordLog(ProceedingJoinPoint joinPoint, long cost) {
// 获取注解信息
MethodSignature signature = (MethodSignature) joinPoint.getSignature();
LogAnnotation annotation = signature.getMethod().getAnnotation(LogAnnotation.class);
// 获取请求IP、用户
HttpServletRequest request = HttpContextUtils.getHttpServletRequest();
String ip = IpUtils.getIpAddr(request);
SysUser user = UserThreadLocal.get();
log.info("=================操作日志=================");
log.info("模块:{}", annotation.module());
log.info("操作:{}", annotation.operation());
log.info("请求IP:{}", ip);
log.info("操作用户:{}", user != null ? user.getNickname() : "未登录");
log.info("请求参数:{}", JSON.toJSONString(joinPoint.getArgs()));
log.info("执行耗时:{}ms", cost);
}
}
代码解析:
- AOP 实现日志与业务代码完全解耦,业务方法只需加一个注解即可自动记录日志;
- 环绕通知可以统计方法执行耗时,便于性能监控;
- 生产环境可将日志持久化到数据库,配合后台做操作审计。
6.3 配套工具类
HttpContextUtils:获取当前请求对象
java
public class HttpContextUtils {
public static HttpServletRequest getHttpServletRequest() {
ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
return attributes == null ? null : attributes.getRequest();
}
}
IpUtils:获取客户端真实 IP
java
public class IpUtils {
public static String getIpAddr(HttpServletRequest request) {
String ip = request.getHeader("x-forwarded-for");
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("Proxy-Client-IP");
}
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getHeader("WL-Proxy-Client-IP");
}
if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) {
ip = request.getRemoteAddr();
}
// 多级代理时,第一个IP为真实IP
return ip.split(",")[0].trim();
}
}
七、消息队列:RocketMQ 解决缓存一致性
7.1 问题背景
更新文章时,先更新数据库再删除缓存,如果删除缓存失败,会导致数据库是新数据、缓存是旧数据,出现数据不一致。使用 MQ 异步解耦,保证最终一致性。
7.2 生产者:更新文章后发送消息
java
@Service
public class ArticleServiceImpl {
@Autowired
private RocketMQTemplate rocketMQTemplate;
@Override
@Transactional(rollbackFor = Exception.class)
public Result updateArticle(Article article) {
// 1. 更新数据库
this.updateById(article);
// 2. 更新标签关联(略)
// 3. 发送消息,通知删除缓存
rocketMQTemplate.convertAndSend("article-cache-topic", article.getId());
return Result.success();
}
}
7.3 消费者:监听消息删除缓存
java
@Component
@RocketMQMessageListener(topic = "article-cache-topic", consumerGroup = "blog-cache-group")
public class CacheConsumer implements RocketMQListener<Long> {
@Autowired
private RedisTemplate<String, Object> redisTemplate;
@Override
public void onMessage(Long articleId) {
// 删除文章详情缓存
redisTemplate.delete(RedisConstants.ARTICLE_DETAIL_KEY + articleId);
// 同时删除首页列表、热门文章等相关缓存
redisTemplate.delete(RedisConstants.ARTICLE_HOT_KEY);
log.info("已清除文章缓存,id:{}", articleId);
}
}
代码解析:
- 消息队列实现了业务主流程与缓存删除的解耦,即使删除缓存失败,MQ 也会重试;
- 保证最终一致性,适用于对实时性要求不是极高的场景;
- 本项目作为练手项目,也可直接同步删除缓存,MQ 属于进阶优化点。
7.4 接口幂等性设计
防止用户重复提交、MQ 重复消费导致的数据重复问题。
防重提交方案(Redis 令牌):
- 进入发布页面前,后端生成唯一防重 token,存入 Redis 并返回前端;
- 提交请求时,请求头携带该 token;
- 后端校验 token 是否存在,存在则删除 token 并执行业务,不存在则返回重复提交。
java
// 生成防重token
public String generateToken() {
String token = UUID.randomUUID().toString();
redisTemplate.opsForValue().set("submit:token:" + token, "1", 300, TimeUnit.SECONDS);
return token;
}
// 校验防重token
public boolean checkToken(String token) {
String key = "submit:token:" + token;
Boolean delete = redisTemplate.delete(key);
return Boolean.TRUE.equals(delete);
}
八、后台管理:Spring Security + RBAC 权限
8.1 核心配置类
java
@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true) // 开启方法级权限注解
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Autowired
private UserDetailsServiceImpl userDetailsService;
@Autowired
private JwtAuthenticationFilter jwtAuthenticationFilter;
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService).passwordEncoder(passwordEncoder());
}
@Override
protected void configure(HttpSecurity http) throws Exception {
http.csrf().disable()
.authorizeRequests()
.antMatchers("/admin/login").permitAll()
.anyRequest().authenticated()
.and()
.formLogin().disable()
// 前后端分离,用JWT替代session
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
// 添加JWT认证过滤器
http.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
@Override
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}
}
8.2 自定义用户权限加载
java
@Service
public class UserDetailsServiceImpl implements UserDetailsService {
@Autowired
private AdminUserMapper adminUserMapper;
@Autowired
private PermissionMapper permissionMapper;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
// 1. 查询管理员用户
AdminUser user = adminUserMapper.selectOne(
Wrappers.lambdaQuery(AdminUser.class).eq(AdminUser::getUsername, username)
);
if (user == null) {
throw new UsernameNotFoundException("用户不存在");
}
// 2. 根据用户id查询所有权限标识
List<String> perms = permissionMapper.selectPermsByUserId(user.getId());
List<SimpleGrantedAuthority> authorities = perms.stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
// 3. 返回Spring Security需要的用户对象
return new User(user.getUsername(), user.getPassword(), authorities);
}
}
8.3 方法级权限控制
java
@RestController
@RequestMapping("/admin/article")
public class AdminArticleController {
// 只有拥有 sys:article:delete 权限的用户才能调用
@DeleteMapping("/{id}")
@PreAuthorize("hasAuthority('sys:article:delete')")
public Result delete(@PathVariable Long id) {
articleService.removeById(id);
return Result.success();
}
}
代码解析:
- RBAC 模型:用户 → 角色 → 权限,通过中间表关联,灵活分配权限;
UserDetailsService是 Spring Security 的核心扩展点,从数据库加载用户与权限;@PreAuthorize注解实现细粒度接口权限控制,权限标识与数据库对应。
九、项目部署与环境配置
9.1 多环境配置
SpringBoot Profile 实现开发 / 生产环境隔离。
application.yml 主配置
java
spring:
profiles:
active: dev # 切换环境:dev开发 prod生产
application-dev.yml 开发环境
java
spring:
datasource:
url: jdbc:mysql://localhost:3306/blog?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: root
password: root
redis:
host: localhost
port: 6379
application-prod.yml 生产环境
java
spring:
datasource:
url: jdbc:mysql://内网地址:3306/blog?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: 生产账号
password: 生产密码
redis:
host: 内网Redis地址
port: 6379
password: Redis密码
9.2 项目打包
bash
# Maven打包,跳过测试
mvn clean package -DskipTests
打包后在target目录生成 jar 包,如blog-admin.jar。
9.3 Docker 部署
Dockerfile
bash
# 基础镜像
FROM openjdk:8-jdk-alpine
# 设置时区
RUN apk add --no-cache tzdata
ENV TZ=Asia/Shanghai
# 工作目录
WORKDIR /app
# 复制jar包
COPY target/blog-admin.jar app.jar
# 暴露端口
EXPOSE 8080
# 启动命令
ENTRYPOINT ["java","-jar","-Dspring.profiles.active=prod","app.jar"]
构建镜像并启动
bash
# 构建镜像
docker build -t blog-app:v1 .
# 启动容器
docker run -d -p 8080:8080 --name blog blog-app:v1
9.4 Nginx 反向代理
bash
server {
listen 80;
server_name yourdomain.com;
# 前端静态资源
location / {
root /usr/share/nginx/html/dist;
index index.html;
try_files $uri $uri/ /index.html;
}
# 后端接口转发
location /api/ {
proxy_pass http://127.0.0.1:8080/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
十、常见后端问题与排坑指南
1.MyBatis-Plus 分页不生效
原因:未配置分页插件;解决方案:添加MybatisPlusInterceptor分页拦截器
2.事务不生效
常见原因:方法不是 public;同类中方法调用(没有经过代理对象);异常被 try-catch 吃掉没抛出;@Transactional 没加 rollbackFor。
解决:方法声明为 public;注入自身调用;异常向外抛出;指定 rollbackFor = Exception.class。
3.跨域配置不生效
原因:拦截器提前返回响应,没走 SpringMVC 的跨域处理;
解决:拦截器返回错误时手动添加跨域响应头,或使用 CorsFilter 过滤器级别的跨域配置。
4.Redis 序列化异常
原因:实体类没实现 Serializable,或 Jackson 序列化时类型不匹配;
解决:使用 Jackson2JsonRedisSerializer 序列化,配置 ObjectMapper 支持多态类型。
5.ThreadLocal 数据错乱
原因:线程池复用线程,请求结束未 remove;
解决:在拦截器afterCompletion中强制调用 remove ()。
6.@Async 异步不生效
原因:同类调用,没有走 Spring 代理;
解决:异步方法单独放一个类,通过注入调用。
7.日期时间不一致 / 时区问题
原因:MySQL、JVM、服务器时区不统一;
解决:JDBC 连接加serverTimezone=Asia/Shanghai,JVM 启动参数设置时区。
十一、面试高频问题整理
1.ThreadLocal 原理?为什么会内存泄漏?项目中如何避免?
ThreadLocal 为每个线程创建独立副本,底层是 ThreadLocalMap,key 为弱引用,value 为强引用。线程池复用线程时,如果不手动 remove,value 会一直被线程持有无法回收,造成内存泄漏。本项目在拦截器afterCompletion中强制调用 remove ()。
2.JWT 登录的优缺点?为什么还要配合 Redis?
优点:无状态、服务端无需存储 session、天然支持分布式;缺点:签发后无法主动失效。Redis 用于存储 token 黑名单,实现退出登录、强制下线等功能。
3.项目中如何处理缓存一致性问题?
更新数据库后通过 RocketMQ 发送消息,消费者异步删除缓存,保证最终一致性;同时所有缓存设置过期时间,作为兜底方案。
4.为什么阅读量要异步更新?直接 update 不行吗?
热点文章访问量高,同步写数据库会造成大量 IO 压力,甚至拖垮数据库。Redis + 定时任务批量同步可以极大削峰,降低数据库压力,提升接口响应速度。
5.缓存穿透、击穿、雪崩分别是什么?怎么解决?
穿透:查不存在的数据,打穿缓存;解决:缓存空值、布隆过滤器。
击穿:热点 key 过期,大量请求打 DB;解决:互斥锁、热点永不过期。
雪崩:大量 key 同时过期 / Redis 宕机;解决:随机过期时间、集群、降级。
6.全局异常处理的好处?
统一错误返回格式,避免堆栈信息泄露,集中管理异常日志,业务代码无需大量 try-catch,专注业务逻辑。
7.AOP 在项目中的应用场景?
操作日志、权限校验、性能监控、事务管理,实现非业务逻辑与业务代码解耦。
8.RBAC 权限模型的设计思路?
用户、角色、权限三张主表 + 两张中间表,用户关联角色,角色关联权限;通过给角色分配权限、给用户分配角色实现灵活的权限管控,最小粒度到接口权限标识。
9.@Transactional 事务失效的场景有哪些?
方法非 public、同类内部调用、异常被捕获未抛出、未指定 rollbackFor、数据库引擎不支持事务、多线程环境。