
最近在開發一個基于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語句而是一個考慮了并發、性能、一致性的小型系統。在實際項目中你需要根據業務規模和技術架構做出權衡和選擇。