尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

3个技巧搞定最近文档随身,性能优化不再看Stack Overflow报错

3个技巧搞定最近文档随身,性能优化不再看Stack Overflow报错 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 RedisTemplateString, 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 ListRecentDocVO getRecentDocs(Long userId) {// 1. 构建缓存Key,注意格式,避免冲突String cacheKey = CACHE_KEY_PREFIX + userId;// 2. 先查缓存ListRecentDocVO cachedDocs = (ListRecentDocVO) redisTemplate.opsForValue().get(cacheKey);// 命中缓存,直接返回,耗时 5msif (cachedDocs != null) {return cachedDocs;}// 3. 缓存未命中,查数据库// 注意:这里必须加索引,否则全表扫描会拖垮DBListDocument dbDocs = docRepository.findTop10ByUserIdOrderByLastAccessTimeDesc(userId);// 4. 实体转VO,剥离敏感字段ListRecentDocVO 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/10012. 观察指标 在压测过程中,打开你的监控面板(Prometheus + Grafana 或简单的 Arthas),关注三个指标:RT (Response Time):平均响应时间。优化前:可能 200ms - 500ms(取决于数据库负载)。 优化后:应该稳定在 10ms - 30ms 之间。QPS (Queries Per Second):吞吐量。优化前:可能卡在 500 QPS,再高就报错。 优化后:应该能轻松突破 5000 QPS,瓶颈转移到 CPU 或网络带宽。Error Rate:错误率。必须保持 0%。如果出现 502 或 504,说明线程池配置不当或数据库连接池爆了。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 = (ListRecentDocVO) 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?或者你有什么独特的“土法炼钢”经验?欢迎在评论区分享,我们一起避坑!
返回列表