3个技巧搞定最近文档随身,性能优化不再看Stack Overflow报错
打开IDE,点击运行,屏幕瞬间被红色的 java.lang.NullPointerException 和 java.sql.SQLException 刷屏。StackTrace 长得像天书,at com.example.service.DocService.getRecentDocs(DocService.java:42) 这一行你盯着看了十分钟,脑子还是空的。别慌,这不是你代码写得太烂,而是“最近文档随身”这个功能在高频访问下,把数据库连接池撑爆了,或者是在序列化大对象时卡死了线程。今天我们就从零搭建一个轻量级的“最近文档随身”服务,不聊虚的,直接上代码,解决报错,顺便把性能优化这块硬骨头啃下来。
项目目标与痛点分析
很多初学者或者刚入职的开发者,在实现“用户最近访问的文档列表”时,最容易踩的坑就是直接查库。每次用户打开首页,后端就执行一次 SELECT * FROM documents WHERE user_id = ? ORDER BY last_access_time DESC LIMIT 10。
这在测试环境没问题,数据量小嘛。但一旦上线,QPS(每秒查询率)上到几千,数据库 CPU 直接飙到 90% 以上。这时候你再去看 StackTrace,看到的往往不是业务逻辑错误,而是 ConnectionPoolTimeoutException(连接池超时)或者 Too many connections。Stack Overflow 上有大量类似案例,核心原因就一个字:慢。
我们的目标很明确:
- 快:接口响应时间控制在 50ms 以内。
- 稳:高并发下不丢数据,不挂服务。
- 简:代码结构清晰,方便后续扩展。
我们要做的,不是简单的 CRUD,而是一个带有缓存策略和异步更新机制的“最近文档”服务。
目录结构:工程化思维体现
在写第一行代码前,先定好目录。好的目录结构能让代码“呼吸”,也能让队友接手时不骂娘。我们采用标准的 Maven 多模块结构,但为了聚焦核心逻辑,这里只展示 src/main/java 下的关键包。
com.example.recentdocs
├── controller
│ └── RecentDocController.java # 接口入口,处理HTTP请求
├── service
│ ├── RecentDocService.java # 业务逻辑接口
│ └── impl
│ └── RecentDocServiceImpl.java # 核心实现,包含缓存与DB交互
├── repository
│ └── DocRepository.java # 数据访问层,JPA或MyBatis
├── model
│ ├── entity
│ │ └── Document.java # 数据库实体
│ └── dto
│ └── RecentDocVO.java # 返回给前端的数据视图
├── config
│ ├── RedisConfig.java # Redis配置,解决序列化问题
│ └── ThreadPoolConfig.java # 线程池配置,避免默认线程池坑
└── util└── JsonUtil.java # JSON工具类,统一处理序列化
关键点:单独抽出 dto 和 vo 层。很多新人喜欢直接把 Entity 返回给前端,这会导致数据库字段泄露,而且当实体类增加字段时,前端可能收到不需要的数据,增加带宽浪费。在性能优化中,减少传输数据量是隐形的大头。
核心代码实现:逐行拆解避坑
1. 数据模型定义
先看数据库实体,这是基础。
@Entity
@Table(name = "documents")
public class Document {@Id@GeneratedValue(strategy = GenerationType.IDENTITY)private Long id;private String title;private Long userId;private LocalDateTime lastAccessTime; // 最近访问时间,排序关键字private String contentHash; // 内容哈希,用于判断是否更新
}
这里有一个细节:lastAccessTime 用 LocalDateTime 而不是 Date。Java 8 之后,时间处理不再需要那些让人头大的 SimpleDateFormat 线程安全问题。
2. 核心服务层:缓存优先策略
这是整个项目的灵魂。我们采用 Cache-Aside 模式(旁路缓存模式),这是 Stack Overflow 上高票回答推荐的经典方案。
@Service
public class RecentDocServiceImpl implements RecentDocService {@Autowiredprivate DocRepository docRepository;@Autowiredprivate RedisTemplate<String, Object> redisTemplate;private static final String CACHE_KEY_PREFIX = "recent:docs:user:";private static final int CACHE_TTL_SECONDS = 300; // 缓存5分钟/*** 获取用户最近访问的文档列表* @param userId 用户ID* @return 最近10个文档的VO列表*/public List<RecentDocVO> getRecentDocs(Long userId) {// 1. 构建缓存Key,注意格式,避免冲突String cacheKey = CACHE_KEY_PREFIX + userId;// 2. 先查缓存List<RecentDocVO> cachedDocs = (List<RecentDocVO>) redisTemplate.opsForValue().get(cacheKey);// 命中缓存,直接返回,耗时 < 5msif (cachedDocs != null) {return cachedDocs;}// 3. 缓存未命中,查数据库// 注意:这里必须加索引,否则全表扫描会拖垮DBList<Document> dbDocs = docRepository.findTop10ByUserIdOrderByLastAccessTimeDesc(userId);// 4. 实体转VO,剥离敏感字段List<RecentDocVO> voList = dbDocs.stream().map(this::convertToVO).collect(Collectors.toList());// 5. 写回缓存redisTemplate.opsForValue().set(cacheKey, voList, CACHE_TTL_SECONDS, TimeUnit.SECONDS);return voList;}private RecentDocVO convertToVO(Document doc) {RecentDocVO vo = new RecentDocVO();vo.setId(doc.getId());vo.setTitle(doc.getTitle());// 时间格式化,避免前端二次处理vo.setLastAccessTime(doc.getLastAccessTime().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));return vo;}
}
逐行避坑指南:
- 第12行:
redisTemplate.opsForValue().get。很多人用RedisTemplate不加配置,默认序列化是 JDK 序列化,存进去的是二进制乱码,在 Redis 客户端里根本看不了,而且体积大。务必在RedisConfig中配置GenericJackson2JsonRedisSerializer。 - 第20行:
findTop10By...。Spring Data JPA 的方法名查询,虽然方便,但底层生成的 SQL 如果没有索引,性能极差。请确保user_id和last_access_time上有联合索引。 - 第26行:
stream().map()。这里做了 Entity 到 VO 的转换。不要偷懒直接返回 Entity,前端不需要知道contentHash这种内部字段。
3. 更新逻辑:异步削峰
用户访问文档时,需要更新 last_access_time。如果每次访问都同步写数据库,数据库的写压力会非常大。
@Override
public void recordAccess(Long docId, Long userId) {// 1. 立即更新内存/缓存中的状态(可选,视业务需求)// 2. 异步更新数据库,避免阻塞主线程threadPoolTaskExecutor.execute(() -> {try {Document doc = docRepository.findById(docId).orElseThrow();doc.setLastAccessTime(LocalDateTime.now());docRepository.save(doc);// 3. 关键步骤:更新数据库后,必须删除或更新缓存// 这里选择删除,下次查询时再回填,保证一致性String cacheKey = CACHE_KEY_PREFIX + userId;redisTemplate.delete(cacheKey);} catch (Exception e) {// 记录日志,不要吞异常log.error("Failed to update recent doc access", e);}});
}
为什么用异步? 想象一下,1000个用户同时点击同一个热门文档。如果同步更新,1000个线程都在等数据库写入完成。数据库的磁盘 I/O 是有瓶颈的,线程会堆积,Tomcat 线程池耗尽,服务假死。 异步化后,主线程只做一件事:扔进线程池,立刻返回 HTTP 200。数据库在后台慢慢写,哪怕慢了 200ms,用户也感知不到。
注意:threadPoolTaskExecutor 必须是自定义的线程池,严禁使用 Executors.newFixedThreadPool 或 newCachedThreadPool。前者队列无界,OOM 风险高;后者线程数无界,CPU 打满风险高。请在 ThreadPoolConfig 中显式指定核心线程数、最大线程数和队列容量。
运行与测试:数据不说谎
代码写完了,别急着跑。我们要用数据验证性能优化是否生效。
1. 压测工具选择
推荐使用 JMeter 或 wrk。这里以 wrk 为例,简单粗暴。
# 安装 wrk (macOS)
brew install wrk# 压测命令:100个并发线程,持续运行30秒
wrk -t100 -c100 -d30s http://localhost:8080/api/recent/docs/1001
2. 观察指标
在压测过程中,打开你的监控面板(Prometheus + Grafana 或简单的 Arthas),关注三个指标:
- RT (Response Time):平均响应时间。
- 优化前:可能 200ms - 500ms(取决于数据库负载)。
- 优化后:应该稳定在 10ms - 30ms 之间。
- QPS (Queries Per Second):吞吐量。
- 优化前:可能卡在 500 QPS,再高就报错。
- 优化后:应该能轻松突破 5000 QPS,瓶颈转移到 CPU 或网络带宽。
- Error Rate:错误率。
- 必须保持 0%。如果出现
502或504,说明线程池配置不当或数据库连接池爆了。
- 必须保持 0%。如果出现
3. 常见报错排查
如果在压测中看到 RedisConnectionException,检查 RedisConfig 中的连接池参数 maxTotal 和 maxIdle 是否过小。
如果看到 SQLTransientConnectionException,检查 HikariCP 的 maximumPoolSize 是否小于并发线程数。
优化扩展:从“能用”到“好用”
基础功能跑通了,还有几个进阶技巧,能让你的项目在面试或实际工作中脱颖而出。
1. 缓存击穿保护
如果某个用户突然被大量请求(比如爬虫攻击),缓存过期瞬间,所有请求都会打到数据库。
解决方案:在 getRecentDocs 中加一把锁。
if (cachedDocs == null) {// 使用 Redis 分布式锁,防止并发查库String lockKey = "lock:recent:docs:user:" + userId;boolean locked = redisTemplate.opsForValue().setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS);if (locked) {try {// 双重检查:可能其他线程已经填好了缓存cachedDocs = (List<RecentDocVO>) redisTemplate.opsForValue().get(cacheKey);if (cachedDocs == null) {// 查库、转VO、写缓存// ... 省略具体逻辑}} finally {redisTemplate.delete(lockKey);}} else {// 没抢到锁,休眠 50ms 再重试,或者直接等待Thread.sleep(50);return getRecentDocs(userId); // 递归重试,注意深度}
}
2. 冷热数据分离
“最近文档”本质上是热点数据。如果文档总量达到千万级,可以考虑将最近 30 天的数据放在 Redis 或专门的 OLAP 引擎中,历史数据留在 MySQL。 实施建议:
- 新建一张
hot_documents表,只存最近 30 天有访问的文档。 - 通过定时任务(Quartz 或 XXL-Job),每小时将 MySQL 中的热数据同步到 Redis。
- 查询时,先查 Redis,再查
hot_documents,最后查 MySQL。
3. 监控告警
不要等用户投诉了才知道挂了。
- 在
getRecentDocs中埋点,记录每次调用的耗时。 - 如果平均耗时超过 100ms,触发告警。
- 如果 Redis 命中率低于 80%,说明缓存策略失效,需要检查 Key 的设计或 TTL 设置。
小结
“最近文档随身”这个功能看似简单,实则是考察工程师全栈思维的试金石。
我们从最初的直接查库,到引入Redis 缓存,再到异步更新,最后加上分布式锁和监控,每一步都是为了解决具体的性能瓶颈。
- 报错看不懂? 不要慌,看 StackTrace 的第一行非框架代码。那是你逻辑出错的起点。
- 性能慢? 先查数据库慢查询日志,再看 Redis 命中率,最后看线程池状态。
- 架构乱? 坚持分层设计,Entity、VO、Service、Controller 各司其职,不要混用。
记住,没有银弹,只有合适的工具组合。在 Stack Overflow 上搜索问题时,带上你的具体代码、报错日志和环境配置,你能得到更精准的帮助。
互动时间:
你公司项目里是怎么处理“最近访问”这类高频读写场景的?是用 Redis 缓存,还是上了 Elasticsearch?或者你有什么独特的“土法炼钢”经验?欢迎在评论区分享,我们一起避坑!