Przeglądaj źródła

优化飞书redis

xw 3 dni temu
rodzic
commit
33e59ba25c

+ 77 - 29
fs-service/src/main/java/com/fs/feishu/service/FeiShuService.java

@@ -67,15 +67,22 @@ public class FeiShuService {
     /** 当天已创建的免授权文档 cacheKey 索引(Redis Set),供失效时精确删除 */
     private static final String FEISHU_DIRECT_DOC_INDEX_PREFIX = "feishu:direct_doc:index:";
     private static final DateTimeFormatter DIRECT_DOC_DATE_FORMAT = DateTimeFormatter.BASIC_ISO_DATE;
-    private static final long DIRECT_DOC_LOCK_WAIT_MS = 30_000L;
-    private static final long DIRECT_DOC_LOCK_POLL_MS = 200L;
+    /**
+     * 抢锁最长等待。介于原 30s 与激进 3s 之间:兼顾飞书建文档耗时与 Redis 压力。
+     * 超时后快速失败,由上层记录失败日志,避免拖垮 Redis CPU。
+     */
+    private static final long DIRECT_DOC_LOCK_WAIT_MS = 8_000L;
+    /** 等待期间本地休眠间隔;仅在休眠后探测一次缓存/锁,不再 200ms 狂刷 */
+    private static final long DIRECT_DOC_LOCK_POLL_MS = 500L;
     private static final long DIRECT_DOC_LOCK_TTL_MINUTES = 2L;
     private static final int DIRECT_DOC_FOLDER_LOCKED_MAX_RETRIES = 3;
     /**
-     * 索引缺失时的有界兜底:最大 shard 下标(含)。
-     * 仅兼容上线索引前已写入、未登记到 Set 的旧缓存;有索引后按实际分片删除,不受此限制。
+     * 索引缺失时的分片探测上界(含)。分片从 0 连续分配,遇到连续空分片即停止
+     * 仅兼容上线索引前旧缓存;有索引后按实际分片删除。
      */
-    private static final int DIRECT_DOC_INVALIDATE_MAX_SHARD = 500;
+    private static final int DIRECT_DOC_INVALIDATE_MAX_SHARD = 200;
+    /** 连续若干分片都不存在则停止探测(分片连续分配,无需继续往后扫) */
+    private static final int DIRECT_DOC_INVALIDATE_STOP_AFTER_MISS = 2;
     /** 批量 DEL 分批大小,避免单次命令 key 过多 */
     private static final int DIRECT_DOC_DELETE_BATCH_SIZE = 200;
     private static final long[] DIRECT_DOC_FOLDER_LOCKED_RETRY_DELAYS_MS = {500L, 1000L, 2000L};
@@ -391,18 +398,23 @@ public class FeiShuService {
         String lockKey = FEISHU_DIRECT_DOC_LOCK_PREFIX + cacheKey;
         boolean locked = false;
         try {
-            long deadline = System.currentTimeMillis() + DIRECT_DOC_LOCK_WAIT_MS;
-            while (System.currentTimeMillis() < deadline) {
-                cached = redisCache.getCacheObject(cacheKey);
-                if (StringUtils.isNotBlank(cached)) {
-                    return cached;
-                }
-                locked = Boolean.TRUE.equals(redisCache.setIfAbsent(lockKey, "1",
-                        DIRECT_DOC_LOCK_TTL_MINUTES, TimeUnit.MINUTES));
-                if (locked) {
-                    break;
+            // 先抢一次锁;抢不到则本地 sleep,再少量探测,避免高频 GET+SETNX 打满 Redis
+            locked = Boolean.TRUE.equals(redisCache.setIfAbsent(lockKey, "1",
+                    DIRECT_DOC_LOCK_TTL_MINUTES, TimeUnit.MINUTES));
+            if (!locked) {
+                long deadline = System.currentTimeMillis() + DIRECT_DOC_LOCK_WAIT_MS;
+                while (System.currentTimeMillis() < deadline) {
+                    Thread.sleep(DIRECT_DOC_LOCK_POLL_MS);
+                    cached = redisCache.getCacheObject(cacheKey);
+                    if (StringUtils.isNotBlank(cached)) {
+                        return cached;
+                    }
+                    locked = Boolean.TRUE.equals(redisCache.setIfAbsent(lockKey, "1",
+                            DIRECT_DOC_LOCK_TTL_MINUTES, TimeUnit.MINUTES));
+                    if (locked) {
+                        break;
+                    }
                 }
-                Thread.sleep(DIRECT_DOC_LOCK_POLL_MS);
             }
 
             if (!locked) {
@@ -440,10 +452,10 @@ public class FeiShuService {
     }
 
     /**
-     * 一键群发补发前清除当天已缓存的免授权飞书文档(含各 shard 及 lock),避免复用已被风控的旧链接。
+     * 一键群发前清除「该销售 + 该课节 + 该飞书账号 + 当天」的免授权文档缓存(含分片与 lock),
+     * 避免复用可能已被风控的旧链接。不会清其他销售/其他课的缓存。
      * <p>
-     * 优先读写路径维护的分片索引 Set 精确删除;索引为空时再有界兜底(兼容上线索引前的旧缓存)。
-     * 全程不使用 SCAN,避免 Redis CPU 毛刺。
+     * 优先按索引 Set 精确删除;无索引时按连续分片探测后精确 DEL。全程不使用 SCAN/KEYS。
      */
     public void invalidateDirectDocCache(Long companyUserId, Long videoId, Long courseId, Long feishuAccountId) {
         if (companyUserId == null || videoId == null) {
@@ -473,19 +485,55 @@ public class FeiShuService {
             }
             keysToDelete.add(indexKey);
         } else {
-            // 索引缺失:有界枚举兜底,覆盖无索引时期写入的缓存
-            keysToDelete.add(baseKey);
-            keysToDelete.add(FEISHU_DIRECT_DOC_LOCK_PREFIX + baseKey);
-            for (int shard = 0; shard <= DIRECT_DOC_INVALIDATE_MAX_SHARD; shard++) {
-                String shardKey = baseKey + ":" + shard;
-                keysToDelete.add(shardKey);
-                keysToDelete.add(FEISHU_DIRECT_DOC_LOCK_PREFIX + shardKey);
-            }
+            // 索引缺失(兼容旧缓存):只删真实存在的 key,分片从 0 连续探测,遇空即停
+            collectExistingDirectDocKeys(baseKey, keysToDelete);
+        }
+
+        if (keysToDelete.isEmpty()) {
+            log.info("一键群发刷新飞书文档缓存:无命中 key, companyUserId={}, videoId={}, courseId={}, accountId={}",
+                    companyUserId, videoId, courseIdVal, resolvedAccountId);
+            return;
         }
 
         long deleted = deleteDirectDocKeysInBatches(keysToDelete);
-        log.info("一键群发刷新飞书文档缓存:删除完成, usedIndex={}, deleted={}, tryKeys={}, companyUserId={}, videoId={}, courseId={}, accountId={}",
-                usedIndex, deleted, keysToDelete.size(), companyUserId, videoId, courseIdVal, resolvedAccountId);
+        log.info("一键群发刷新飞书文档缓存:已清除 {} 个 key, usedIndex={}, companyUserId={}, videoId={}, courseId={}, accountId={}",
+                deleted, usedIndex, companyUserId, videoId, courseIdVal, resolvedAccountId);
+    }
+
+    /**
+     * 无索引时的精确删除:EXISTS 探测 base + 连续 shard,仅收集存在的 doc/lock key。
+     */
+    private void collectExistingDirectDocKeys(String baseKey, List<String> keysToDelete) {
+        addDirectDocKeyIfExists(baseKey, keysToDelete);
+        int missStreak = 0;
+        for (int shard = 0; shard <= DIRECT_DOC_INVALIDATE_MAX_SHARD; shard++) {
+            String shardKey = baseKey + ":" + shard;
+            boolean existed = addDirectDocKeyIfExists(shardKey, keysToDelete);
+            if (existed) {
+                missStreak = 0;
+            } else {
+                missStreak++;
+                if (missStreak >= DIRECT_DOC_INVALIDATE_STOP_AFTER_MISS) {
+                    break;
+                }
+            }
+        }
+    }
+
+    /**
+     * @return true 表示 doc 或 lock 至少有一个存在并已加入删除列表
+     */
+    private boolean addDirectDocKeyIfExists(String docKey, List<String> keysToDelete) {
+        String lockKey = FEISHU_DIRECT_DOC_LOCK_PREFIX + docKey;
+        boolean docExists = redisCache.hasKey(docKey);
+        boolean lockExists = redisCache.hasKey(lockKey);
+        if (docExists) {
+            keysToDelete.add(docKey);
+        }
+        if (lockExists) {
+            keysToDelete.add(lockKey);
+        }
+        return docExists || lockExists;
     }
 
     /**

+ 2 - 2
fs-service/src/main/java/com/fs/feishu/util/FeishuDirectDocShardAllocator.java

@@ -61,9 +61,9 @@ public class FeishuDirectDocShardAllocator {
     }
 
     /**
-     * 群发场景下,每组维度首次分配分片前刷新Redis文档缓存,避免重复扫描
+     * 群发场景下,每个「销售+课节+账号」维度在同一次群发请求内只清一次缓存。
      *
-     * @return true 代表当前key首次标记,需要执行缓存刷新逻辑
+     * @return true 代表当前维度首次标记,需要执行缓存刷新逻辑
      */
     public boolean markDirectDocCacheForRefresh(Long companyUserId, Long videoId, Long courseId, Long feishuAccountId) {
         if (!refreshCacheBeforeUse) {