1. 项目概述:为什么一个“帖子详情页”值得单独深挖
你点开一个社区App,滑到某条热门内容,手指停住——标题吸引人,配图有质感,但真正让你停留三秒以上的,是下面那串不断滚动的评论:“楼主这个思路太绝了!”“求源码!”“已复现,附上我的优化版本”。这个瞬间,背后跑着的不是什么高大上的分布式架构,而是一套扎实、稳定、能扛住并发又不卡顿的帖子详情及评论系统。SpringBoot 实现帖子详情及评论,表面看是教你怎么写两个接口、连三张表,实则是一次对Web开发底层逻辑的集中检验:数据怎么查才不拖垮数据库?评论列表怎么分页才不翻页就卡死?用户发完评论,页面怎么秒级刷新而不刷新整个页面?点赞数变更如何避免脏读?这些都不是“能跑就行”的问题,而是决定一个社区产品生死的关键体验节点。
我带过不少刚转行的开发者做类似项目,90%的人第一版都会栽在同一个坑里:用一条SQL把帖子主信息+所有评论+每个评论的用户头像昵称全JOIN出来,结果一到测试环境,50人同时刷详情页,数据库CPU直接飙到95%,页面加载从800ms变成6秒。后来我们拆开看,问题不在SpringBoot框架本身,而在对“单体应用内资源调度”的误判——你以为你在写Java代码,其实你是在和MySQL的连接池、MyBatis的一级二级缓存、HTTP请求的生命周期、前端Vue/React的响应式更新机制,打一场多线程协同战。所以这篇内容,不讲“SpringBoot怎么配置yml”,也不堆砌@RestController注解示例,而是带你从真实压测现场倒推:当第327个用户点击“加载更多评论”时,后端到底发生了什么?哪一行代码在等锁?哪个查询在扫全表?缓存该放在Controller层还是Service层?Redis的key设计成post:123:comments:list还是post:123:comments:page:2?这些细节,才是“SpringBoot实现帖子详情及评论”这件事的真正技术含量。适合正在做毕业设计、接外包小项目的开发者,也适合想补全Web开发链路认知的中级工程师——它不炫技,但每一步都踩在真实线上问题的脉搏上。
2. 整体架构设计与技术选型逻辑
2.1 为什么放弃“单表JOIN全量查询”这种直觉方案
新手最容易想到的方案,是建三张表:post(帖子主表)、comment(评论表)、user(用户表),然后在Mapper里写一条巨长的SQL:
<select id="getPostWithComments" resultType="PostDetailVO"> SELECT p.id, p.title, p.content, p.create_time, c.id as comment_id, c.content as comment_content, c.create_time as comment_time, u.nickname, u.avatar_url FROM post p LEFT JOIN comment c ON p.id = c.post_id LEFT JOIN user u ON c.user_id = u.id WHERE p.id = #{postId} ORDER BY c.create_time DESC </select>看起来干净利落,但实测下来问题极多。我拿某高校课程设计项目做过对比:当单个帖子下有1200条评论时,这条SQL执行时间从平均42ms飙升到380ms,且随着评论数增长呈非线性上升。根本原因在于MySQL的JOIN机制——它会先生成笛卡尔积中间结果集,再过滤。哪怕加了post_id索引,只要comment表没有覆盖索引(covering index),MySQL就必须回表查user表的nickname和avatar_url,一次查询触发三次磁盘IO。更致命的是,MyBatis默认的resultMap映射会为每一条评论创建一个独立的CommentVO对象,1200条评论意味着1200次对象实例化+GC压力,JVM年轻代频繁Minor GC,反而拖慢整体响应。
所以我们的第一道防线,就是物理拆分查询:帖子主信息、评论列表、用户信息,必须拆成至少三个独立查询。这不是增加复杂度,而是把不可控的数据库耦合,换成可控的Java内存组装。就像修水管,与其指望一根总管扛住所有水压,不如在关键节点装上减压阀和分流器。
2.2 分层缓存策略:从“全量缓存”到“分片缓存”的演进
缓存不是加个@Cacheable就完事。我们试过最粗暴的方案:把整个PostDetailVO对象塞进Redis,key设为post:detail:${postId}。初期效果惊艳,QPS从120干到800。但两周后运维报警:Redis内存每天涨2GB,因为用户发帖、删帖、改标题、删评论,都会让旧缓存失效,而新缓存又没设置合理过期时间,大量冷数据堆积。更麻烦的是,当某条热门帖子被10万人同时访问,缓存穿透风险极高——万一Redis挂了,所有请求直击DB,瞬间雪崩。
于是我们转向分片缓存(Fragment Caching):
- 帖子主信息(title/content/createTime)缓存为
post:base:${postId},TTL设为2小时(内容修改频率低); - 评论列表缓存为
post:comments:${postId}:page:${pageNum},TTL仅10分钟(评论实时性要求高); - 用户基础信息(nickname/avatar)缓存为
user:profile:${userId},TTL 24小时(用户资料极少变动)。
这样做的好处是:
- 失效粒度可控:用户删一条评论,只需删除
post:comments:${postId}:page:1,不影响post:base和其他页的评论缓存; - 内存占用下降70%:不再缓存冗余字段,比如评论列表缓存里不存用户头像URL,只存
userId,前端需要时再查user:profile; - 穿透防护自然形成:即使
post:comments缓存全失效,最多影响单页评论查询,DB压力分散到多个小查询,而非一个巨无霸SQL。
提示:分片缓存的key设计必须包含业务维度。曾有个团队把评论缓存key设为
comments:${postId},结果发现不同分页参数(page=1/page=2)共用一个key,导致用户下拉加载第二页时,返回的还是第一页数据。正确做法是把分页参数作为key的一部分,或用Hash结构存多页数据。
2.3 数据库表结构与索引设计:不只是“加个id”
表结构看着简单,但索引设计直接决定性能天花板。我们最终采用的三张表结构如下(精简核心字段):
-- 帖子主表 CREATE TABLE `post` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `title` VARCHAR(200) NOT NULL, `content` TEXT, `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, `status` TINYINT DEFAULT 1 -- 1:正常, 0:已删除 ); -- 评论表 CREATE TABLE `comment` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `post_id` BIGINT NOT NULL, `user_id` BIGINT NOT NULL, `content` VARCHAR(500) NOT NULL, `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `status` TINYINT DEFAULT 1 ); -- 用户表(简化版) CREATE TABLE `user` ( `id` BIGINT PRIMARY KEY AUTO_INCREMENT, `nickname` VARCHAR(50), `avatar_url` VARCHAR(200), `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP );关键索引不是默认的主键,而是这三条:
comment表上的联合索引:INDEX idx_post_status_time (post_id, status, create_time)- 为什么?查询某帖子下的评论,条件必是
WHERE post_id = ? AND status = 1,再按create_time DESC排序。这个索引能让MySQL直接定位到符合条件的行,并按时间倒序取出,避免filesort。
- 为什么?查询某帖子下的评论,条件必是
comment表上的覆盖索引:INDEX idx_post_user_time (post_id, user_id, create_time)- 用于“查某帖子下某用户最新评论”这类运营需求,索引里已包含所有查询字段,无需回表。
post表上的状态索引:INDEX idx_status_time (status, create_time)- 为后台管理“审核待发布帖子”提供支撑,避免全表扫描。
注意:不要迷信“给所有WHERE字段加索引”。曾有个项目在
comment表的content字段上加了全文索引,结果发现99%的查询都是按post_id查,全文索引反而拖慢INSERT速度,且占用大量磁盘空间。索引是双刃剑,每加一个,写操作就多一次B+树分裂。
2.4 接口粒度设计:RESTful不是教条,而是用户体验的翻译器
很多教程教你怎么写GET /api/posts/{id}返回帖子详情,但真实场景中,前端要的从来不是“一个大JSON”。比如一个详情页,顶部要帖子标题和作者,中间是富文本内容,底部是评论区,评论区又分“最新评论”和“热门评论”Tab。如果后端只提供一个/posts/{id}接口,前端就得自己拆解数据、处理空状态、控制加载顺序——这本质是把UI逻辑强塞给前端。
所以我们定义了四个原子化接口:
| 接口路径 | HTTP方法 | 返回内容 | 设计意图 |
|---|---|---|---|
GET /api/posts/{id}/base | GET | 帖子标题、摘要、作者ID、发布时间、阅读数 | 首屏秒开,体积<2KB,CDN可缓存 |
GET /api/posts/{id}/content | GET | 富文本HTML内容(含图片base64或CDN链接) | 内容加载可异步,失败不影响主体展示 |
GET /api/posts/{id}/comments | GET | 评论列表(分页参数page/size) | 支持无限滚动,服务端控制分页逻辑 |
GET /api/comments/hot?postId={id} | GET | 按点赞数排序的Top10评论 | 热门Tab独立加载,不干扰最新评论流 |
这种设计让前端可以精准控制加载优先级:先拉/base渲染骨架,再并行拉/content和/comments?page=1,用户下滑时再拉/comments?page=2。更重要的是,每个接口职责单一,缓存策略可独立配置,出问题时排查范围缩小到单个模块。
3. 核心功能实现与关键代码解析
3.1 帖子详情查询:从“查一条”到“查三段”的代码落地
我们摒弃了MyBatis的XML复杂映射,改用分步查询+DTO组装的方式。核心Service方法如下:
@Service public class PostDetailService { @Resource private PostMapper postMapper; @Resource private CommentMapper commentMapper; @Resource private UserMapper userMapper; @Resource private RedisTemplate<String, Object> redisTemplate; public PostDetailVO getPostDetail(Long postId, Integer pageNum, Integer pageSize) { // 步骤1:查帖子主信息(带缓存) Post post = getPostBaseFromCache(postId); if (post == null) { post = postMapper.selectById(postId); if (post == null || post.getStatus() != 1) { throw new BusinessException("帖子不存在或已被删除"); } // 缓存2小时 redisTemplate.opsForValue().set( "post:base:" + postId, post, 2, TimeUnit.HOURS ); } // 步骤2:查评论列表(带分页+缓存) Page<Comment> commentPage = getCommentsFromCache(postId, pageNum, pageSize); // 步骤3:批量查用户信息(避免N+1) List<Long> userIds = commentPage.getRecords().stream() .map(Comment::getUserId) .distinct() .collect(Collectors.toList()); Map<Long, User> userMap = getUserProfilesBatch(userIds); // 步骤4:组装VO(纯内存操作,无DB/Redis调用) return buildPostDetailVO(post, commentPage, userMap); } private Post getPostBaseFromCache(Long postId) { Object cached = redisTemplate.opsForValue().get("post:base:" + postId); return cached instanceof Post ? (Post) cached : null; } private Page<Comment> getCommentsFromCache(Long postId, Integer pageNum, Integer pageSize) { String cacheKey = "post:comments:" + postId + ":page:" + pageNum; Object cached = redisTemplate.opsForValue().get(cacheKey); if (cached instanceof Page) { return (Page<Comment>) cached; } // 缓存未命中,查DB Page<Comment> page = new Page<>(pageNum, pageSize); Page<Comment> dbPage = commentMapper.selectPage(page, new QueryWrapper<Comment>().lambda() .eq(Comment::getPostId, postId) .eq(Comment::getStatus, 1) .orderByDesc(Comment::getCreateTime) ); // 缓存10分钟 redisTemplate.opsForValue().set(cacheKey, dbPage, 10, TimeUnit.MINUTES); return dbPage; } private Map<Long, User> getUserProfilesBatch(List<Long> userIds) { // 批量查用户,避免循环查 List<User> users = userMapper.selectBatchIds(userIds); return users.stream().collect(Collectors.toMap(User::getId, u -> u)); } private PostDetailVO buildPostDetailVO(Post post, Page<Comment> commentPage, Map<Long, User> userMap) { PostDetailVO vo = new PostDetailVO(); vo.setId(post.getId()); vo.setTitle(post.getTitle()); vo.setContent(post.getContent()); vo.setCreateTime(post.getCreateTime()); List<CommentVO> commentVOs = commentPage.getRecords().stream() .map(comment -> { CommentVO cvo = new CommentVO(); cvo.setId(comment.getId()); cvo.setContent(comment.getContent()); cvo.setCreateTime(comment.getCreateTime()); // 从预加载的Map中取用户信息,避免null检查 User user = userMap.get(comment.getUserId()); if (user != null) { cvo.setNickname(user.getNickname()); cvo.setAvatarUrl(user.getAvatarUrl()); } return cvo; }) .collect(Collectors.toList()); vo.setComments(commentVOs); vo.setTotal(commentPage.getTotal()); vo.setPages(commentPage.getPages()); return vo; } }这段代码的关键设计点在于:
- 缓存穿透防护:
getPostBaseFromCache返回null时,不直接查DB,而是先校验缓存是否为空,再查DB,避免缓存雪崩; - 批量查询意识:
getUserProfilesBatch用selectBatchIds一次性查多个用户,而不是在for循环里逐个查,将N次查询压缩为1次; - VO组装零依赖:
buildPostDetailVO方法里没有任何外部调用,纯Java对象转换,性能稳定可预测; - 分页参数校验前置:实际项目中,我们在Controller层就做了
pageNum和pageSize的合法性校验(如pageSize不能超过100),防止恶意请求拖垮DB。
3.2 评论提交与实时更新:如何让“发送”按钮不卡顿
评论提交看似简单,但涉及事务边界、缓存更新、通知推送多个环节。我们采用最终一致性方案,而非强一致:
@Transactional(rollbackFor = Exception.class) public CommentVO submitComment(SubmitCommentDTO dto) { // 1. 参数校验(内容长度、敏感词过滤) if (dto.getContent().length() > 500) { throw new BusinessException("评论内容不能超过500字"); } if (sensitiveWordFilter.containsSensitiveWord(dto.getContent())) { throw new BusinessException("评论包含敏感词"); } // 2. 插入评论(DB写操作) Comment comment = new Comment(); comment.setPostId(dto.getPostId()); comment.setUserId(dto.getUserId()); comment.setContent(dto.getContent()); comment.setStatus(1); comment.setCreateTime(new Date()); commentMapper.insert(comment); // 3. 更新帖子评论数(异步,避免阻塞主流程) updatePostCommentCountAsync(dto.getPostId()); // 4. 清除相关缓存(注意:只清,不写) String baseKey = "post:base:" + dto.getPostId(); String commentsKey = "post:comments:" + dto.getPostId() + ":page:1"; redisTemplate.delete(baseKey); redisTemplate.delete(commentsKey); // 5. 组装返回VO(不含用户信息,前端自行拉取) CommentVO vo = new CommentVO(); vo.setId(comment.getId()); vo.setContent(comment.getContent()); vo.setCreateTime(comment.getCreateTime()); vo.setUserId(dto.getUserId()); // 前端用此ID查用户头像昵称 return vo; } @Async // 异步执行,不阻塞主线程 public void updatePostCommentCountAsync(Long postId) { try { // 先查当前评论数(避免并发更新丢失) Long count = commentMapper.selectCount( new QueryWrapper<Comment>().lambda() .eq(Comment::getPostId, postId) .eq(Comment::getStatus, 1) ); // 更新帖子表的comment_count字段(需提前在post表加此字段) postMapper.updateCommentCount(postId, count); } catch (Exception e) { log.error("异步更新评论数失败,postId={}", postId, e); // 失败可重试,或由定时任务兜底 } }这里有几个反直觉的设计:
- 不实时返回用户信息:
submitComment返回的VO里只有userId,不查User表。因为用户发完评论,最关心的是“我发成功了没”,而不是“我的头像显示对不对”。头像昵称由前端在收到响应后,用userId单独请求/api/users/{id}获取,这样既解耦,又让前端能控制加载策略(比如头像用懒加载); - 缓存只删不写:提交评论后,我们删除
post:base和page:1的缓存,而不是立刻重建。因为用户发完评论,大概率会继续刷最新评论,此时page:1缓存自然重建;而post:base里的评论数字段,由异步任务更新,稍有延迟可接受; - 敏感词过滤前置:在事务开始前就做,避免无效事务占用数据库连接。
3.3 分页与无限滚动:为什么LIMIT 10000, 20是性能杀手
MySQL的LIMIT offset, size在大数据量下效率极低。当offset达到10万时,MySQL仍需扫描前10万行才能定位到目标数据。我们项目中遇到的真实案例:某帖子有8万条评论,用户滑到第500页(offset=99980),接口响应时间高达4.2秒。
解决方案是游标分页(Cursor-based Pagination),用lastId替代offset:
// 前端传参:lastId=12345(上一页最后一条评论ID) @GetMapping("/comments") public Result<Page<CommentVO>> getCommentsByCursor( @RequestParam Long postId, @RequestParam(required = false) Long lastId, @RequestParam(defaultValue = "20") Integer size) { Page<CommentVO> page = new Page<>(1, size); // 页码固定为1,忽略offset // 构造查询条件:比lastId小的评论(按时间倒序,ID通常递增) QueryWrapper<Comment> wrapper = new QueryWrapper<>(); wrapper.lambda() .eq(Comment::getPostId, postId) .eq(Comment::getStatus, 1); if (lastId != null) { wrapper.lt(Comment::getId, lastId); // 只查ID小于lastId的记录 } wrapper.orderByDesc(Comment::getId) // 用ID排序,比create_time更稳定 .last("LIMIT " + size); // 物理限制,避免全表扫描 List<Comment> comments = commentMapper.selectList(wrapper); // 补充用户信息(同前文批量查询逻辑) List<CommentVO> vos = buildCommentVOs(comments); // 计算是否有下一页:查一条额外数据 boolean hasNext = false; if (comments.size() == size) { Long maxId = comments.get(comments.size() - 1).getId(); Long nextId = commentMapper.selectOne( new QueryWrapper<Comment>() .lambda() .eq(Comment::getPostId, postId) .eq(Comment::getStatus, 1) .lt(Comment::getId, maxId) .orderByDesc(Comment::getId) .last("LIMIT 1") ) != null ? maxId : null; hasNext = nextId != null; } return Result.success(new Page<>(1, size, vos.size(), vos)); }游标分页的核心优势:
- 性能恒定:无论翻到第几页,查询都基于索引字段(如
id)的范围扫描,时间复杂度O(log n); - 数据一致性好:不会因中间插入新评论导致“重复显示”或“漏掉评论”(传统
offset分页的典型问题); - 前端实现简单:只需记住上一页最后一条的
id,作为下一页的lastId参数传入。
实操心得:游标分页要求排序字段必须有唯一索引且单调递增。我们用
id而非create_time,是因为同一毫秒可能产生多条评论,create_time不唯一。若业务必须按时间排序,可在create_time后拼接id作为复合游标,如2023-10-01 12:00:00.123_456789。
3.4 点赞与互动状态:用Redis原子操作替代数据库行锁
点赞功能常被低估,但它是最容易引发并发问题的场景。两个用户同时点同一个帖子的赞,若用数据库UPDATE,可能出现“超赞”(本应+1,结果+2)或“漏赞”(两个UPDATE同时读到count=5,都写入6)。
我们采用Redis原子操作+数据库异步落库的混合方案:
@Service public class LikeService { @Resource private RedisTemplate<String, Object> redisTemplate; @Resource private PostMapper postMapper; /** * 用户点赞/取消点赞 * @param userId 用户ID * @param postId 帖子ID * @param action 1:点赞, 0:取消 */ public void toggleLike(Long userId, Long postId, Integer action) { String likeKey = "post:like:" + postId; String userLikeKey = "user:like:post:" + userId; // 记录用户点过哪些帖子 // 1. Redis原子操作:点赞用SADD,取消用SREM if (action == 1) { // 添加用户到点赞集合 Boolean isAdded = redisTemplate.opsForSet().add(likeKey, userId); if (Boolean.TRUE.equals(isAdded)) { // 成功添加,才去更新计数(避免重复计数) redisTemplate.opsForValue().increment("post:like:count:" + postId, 1); // 记录用户行为 redisTemplate.opsForSet().add(userLikeKey, postId); } } else { // 移除用户 Long removed = redisTemplate.opsForSet().remove(likeKey, userId); if (removed > 0) { redisTemplate.opsForValue().decrement("post:like:count:" + postId, 1); redisTemplate.opsForSet().remove(userLikeKey, postId); } } } /** * 获取帖子点赞数(最终一致性,允许短暂延迟) */ public Long getLikeCount(Long postId) { Object count = redisTemplate.opsForValue().get("post:like:count:" + postId); return count instanceof Long ? (Long) count : 0L; } /** * 获取用户是否已点赞(实时性要求高) */ public Boolean hasLiked(Long userId, Long postId) { return redisTemplate.opsForSet().isMember("post:like:" + postId, userId); } }这个方案的精妙之处在于:
- SET集合去重:
SADD天然幂等,多次执行同一用户点赞,只算一次; - 计数与成员分离:
post:like:count是独立计数器,post:like:{postId}是用户ID集合,两者通过原子操作联动,避免竞态; - 异步补偿:Redis的计数可能与DB不一致(如Redis宕机),我们用定时任务每5分钟同步一次:扫描
post:like:count:*,将Redis值写入DB的post.like_count字段,DB作为权威数据源。
4. 常见问题与实战排错指南
4.1 缓存雪崩、穿透、击穿:不是理论,是凌晨三点的报警电话
这三个词被讲烂了,但真正在生产环境遇到时,表现往往很具体。以下是我们在某次线上事故中记录的真实排错过程:
现象:凌晨2:17,监控显示/api/posts/{id}/comments接口错误率从0.1%飙升至35%,平均响应时间从120ms涨到2.3秒,数据库CPU持续95%。
排查步骤:
- 看日志:发现大量
RedisConnectionFailureException,确认Redis集群节点故障; - 看缓存Key:
post:comments:123:page:1等key全部失效(TTL统一设为10分钟,且未设随机偏移); - 看DB慢查询:
SELECT * FROM comment WHERE post_id = 123 AND status = 1 ORDER BY create_time DESC LIMIT 0,20执行超时;
根因:缓存雪崩(大量key在同一时间过期)+ 缓存穿透(部分帖子ID不存在,请求直击DB)。
解决方案:
- 雪崩防护:所有评论缓存TTL加上随机偏移,如
10分钟 + random(0-300秒); - 穿透防护:对
post:comments:${postId}:page:${pageNum},若DB查不到数据,也写入一个空集合[]到Redis,TTL设为2分钟(短于正常缓存,避免长期占内存); - 击穿防护(热点key失效):对
post:base:123这类高热帖子,用SETNX加分布式锁,锁住后只允许一个线程重建缓存,其他线程等待。
注意:空值缓存不能永久,否则恶意刷不存在的postId会耗尽Redis内存。我们设2分钟,足够应对突发流量,又不会长期驻留。
4.2 MySQL死锁日志解读:从“Lock wait timeout exceeded”到定位SQL
死锁不是玄学。某次用户反馈“发评论偶尔失败”,日志里只有一句org.springframework.dao.DeadlockLoserDataAccessException: Deadlock found when trying to get lock。我们登录MySQL,执行SHOW ENGINE INNODB STATUS\G,截取到关键段落:
------------------------ LATEST DETECTED DEADLOCK ------------------------ 2023-10-01 14:22:33 0x7f8a1c012700 *** (1) TRANSACTION: TRANSACTION 123456789, ACTIVE 0.001 sec inserting mysql tables in use 1, locked 1 LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s) MySQL thread id 1001, OS thread handle 140223456789012, query id 567890 localhost root update INSERT INTO comment (post_id, user_id, content, create_time) VALUES (123, 456, 'test', '2023-10-01 14:22:33') *** (1) HOLDS THE LOCK(S): RECORD LOCKS space id 123 page no 1001 n bits 128 index idx_post_status_time of table `mydb`.`comment` trx id 123456789 lock_mode X locks gap before rec *** (2) TRANSACTION: TRANSACTION 123456790, ACTIVE 0.002 sec inserting mysql tables in use 1, locked 1 LOCK WAIT 2 lock struct(s), heap size 1136, 1 row lock(s) MySQL thread id 1002, OS thread handle 140223456789013, query id 567891 localhost root update INSERT INTO comment (post_id, user_id, content, create_time) VALUES (123, 789, 'hello', '2023-10-01 14:22:33') *** (2) HOLDS THE LOCK(S): RECORD LOCKS space id 123 page no 1001 n bits 128 index idx_post_status_time of table `mydb`.`comment` trx id 123456790 lock_mode X locks gap before rec *** WE ROLL BACK TRANSACTION (2)解读:
- 事务1和事务2都在向
comment表插入数据; - 它们都试图获取
idx_post_status_time索引上的“间隙锁(gap lock)”,因为要保证post_id=123 AND status=1的记录有序; - 两个事务互相等待对方释放间隙锁,形成死锁;
- MySQL选择回滚事务2(编号更大者)。
解决办法:
- 减少索引范围:原索引
idx_post_status_time (post_id, status, create_time)中,create_time是不必要的,因为插入时create_time是当前时间,几乎相同,导致间隙锁范围过大。改为idx_post_status (post_id, status)即可; - 业务层重试:捕获
DeadlockLoserDataAccessException,自动重试1次(概率极低,但能提升用户体验)。
4.3 SpringBoot Actuator暴露的真相:那些被忽略的内存泄漏点
Actuator的/actuator/metrics/jvm.memory.used和/actuator/heapdump是排查内存问题的利器。我们曾发现一个诡异现象:服务运行3天后,/api/posts/{id}/comments接口越来越慢,但CPU、DB监控一切正常。
导出Heap Dump后,用Eclipse MAT分析,发现com.alibaba.druid.pool.DruidDataSource对象占用了70%堆内存。进一步追踪,发现是Druid连接池的removeAbandonedOnBorrow=true配置,在高并发下误判活跃连接为“废弃连接”,频繁回收重建,导致连接池内部状态混乱,最终引发内存泄漏。
解决方案:
- 关闭
removeAbandonedOnBorrow,改用removeAbandonedOnMaintenance=true,只在连接池维护时检查; - 设置合理的
maxWait(如3秒),超时直接抛异常,避免线程长时间阻塞; - 在
application.yml中显式配置连接池参数:
spring: datasource: druid: initial-size: 5 min-idle: 5 max-active: 20 max-wait: 3000 # 3秒 time-between-eviction-runs-millis: 60000 min-evictable-idle-time-millis: 300000 remove-abandoned-on-maintenance: true remove-abandoned-timeout: 1800实操心得:不要迷信“默认配置”。Druid的默认
maxActive=8,在现代云服务器上完全不够用;minIdle设为0会导致连接池空闲时全销毁,下次请求又要重建,徒增延迟。
4.4 前端无限滚动与后端游标分页的联调陷阱
游标分页虽好,但前后端联调时极易踩坑。某次测试发现:用户滑到第3页,接口返回的数据和第2页末尾重复。
排查发现:
- 前端传的
lastId是第2页最后一条评论的id,值为1005; - 后端SQL是
WHERE id < 1005 ORDER BY id DESC LIMIT 20; - 但数据库里存在两条
id为1005的评论(因主键用replace into或on duplicate key update导致); - 结果
id < 1005查到了本该属于第2页的id=1004的评论,造成重复。
解决方案:
- 强制主键唯一:确保
comment.id是自增主键,杜绝重复; - 复合游标:当业务允许时,用
create_time和id组合游标,如lastCursor=2023-10-01 12:00:00.123_1005,后端解析后,SQL改为:WHERE (create_time < '2023-10-01 12:00:00.123') OR (create_time = '2023-10-01 12:00:00.123' AND id < 1005) ORDER BY create_time DESC, id DESC LIMIT 20 - 前端防抖:在滚动事件里加
setTimeout防抖,避免快速滚动触发多次请求。