尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Spring Boot高并发场景下用户观看记录模块设计与实现

Spring Boot高并发场景下用户观看记录模块设计与实现 最近在开发一个基于Spring Boot的在线视频平台项目时遇到了一个非常典型的场景如何优雅地处理一个业务模块比如“用户观看记录”从数据采集、处理、存储到前端展示的完整流程。这让我想起了一个有趣的比喻——就像记录“宁姆韦德普通的一天”看似简单实则涉及后端服务、数据库、缓存、消息队列乃至前端组件的协同工作。本文将围绕这个业务场景拆解其技术实现手把手带你构建一个高可用、可扩展的观看记录功能模块。本文适合有一定Spring Boot和MyBatis基础的开发者无论是想学习如何设计一个完整的业务闭环还是希望优化现有项目的类似功能都能从中获得启发。我们将从需求分析、表设计开始逐步完成核心服务、异步处理、缓存策略的实现并最终提供一个可复用的前端组件思路。1. 业务背景与核心概念在视频平台中“观看记录”是一个基础但至关重要的功能。它不仅仅是记录用户看了什么更关联着个性化推荐、内容热度计算、用户行为分析等多个下游业务。1.1 核心价值与挑战用户体验方便用户续播、查找历史。业务智能为推荐系统提供原始数据。技术挑战高并发写入热门视频同时有成千上万人观看。实时性要求用户希望记录能即时同步到所有设备。数据一致性记录进度需要准确避免跳错时间点。存储成本用户观看行为频繁数据量增长快。1.2 业务流程拆解一个完整的“记录”动作可以分解为以下几个步骤事件触发前端播放器每隔一段时间如15秒或暂停、退出时上报进度。请求接收后端API接收上报数据。业务处理清洗、验证数据补充业务信息如视频标题。数据持久化将记录存入数据库。为了应对高并发此处常引入异步和批处理。缓存更新更新用户最新的观看记录缓存供快速查询。下游通知可选。通过消息队列通知推荐、统计等服务。接下来我们将从环境搭建开始一步步实现这个流程。2. 环境准备与项目结构我们使用当前主流的Java技术栈进行演示。2.1 基础环境JDK: 17 或以上 (推荐17长期支持版本)Maven: 3.6IDE: IntelliJ IDEA 或 VS Code数据库: MySQL 8.0缓存: Redis 6.x2.2 项目初始化与依赖使用 Spring Initializr 创建一个Spring Boot项目选择以下依赖Spring Web: 提供RESTful API支持。Spring Data Redis: 操作Redis缓存。MyBatis Framework: 数据库ORM框架。MySQL Driver: 连接MySQL数据库。Lombok: 简化Java Bean代码。生成的pom.xml关键依赖如下dependencies dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-data-redis/artifactId /dependency dependency groupIdorg.mybatis.spring.boot/groupId artifactIdmybatis-spring-boot-starter/artifactId version3.0.3/version !-- 请使用最新稳定版 -- /dependency dependency groupIdcom.mysql/groupId artifactIdmysql-connector-j/artifactId scoperuntime/scope /dependency dependency groupIdorg.projectlombok/groupId artifactIdlombok/artifactId optionaltrue/optional /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-test/artifactId scopetest/scope /dependency /dependencies2.3 配置文件配置application.yml设置数据源、Redis和MyBatis。server: port: 8080 spring: datasource: url: jdbc:mysql://localhost:3306/video_platform?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: your_username password: your_password driver-class-name: com.mysql.cj.jdbc.Driver redis: host: localhost port: 6379 password: # 如果有密码则填写 database: 0 lettuce: pool: max-active: 8 max-wait: -1ms max-idle: 8 min-idle: 0 mybatis: mapper-locations: classpath:mapper/*.xml configuration: map-underscore-to-camel-case: true # 开启驼峰命名自动转换2.4 项目结构预览src/main/java/com/example/videoplatform/ ├── VideoPlatformApplication.java ├── config/ # 配置类 ├── controller/ # 控制层接收API请求 ├── service/ # 业务逻辑层 │ ├── impl/ ├── mapper/ # MyBatis Mapper接口 ├── entity/ # 实体类对应数据库表 ├── dto/ # 数据传输对象 ├── vo/ # 视图对象用于接口返回 └── async/ # 异步处理组件3. 数据库设计与实体建模观看记录的核心在于表结构设计需平衡查询效率与存储空间。3.1 表结构设计 (SQL)-- 用户观看记录表 CREATE TABLE user_watch_history ( id bigint(20) NOT NULL AUTO_INCREMENT COMMENT 主键ID, user_id bigint(20) NOT NULL COMMENT 用户ID, video_id bigint(20) NOT NULL COMMENT 视频ID, watch_progress int(11) NOT NULL DEFAULT 0 COMMENT 观看进度秒, video_duration int(11) NOT NULL COMMENT 视频总时长秒, latest_watch_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 最近观看时间, created_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 记录创建时间, updated_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 记录更新时间, is_deleted tinyint(1) NOT NULL DEFAULT 0 COMMENT 逻辑删除标志, PRIMARY KEY (id), -- 唯一索引一个用户对同一个视频只保留一条最新记录 UNIQUE KEY uk_user_video (user_id,video_id), -- 用于查询用户的历史记录列表 KEY idx_user_time (user_id,latest_watch_time) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4 COMMENT用户观看记录表;设计要点uk_user_video唯一索引确保一个用户对一个视频只有一条记录更新时使用ON DUPLICATE KEY UPDATE或先查后改避免数据膨胀。idx_user_time索引优化按用户和时间倒序查询列表的性能。is_deleted逻辑删除标志避免物理删除。3.2 实体类 (Entity)对应上述表结构创建Java实体类。// 文件路径src/main/java/com/example/videoplatform/entity/UserWatchHistory.java package com.example.videoplatform.entity; import lombok.Data; import java.time.LocalDateTime; Data public class UserWatchHistory { private Long id; private Long userId; private Long videoId; private Integer watchProgress; // 单位秒 private Integer videoDuration; // 单位秒 private LocalDateTime latestWatchTime; private LocalDateTime createdTime; private LocalDateTime updatedTime; private Boolean isDeleted; }3.3 数据传输对象 (DTO) 和视图对象 (VO)DTO (WatchProgressDTO)用于接收前端上报的进度数据。Data public class WatchProgressDTO { NotNull(message 视频ID不能为空) private Long videoId; Min(value 0, message 进度不能小于0) private Integer progress; // 当前播放进度秒 Min(value 1, message 时长必须大于0) private Integer duration; // 视频总时长秒 }VO (WatchHistoryVO)用于返回给前端的观看记录信息通常会关联视频信息。Data public class WatchHistoryVO { private Long videoId; private String videoTitle; private String coverUrl; private Integer watchProgress; private Integer videoDuration; private String latestWatchTime; // 格式化后的时间字符串 // 可以计算一个进度百分比方便前端显示 public String getProgressPercentage() { if (videoDuration null || videoDuration 0) return 0%; double percentage (watchProgress.doubleValue() / videoDuration) * 100; return String.format(%.1f%%, Math.min(percentage, 100)); } }4. 核心业务逻辑实现我们将采用“异步处理 缓存”的策略来应对高并发写入和实时查询。4.1 Mapper 接口与 XML首先定义数据访问层。// 文件路径src/main/java/com/example/videoplatform/mapper/UserWatchHistoryMapper.java Mapper public interface UserWatchHistoryMapper { // 插入或更新记录使用ON DUPLICATE KEY UPDATE int upsert(UserWatchHistory history); // 查询用户最新的N条观看记录 ListUserWatchHistory selectByUserId(Param(userId) Long userId, Param(limit) Integer limit); // 逻辑删除某条记录 int logicDelete(Param(id) Long id, Param(userId) Long userId); }对应的UserWatchHistoryMapper.xml!-- 文件路径src/main/resources/mapper/UserWatchHistoryMapper.xml -- mapper namespacecom.example.videoplatform.mapper.UserWatchHistoryMapper insert idupsert parameterTypeUserWatchHistory INSERT INTO user_watch_history (user_id, video_id, watch_progress, video_duration, latest_watch_time) VALUES (#{userId}, #{videoId}, #{watchProgress}, #{videoDuration}, NOW()) ON DUPLICATE KEY UPDATE watch_progress VALUES(watch_progress), video_duration VALUES(video_duration), latest_watch_time NOW(), updated_time NOW() /insert select idselectByUserId resultTypeUserWatchHistory SELECT * FROM user_watch_history WHERE user_id #{userId} AND is_deleted 0 ORDER BY latest_watch_time DESC LIMIT #{limit} /select update idlogicDelete UPDATE user_watch_history SET is_deleted 1, updated_time NOW() WHERE id #{id} AND user_id #{userId} /update /mapper4.2 服务层实现 (Service)服务层负责核心业务逻辑这里我们引入异步处理。// 文件路径src/main/java/com/example/videoplatform/service/WatchHistoryService.java public interface WatchHistoryService { void recordWatchProgress(Long userId, WatchProgressDTO dto); ListWatchHistoryVO getWatchHistory(Long userId, Integer limit); boolean deleteHistory(Long userId, Long recordId); }// 文件路径src/main/java/com/example/videoplatform/service/impl/WatchHistoryServiceImpl.java Service Slf4j public class WatchHistoryServiceImpl implements WatchHistoryService { Autowired private UserWatchHistoryMapper historyMapper; Autowired private RedisTemplateString, Object redisTemplate; Autowired private AsyncTaskExecutor asyncTaskExecutor; // 自定义的异步执行器 private static final String WATCH_HISTORY_KEY_PREFIX wh:uid:; Override public void recordWatchProgress(Long userId, WatchProgressDTO dto) { // 1. 参数校验 (略) // 2. 构造实体 UserWatchHistory history new UserWatchHistory(); history.setUserId(userId); history.setVideoId(dto.getVideoId()); history.setWatchProgress(dto.getProgress()); history.setVideoDuration(dto.getDuration()); // 3. 异步执行数据库持久化 asyncTaskExecutor.execute(() - { try { int rows historyMapper.upsert(history); log.debug(观看记录持久化成功userId:{}, videoId:{}, affected rows:{}, userId, dto.getVideoId(), rows); } catch (Exception e) { log.error(观看记录持久化失败userId:{}, videoId:{}, userId, dto.getVideoId(), e); // 此处可加入降级策略如存入本地队列重试或记录日志 } }); // 4. 同步更新Redis缓存 (保证实时性) String cacheKey WATCH_HISTORY_KEY_PREFIX userId; WatchHistoryVO cacheVO new WatchHistoryVO(); // 这里需要从其他服务或数据库获取视频详情简化演示 cacheVO.setVideoId(dto.getVideoId()); cacheVO.setWatchProgress(dto.getProgress()); cacheVO.setVideoDuration(dto.getDuration()); cacheVO.setLatestWatchTime(LocalDateTime.now().toString()); // 使用Hash结构存储field为videoId redisTemplate.opsForHash().put(cacheKey, dto.getVideoId().toString(), cacheVO); // 设置缓存过期时间例如7天 redisTemplate.expire(cacheKey, 7, TimeUnit.DAYS); } Override public ListWatchHistoryVO getWatchHistory(Long userId, Integer limit) { ListWatchHistoryVO result new ArrayList(); String cacheKey WATCH_HISTORY_KEY_PREFIX userId; // 1. 先查缓存 MapObject, Object cacheMap redisTemplate.opsForHash().entries(cacheKey); if (cacheMap ! null !cacheMap.isEmpty()) { // 缓存存在转换并排序 cacheMap.values().forEach(obj - result.add((WatchHistoryVO) obj)); result.sort((a, b) - b.getLatestWatchTime().compareTo(a.getLatestWatchTime())); if (limit ! null result.size() limit) { return result.subList(0, limit); } return result; } // 2. 缓存不存在查数据库 ListUserWatchHistory dbList historyMapper.selectByUserId(userId, limit ! null ? limit : 50); if (dbList.isEmpty()) { return result; } // 3. 转换并填充视频详情 (此处简化实际需调用视频服务) for (UserWatchHistory history : dbList) { WatchHistoryVO vo convertToVO(history); // 假设的转换方法 result.add(vo); // 4. 异步回写缓存 redisTemplate.opsForHash().put(cacheKey, history.getVideoId().toString(), vo); } redisTemplate.expire(cacheKey, 7, TimeUnit.DAYS); return result; } // convertToVO 等方法省略... }4.3 异步执行器配置为了避免数据库写入阻塞主线程我们配置一个专用的线程池。// 文件路径src/main/java/com/example/videoplatform/config/AsyncConfig.java Configuration EnableAsync public class AsyncConfig { Bean(asyncTaskExecutor) public TaskExecutor asyncTaskExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); // 核心线程数 executor.setCorePoolSize(5); // 最大线程数 executor.setMaxPoolSize(20); // 队列容量 executor.setQueueCapacity(1000); // 线程名前缀 executor.setThreadNamePrefix(WatchHistory-Async-); // 拒绝策略由调用线程直接执行 executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); executor.initialize(); return executor; } }4.4 控制层 (Controller)提供对外的REST API。// 文件路径src/main/java/com/example/videoplatform/controller/WatchHistoryController.java RestController RequestMapping(/api/watch-history) Slf4j public class WatchHistoryController { Autowired private WatchHistoryService watchHistoryService; PostMapping(/record) public ResponseEntityVoid recordProgress(RequestBody Valid WatchProgressDTO dto, RequestHeader(X-User-Id) Long userId) { // 实际项目中userId应从Token或Session中获取此处简化 if (userId null || userId 0) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } try { watchHistoryService.recordWatchProgress(userId, dto); return ResponseEntity.ok().build(); } catch (Exception e) { log.error(记录观看进度失败userId:{}, dto:{}, userId, dto, e); return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build(); } } GetMapping(/list) public ResponseEntityListWatchHistoryVO getHistory(RequestParam(defaultValue 20) Integer limit, RequestHeader(X-User-Id) Long userId) { if (userId null || userId 0) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } ListWatchHistoryVO history watchHistoryService.getWatchHistory(userId, limit); return ResponseEntity.ok(history); } DeleteMapping(/{recordId}) public ResponseEntityVoid deleteHistory(PathVariable Long recordId, RequestHeader(X-User-Id) Long userId) { boolean success watchHistoryService.deleteHistory(userId, recordId); return success ? ResponseEntity.ok().build() : ResponseEntity.notFound().build(); } }5. 前端交互模拟与测试后端完成后我们需要验证API。这里使用curl命令和单元测试进行模拟。5.1 上报观看进度 (模拟请求)curl -X POST http://localhost:8080/api/watch-history/record \ -H Content-Type: application/json \ -H X-User-Id: 123 \ -d { videoId: 1001, progress: 125, duration: 600 }5.2 查询观看记录curl -X GET http://localhost:8080/api/watch-history/list?limit10 \ -H X-User-Id: 1235.3 服务层单元测试示例// 文件路径src/test/java/com/example/videoplatform/service/WatchHistoryServiceTest.java SpringBootTest Slf4j class WatchHistoryServiceTest { Autowired private WatchHistoryService watchHistoryService; Test void testRecordAndGetHistory() { Long userId 999L; WatchProgressDTO dto new WatchProgressDTO(); dto.setVideoId(2001L); dto.setProgress(30); dto.setDuration(180); // 测试记录 watchHistoryService.recordWatchProgress(userId, dto); // 等待异步任务执行测试环境可简单等待 try { Thread.sleep(1000); } catch (InterruptedException e) { } // 测试查询 ListWatchHistoryVO history watchHistoryService.getWatchHistory(userId, 10); Assertions.assertNotNull(history); Assertions.assertFalse(history.isEmpty()); Assertions.assertEquals(dto.getVideoId(), history.get(0).getVideoId()); log.info(测试通过查询到记录{}, history.get(0).getProgressPercentage()); } }6. 常见问题与排查思路在实际开发和运维中你可能会遇到以下问题问题现象可能原因排查思路与解决方案记录上报成功但查询不到或进度未更新1. 异步任务执行失败。2. Redis缓存未正确更新或已过期。3. 数据库唯一键冲突导致更新失败。1. 查看应用日志搜索“观看记录持久化失败”。2. 使用redis-cli检查对应Key是否存在HGETALL wh:uid:123。3. 检查数据库user_watch_history表确认数据是否存在及进度是否正确。接口响应缓慢尤其是记录上报接口1. 数据库写入慢如未建索引、锁表。2. Redis连接池耗尽或网络延迟高。3. 异步线程池队列满触发拒绝策略。1. 使用EXPLAIN分析upsert语句。2. 监控Redis连接数和响应时间。3. 调整异步线程池配置CorePoolSize,QueueCapacity或监控线程池状态。缓存与数据库数据不一致1. 缓存更新成功但数据库更新失败。2. 缓存过期后从数据库回写时数据已变。1.保证最终一致性异步任务失败后应有重试机制如存入死信队列。2.使用较短的缓存过期时间如30分钟并考虑在更新数据库后主动刷新缓存。高并发下数据库压力大即使异步瞬时写入量也可能很大。1.引入消息队列如Kafka/RocketMQ将记录先发往队列由消费者批量写入数据库。2.合并写入在内存中暂存一段时间内的进度合并为一次更新。用户量巨大Redis内存占用高每个用户的记录都缓存。1.限制缓存数量每个用户只缓存最新的N条如50条。2.使用更紧凑的数据结构例如只缓存videoId:progress的映射其他信息懒加载。3.设置合理的过期策略。7. 最佳实践与进阶优化实现基础功能后我们可以从性能、可靠性和可扩展性方面进行优化。7.1 性能优化数据库层面对user_id,video_id,latest_watch_time建立联合索引优化查询。定期归档或清理很久之前如一年前的观看记录可以迁移到历史表或冷存储。缓存层面使用Redis Pipeline批量操作缓存减少网络往返。考虑使用Redis Sorted Set来存储用户观看记录score设置为观看时间戳天然支持按时间排序且可以方便地按范围查询和限制数量。7.2 可靠性保障异步任务可靠性将异步任务提交到持久化消息队列如RocketMQ确保即使应用重启任务也不会丢失。实现消费者端的幂等性处理防止因重试导致的数据重复更新。降级与熔断当Redis不可用时应能降级为直接查询数据库避免核心功能不可用。使用 Resilience4j 或 Sentinel 对数据库调用进行熔断保护。7.3 架构扩展分库分表当用户量达到千万甚至亿级单表性能成为瓶颈。可按user_id进行分片。读写分离将读请求查询历史记录路由到从库减轻主库压力。引入Elasticsearch如果需要支持复杂的搜索如按视频标题搜索观看记录可以将记录同步到ES中。7.4 前端优化建议上报节流避免每秒上报多次可以使用防抖暂停时上报或节流每15秒上报一次策略。离线记录在弱网环境下可将记录暂存于浏览器的IndexedDB或localStorage待网络恢复后同步。进度同步在多端Web、App、TV观看时通过WebSocket或轮询及时同步最新进度提供无缝体验。通过以上步骤我们完成了一个从需求分析到代码实现再到优化扩展的“观看记录”功能模块。它不再是一个简单的INSERT语句而是一个考虑了并发、性能、一致性的小型系统。在实际项目中你需要根据业务规模和技术架构做出权衡和选择。
返回列表