Prechádzať zdrojové kódy

优化看课redis,索引Set+ SCAN兜底

xw 1 deň pred
rodič
commit
300d4dc79b

+ 15 - 0
fs-common/src/main/java/com/fs/common/constant/CourseWatchKeysConstant.java

@@ -0,0 +1,15 @@
+package com.fs.common.constant;
+
+/**
+ * H5 微信看课 Redis key(与 {@link com.fs.course.utils.H5WxUserWatchRedisUtil} 配合使用)
+ */
+public final class CourseWatchKeysConstant {
+
+    private CourseWatchKeysConstant() {
+    }
+
+    public static final String H5_WX_HEARTBEAT_PREFIX = "h5wxuser:watch:heartbeat:";
+    public static final String H5_WX_HEARTBEAT_INDEX = "h5wxuser:watch:heartbeat:index";
+    public static final String H5_WX_DURATION_PREFIX = "h5wxuser:watch:duration:";
+    public static final String H5_WX_DURATION_INDEX = "h5wxuser:watch:duration:index";
+}

+ 174 - 0
fs-common/src/main/java/com/fs/common/utils/redis/RedisActiveKeyIndexRepairService.java

@@ -0,0 +1,174 @@
+package com.fs.common.utils.redis;
+
+import com.fs.common.constant.CourseWatchKeysConstant;
+import com.fs.common.constant.LiveKeysConstant;
+import com.fs.common.core.redis.RedisCache;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.redis.connection.RedisConnection;
+import org.springframework.data.redis.core.Cursor;
+import org.springframework.data.redis.core.RedisCallback;
+import org.springframework.data.redis.core.ScanOptions;
+import org.springframework.data.redis.serializer.RedisSerializer;
+import org.springframework.stereotype.Service;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+import java.util.concurrent.TimeUnit;
+
+/**
+ * 使用 SCAN 将 Redis 中真实存在的活跃 key 与 Set 索引对账(兜底,避免漏 track)。
+ */
+@Service
+public class RedisActiveKeyIndexRepairService {
+
+    private static final Logger log = LoggerFactory.getLogger(RedisActiveKeyIndexRepairService.class);
+
+    private static final String REPAIR_LOCK_KEY = "redis:active_key_index:repair:lock";
+    private static final int SCAN_COUNT = 500;
+    private static final int LOCK_MINUTES = 45;
+
+    @Autowired
+    private RedisCache redisCache;
+
+    /**
+     * 修补全部已注册的索引(多节点仅一个实例执行)。
+     */
+    public void repairAllIndexes() {
+        if (!tryAcquireRepairLock()) {
+            log.info("Redis 活跃 key 索引修补跳过:其他节点正在执行");
+            return;
+        }
+        try {
+            log.info("Redis 活跃 key 索引修补开始");
+            List<RepairResult> results = new ArrayList<>();
+            for (IndexRepairTarget target : repairTargets()) {
+                results.add(repairIndex(target));
+            }
+            log.info("Redis 活跃 key 索引修补完成: {}", results);
+        } catch (Exception e) {
+            log.error("Redis 活跃 key 索引修补失败", e);
+        } finally {
+            redisCache.deleteObject(REPAIR_LOCK_KEY);
+        }
+    }
+
+    public RepairResult repairIndex(IndexRepairTarget target) {
+        Set<String> scannedKeys = scanKeys(target.scanPattern);
+        int added = 0;
+        for (String cacheKey : scannedKeys) {
+            if (Boolean.TRUE.equals(redisCache.redisTemplate.hasKey(cacheKey))) {
+                Long addedCount = redisCache.redisTemplate.opsForSet().add(target.indexKey, cacheKey);
+                if (addedCount != null && addedCount > 0) {
+                    added++;
+                }
+            }
+        }
+
+        int removed = 0;
+        Set<Object> members = redisCache.redisTemplate.opsForSet().members(target.indexKey);
+        if (members != null) {
+            for (Object member : members) {
+                String cacheKey = member.toString();
+                if (!Boolean.TRUE.equals(redisCache.redisTemplate.hasKey(cacheKey))) {
+                    redisCache.redisTemplate.opsForSet().remove(target.indexKey, cacheKey);
+                    removed++;
+                }
+            }
+        }
+        return new RepairResult(target.name, target.scanPattern, target.indexKey, scannedKeys.size(), added, removed);
+    }
+
+    private List<IndexRepairTarget> repairTargets() {
+        List<IndexRepairTarget> targets = new ArrayList<>();
+        targets.add(new IndexRepairTarget("liveAutoTask",
+                LiveKeysConstant.LIVE_AUTO_TASK_PREFIX + "*",
+                LiveKeysConstant.LIVE_AUTO_TASK_INDEX));
+        targets.add(new IndexRepairTarget("liveLotteryTask",
+                LiveKeysConstant.LIVE_LOTTERY_TASK_PREFIX + "*",
+                LiveKeysConstant.LIVE_LOTTERY_TASK_INDEX));
+        targets.add(new IndexRepairTarget("liveRedTask",
+                LiveKeysConstant.LIVE_RED_TASK_PREFIX + "*",
+                LiveKeysConstant.LIVE_RED_TASK_INDEX));
+        targets.add(new IndexRepairTarget("liveTagMark",
+                LiveKeysConstant.LIVE_TAG_MARK_CACHE.replace("%s", "*"),
+                LiveKeysConstant.LIVE_TAG_MARK_INDEX));
+        targets.add(new IndexRepairTarget("liveCouponNum",
+                LiveKeysConstant.LIVE_COUPON_NUM_PREFIX + "*",
+                LiveKeysConstant.LIVE_COUPON_NUM_INDEX));
+        targets.add(new IndexRepairTarget("liveUserWatchLog",
+                "live:user:watch:log:*",
+                LiveKeysConstant.LIVE_USER_WATCH_LOG_INDEX));
+        targets.add(new IndexRepairTarget("h5wxHeartbeat",
+                CourseWatchKeysConstant.H5_WX_HEARTBEAT_PREFIX + "*",
+                CourseWatchKeysConstant.H5_WX_HEARTBEAT_INDEX));
+        targets.add(new IndexRepairTarget("h5wxDuration",
+                CourseWatchKeysConstant.H5_WX_DURATION_PREFIX + "*",
+                CourseWatchKeysConstant.H5_WX_DURATION_INDEX));
+        return targets;
+    }
+
+    private Set<String> scanKeys(String pattern) {
+        Set<String> keys = new HashSet<>();
+        ScanOptions options = ScanOptions.scanOptions().match(pattern).count(SCAN_COUNT).build();
+        redisCache.redisTemplate.execute((RedisCallback<Void>) connection -> {
+            RedisSerializer<String> keySerializer = redisCache.redisTemplate.getStringSerializer();
+            try (Cursor<byte[]> cursor = connection.scan(options)) {
+                while (cursor.hasNext()) {
+                    String key = keySerializer.deserialize(cursor.next());
+                    if (key != null) {
+                        keys.add(key);
+                    }
+                }
+            } catch (IOException e) {
+                throw new RuntimeException("Redis SCAN failed, pattern=" + pattern, e);
+            }
+            return null;
+        });
+        return keys;
+    }
+
+    private boolean tryAcquireRepairLock() {
+        return redisCache.setIfAbsent(REPAIR_LOCK_KEY, "1", LOCK_MINUTES, TimeUnit.MINUTES);
+    }
+
+    public static final class IndexRepairTarget {
+        private final String name;
+        private final String scanPattern;
+        private final String indexKey;
+
+        public IndexRepairTarget(String name, String scanPattern, String indexKey) {
+            this.name = name;
+            this.scanPattern = scanPattern;
+            this.indexKey = indexKey;
+        }
+    }
+
+    public static final class RepairResult {
+        private final String name;
+        private final String scanPattern;
+        private final String indexKey;
+        private final int scannedCount;
+        private final int addedToIndex;
+        private final int removedFromIndex;
+
+        public RepairResult(String name, String scanPattern, String indexKey,
+                            int scannedCount, int addedToIndex, int removedFromIndex) {
+            this.name = name;
+            this.scanPattern = scanPattern;
+            this.indexKey = indexKey;
+            this.scannedCount = scannedCount;
+            this.addedToIndex = addedToIndex;
+            this.removedFromIndex = removedFromIndex;
+        }
+
+        @Override
+        public String toString() {
+            return name + "{scanned=" + scannedCount + ", added=" + addedToIndex + ", removed=" + removedFromIndex + "}";
+        }
+    }
+}

+ 12 - 0
fs-live-app/src/main/java/com/fs/live/task/Task.java

@@ -9,6 +9,7 @@ import com.fs.common.core.domain.R;
 import com.fs.common.core.redis.RedisCache;
 import com.fs.common.utils.StringUtils;
 import com.fs.common.utils.redis.LiveDelayedTaskRedisUtil;
+import com.fs.common.utils.redis.RedisActiveKeyIndexRepairService;
 import com.fs.framework.aspectj.lock.DistributeLock;
 import com.fs.erp.service.FsJstAftersalePushService;
 import com.fs.his.service.IFsUserService;
@@ -84,6 +85,8 @@ public class Task {
     public FsJstAftersalePushService fsJstAftersalePushService;
     @Autowired
     private LiveDelayedTaskRedisUtil liveDelayedTaskRedisUtil;
+    @Autowired
+    private RedisActiveKeyIndexRepairService redisActiveKeyIndexRepairService;
 
     @Scheduled(cron = "0 0/1 * * * ?")
     @DistributeLock(key = "updateLiveStatusByTime", scene = "task")
@@ -1126,4 +1129,13 @@ public class Task {
 //            log.error("批量同步观看时长任务异常", e);
 //        }
 //    }
+
+    /**
+     * 每天凌晨 1 点:SCAN 修补活跃 key 索引(多节点由 Redis 锁保证只执行一次)
+     */
+    @Scheduled(cron = "0 0 1 * * ?")
+    @DistributeLock(key = "repairRedisActiveKeyIndexes", scene = "task")
+    public void repairRedisActiveKeyIndexes() {
+        redisActiveKeyIndexRepairService.repairAllIndexes();
+    }
 }

+ 15 - 3
fs-qw-task/src/main/java/com/fs/app/task/CourseWatchLogScheduler.java

@@ -5,6 +5,7 @@ import com.fs.common.core.redis.RedisCache;
 import com.fs.course.mapper.FsCourseWatchLogMapper;
 import com.fs.course.mapper.FsUserCourseVideoMapper;
 import com.fs.course.service.IFsCourseLinkService;
+import com.fs.common.utils.redis.RedisActiveKeyIndexRepairService;
 import com.fs.course.service.IFsCourseWatchLogService;
 import com.fs.sop.mapper.QwSopLogsMapper;
 import com.fs.system.service.ISysConfigService;
@@ -37,6 +38,9 @@ public class CourseWatchLogScheduler {
     @Autowired
     RedisCache redisCache;
 
+    @Autowired
+    private RedisActiveKeyIndexRepairService redisActiveKeyIndexRepairService;
+
     @Autowired
     private FsUserCourseVideoMapper courseVideoMapper;
 
@@ -192,8 +196,16 @@ public class CourseWatchLogScheduler {
 
     }
 
-
-
-
+    /**
+     * 每天凌晨 1 点:SCAN 修补活跃 key 索引(与业务 track 配合,兜底漏登记/发版空窗)
+     */
+    @Scheduled(cron = "0 0 1 * * ?")
+    public void repairRedisActiveKeyIndexes() {
+        try {
+            redisActiveKeyIndexRepairService.repairAllIndexes();
+        } catch (Exception e) {
+            log.error("Redis 活跃 key 索引修补定时任务失败", e);
+        }
+    }
 
 }

+ 3 - 4
fs-service/src/main/java/com/fs/course/dto/RedisKeyInfo.java

@@ -1,6 +1,7 @@
 package com.fs.course.dto;
 
 import com.fs.course.enums.RedisKeyType;
+import com.fs.course.utils.H5WxUserWatchRedisUtil;
 import lombok.Data;
 
 @Data
@@ -41,16 +42,14 @@ public class RedisKeyInfo {
      * 构建时长key
      */
     public String buildDurationKey() {
-        return String.format("h5wxuser:watch:duration:%d:%d:%d",
-                userId, videoId, companyUserId);
+        return H5WxUserWatchRedisUtil.durationKey(userId, videoId, companyUserId);
     }
 
     /**
      * 构建心跳key
      */
     public String buildHeartbeatKey() {
-        return String.format("h5wxuser:watch:heartbeat:%d:%d:%d",
-                userId, videoId, companyUserId);
+        return H5WxUserWatchRedisUtil.heartbeatKey(userId, videoId, companyUserId);
     }
 
     @Override

+ 11 - 5
fs-service/src/main/java/com/fs/course/service/impl/FsCourseWatchLogServiceImpl.java

@@ -22,6 +22,7 @@ import com.fs.company.domain.CompanyUser;
 import com.fs.company.mapper.CompanyMapper;
 import com.fs.course.config.CourseConfig;
 import com.fs.course.config.RedisKeyScanner;
+import com.fs.course.utils.H5WxUserWatchRedisUtil;
 import com.fs.course.domain.*;
 import com.fs.course.mapper.*;
 import com.fs.course.param.*;
@@ -107,6 +108,8 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
     @Autowired
     private RedisCache redisCache;
     @Autowired
+    private H5WxUserWatchRedisUtil h5WxUserWatchRedisUtil;
+    @Autowired
     private IQwExternalContactCacheService qwExternalContactCacheService;
     @Autowired
     private QwWatchLogMapper qwWatchLogMapper;
@@ -381,8 +384,7 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
     @Override
     public void scheduleUpdateDurationToDatabase() {
         log.info("WXH5-开始更新会员看课时长,检查完课>>>>>>");
-        //读取所有的key
-        Collection<String> keys = redisCache.keys("h5wxuser:watch:duration:*");
+        Set<String> keys = h5WxUserWatchRedisUtil.listDurationKeys();
 
         //读取看课配置
         String json = configService.selectConfigByKey("course.config");
@@ -407,6 +409,7 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
             String durationStr = redisCache.getCacheObject(key);
             if (durationStr == null) {
                 log.error("key中数据为null:{}", key);
+                h5WxUserWatchRedisUtil.untrackDuration(key);
                 continue;  // 如果 Redis 中没有记录,跳过
             }
             Long duration = Long.valueOf(durationStr);
@@ -431,11 +434,13 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
                 if (percentage >= config.getAnswerRate()) {
                     watchLog.setLogType(2); // 设置状态为“已完成”checkFsUserWatchStatus
                     watchLog.setFinishTime(new Date());
-                    String heartbeatKey = "h5wxuser:watch:heartbeat:" + userId + ":" + videoId + ":" + companyUserId;
+                    String heartbeatKey = H5WxUserWatchRedisUtil.heartbeatKey(userId, videoId, companyUserId);
                     // 完课删除心跳记录
                     redisCache.deleteObject(heartbeatKey);
+                    h5WxUserWatchRedisUtil.untrackHeartbeat(heartbeatKey);
                     // 完课删除看课时长记录
                     redisCache.deleteObject(key);
+                    h5WxUserWatchRedisUtil.untrackDuration(key);
                 }
             }
             //集合中增加
@@ -468,8 +473,7 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
     @Override
     public void checkFsUserWatchStatus() {
         log.info("WXH5-开始更新会员看课中断记录>>>>>");
-        // 从 Redis 中获取所有正在看课的用户记录
-        Collection<String> keys = redisCache.keys("h5wxuser:watch:heartbeat:*");
+        Set<String> keys = h5WxUserWatchRedisUtil.listHeartbeatKeys();
         LocalDateTime now = LocalDateTime.now();
         List<FsCourseWatchLog> logs = new ArrayList<>();
         for (String key : keys) {
@@ -481,6 +485,7 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
             // 获取最后心跳时间
             String lastHeartbeatStr = redisCache.getCacheObject(key);
             if (lastHeartbeatStr == null) {
+                h5WxUserWatchRedisUtil.untrackHeartbeat(key);
                 continue; // 如果 Redis 中没有记录,跳过
             }
             LocalDateTime lastHeartbeatTime = LocalDateTime.parse(lastHeartbeatStr);
@@ -494,6 +499,7 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
                 watchLog.setLogType(4);
                 // 从 Redis 中删除该记录
                 redisCache.deleteObject(key);
+                h5WxUserWatchRedisUtil.untrackHeartbeat(key);
             } else {
                 watchLog.setLogType(1);
             }

+ 12 - 7
fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseVideoServiceImpl.java

@@ -47,6 +47,7 @@ import com.fs.course.service.IFsCourseLinkService;
 import com.fs.course.service.IFsUserCompanyBindService;
 import com.fs.course.service.IFsUserCompanyUserService;
 import com.fs.course.service.IFsUserCourseVideoService;
+import com.fs.course.utils.H5WxUserWatchRedisUtil;
 import com.fs.course.param.newfs.*;
 import com.fs.course.service.*;
 import com.fs.course.vo.*;
@@ -210,6 +211,8 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
     @Autowired
     RedisCache redisCache;
     @Autowired
+    private H5WxUserWatchRedisUtil h5WxUserWatchRedisUtil;
+    @Autowired
     private ISysConfigService sysConfigService;
 
     @Autowired
@@ -2793,10 +2796,10 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
                 fsCourseWatchLog.setLogType(1);
                 fsCourseWatchLog.setAppId(param.getAppId());
                 courseWatchLogMapper.insertFsCourseWatchLog(fsCourseWatchLog);
-                String redisKey = "h5wxuser:watch:heartbeat:" + param.getUserId() + ":" + param.getVideoId() + ":" + 0;
+                String redisKey = H5WxUserWatchRedisUtil.heartbeatKey(param.getUserId(), param.getVideoId(), 0L);
                 redisCache.setCacheObject(redisKey, LocalDateTime.now().toString());
-                // 设置 Redis 记录的过期时间(例如 5 分钟)
                 redisCache.expire(redisKey, 300, TimeUnit.SECONDS);
+                h5WxUserWatchRedisUtil.trackHeartbeat(redisKey);
             }
             return ResponseResult.ok(fsUser);
         }
@@ -2935,10 +2938,10 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
             fsCourseWatchLog.setAppId(param.getAppId());
             courseWatchLogMapper.insertFsCourseWatchLog(fsCourseWatchLog);
 
-            String redisKey = "h5wxuser:watch:heartbeat:" + param.getUserId() + ":" + param.getVideoId() + ":" + param.getCompanyUserId();
+            String redisKey = H5WxUserWatchRedisUtil.heartbeatKey(param.getUserId(), param.getVideoId(), param.getCompanyUserId());
             redisCache.setCacheObject(redisKey, LocalDateTime.now().toString());
-            // 设置 Redis 记录的过期时间(例如 5 分钟)
             redisCache.expire(redisKey, 300, TimeUnit.SECONDS);
+            h5WxUserWatchRedisUtil.trackHeartbeat(redisKey);
         }
 
         // 添加会员销售关系表数据
@@ -3304,7 +3307,7 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
             return R.error();
         }
         // 从Redis中获取观看时长
-        String redisKey = "h5wxuser:watch:duration:" + param.getUserId() + ":" + param.getVideoId() + ":" + param.getCompanyUserId();
+        String redisKey = H5WxUserWatchRedisUtil.durationKey(param.getUserId(), param.getVideoId(), param.getCompanyUserId());
 //        log.info("看课redis缓存key:{}", redisKey);
         try {
             String durationStr = redisCache.getCacheObject(redisKey);
@@ -3315,6 +3318,7 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
             if (param.getDuration() != null && param.getDuration() > duration) {
                 //24小时过期
                 redisCache.setCacheObject(redisKey, param.getDuration().toString(), 2, TimeUnit.HOURS);
+                h5WxUserWatchRedisUtil.trackDuration(redisKey);
             }
 
             //更新缓存中的心跳时间
@@ -3624,10 +3628,11 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
 
     //会员-更新心跳时间
     public void updateHeartbeatWx(FsUserCourseVideoUParam param) {
-        String redisKey = "h5wxuser:watch:heartbeat:" + param.getUserId() + ":" + param.getVideoId() + ":" + param.getCompanyUserId();
+        Long companyUserId = param.getCompanyUserId() == null ? 0L : param.getCompanyUserId();
+        String redisKey = H5WxUserWatchRedisUtil.heartbeatKey(param.getUserId(), param.getVideoId(), companyUserId);
         redisCache.setCacheObject(redisKey, LocalDateTime.now().toString());
-        // 设置 Redis 记录的过期时间(例如 5 分钟)
         redisCache.expire(redisKey, 300, TimeUnit.SECONDS);
+        h5WxUserWatchRedisUtil.trackHeartbeat(redisKey);
     }
 
 

+ 98 - 0
fs-service/src/main/java/com/fs/course/utils/H5WxUserWatchRedisUtil.java

@@ -0,0 +1,98 @@
+package com.fs.course.utils;
+
+import com.fs.common.constant.CourseWatchKeysConstant;
+import com.fs.common.core.redis.RedisCache;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.Set;
+
+/**
+ * H5 微信看课心跳/时长 Redis 索引,避免 KEYS h5wxuser:watch:* 全库扫描。
+ */
+@Component
+public class H5WxUserWatchRedisUtil {
+
+    public static final String HEARTBEAT_PREFIX = CourseWatchKeysConstant.H5_WX_HEARTBEAT_PREFIX;
+    public static final String HEARTBEAT_INDEX = CourseWatchKeysConstant.H5_WX_HEARTBEAT_INDEX;
+    public static final String DURATION_PREFIX = CourseWatchKeysConstant.H5_WX_DURATION_PREFIX;
+    public static final String DURATION_INDEX = CourseWatchKeysConstant.H5_WX_DURATION_INDEX;
+
+    @Autowired
+    private RedisCache redisCache;
+
+    public static String heartbeatKey(Long userId, Long videoId, Long companyUserId) {
+        return String.format("%s%d:%d:%d", HEARTBEAT_PREFIX, userId, videoId, companyUserId);
+    }
+
+    public static String durationKey(Long userId, Long videoId, Long companyUserId) {
+        return String.format("%s%d:%d:%d", DURATION_PREFIX, userId, videoId, companyUserId);
+    }
+
+    public void trackHeartbeat(String cacheKey) {
+        track(HEARTBEAT_INDEX, cacheKey);
+    }
+
+    public void trackHeartbeat(Long userId, Long videoId, Long companyUserId) {
+        trackHeartbeat(heartbeatKey(userId, videoId, companyUserId));
+    }
+
+    public void untrackHeartbeat(String cacheKey) {
+        untrack(HEARTBEAT_INDEX, cacheKey);
+    }
+
+    public void untrackHeartbeat(Long userId, Long videoId, Long companyUserId) {
+        untrackHeartbeat(heartbeatKey(userId, videoId, companyUserId));
+    }
+
+    public Set<String> listHeartbeatKeys() {
+        return listIndexedKeys(HEARTBEAT_INDEX);
+    }
+
+    public void trackDuration(String cacheKey) {
+        track(DURATION_INDEX, cacheKey);
+    }
+
+    public void trackDuration(Long userId, Long videoId, Long companyUserId) {
+        trackDuration(durationKey(userId, videoId, companyUserId));
+    }
+
+    public void untrackDuration(String cacheKey) {
+        untrack(DURATION_INDEX, cacheKey);
+    }
+
+    public void untrackDuration(Long userId, Long videoId, Long companyUserId) {
+        untrackDuration(durationKey(userId, videoId, companyUserId));
+    }
+
+    public Set<String> listDurationKeys() {
+        return listIndexedKeys(DURATION_INDEX);
+    }
+
+    private void track(String indexKey, String cacheKey) {
+        redisCache.redisTemplate.opsForSet().add(indexKey, cacheKey);
+    }
+
+    private void untrack(String indexKey, String cacheKey) {
+        redisCache.redisTemplate.opsForSet().remove(indexKey, cacheKey);
+    }
+
+    private Set<String> listIndexedKeys(String indexKey) {
+        Set<Object> members = redisCache.redisTemplate.opsForSet().members(indexKey);
+        if (members == null || members.isEmpty()) {
+            return Collections.emptySet();
+        }
+        Set<String> result = new HashSet<>(members.size());
+        for (Object member : members) {
+            String cacheKey = member.toString();
+            if (Boolean.TRUE.equals(redisCache.redisTemplate.hasKey(cacheKey))) {
+                result.add(cacheKey);
+            } else {
+                untrack(indexKey, cacheKey);
+            }
+        }
+        return result;
+    }
+}