場景下用戶觀看記錄模塊設(shè)計與實現(xiàn))
最近在開發(fā)一個基于Spring Boot的在線視頻平臺項目時遇到了一個非常典型的場景如何優(yōu)雅地處理一個業(yè)務(wù)模塊比如“用戶觀看記錄”從數(shù)據(jù)采集、處理、存儲到前端展示的完整流程。這讓我想起了一個有趣的比喻——就像記錄“寧姆韋德普通的一天”看似簡單實則涉及后端服務(wù)、數(shù)據(jù)庫、緩存、消息隊列乃至前端組件的協(xié)同工作。本文將圍繞這個業(yè)務(wù)場景拆解其技術(shù)實現(xiàn)手把手帶你構(gòu)建一個高可用、可擴(kuò)展的觀看記錄功能模塊。本文適合有一定Spring Boot和MyBatis基礎(chǔ)的開發(fā)者無論是想學(xué)習(xí)如何設(shè)計一個完整的業(yè)務(wù)閉環(huán)還是希望優(yōu)化現(xiàn)有項目的類似功能都能從中獲得啟發(fā)。我們將從需求分析、表設(shè)計開始逐步完成核心服務(wù)、異步處理、緩存策略的實現(xiàn)并最終提供一個可復(fù)用的前端組件思路。1. 業(yè)務(wù)背景與核心概念在視頻平臺中“觀看記錄”是一個基礎(chǔ)但至關(guān)重要的功能。它不僅僅是記錄用戶看了什么更關(guān)聯(lián)著個性化推薦、內(nèi)容熱度計算、用戶行為分析等多個下游業(yè)務(wù)。1.1 核心價值與挑戰(zhàn)用戶體驗方便用戶續(xù)播、查找歷史。業(yè)務(wù)智能為推薦系統(tǒng)提供原始數(shù)據(jù)。技術(shù)挑戰(zhàn)高并發(fā)寫入熱門視頻同時有成千上萬人觀看。實時性要求用戶希望記錄能即時同步到所有設(shè)備。數(shù)據(jù)一致性記錄進(jìn)度需要準(zhǔn)確避免跳錯時間點。存儲成本用戶觀看行為頻繁數(shù)據(jù)量增長快。1.2 業(yè)務(wù)流程拆解一個完整的“記錄”動作可以分解為以下幾個步驟事件觸發(fā)前端播放器每隔一段時間如15秒或暫停、退出時上報進(jìn)度。請求接收后端API接收上報數(shù)據(jù)。業(yè)務(wù)處理清洗、驗證數(shù)據(jù)補(bǔ)充業(yè)務(wù)信息如視頻標(biāo)題。數(shù)據(jù)持久化將記錄存入數(shù)據(jù)庫。為了應(yīng)對高并發(fā)此處常引入異步和批處理。緩存更新更新用戶最新的觀看記錄緩存供快速查詢。下游通知可選。通過消息隊列通知推薦、統(tǒng)計等服務(wù)。接下來我們將從環(huán)境搭建開始一步步實現(xiàn)這個流程。2. 環(huán)境準(zhǔn)備與項目結(jié)構(gòu)我們使用當(dāng)前主流的Java技術(shù)棧進(jìn)行演示。2.1 基礎(chǔ)環(huán)境JDK: 17 或以上 (推薦17長期支持版本)Maven: 3.6IDE: IntelliJ IDEA 或 VS Code數(shù)據(jù)庫: MySQL 8.0緩存: Redis 6.x2.2 項目初始化與依賴使用 Spring Initializr 創(chuàng)建一個Spring Boot項目選擇以下依賴Spring Web: 提供RESTful API支持。Spring Data Redis: 操作Redis緩存。MyBatis Framework: 數(shù)據(jù)庫ORM框架。MySQL Driver: 連接MySQL數(shù)據(jù)庫。Lombok: 簡化Java Bean代碼。生成的pom.xml關(guān)鍵依賴如下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 !-- 請使用最新穩(wěn)定版 -- /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設(shè)置數(shù)據(jù)源、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 # 開啟駝峰命名自動轉(zhuǎn)換2.4 項目結(jié)構(gòu)預(yù)覽src/main/java/com/example/videoplatform/ ├── VideoPlatformApplication.java ├── config/ # 配置類 ├── controller/ # 控制層接收API請求 ├── service/ # 業(yè)務(wù)邏輯層 │ ├── impl/ ├── mapper/ # MyBatis Mapper接口 ├── entity/ # 實體類對應(yīng)數(shù)據(jù)庫表 ├── dto/ # 數(shù)據(jù)傳輸對象 ├── vo/ # 視圖對象用于接口返回 └── async/ # 異步處理組件3. 數(shù)據(jù)庫設(shè)計與實體建模觀看記錄的核心在于表結(jié)構(gòu)設(shè)計需平衡查詢效率與存儲空間。3.1 表結(jié)構(gòu)設(shè)計 (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 觀看進(jìn)度秒, 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 記錄創(chuàng)建時間, updated_time datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT 記錄更新時間, is_deleted tinyint(1) NOT NULL DEFAULT 0 COMMENT 邏輯刪除標(biāo)志, 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用戶觀看記錄表;設(shè)計要點uk_user_video唯一索引確保一個用戶對一個視頻只有一條記錄更新時使用ON DUPLICATE KEY UPDATE或先查后改避免數(shù)據(jù)膨脹。idx_user_time索引優(yōu)化按用戶和時間倒序查詢列表的性能。is_deleted邏輯刪除標(biāo)志避免物理刪除。3.2 實體類 (Entity)對應(yīng)上述表結(jié)構(gòu)創(chuàng)建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 數(shù)據(jù)傳輸對象 (DTO) 和視圖對象 (VO)DTO (WatchProgressDTO)用于接收前端上報的進(jìn)度數(shù)據(jù)。Data public class WatchProgressDTO { NotNull(message 視頻ID不能為空) private Long videoId; Min(value 0, message 進(jìn)度不能小于0) private Integer progress; // 當(dāng)前播放進(jìn)度秒 Min(value 1, message 時長必須大于0) private Integer duration; // 視頻總時長秒 }VO (WatchHistoryVO)用于返回給前端的觀看記錄信息通常會關(guān)聯(lián)視頻信息。Data public class WatchHistoryVO { private Long videoId; private String videoTitle; private String coverUrl; private Integer watchProgress; private Integer videoDuration; private String latestWatchTime; // 格式化后的時間字符串 // 可以計算一個進(jìn)度百分比方便前端顯示 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. 核心業(yè)務(wù)邏輯實現(xiàn)我們將采用“異步處理 緩存”的策略來應(yīng)對高并發(fā)寫入和實時查詢。4.1 Mapper 接口與 XML首先定義數(shù)據(jù)訪問層。// 文件路徑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); }對應(yīng)的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 服務(wù)層實現(xiàn) (Service)服務(wù)層負(fù)責(zé)核心業(yè)務(wù)邏輯這里我們引入異步處理。// 文件路徑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; // 自定義的異步執(zhí)行器 private static final String WATCH_HISTORY_KEY_PREFIX wh:uid:; Override public void recordWatchProgress(Long userId, WatchProgressDTO dto) { // 1. 參數(shù)校驗 (略) // 2. 構(gòu)造實體 UserWatchHistory history new UserWatchHistory(); history.setUserId(userId); history.setVideoId(dto.getVideoId()); history.setWatchProgress(dto.getProgress()); history.setVideoDuration(dto.getDuration()); // 3. 異步執(zhí)行數(shù)據(jù)庫持久化 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(); // 這里需要從其他服務(wù)或數(shù)據(jù)庫獲取視頻詳情簡化演示 cacheVO.setVideoId(dto.getVideoId()); cacheVO.setWatchProgress(dto.getProgress()); cacheVO.setVideoDuration(dto.getDuration()); cacheVO.setLatestWatchTime(LocalDateTime.now().toString()); // 使用Hash結(jié)構(gòu)存儲field為videoId redisTemplate.opsForHash().put(cacheKey, dto.getVideoId().toString(), cacheVO); // 設(shè)置緩存過期時間例如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()) { // 緩存存在轉(zhuǎn)換并排序 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. 緩存不存在查數(shù)據(jù)庫 ListUserWatchHistory dbList historyMapper.selectByUserId(userId, limit ! null ? limit : 50); if (dbList.isEmpty()) { return result; } // 3. 轉(zhuǎn)換并填充視頻詳情 (此處簡化實際需調(diào)用視頻服務(wù)) for (UserWatchHistory history : dbList) { WatchHistoryVO vo convertToVO(history); // 假設(shè)的轉(zhuǎn)換方法 result.add(vo); // 4. 異步回寫緩存 redisTemplate.opsForHash().put(cacheKey, history.getVideoId().toString(), vo); } redisTemplate.expire(cacheKey, 7, TimeUnit.DAYS); return result; } // convertToVO 等方法省略... }4.3 異步執(zhí)行器配置為了避免數(shù)據(jù)庫寫入阻塞主線程我們配置一個專用的線程池。// 文件路徑src/main/java/com/example/videoplatform/config/AsyncConfig.java Configuration EnableAsync public class AsyncConfig { Bean(asyncTaskExecutor) public TaskExecutor asyncTaskExecutor() { ThreadPoolTaskExecutor executor new ThreadPoolTaskExecutor(); // 核心線程數(shù) executor.setCorePoolSize(5); // 最大線程數(shù) executor.setMaxPoolSize(20); // 隊列容量 executor.setQueueCapacity(1000); // 線程名前綴 executor.setThreadNamePrefix(WatchHistory-Async-); // 拒絕策略由調(diào)用線程直接執(zhí)行 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應(yīng)從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(記錄觀看進(jìn)度失敗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命令和單元測試進(jìn)行模擬。5.1 上報觀看進(jìn)度 (模擬請求)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 服務(wù)層單元測試示例// 文件路徑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); // 等待異步任務(wù)執(zhí)行測試環(huán)境可簡單等待 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. 常見問題與排查思路在實際開發(fā)和運維中你可能會遇到以下問題問題現(xiàn)象可能原因排查思路與解決方案記錄上報成功但查詢不到或進(jìn)度未更新1. 異步任務(wù)執(zhí)行失敗。2. Redis緩存未正確更新或已過期。3. 數(shù)據(jù)庫唯一鍵沖突導(dǎo)致更新失敗。1. 查看應(yīng)用日志搜索“觀看記錄持久化失敗”。2. 使用redis-cli檢查對應(yīng)Key是否存在HGETALL wh:uid:123。3. 檢查數(shù)據(jù)庫user_watch_history表確認(rèn)數(shù)據(jù)是否存在及進(jìn)度是否正確。接口響應(yīng)緩慢尤其是記錄上報接口1. 數(shù)據(jù)庫寫入慢如未建索引、鎖表。2. Redis連接池耗盡或網(wǎng)絡(luò)延遲高。3. 異步線程池隊列滿觸發(fā)拒絕策略。1. 使用EXPLAIN分析upsert語句。2. 監(jiān)控Redis連接數(shù)和響應(yīng)時間。3. 調(diào)整異步線程池配置CorePoolSize,QueueCapacity或監(jiān)控線程池狀態(tài)。緩存與數(shù)據(jù)庫數(shù)據(jù)不一致1. 緩存更新成功但數(shù)據(jù)庫更新失敗。2. 緩存過期后從數(shù)據(jù)庫回寫時數(shù)據(jù)已變。1.保證最終一致性異步任務(wù)失敗后應(yīng)有重試機(jī)制如存入死信隊列。2.使用較短的緩存過期時間如30分鐘并考慮在更新數(shù)據(jù)庫后主動刷新緩存。高并發(fā)下數(shù)據(jù)庫壓力大即使異步瞬時寫入量也可能很大。1.引入消息隊列如Kafka/RocketMQ將記錄先發(fā)往隊列由消費者批量寫入數(shù)據(jù)庫。2.合并寫入在內(nèi)存中暫存一段時間內(nèi)的進(jìn)度合并為一次更新。用戶量巨大Redis內(nèi)存占用高每個用戶的記錄都緩存。1.限制緩存數(shù)量每個用戶只緩存最新的N條如50條。2.使用更緊湊的數(shù)據(jù)結(jié)構(gòu)例如只緩存videoId:progress的映射其他信息懶加載。3.設(shè)置合理的過期策略。7. 最佳實踐與進(jìn)階優(yōu)化實現(xiàn)基礎(chǔ)功能后我們可以從性能、可靠性和可擴(kuò)展性方面進(jìn)行優(yōu)化。7.1 性能優(yōu)化數(shù)據(jù)庫層面對user_id,video_id,latest_watch_time建立聯(lián)合索引優(yōu)化查詢。定期歸檔或清理很久之前如一年前的觀看記錄可以遷移到歷史表或冷存儲。緩存層面使用Redis Pipeline批量操作緩存減少網(wǎng)絡(luò)往返??紤]使用Redis Sorted Set來存儲用戶觀看記錄score設(shè)置為觀看時間戳天然支持按時間排序且可以方便地按范圍查詢和限制數(shù)量。7.2 可靠性保障異步任務(wù)可靠性將異步任務(wù)提交到持久化消息隊列如RocketMQ確保即使應(yīng)用重啟任務(wù)也不會丟失。實現(xiàn)消費者端的冪等性處理防止因重試導(dǎo)致的數(shù)據(jù)重復(fù)更新。降級與熔斷當(dāng)Redis不可用時應(yīng)能降級為直接查詢數(shù)據(jù)庫避免核心功能不可用。使用 Resilience4j 或 Sentinel 對數(shù)據(jù)庫調(diào)用進(jìn)行熔斷保護(hù)。7.3 架構(gòu)擴(kuò)展分庫分表當(dāng)用戶量達(dá)到千萬甚至億級單表性能成為瓶頸??砂磚ser_id進(jìn)行分片。讀寫分離將讀請求查詢歷史記錄路由到從庫減輕主庫壓力。引入Elasticsearch如果需要支持復(fù)雜的搜索如按視頻標(biāo)題搜索觀看記錄可以將記錄同步到ES中。7.4 前端優(yōu)化建議上報節(jié)流避免每秒上報多次可以使用防抖暫停時上報或節(jié)流每15秒上報一次策略。離線記錄在弱網(wǎng)環(huán)境下可將記錄暫存于瀏覽器的IndexedDB或localStorage待網(wǎng)絡(luò)恢復(fù)后同步。進(jìn)度同步在多端Web、App、TV觀看時通過WebSocket或輪詢及時同步最新進(jìn)度提供無縫體驗。通過以上步驟我們完成了一個從需求分析到代碼實現(xiàn)再到優(yōu)化擴(kuò)展的“觀看記錄”功能模塊。它不再是一個簡單的INSERT語句而是一個考慮了并發(fā)、性能、一致性的小型系統(tǒng)。在實際項目中你需要根據(jù)業(yè)務(wù)規(guī)模和技術(shù)架構(gòu)做出權(quán)衡和選擇。