diff --git a/rc-service/rc-inner-api/other-inner/other-inner-model/src/main/java/com/red/circle/other/inner/model/cache/EmptyRoomCacheKeys.java b/rc-service/rc-inner-api/other-inner/other-inner-model/src/main/java/com/red/circle/other/inner/model/cache/EmptyRoomCacheKeys.java
new file mode 100644
index 00000000..f52059da
--- /dev/null
+++ b/rc-service/rc-inner-api/other-inner/other-inner-model/src/main/java/com/red/circle/other/inner/model/cache/EmptyRoomCacheKeys.java
@@ -0,0 +1,35 @@
+package com.red.circle.other.inner.model.cache;
+
+/**
+ * 空房间缓存的跨服务 Redis key 约定。
+ *
+ *
live-service 负责写入空房间快照,other-service 负责消费索引生成发现页。key 只能在这个
+ * 共享模型中定义,避免两个服务独立拼接后发生不可见的数据分叉。
+ */
+public final class EmptyRoomCacheKeys {
+
+ /** 保留旧前缀,使滚动发布期间尚未升级的 other-service 仍可读取房间快照。 */
+ private static final String PAYLOAD_PREFIX = "empty_room_cache:";
+
+ /**
+ * 索引必须避开 {@code empty_room_cache:*}。旧版 other-service 会扫描该通配符并对结果执行
+ * GET;如果索引也使用旧前缀,旧实例读到 ZSet 会产生 WRONGTYPE。
+ */
+ private static final String INDEX_PREFIX = "empty_room_index:v2:";
+ private static final String ALL_REGIONS = "__ALL__";
+
+ private EmptyRoomCacheKeys() {
+ }
+
+ public static String payload(Long roomId) {
+ return PAYLOAD_PREFIX + roomId;
+ }
+
+ public static String regionIndex(String sysOrigin, String region) {
+ return INDEX_PREFIX + sysOrigin + ":" + region;
+ }
+
+ public static String allRegionsIndex(String sysOrigin) {
+ return INDEX_PREFIX + sysOrigin + ":" + ALL_REGIONS;
+ }
+}
diff --git a/rc-service/rc-service-live/live-application/src/main/java/com/red/circle/live/app/command/user/LiveQuitRoomExe.java b/rc-service/rc-service-live/live-application/src/main/java/com/red/circle/live/app/command/user/LiveQuitRoomExe.java
index dbf0be88..ea2e68ca 100644
--- a/rc-service/rc-service-live/live-application/src/main/java/com/red/circle/live/app/command/user/LiveQuitRoomExe.java
+++ b/rc-service/rc-service-live/live-application/src/main/java/com/red/circle/live/app/command/user/LiveQuitRoomExe.java
@@ -1,12 +1,12 @@
package com.red.circle.live.app.command.user;
-import com.alibaba.fastjson.JSON;
import com.red.circle.common.business.dto.cmd.app.AppRoomIdCmd;
import com.red.circle.component.redis.service.RedisService;
-import com.red.circle.framework.dto.ResultResponse;
-import com.red.circle.live.domain.gateway.LiveMicrophoneGateway;
-import com.red.circle.live.domain.live.LiveMicrophone;
-import com.red.circle.live.domain.live.LiveMusicStatus;
+import com.red.circle.framework.dto.ResultResponse;
+import com.red.circle.live.domain.gateway.LiveMicrophoneGateway;
+import com.red.circle.live.domain.live.LiveMicrophone;
+import com.red.circle.live.domain.live.LiveMusicStatus;
+import com.red.circle.live.infra.database.cache.service.EmptyRoomCacheWriter;
import com.red.circle.live.infra.database.cache.service.LiveMicCacheService;
import com.red.circle.live.infra.database.cache.service.LiveMusicHeartbeatService;
import com.red.circle.live.infra.database.cache.service.UserAgoraTokenCacheService;
@@ -16,7 +16,6 @@ import com.red.circle.tool.core.collection.CollectionUtils;
import com.red.circle.tool.core.thread.ThreadPoolManager;
import java.util.List;
import java.util.Objects;
-import java.util.concurrent.TimeUnit;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
@@ -37,10 +36,8 @@ public class LiveQuitRoomExe {
private final ActiveVoiceRoomClient activeVoiceRoomClient;
private final RedisService redisService;
private final UserAgoraTokenCacheService userAgoraTokenCacheService;
-
- private static final String EMPTY_ROOM_CACHE_KEY = "empty_room_cache:";
- private static final int CACHE_EXPIRE_MINUTES = 10;
-
+ private final EmptyRoomCacheWriter emptyRoomCacheWriter;
+
public void execute(AppRoomIdCmd cmd) {
Long roomId = cmd.getRoomId();
Long userId = cmd.requiredReqUserId();
@@ -94,13 +91,8 @@ public class LiveQuitRoomExe {
ResultResponse noBodyRoom = activeVoiceRoomClient.createNoBodyRoom(roomId);
ActiveVoiceRoomCO body = noBodyRoom.getBody();
if (Objects.nonNull(body)) {
- String cacheKey = EMPTY_ROOM_CACHE_KEY + roomId;
- redisService.setIfAbsent(
- cacheKey,
- JSON.toJSONString(body),
- CACHE_EXPIRE_MINUTES,
- TimeUnit.MINUTES
- );
+ // 新 live 同时维护 legacy payload 与 ZSet,使它可以先于 other 发布且不影响旧 reader。
+ emptyRoomCacheWriter.save(body);
}
} catch (Exception e) {
log.error("quitRoom cacheEmptyRoom error roomId={}, userId={}", roomId, userId, e);
diff --git a/rc-service/rc-service-live/live-infrastructure/src/main/java/com/red/circle/live/infra/database/cache/service/EmptyRoomCacheWriter.java b/rc-service/rc-service-live/live-infrastructure/src/main/java/com/red/circle/live/infra/database/cache/service/EmptyRoomCacheWriter.java
new file mode 100644
index 00000000..0880fcbe
--- /dev/null
+++ b/rc-service/rc-service-live/live-infrastructure/src/main/java/com/red/circle/live/infra/database/cache/service/EmptyRoomCacheWriter.java
@@ -0,0 +1,16 @@
+package com.red.circle.live.infra.database.cache.service;
+
+import com.red.circle.other.inner.model.dto.live.ActiveVoiceRoomCO;
+
+/**
+ * 维护发现页所需的空房间快照与索引。
+ */
+public interface EmptyRoomCacheWriter {
+
+ /**
+ * 保存空房间快照,并把房间登记到所属区域和同系统全区域索引。
+ *
+ * @param room other-service 生成的完整空房间快照
+ */
+ void save(ActiveVoiceRoomCO room);
+}
diff --git a/rc-service/rc-service-live/live-infrastructure/src/main/java/com/red/circle/live/infra/database/cache/service/impl/EmptyRoomCacheWriterImpl.java b/rc-service/rc-service-live/live-infrastructure/src/main/java/com/red/circle/live/infra/database/cache/service/impl/EmptyRoomCacheWriterImpl.java
new file mode 100644
index 00000000..cf95a8f9
--- /dev/null
+++ b/rc-service/rc-service-live/live-infrastructure/src/main/java/com/red/circle/live/infra/database/cache/service/impl/EmptyRoomCacheWriterImpl.java
@@ -0,0 +1,112 @@
+package com.red.circle.live.infra.database.cache.service.impl;
+
+import com.alibaba.fastjson.JSON;
+import com.red.circle.component.redis.service.RedisService;
+import com.red.circle.live.infra.database.cache.service.EmptyRoomCacheWriter;
+import com.red.circle.other.inner.model.cache.EmptyRoomCacheKeys;
+import com.red.circle.other.inner.model.dto.live.ActiveVoiceRoomCO;
+import com.red.circle.tool.core.text.StringUtils;
+import java.util.Objects;
+import java.util.concurrent.TimeUnit;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+/**
+ * 使用“单房间快照 + ZSet 到期索引”维护空房间。
+ *
+ * ZSet score 是快照实际过期毫秒。这样 Set/Hash 不具备的成员级 TTL 可以在读取时通过
+ * ZREMRANGEBYSCORE 有界清理,同时发现页不再遍历整个 Redis DB。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class EmptyRoomCacheWriterImpl implements EmptyRoomCacheWriter {
+
+ private static final long PAYLOAD_TTL_MINUTES = 10L;
+ private static final long INDEX_TTL_MINUTES = PAYLOAD_TTL_MINUTES + 1L;
+
+ private final RedisService redisService;
+
+ @Override
+ public void save(ActiveVoiceRoomCO room) {
+ if (!isIndexable(room)) {
+ log.warn("跳过字段不完整的空房间索引, roomId={}, sysOrigin={}, region={}",
+ Objects.isNull(room) ? null : room.getId(),
+ Objects.isNull(room) ? null : room.getSysOrigin(),
+ Objects.isNull(room) ? null : room.getRegion());
+ return;
+ }
+
+ String payloadKey = EmptyRoomCacheKeys.payload(room.getId());
+ try {
+ // 保持原有 SETNX 语义:普通成员频繁退出不能持续延长同一空房间的展示窗口。
+ redisService.setIfAbsent(
+ payloadKey,
+ JSON.toJSONString(room),
+ PAYLOAD_TTL_MINUTES,
+ TimeUnit.MINUTES
+ );
+
+ Long remainingMillis = ensurePayloadTtl(payloadKey, room);
+ if (Objects.isNull(remainingMillis) || remainingMillis <= 0L) {
+ log.warn("空房间快照在建立索引前已过期, roomId={}, payloadKey={}", room.getId(), payloadKey);
+ return;
+ }
+
+ double expiresAtMillis = System.currentTimeMillis() + remainingMillis;
+ String roomMember = room.getId().toString();
+ addIndexMember(
+ EmptyRoomCacheKeys.regionIndex(room.getSysOrigin(), room.getRegion()),
+ roomMember,
+ expiresAtMillis
+ );
+ addIndexMember(
+ EmptyRoomCacheKeys.allRegionsIndex(room.getSysOrigin()),
+ roomMember,
+ expiresAtMillis
+ );
+ } catch (RuntimeException e) {
+ // 空房间只是发现页补充数据,索引故障不能反向破坏已经完成的退房主链路。
+ log.error("维护空房间索引失败, roomId={}, sysOrigin={}, region={}",
+ room.getId(), room.getSysOrigin(), room.getRegion(), e);
+ }
+ }
+
+ private Long ensurePayloadTtl(String payloadKey, ActiveVoiceRoomCO room) {
+ Long remainingMillis = redisService.getExpire(payloadKey, TimeUnit.MILLISECONDS);
+ if (Objects.nonNull(remainingMillis) && remainingMillis > 0L) {
+ return remainingMillis;
+ }
+
+ if (Objects.equals(remainingMillis, -1L)) {
+ // 修复历史上可能残留的无 TTL 快照,避免索引成员永久存在。
+ redisService.expire(payloadKey, PAYLOAD_TTL_MINUTES, TimeUnit.MINUTES);
+ return TimeUnit.MINUTES.toMillis(PAYLOAD_TTL_MINUTES);
+ }
+
+ // 快照可能恰好在 SETNX 与 TTL 查询之间过期;仅重试一次,避免异常 Redis 下无界重试。
+ redisService.setIfAbsent(
+ payloadKey,
+ JSON.toJSONString(room),
+ PAYLOAD_TTL_MINUTES,
+ TimeUnit.MINUTES
+ );
+ return redisService.getExpire(payloadKey, TimeUnit.MILLISECONDS);
+ }
+
+ private void addIndexMember(String indexKey, String roomMember, double expiresAtMillis) {
+ // 每次写入先增量淘汰已过期成员;持续活跃的 ALL 索引也只保留最近十分钟窗口,避免集中大删除。
+ redisService.zsetRemoveByScore(indexKey, 0D, System.currentTimeMillis());
+ redisService.zsetAdd(indexKey, roomMember, expiresAtMillis);
+ // 索引 TTL 比成员窗口多一分钟;无新增写入时,最后一批成员清理后索引也会自然释放。
+ redisService.expire(indexKey, INDEX_TTL_MINUTES, TimeUnit.MINUTES);
+ }
+
+ private boolean isIndexable(ActiveVoiceRoomCO room) {
+ return Objects.nonNull(room)
+ && Objects.nonNull(room.getId())
+ && StringUtils.isNotBlank(room.getSysOrigin())
+ && StringUtils.isNotBlank(room.getRegion());
+ }
+}
diff --git a/rc-service/rc-service-live/live-infrastructure/src/test/java/com/red/circle/live/infra/database/cache/service/impl/EmptyRoomCacheWriterImplTest.java b/rc-service/rc-service-live/live-infrastructure/src/test/java/com/red/circle/live/infra/database/cache/service/impl/EmptyRoomCacheWriterImplTest.java
new file mode 100644
index 00000000..513821d0
--- /dev/null
+++ b/rc-service/rc-service-live/live-infrastructure/src/test/java/com/red/circle/live/infra/database/cache/service/impl/EmptyRoomCacheWriterImplTest.java
@@ -0,0 +1,276 @@
+package com.red.circle.live.infra.database.cache.service.impl;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import com.alibaba.fastjson.JSON;
+import com.red.circle.component.redis.service.RedisService;
+import com.red.circle.other.inner.model.dto.live.ActiveVoiceRoomCO;
+import java.lang.reflect.Proxy;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.Queue;
+import java.util.concurrent.TimeUnit;
+import org.junit.Test;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.core.ValueOperations;
+import org.springframework.data.redis.core.ZSetOperations;
+
+public class EmptyRoomCacheWriterImplTest {
+
+ private static final long ROOM_ID = 1001L;
+ private static final long PAYLOAD_TTL_MILLIS = TimeUnit.MINUTES.toMillis(10L);
+
+ @Test
+ public void saveShouldKeepLegacyPayloadAndWriteRegionAndAllRegionIndexes() {
+ RecordingRedisTemplate redisTemplate = new RecordingRedisTemplate();
+ redisTemplate.remainingTtls.add(TimeUnit.MINUTES.toMillis(7L));
+ EmptyRoomCacheWriterImpl writer = writer(redisTemplate);
+
+ long beforeSave = System.currentTimeMillis();
+ writer.save(room(ROOM_ID, "YOLO", "AE"));
+ long afterSave = System.currentTimeMillis();
+
+ assertEquals(1, redisTemplate.payloadWrites.size());
+ PayloadWrite payloadWrite = redisTemplate.payloadWrites.get(0);
+ assertEquals("empty_room_cache:1001", payloadWrite.key());
+ assertEquals(10L, payloadWrite.ttl());
+ assertEquals(TimeUnit.MINUTES, payloadWrite.unit());
+ ActiveVoiceRoomCO cachedRoom = JSON.parseObject(payloadWrite.value().toString(),
+ ActiveVoiceRoomCO.class);
+ assertEquals(Long.valueOf(ROOM_ID), cachedRoom.getId());
+ assertEquals("YOLO", cachedRoom.getSysOrigin());
+ assertEquals("AE", cachedRoom.getRegion());
+
+ assertEquals(2, redisTemplate.indexWrites.size());
+ assertEquals(2, redisTemplate.indexCleanupCalls.size());
+ IndexWrite regionIndex = redisTemplate.indexWrites.get(0);
+ IndexWrite allRegionsIndex = redisTemplate.indexWrites.get(1);
+ assertEquals("empty_room_index:v2:YOLO:AE", regionIndex.key());
+ assertEquals("empty_room_index:v2:YOLO:__ALL__", allRegionsIndex.key());
+ assertEquals("1001", regionIndex.member());
+ assertEquals("1001", allRegionsIndex.member());
+ assertEquals(regionIndex.score(), allRegionsIndex.score(), 0D);
+ assertEquals("empty_room_index:v2:YOLO:AE", redisTemplate.indexCleanupCalls.get(0).key());
+ assertEquals("empty_room_index:v2:YOLO:__ALL__", redisTemplate.indexCleanupCalls.get(1).key());
+
+ long remainingMillis = TimeUnit.MINUTES.toMillis(7L);
+ assertTrue(regionIndex.score() >= beforeSave + remainingMillis);
+ assertTrue(regionIndex.score() <= afterSave + remainingMillis);
+ assertEquals(List.of(
+ new ExpireCall("empty_room_index:v2:YOLO:AE", 11L, TimeUnit.MINUTES),
+ new ExpireCall("empty_room_index:v2:YOLO:__ALL__", 11L, TimeUnit.MINUTES)
+ ), redisTemplate.expireCalls);
+ assertTrue(redisTemplate.events.indexOf("payload:empty_room_cache:1001")
+ < redisTemplate.events.indexOf("index:empty_room_index:v2:YOLO:AE"));
+ }
+
+ @Test
+ public void saveShouldRepairLegacyPayloadWithoutTtlBeforeIndexing() {
+ RecordingRedisTemplate redisTemplate = new RecordingRedisTemplate();
+ redisTemplate.remainingTtls.add(-1L);
+ EmptyRoomCacheWriterImpl writer = writer(redisTemplate);
+
+ long beforeSave = System.currentTimeMillis();
+ writer.save(room(ROOM_ID, "YOLO", "AE"));
+ long afterSave = System.currentTimeMillis();
+
+ assertEquals(new ExpireCall("empty_room_cache:1001", 10L, TimeUnit.MINUTES),
+ redisTemplate.expireCalls.get(0));
+ assertEquals(3, redisTemplate.expireCalls.size());
+ assertEquals(2, redisTemplate.indexWrites.size());
+ double score = redisTemplate.indexWrites.get(0).score();
+ assertTrue(score >= beforeSave + PAYLOAD_TTL_MILLIS);
+ assertTrue(score <= afterSave + PAYLOAD_TTL_MILLIS);
+ }
+
+ @Test
+ public void saveShouldRetrySnapshotThatExpiredBeforeTtlRead() {
+ RecordingRedisTemplate redisTemplate = new RecordingRedisTemplate();
+ redisTemplate.remainingTtls.add(-2L);
+ redisTemplate.remainingTtls.add(TimeUnit.MINUTES.toMillis(5L));
+ EmptyRoomCacheWriterImpl writer = writer(redisTemplate);
+
+ writer.save(room(ROOM_ID, "YOLO", "AE"));
+
+ assertEquals(2, redisTemplate.payloadWrites.size());
+ assertEquals("empty_room_cache:1001", redisTemplate.payloadWrites.get(0).key());
+ assertEquals("empty_room_cache:1001", redisTemplate.payloadWrites.get(1).key());
+ assertEquals(2, redisTemplate.indexWrites.size());
+ }
+
+ @Test
+ public void saveShouldIgnoreRoomsThatCannotBePartitioned() {
+ RecordingRedisTemplate redisTemplate = new RecordingRedisTemplate();
+ EmptyRoomCacheWriterImpl writer = writer(redisTemplate);
+
+ writer.save(null);
+ writer.save(room(null, "YOLO", "AE"));
+ writer.save(room(ROOM_ID, " ", "AE"));
+ writer.save(room(ROOM_ID, "YOLO", " "));
+
+ assertTrue(redisTemplate.payloadWrites.isEmpty());
+ assertTrue(redisTemplate.indexWrites.isEmpty());
+ assertTrue(redisTemplate.expireCalls.isEmpty());
+ assertEquals(0, redisTemplate.getExpireCalls);
+ }
+
+ @Test
+ public void saveShouldNotPropagatePayloadWriteFailure() {
+ RecordingRedisTemplate redisTemplate = new RecordingRedisTemplate();
+ redisTemplate.payloadWriteFailure = new IllegalStateException("redis unavailable");
+ EmptyRoomCacheWriterImpl writer = writer(redisTemplate);
+
+ // 空房缓存是退房后的附加效果;Redis 故障不能逃逸到调用者并破坏主链路。
+ writer.save(room(ROOM_ID, "YOLO", "AE"));
+
+ assertEquals(1, redisTemplate.payloadWriteAttempts);
+ assertTrue(redisTemplate.indexWrites.isEmpty());
+ }
+
+ @Test
+ public void saveShouldNotPropagateIndexWriteFailure() {
+ RecordingRedisTemplate redisTemplate = new RecordingRedisTemplate();
+ redisTemplate.remainingTtls.add(TimeUnit.MINUTES.toMillis(5L));
+ redisTemplate.indexWriteFailure = new IllegalStateException("zset unavailable");
+ EmptyRoomCacheWriterImpl writer = writer(redisTemplate);
+
+ writer.save(room(ROOM_ID, "YOLO", "AE"));
+
+ assertEquals(1, redisTemplate.payloadWrites.size());
+ assertEquals(1, redisTemplate.indexWriteAttempts);
+ assertTrue(redisTemplate.indexWrites.isEmpty());
+ assertTrue(redisTemplate.expireCalls.isEmpty());
+ }
+
+ private static EmptyRoomCacheWriterImpl writer(RecordingRedisTemplate redisTemplate) {
+ return new EmptyRoomCacheWriterImpl(new RedisService(redisTemplate));
+ }
+
+ private static ActiveVoiceRoomCO room(Long roomId, String sysOrigin, String region) {
+ ActiveVoiceRoomCO room = new ActiveVoiceRoomCO();
+ room.setId(roomId);
+ room.setSysOrigin(sysOrigin);
+ room.setRegion(region);
+ room.setUserId(2002L);
+ room.setRoomAccount("10001");
+ return room;
+ }
+
+ private record PayloadWrite(String key, Object value, long ttl, TimeUnit unit) {
+ }
+
+ private record IndexWrite(String key, Object member, double score) {
+ }
+
+ private record IndexCleanupCall(String key, double minScore, double maxScore) {
+ }
+
+ private record ExpireCall(String key, long ttl, TimeUnit unit) {
+ }
+
+ /**
+ * 只实现 writer 允许使用的 Redis 命令。生产代码若意外增加扫描或其他命令,测试会立即失败,
+ * 从而守住房间发现链路不能再退化为全库遍历的边界。
+ */
+ private static final class RecordingRedisTemplate extends RedisTemplate {
+
+ private final List payloadWrites = new ArrayList<>();
+ private final List indexWrites = new ArrayList<>();
+ private final List indexCleanupCalls = new ArrayList<>();
+ private final List expireCalls = new ArrayList<>();
+ private final List events = new ArrayList<>();
+ private final Queue remainingTtls = new ArrayDeque<>();
+ private RuntimeException payloadWriteFailure;
+ private RuntimeException indexWriteFailure;
+ private int payloadWriteAttempts;
+ private int indexWriteAttempts;
+ private int getExpireCalls;
+
+ @Override
+ @SuppressWarnings("unchecked")
+ public ValueOperations opsForValue() {
+ return proxy(ValueOperations.class, (methodName, args) -> {
+ if (Objects.equals("setIfAbsent", methodName) && args.length == 4) {
+ payloadWriteAttempts++;
+ if (payloadWriteFailure != null) {
+ throw payloadWriteFailure;
+ }
+ payloadWrites.add(new PayloadWrite(
+ args[0].toString(), args[1], ((Number) args[2]).longValue(), (TimeUnit) args[3]
+ ));
+ events.add("payload:" + args[0]);
+ return Boolean.TRUE;
+ }
+ throw new UnsupportedOperationException(methodName);
+ });
+ }
+
+ @Override
+ @SuppressWarnings("unchecked")
+ public ZSetOperations opsForZSet() {
+ return proxy(ZSetOperations.class, (methodName, args) -> {
+ if (Objects.equals("add", methodName) && args.length == 3) {
+ indexWriteAttempts++;
+ if (indexWriteFailure != null) {
+ throw indexWriteFailure;
+ }
+ indexWrites.add(new IndexWrite(
+ args[0].toString(), args[1], ((Number) args[2]).doubleValue()
+ ));
+ events.add("index:" + args[0]);
+ return Boolean.TRUE;
+ }
+ if (Objects.equals("removeRangeByScore", methodName) && args.length == 3) {
+ indexCleanupCalls.add(new IndexCleanupCall(
+ args[0].toString(),
+ ((Number) args[1]).doubleValue(),
+ ((Number) args[2]).doubleValue()
+ ));
+ return 0L;
+ }
+ throw new UnsupportedOperationException(methodName);
+ });
+ }
+
+ @Override
+ public Long getExpire(String key, TimeUnit timeUnit) {
+ getExpireCalls++;
+ assertEquals(TimeUnit.MILLISECONDS, timeUnit);
+ if (remainingTtls.isEmpty()) {
+ throw new AssertionError("Missing getExpire result for " + key);
+ }
+ return remainingTtls.remove();
+ }
+
+ @Override
+ public Boolean expire(String key, long timeout, TimeUnit unit) {
+ expireCalls.add(new ExpireCall(key, timeout, unit));
+ return Boolean.TRUE;
+ }
+
+ @SuppressWarnings("unchecked")
+ private static T proxy(Class type, Operation operation) {
+ return (T) Proxy.newProxyInstance(type.getClassLoader(), new Class>[] {type},
+ (proxy, method, args) -> {
+ if (method.getDeclaringClass().equals(Object.class)) {
+ return switch (method.getName()) {
+ case "toString" -> type.getSimpleName() + "RecordingProxy";
+ case "hashCode" -> System.identityHashCode(proxy);
+ case "equals" -> proxy == args[0];
+ default -> throw new UnsupportedOperationException(method.getName());
+ };
+ }
+ return operation.invoke(method.getName(), args == null ? new Object[0] : args);
+ });
+ }
+ }
+
+ @FunctionalInterface
+ private interface Operation {
+
+ Object invoke(String methodName, Object[] args);
+ }
+}
diff --git a/rc-service/rc-service-other/other-application/src/main/java/com/red/circle/other/app/command/room/query/RoomVoiceDiscoverQryExe.java b/rc-service/rc-service-other/other-application/src/main/java/com/red/circle/other/app/command/room/query/RoomVoiceDiscoverQryExe.java
index 5c5e9ca5..34b3cdb0 100644
--- a/rc-service/rc-service-other/other-application/src/main/java/com/red/circle/other/app/command/room/query/RoomVoiceDiscoverQryExe.java
+++ b/rc-service/rc-service-other/other-application/src/main/java/com/red/circle/other/app/command/room/query/RoomVoiceDiscoverQryExe.java
@@ -17,6 +17,7 @@ import com.red.circle.other.domain.gateway.user.ability.UserSVipGateway;
import com.red.circle.other.domain.model.user.UserProfile;
import com.red.circle.other.domain.rocket.RocketStatus;
import com.red.circle.other.infra.common.sys.CountryCodeAliasSupport;
+import com.red.circle.other.infra.database.cache.service.other.EmptyRoomCacheReader;
import com.red.circle.other.infra.database.cache.service.user.RegionRoomCacheService;
import com.red.circle.other.infra.database.mongo.entity.live.ActiveVoiceRoom;
import com.red.circle.other.infra.database.mongo.entity.live.RoomProfileManager;
@@ -26,18 +27,15 @@ import com.red.circle.other.infra.database.mongo.service.live.RoomProfileManager
import com.red.circle.other.infra.database.mongo.service.live.RoomUserBlacklistService;
import com.red.circle.other.infra.database.rds.entity.sys.SysCountryCode;
import com.red.circle.other.infra.database.rds.service.sys.SysCountryCodeService;
-import com.red.circle.other.infra.database.rds.service.user.device.LatestMobileDeviceService;
+import com.red.circle.other.infra.database.rds.service.user.device.LatestMobileDeviceService;
import com.red.circle.other.inner.model.cmd.live.AppActiveVoiceRoomQryCmd;
-import com.red.circle.other.inner.model.dto.live.ActiveVoiceRoomCO;
import com.red.circle.tool.core.collection.CollectionUtils;
import java.util.*;
+import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
-import org.springframework.beans.BeanUtils;
-import org.springframework.data.redis.core.ScanOptions;
-import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Component;
/**
@@ -57,22 +55,23 @@ public class RoomVoiceDiscoverQryExe {
private final ActiveVoiceRoomService activeVoiceRoomService;
private final RoomUserBlacklistService roomUserBlacklistService;
private final LatestMobileDeviceService latestMobileDeviceService;
- private final RoomProfileManagerService roomProfileManagerService;
- private final RegionRoomCacheService regionRoomCacheService;
- private final LiveMicClient liveMicClient;
- private final RedisTemplate redisTemplate;
+ private final RoomProfileManagerService roomProfileManagerService;
+ private final RegionRoomCacheService regionRoomCacheService;
+ private final LiveMicClient liveMicClient;
+ private final EmptyRoomCacheReader emptyRoomCacheReader;
private final RocketStatusCacheService rocketStatusCacheService;
private final GameLudoService gameLudoService;
private final UserProfileGateway userProfileGateway;
private final CountryCodeAliasSupport countryCodeAliasSupport;
- private static final String EMPTY_ROOM_CACHE_KEY = "empty_room_cache:*";
private static final String MEMBER_QUANTITY_EXT_KEY = "memberQuantity";
private static final String FIXED_WEIGHTS_EXT_KEY = "fixedWeights";
private static final int DISCOVERY_LIMIT = 50;
private static final long DISCOVERY_SLOW_STEP_MILLIS = 100L;
private static final long DISCOVERY_SLOW_TOTAL_MILLIS = 300L;
- private static final long EMPTY_ROOM_SCAN_COUNT = 200L;
+ private static final int REBUILD_WAIT_ATTEMPTS = 20;
+ private static final long REBUILD_WAIT_MILLIS = 50L;
+ private static final long EMPTY_RESULT_CACHE_SECONDS = 30L;
public List execute(AppExtCommand cmd) {
long totalStart = System.currentTimeMillis();
@@ -87,99 +86,160 @@ public class RoomVoiceDiscoverQryExe {
stepStart = logDiscoveryStep("prepare", stepStart, reqUserId, sysOrigin, region);
String key = discoveryCacheKey(sysOrigin, region, allRegionWhiteListUser);
- String regionsRoom = regionRoomCacheService.getRegionsRoom(key);
+ List cachedRooms = readDiscoveryCache(key);
stepStart = logDiscoveryStep("read-region-cache", stepStart, reqUserId, sysOrigin, region);
- if (Objects.nonNull(regionsRoom)) {
- List roomList = JSON.parseArray(regionsRoom, RoomVoiceProfileCO.class);
- if (CollectionUtils.isEmpty(roomList)) {
- logDiscoveryTotal(totalStart, reqUserId, sysOrigin, region, allRegionWhiteListUser, true, 0);
+ if (Objects.nonNull(cachedRooms)) {
+ stepStart = logDiscoveryStep("filter-cache", stepStart, reqUserId, sysOrigin, region);
+ logDiscoveryTotal(
+ totalStart,
+ reqUserId,
+ sysOrigin,
+ region,
+ allRegionWhiteListUser,
+ true,
+ cachedRooms.size()
+ );
+ return cachedRooms;
+ }
+
+ String rebuildToken = UUID.randomUUID().toString();
+ boolean rebuildLockAcquired = false;
+ boolean rebuildLockUnavailable = false;
+ try {
+ rebuildLockAcquired = regionRoomCacheService.tryAcquireRebuildLock(key, rebuildToken);
+ } catch (RuntimeException e) {
+ // Redis 锁异常时保留发现页可用性;后续缓存/索引访问仍各自 fail-open,不扩大主链路故障。
+ rebuildLockUnavailable = true;
+ log.error("发现缓存重建锁不可用,进入无锁降级, key={}, userId={}", key, reqUserId, e);
+ }
+
+ if (!rebuildLockAcquired && !rebuildLockUnavailable) {
+ List publishedRooms = waitForPublishedDiscoveryCache(key);
+ stepStart = logDiscoveryStep("wait-region-cache", stepStart, reqUserId, sysOrigin, region);
+ if (Objects.nonNull(publishedRooms)) {
+ logDiscoveryTotal(
+ totalStart,
+ reqUserId,
+ sysOrigin,
+ region,
+ allRegionWhiteListUser,
+ true,
+ publishedRooms.size()
+ );
+ return publishedRooms;
+ }
+
+ try {
+ // leader 可能失败且已释放锁;等待结束后只允许重新竞争,禁止 follower 直接并发回源。
+ rebuildLockAcquired = regionRoomCacheService.tryAcquireRebuildLock(key, rebuildToken);
+ } catch (RuntimeException e) {
+ rebuildLockUnavailable = true;
+ log.error("等待后重试发现缓存重建锁失败,进入无锁降级, key={}, userId={}", key, reqUserId, e);
+ }
+ if (!rebuildLockAcquired && !rebuildLockUnavailable) {
+ log.warn("等待发现缓存重建超时,拒绝并发回源, key={}, userId={}", key, reqUserId);
+ logDiscoveryTotal(totalStart, reqUserId, sysOrigin, region, allRegionWhiteListUser, false, 0);
return CollectionUtils.newArrayList();
}
- List availableRooms = roomVoiceProfileCommon
- .filterAvailableRoomProfiles(roomList);
- if (availableRooms.size() != roomList.size()) {
- regionRoomCacheService.remove(key);
- }
- stepStart = logDiscoveryStep("filter-cache", stepStart, reqUserId, sysOrigin, region);
- logDiscoveryTotal(totalStart, reqUserId, sysOrigin, region, allRegionWhiteListUser, true, availableRooms.size());
- return availableRooms;
}
- List activeVoiceRooms = allRegionWhiteListUser
- ? activeVoiceRoomService.listDiscover(
+ try {
+ if (rebuildLockAcquired) {
+ // 获取锁后必须二次检查,覆盖“首次 miss 与 SETNX 之间已有 leader 发布”的竞态窗口。
+ List doubleCheckedRooms = readDiscoveryCache(key);
+ stepStart = logDiscoveryStep(
+ "double-check-region-cache",
+ stepStart,
+ reqUserId,
sysOrigin,
- true,
- region,
- Optional.ofNullable(userProfile).map(UserProfile::getCountryCode).orElse(null),
- DISCOVERY_LIMIT
- )
- : roomRegionFilterCommon.filterActiveVoiceRoomsByRegion(
- activeVoiceRoomService.listByRegion(sysOrigin, region, DISCOVERY_LIMIT),
region
);
- stepStart = logDiscoveryStep("query-active-rooms", stepStart, reqUserId, sysOrigin, region);
+ if (Objects.nonNull(doubleCheckedRooms)) {
+ logDiscoveryTotal(
+ totalStart,
+ reqUserId,
+ sysOrigin,
+ region,
+ allRegionWhiteListUser,
+ true,
+ doubleCheckedRooms.size()
+ );
+ return doubleCheckedRooms;
+ }
+ }
- // 查询没人的房间缓存,加入进去
- List emptyRooms = getEmptyRoomsFromCache();
- stepStart = logDiscoveryStep("scan-empty-room-cache", stepStart, reqUserId, sysOrigin, region);
- if (CollectionUtils.isNotEmpty(emptyRooms)) {
- List matchedEmptyRooms = allRegionWhiteListUser
- ? emptyRooms.stream()
- .filter(room -> Objects.equals(sysOrigin, room.getSysOrigin()))
- .toList()
+ List activeVoiceRooms = allRegionWhiteListUser
+ ? activeVoiceRoomService.listDiscover(
+ sysOrigin,
+ true,
+ region,
+ Optional.ofNullable(userProfile).map(UserProfile::getCountryCode).orElse(null),
+ DISCOVERY_LIMIT
+ )
: roomRegionFilterCommon.filterActiveVoiceRoomsByRegion(
- emptyRooms.stream()
- .filter(room -> Objects.equals(sysOrigin, room.getSysOrigin()))
- .toList(),
+ activeVoiceRoomService.listByRegion(sysOrigin, region, DISCOVERY_LIMIT),
region
);
- if (CollectionUtils.isNotEmpty(matchedEmptyRooms)) {
- List allRooms = new ArrayList<>(activeVoiceRooms);
- Set existingRoomIds = activeVoiceRooms.stream()
- .map(ActiveVoiceRoom::getId)
- .collect(Collectors.toSet());
- matchedEmptyRooms.stream()
- .filter(room -> !existingRoomIds.contains(room.getId()))
- .forEach(allRooms::add);
- activeVoiceRooms = allRooms;
- }
- }
- stepStart = logDiscoveryStep("merge-empty-rooms", stepStart, reqUserId, sysOrigin, region);
+ stepStart = logDiscoveryStep("query-active-rooms", stepStart, reqUserId, sysOrigin, region);
- List roomList = roomVoiceProfileCommon.toListRoomVoiceProfileCO(activeVoiceRooms);
- stepStart = logDiscoveryStep("build-profile-list", stepStart, reqUserId, sysOrigin, region);
- if (roomList.size() < DISCOVERY_LIMIT) {
- List fallbackRoomManagersSource = roomProfileManagerService.listSelectiveLimitByCountryCodes(
+ // 索引已经按系统/区域收敛,读取成本只与当前候选空房数量相关,不再遍历 DB0 全量 key。
+ List emptyRooms = emptyRoomCacheReader.list(
sysOrigin,
- allRegionWhiteListUser ? List.of() : roomRegionFilterCommon.listCountryCodes(currentRegion),
- roomList.stream().map(RoomVoiceProfileCO::getId).toList(),
+ region,
+ allRegionWhiteListUser,
DISCOVERY_LIMIT
);
- List fallbackRoomManagers = allRegionWhiteListUser
- ? fallbackRoomManagersSource
- : roomRegionFilterCommon.filterRoomProfileManagersByRegion(
- fallbackRoomManagersSource,
- region
- );
- roomList = mergeRoomList(roomList, roomVoiceProfileCommon.listRoomVoiceProfilesV2(fallbackRoomManagers),
- DISCOVERY_LIMIT);
- }
- stepStart = logDiscoveryStep("fallback-room-profiles", stepStart, reqUserId, sysOrigin, region);
- roomList = sortByTopWeightAndMemberQuantityDesc(roomList);
- stepStart = logDiscoveryStep("sort-rooms", stepStart, reqUserId, sysOrigin, region);
+ stepStart = logDiscoveryStep("read-empty-room-index", stepStart, reqUserId, sysOrigin, region);
+ if (CollectionUtils.isNotEmpty(emptyRooms)) {
+ List allRooms = new ArrayList<>(activeVoiceRooms);
+ Set existingRoomIds = activeVoiceRooms.stream()
+ .map(ActiveVoiceRoom::getId)
+ .collect(Collectors.toSet());
+ emptyRooms.stream()
+ .filter(room -> !existingRoomIds.contains(room.getId()))
+ .forEach(allRooms::add);
+ activeVoiceRooms = allRooms;
+ }
+ stepStart = logDiscoveryStep("merge-empty-rooms", stepStart, reqUserId, sysOrigin, region);
- if (CollectionUtils.isEmpty(roomList)) {
- logDiscoveryTotal(totalStart, reqUserId, sysOrigin, region, allRegionWhiteListUser, false, 0);
- return CollectionUtils.newArrayList();
- }
- Map svipLevelEnumMap = userSVipGateway.checkSVipIdentity(
- roomList.stream().map(RoomVoiceProfileCO::getUserId).collect(
- Collectors.toSet()));
- roomList = roomList.stream()
- .peek(room -> {
- room.setUserSVipLevel(svipLevelEnumMap.get(room.getUserId()));
- })
- .toList();
+ List roomList = roomVoiceProfileCommon.toListRoomVoiceProfileCO(activeVoiceRooms);
+ stepStart = logDiscoveryStep("build-profile-list", stepStart, reqUserId, sysOrigin, region);
+ if (roomList.size() < DISCOVERY_LIMIT) {
+ List fallbackRoomManagersSource =
+ roomProfileManagerService.listSelectiveLimitByCountryCodes(
+ sysOrigin,
+ allRegionWhiteListUser ? List.of() : roomRegionFilterCommon.listCountryCodes(currentRegion),
+ roomList.stream().map(RoomVoiceProfileCO::getId).toList(),
+ DISCOVERY_LIMIT
+ );
+ List fallbackRoomManagers = allRegionWhiteListUser
+ ? fallbackRoomManagersSource
+ : roomRegionFilterCommon.filterRoomProfileManagersByRegion(
+ fallbackRoomManagersSource,
+ region
+ );
+ roomList = mergeRoomList(
+ roomList,
+ roomVoiceProfileCommon.listRoomVoiceProfilesV2(fallbackRoomManagers),
+ DISCOVERY_LIMIT
+ );
+ }
+ stepStart = logDiscoveryStep("fallback-room-profiles", stepStart, reqUserId, sysOrigin, region);
+ roomList = sortByTopWeightAndMemberQuantityDesc(roomList);
+ stepStart = logDiscoveryStep("sort-rooms", stepStart, reqUserId, sysOrigin, region);
+
+ if (CollectionUtils.isEmpty(roomList)) {
+ // 短负缓存让空区域的 follower 也能结束等待,但不会把新开房间隐藏十分钟。
+ saveDiscoveryCache(key, roomList, reqUserId, sysOrigin, region);
+ logDiscoveryStep("write-empty-region-cache", stepStart, reqUserId, sysOrigin, region);
+ logDiscoveryTotal(totalStart, reqUserId, sysOrigin, region, allRegionWhiteListUser, false, 0);
+ return CollectionUtils.newArrayList();
+ }
+ Map svipLevelEnumMap = userSVipGateway.checkSVipIdentity(
+ roomList.stream().map(RoomVoiceProfileCO::getUserId).collect(Collectors.toSet()));
+ roomList = roomList.stream()
+ .peek(room -> room.setUserSVipLevel(svipLevelEnumMap.get(room.getUserId())))
+ .toList();
stepStart = logDiscoveryStep("fill-svip", stepStart, reqUserId, sysOrigin, region);
// 填充火箭状态
@@ -192,7 +252,12 @@ public class RoomVoiceDiscoverQryExe {
logDiscoveryStep("write-region-cache", stepStart, reqUserId, sysOrigin, region);
logDiscoveryTotal(totalStart, reqUserId, sysOrigin, region, allRegionWhiteListUser, false, roomList.size());
- return roomList;
+ return roomList;
+ } finally {
+ if (rebuildLockAcquired) {
+ releaseRebuildLock(key, rebuildToken, reqUserId);
+ }
+ }
}
private List sortByTopWeightAndMemberQuantityDesc(
@@ -236,13 +301,105 @@ public class RoomVoiceDiscoverQryExe {
return allRegionWhiteListUser ? sysOrigin + region + ":ALL_REGION" : sysOrigin + region;
}
- private void saveDiscoveryCache(String key, List roomList, Long userId,
- String sysOrigin, String region) {
- if (CollectionUtils.isEmpty(roomList)) {
- return;
+ /**
+ * 读取并校验发现页缓存。返回 null 表示真正 miss;空 List 表示命中的负缓存。
+ */
+ private List readDiscoveryCache(String key) {
+ return readDiscoveryCache(key, true);
+ }
+
+ private List readDiscoveryCache(String key, boolean logReadFailure) {
+ String regionsRoom;
+ try {
+ regionsRoom = regionRoomCacheService.getRegionsRoom(key);
+ } catch (RuntimeException e) {
+ // Redis GET 超时按真正 miss 处理,后续仍由分布式锁或无锁降级决定是否回源。
+ if (logReadFailure) {
+ log.warn("读取发现缓存失败,按 miss 降级, key={}", key, e);
+ }
+ return null;
+ }
+ if (Objects.isNull(regionsRoom)) {
+ return null;
}
try {
- regionRoomCacheService.saveRegionsRoom(JSON.toJSONString(roomList), key);
+ List roomList = JSON.parseArray(regionsRoom, RoomVoiceProfileCO.class);
+ if (Objects.isNull(roomList)) {
+ removeDiscoveryCache(key);
+ return null;
+ }
+ if (CollectionUtils.isEmpty(roomList)) {
+ return CollectionUtils.newArrayList();
+ }
+
+ List availableRooms = roomVoiceProfileCommon
+ .filterAvailableRoomProfiles(roomList);
+ if (availableRooms.size() != roomList.size()) {
+ // 本次仍返回过滤后的安全结果,下一请求通过 singleflight 重建完整缓存。
+ removeDiscoveryCache(key);
+ }
+ return availableRooms;
+ } catch (RuntimeException e) {
+ removeDiscoveryCache(key);
+ log.warn("清理无法解析的发现页缓存, key={}", key, e);
+ return null;
+ }
+ }
+
+ private List waitForPublishedDiscoveryCache(String key) {
+ for (int attempt = 0; attempt < REBUILD_WAIT_ATTEMPTS; attempt++) {
+ // follower 轮询期间抑制重复 GET 异常日志;初次读取和锁竞争仍会保留故障证据。
+ List cachedRooms = readDiscoveryCache(key, false);
+ if (Objects.nonNull(cachedRooms)) {
+ return cachedRooms;
+ }
+ if (attempt + 1 >= REBUILD_WAIT_ATTEMPTS) {
+ break;
+ }
+ try {
+ Thread.sleep(REBUILD_WAIT_MILLIS);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ return null;
+ }
+ }
+ return null;
+ }
+
+ private void removeDiscoveryCache(String key) {
+ try {
+ regionRoomCacheService.remove(key);
+ } catch (RuntimeException e) {
+ // 清理失败不能覆盖本次安全返回,残留坏值由 TTL 或下一次成功清理收敛。
+ log.warn("删除失效发现缓存失败, key={}", key, e);
+ }
+ }
+
+ private void releaseRebuildLock(String key, String token, Long userId) {
+ try {
+ if (!regionRoomCacheService.releaseRebuildLock(key, token)) {
+ // token 不一致通常表示租约已过期;Lua 保证不会误删新的持有者。
+ log.warn("发现缓存重建锁未释放,等待 TTL 自愈, key={}, userId={}", key, userId);
+ }
+ } catch (RuntimeException e) {
+ log.error("释放发现缓存重建锁失败, key={}, userId={}", key, userId, e);
+ }
+ }
+
+ private void saveDiscoveryCache(String key, List roomList, Long userId,
+ String sysOrigin, String region) {
+ try {
+ String payload = JSON.toJSONString(roomList);
+ if (CollectionUtils.isEmpty(roomList)) {
+ regionRoomCacheService.saveRegionsRoom(
+ payload,
+ key,
+ EMPTY_RESULT_CACHE_SECONDS,
+ TimeUnit.SECONDS
+ );
+ } else {
+ regionRoomCacheService.saveRegionsRoom(payload, key);
+ }
} catch (Exception e) {
log.warn("写入房间发现缓存失败, key={}, userId={}, sysOrigin={}, region={}",
key, userId, sysOrigin, region, e);
@@ -410,88 +567,8 @@ public class RoomVoiceDiscoverQryExe {
}
}
- /**
- * 从缓存中获取没人的房间
- *
- * @return
- */
- private List getEmptyRoomsFromCache() {
- try {
- Set keys = scanEmptyRoomKeys();
- if (CollectionUtils.isEmpty(keys)) {
- return new ArrayList<>();
- }
-
- List emptyRooms = new ArrayList<>();
- for (String key : keys) {
- String cachedRoom = (String) redisTemplate.opsForValue().get(key);
- if (cachedRoom != null) {
- try {
- // 将缓存对象转换为ActiveVoiceRoomCO,再转换为ActiveVoiceRoom
- ActiveVoiceRoomCO roomCO = JSON.parseObject(cachedRoom, ActiveVoiceRoomCO.class);
- if (roomCO != null) {
- ActiveVoiceRoom room = convertToActiveVoiceRoom(roomCO);
- if (room != null) {
- emptyRooms.add(room);
- }
- }
- } catch (Exception e) {
- log.warn("转换缓存房间对象失败, key: {}", key, e);
- }
- }
- }
- return emptyRooms;
- } catch (Exception e) {
- log.error("获取空房间缓存失败", e);
- return new ArrayList<>();
- }
- }
-
- private Set scanEmptyRoomKeys() {
- Set keys = new HashSet<>();
- ScanOptions options = ScanOptions.scanOptions()
- .match(EMPTY_ROOM_CACHE_KEY)
- .count(EMPTY_ROOM_SCAN_COUNT)
- .build();
- try (var cursor = redisTemplate.scan(options)) {
- if (Objects.isNull(cursor)) {
- return keys;
- }
- while (cursor.hasNext()) {
- keys.add(Objects.toString(cursor.next(), ""));
- }
- }
- return keys;
- }
-
/**
- * 将ActiveVoiceRoomCO转换为ActiveVoiceRoom
- *
- * @param roomCO
- * @return
- */
- private ActiveVoiceRoom convertToActiveVoiceRoom(ActiveVoiceRoomCO roomCO) {
- try {
- ActiveVoiceRoom room = new ActiveVoiceRoom();
- BeanUtils.copyProperties(roomCO, room);
- // 设置在线人数为0,因为这是没人的房间
- room.setOnlineQuantity(0L);
- // 设置默认权重(如果未设置的话)
- if (room.getWeights() == null) {
- room.setWeights(0);
- }
- if (room.getFixedWeights() == null) {
- room.setFixedWeights(0);
- }
- return room;
- } catch (Exception e) {
- log.error("转换ActiveVoiceRoomCO失败", e);
- return null;
- }
- }
-
- /**
- * 去除拉黑和被上锁的的房间
+ * 去除拉黑和被上锁的的房间
*
* @param roomIds
* @return
diff --git a/rc-service/rc-service-other/other-application/src/test/java/com/red/circle/other/app/command/room/query/RoomVoiceDiscoverQryExeTest.java b/rc-service/rc-service-other/other-application/src/test/java/com/red/circle/other/app/command/room/query/RoomVoiceDiscoverQryExeTest.java
index 0d662565..6afd76fb 100644
--- a/rc-service/rc-service-other/other-application/src/test/java/com/red/circle/other/app/command/room/query/RoomVoiceDiscoverQryExeTest.java
+++ b/rc-service/rc-service-other/other-application/src/test/java/com/red/circle/other/app/command/room/query/RoomVoiceDiscoverQryExeTest.java
@@ -1,6 +1,7 @@
package com.red.circle.other.app.command.room.query;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.anySet;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
@@ -22,6 +23,7 @@ import com.red.circle.other.domain.gateway.user.ability.UserSVipGateway;
import com.red.circle.other.domain.model.user.UserProfile;
import com.red.circle.other.domain.model.user.ability.RegionConfig;
import com.red.circle.other.infra.common.sys.CountryCodeAliasSupport;
+import com.red.circle.other.infra.database.cache.service.other.EmptyRoomCacheReader;
import com.red.circle.other.infra.database.cache.service.user.RegionRoomCacheService;
import com.red.circle.other.infra.database.mongo.entity.live.ActiveVoiceRoom;
import com.red.circle.other.infra.database.mongo.entity.live.RoomProfileManager;
@@ -31,9 +33,8 @@ import com.red.circle.other.infra.database.mongo.service.live.RoomUserBlacklistS
import com.red.circle.other.infra.database.rds.service.user.device.LatestMobileDeviceService;
import java.util.List;
import java.util.Map;
-import java.util.Set;
+import java.util.concurrent.TimeUnit;
import org.junit.jupiter.api.Test;
-import org.springframework.data.redis.core.RedisTemplate;
class RoomVoiceDiscoverQryExeTest {
@@ -51,7 +52,7 @@ class RoomVoiceDiscoverQryExeTest {
RoomProfileManagerService roomProfileManagerService = mock(RoomProfileManagerService.class);
RegionRoomCacheService regionRoomCacheService = mock(RegionRoomCacheService.class);
LiveMicClient liveMicClient = mock(LiveMicClient.class);
- RedisTemplate redisTemplate = mock(RedisTemplate.class);
+ EmptyRoomCacheReader emptyRoomCacheReader = mock(EmptyRoomCacheReader.class);
RocketStatusCacheService rocketStatusCacheService = mock(RocketStatusCacheService.class);
GameLudoService gameLudoService = mock(GameLudoService.class);
UserProfileGateway userProfileGateway = mock(UserProfileGateway.class);
@@ -67,7 +68,7 @@ class RoomVoiceDiscoverQryExeTest {
roomProfileManagerService,
regionRoomCacheService,
liveMicClient,
- redisTemplate,
+ emptyRoomCacheReader,
rocketStatusCacheService,
gameLudoService,
userProfileGateway,
@@ -94,10 +95,12 @@ class RoomVoiceDiscoverQryExeTest {
when(roomRegionFilterCommon.requireRegionConfig(1L)).thenReturn(currentRegion);
when(userProfileGateway.getByUserId(1L)).thenReturn(userProfile);
when(regionRoomCacheService.getRegionsRoom("YOLOAE")).thenReturn(null);
+ when(regionRoomCacheService.tryAcquireRebuildLock(anyString(), anyString())).thenReturn(true);
+ when(regionRoomCacheService.releaseRebuildLock(anyString(), anyString())).thenReturn(true);
when(activeVoiceRoomService.listByRegion("YOLO", "AE", 50)).thenReturn(List.of(activeVoiceRoom));
when(roomRegionFilterCommon.filterActiveVoiceRoomsByRegion(List.of(activeVoiceRoom), "AE"))
.thenReturn(List.of(activeVoiceRoom));
- when(redisTemplate.keys("empty_room_cache:*")).thenReturn(Set.of());
+ when(emptyRoomCacheReader.list("YOLO", "AE", false, 50)).thenReturn(List.of());
when(roomVoiceProfileCommon.toListRoomVoiceProfileCO(List.of(activeVoiceRoom))).thenReturn(
List.of(activeRoomCo)
);
@@ -121,6 +124,8 @@ class RoomVoiceDiscoverQryExeTest {
assertEquals(List.of(fallbackRoomCo, activeRoomCo), roomList);
verify(activeVoiceRoomService).listByRegion("YOLO", "AE", 50);
+ verify(emptyRoomCacheReader).list("YOLO", "AE", false, 50);
+ verify(regionRoomCacheService).releaseRebuildLock(anyString(), anyString());
verify(activeVoiceRoomService, never()).listDiscover("YOLO", true, "AE", null, 50);
}
@@ -136,7 +141,7 @@ class RoomVoiceDiscoverQryExeTest {
RoomProfileManagerService roomProfileManagerService = mock(RoomProfileManagerService.class);
RegionRoomCacheService regionRoomCacheService = mock(RegionRoomCacheService.class);
LiveMicClient liveMicClient = mock(LiveMicClient.class);
- RedisTemplate redisTemplate = mock(RedisTemplate.class);
+ EmptyRoomCacheReader emptyRoomCacheReader = mock(EmptyRoomCacheReader.class);
RocketStatusCacheService rocketStatusCacheService = mock(RocketStatusCacheService.class);
GameLudoService gameLudoService = mock(GameLudoService.class);
UserProfileGateway userProfileGateway = mock(UserProfileGateway.class);
@@ -152,7 +157,7 @@ class RoomVoiceDiscoverQryExeTest {
roomProfileManagerService,
regionRoomCacheService,
liveMicClient,
- redisTemplate,
+ emptyRoomCacheReader,
rocketStatusCacheService,
gameLudoService,
userProfileGateway,
@@ -179,9 +184,12 @@ class RoomVoiceDiscoverQryExeTest {
when(roomRegionFilterCommon.requireRegionConfig(1L)).thenReturn(currentRegion);
when(userProfileGateway.getByUserId(1L)).thenReturn(userProfile);
when(regionRoomCacheService.getRegionsRoom("YOLOAE")).thenReturn(null);
+ when(regionRoomCacheService.tryAcquireRebuildLock(anyString(), anyString())).thenReturn(true);
+ when(regionRoomCacheService.releaseRebuildLock(anyString(), anyString())).thenReturn(true);
when(activeVoiceRoomService.listByRegion("YOLO", "AE", 50)).thenReturn(List.of(activeVoiceRoom));
when(roomRegionFilterCommon.filterActiveVoiceRoomsByRegion(List.of(activeVoiceRoom), "AE"))
.thenReturn(List.of(activeVoiceRoom));
+ when(emptyRoomCacheReader.list("YOLO", "AE", false, 50)).thenReturn(List.of());
when(roomVoiceProfileCommon.toListRoomVoiceProfileCO(List.of(activeVoiceRoom))).thenReturn(
List.of(topRoomCo)
);
@@ -218,7 +226,7 @@ class RoomVoiceDiscoverQryExeTest {
RoomProfileManagerService roomProfileManagerService = mock(RoomProfileManagerService.class);
RegionRoomCacheService regionRoomCacheService = mock(RegionRoomCacheService.class);
LiveMicClient liveMicClient = mock(LiveMicClient.class);
- RedisTemplate redisTemplate = mock(RedisTemplate.class);
+ EmptyRoomCacheReader emptyRoomCacheReader = mock(EmptyRoomCacheReader.class);
RocketStatusCacheService rocketStatusCacheService = mock(RocketStatusCacheService.class);
GameLudoService gameLudoService = mock(GameLudoService.class);
UserProfileGateway userProfileGateway = mock(UserProfileGateway.class);
@@ -234,7 +242,7 @@ class RoomVoiceDiscoverQryExeTest {
roomProfileManagerService,
regionRoomCacheService,
liveMicClient,
- redisTemplate,
+ emptyRoomCacheReader,
rocketStatusCacheService,
gameLudoService,
userProfileGateway,
@@ -260,9 +268,11 @@ class RoomVoiceDiscoverQryExeTest {
when(roomRegionFilterCommon.requireRegionConfig(ALL_REGION_WHITE_USER_ID)).thenReturn(currentRegion);
when(userProfileGateway.getByUserId(ALL_REGION_WHITE_USER_ID)).thenReturn(userProfile);
when(regionRoomCacheService.getRegionsRoom("YOLOAE:ALL_REGION")).thenReturn(null);
+ when(regionRoomCacheService.tryAcquireRebuildLock(anyString(), anyString())).thenReturn(true);
+ when(regionRoomCacheService.releaseRebuildLock(anyString(), anyString())).thenReturn(true);
when(activeVoiceRoomService.listDiscover("YOLO", true, "AE", "AE", 50))
.thenReturn(List.of(activeVoiceRoom));
- when(redisTemplate.keys("empty_room_cache:*")).thenReturn(Set.of());
+ when(emptyRoomCacheReader.list("YOLO", "AE", true, 50)).thenReturn(List.of());
when(roomVoiceProfileCommon.toListRoomVoiceProfileCO(List.of(activeVoiceRoom))).thenReturn(
List.of(activeRoomCo)
);
@@ -282,7 +292,151 @@ class RoomVoiceDiscoverQryExeTest {
assertEquals(List.of(activeRoomCo, fallbackRoomCo), roomList);
verify(activeVoiceRoomService, never()).listByRegion("YOLO", "AE", 50);
+ verify(emptyRoomCacheReader).list("YOLO", "AE", true, 50);
verify(roomRegionFilterCommon, never()).filterActiveVoiceRoomsByRegion(List.of(activeVoiceRoom), "AE");
verify(roomRegionFilterCommon, never()).filterRoomProfileManagersByRegion(List.of(fallbackRoomManager), "AE");
}
+
+ @Test
+ void execute_shouldDoubleCheckCacheAfterWinningRebuildLock() {
+ DiscoveryFixture fixture = new DiscoveryFixture();
+ AppExtCommand cmd = fixture.stubCurrentRegionRequest();
+
+ // 首次读取 miss 后,另一实例可能已经发布;leader 获锁后必须二次检查,不能重复回源。
+ when(fixture.regionRoomCacheService.getRegionsRoom("YOLOAE")).thenReturn(null, "[]");
+ when(fixture.regionRoomCacheService.tryAcquireRebuildLock(anyString(), anyString()))
+ .thenReturn(true);
+ when(fixture.regionRoomCacheService.releaseRebuildLock(anyString(), anyString()))
+ .thenReturn(true);
+
+ assertEquals(List.of(), fixture.exe.execute(cmd));
+
+ verify(fixture.activeVoiceRoomService, never()).listByRegion("YOLO", "AE", 50);
+ verify(fixture.emptyRoomCacheReader, never()).list("YOLO", "AE", false, 50);
+ verify(fixture.regionRoomCacheService).releaseRebuildLock(anyString(), anyString());
+ }
+
+ @Test
+ void execute_shouldWaitForLeaderCacheWithoutFollowerOriginQuery() {
+ DiscoveryFixture fixture = new DiscoveryFixture();
+ AppExtCommand cmd = fixture.stubCurrentRegionRequest();
+
+ // follower 第一次 miss 后立即读到 leader 发布的负缓存,不能自行查询 Mongo 或空房索引。
+ when(fixture.regionRoomCacheService.getRegionsRoom("YOLOAE")).thenReturn(null, "[]");
+ when(fixture.regionRoomCacheService.tryAcquireRebuildLock(anyString(), anyString()))
+ .thenReturn(false);
+
+ assertEquals(List.of(), fixture.exe.execute(cmd));
+
+ verify(fixture.activeVoiceRoomService, never()).listByRegion("YOLO", "AE", 50);
+ verify(fixture.emptyRoomCacheReader, never()).list("YOLO", "AE", false, 50);
+ verify(fixture.regionRoomCacheService, never()).releaseRebuildLock(anyString(), anyString());
+ }
+
+ @Test
+ void execute_shouldPublishShortNegativeCacheForEmptyRegion() {
+ DiscoveryFixture fixture = new DiscoveryFixture();
+ AppExtCommand cmd = fixture.stubCurrentRegionRequest();
+
+ when(fixture.regionRoomCacheService.getRegionsRoom("YOLOAE")).thenReturn(null);
+ when(fixture.regionRoomCacheService.tryAcquireRebuildLock(anyString(), anyString()))
+ .thenReturn(true);
+ when(fixture.regionRoomCacheService.releaseRebuildLock(anyString(), anyString()))
+ .thenReturn(true);
+ fixture.stubEmptyRegionOrigin();
+
+ assertEquals(List.of(), fixture.exe.execute(cmd));
+
+ verify(fixture.regionRoomCacheService)
+ .saveRegionsRoom("[]", "YOLOAE", 30L, TimeUnit.SECONDS);
+ verify(fixture.regionRoomCacheService).releaseRebuildLock(anyString(), anyString());
+ }
+
+ @Test
+ void execute_shouldFailOpenToOriginWhenDiscoveryRedisIsUnavailable() {
+ DiscoveryFixture fixture = new DiscoveryFixture();
+ AppExtCommand cmd = fixture.stubCurrentRegionRequest();
+
+ when(fixture.regionRoomCacheService.getRegionsRoom("YOLOAE"))
+ .thenThrow(new IllegalStateException("redis get timeout"));
+ when(fixture.regionRoomCacheService.tryAcquireRebuildLock(anyString(), anyString()))
+ .thenThrow(new IllegalStateException("redis setnx timeout"));
+ fixture.stubEmptyRegionOrigin();
+
+ assertEquals(List.of(), fixture.exe.execute(cmd));
+
+ verify(fixture.activeVoiceRoomService).listByRegion("YOLO", "AE", 50);
+ verify(fixture.emptyRoomCacheReader).list("YOLO", "AE", false, 50);
+ verify(fixture.regionRoomCacheService, never()).releaseRebuildLock(anyString(), anyString());
+ }
+
+ /**
+ * 只为 singleflight 分支集中装配依赖,避免测试中的大量 mock 掩盖真正的回源边界。
+ */
+ private static final class DiscoveryFixture {
+
+ private final UserSVipGateway userSVipGateway = mock(UserSVipGateway.class);
+ private final UserRegionGateway userRegionGateway = mock(UserRegionGateway.class);
+ private final RoomRegionFilterCommon roomRegionFilterCommon = mock(RoomRegionFilterCommon.class);
+ private final RoomVoiceProfileCommon roomVoiceProfileCommon = mock(RoomVoiceProfileCommon.class);
+ private final ActiveVoiceRoomService activeVoiceRoomService = mock(ActiveVoiceRoomService.class);
+ private final RoomUserBlacklistService roomUserBlacklistService =
+ mock(RoomUserBlacklistService.class);
+ private final LatestMobileDeviceService latestMobileDeviceService =
+ mock(LatestMobileDeviceService.class);
+ private final RoomProfileManagerService roomProfileManagerService =
+ mock(RoomProfileManagerService.class);
+ private final RegionRoomCacheService regionRoomCacheService = mock(RegionRoomCacheService.class);
+ private final LiveMicClient liveMicClient = mock(LiveMicClient.class);
+ private final EmptyRoomCacheReader emptyRoomCacheReader = mock(EmptyRoomCacheReader.class);
+ private final RocketStatusCacheService rocketStatusCacheService =
+ mock(RocketStatusCacheService.class);
+ private final GameLudoService gameLudoService = mock(GameLudoService.class);
+ private final UserProfileGateway userProfileGateway = mock(UserProfileGateway.class);
+ private final CountryCodeAliasSupport countryCodeAliasSupport =
+ mock(CountryCodeAliasSupport.class);
+ private final RegionConfig currentRegion =
+ new RegionConfig().setRegionCode("AE").setCountryCodes("AE,SA");
+ private final RoomVoiceDiscoverQryExe exe = new RoomVoiceDiscoverQryExe(
+ userSVipGateway,
+ userRegionGateway,
+ roomRegionFilterCommon,
+ roomVoiceProfileCommon,
+ activeVoiceRoomService,
+ roomUserBlacklistService,
+ latestMobileDeviceService,
+ roomProfileManagerService,
+ regionRoomCacheService,
+ liveMicClient,
+ emptyRoomCacheReader,
+ rocketStatusCacheService,
+ gameLudoService,
+ userProfileGateway,
+ countryCodeAliasSupport
+ );
+
+ private AppExtCommand stubCurrentRegionRequest() {
+ when(roomRegionFilterCommon.requireRegionConfig(1L)).thenReturn(currentRegion);
+ when(userProfileGateway.getByUserId(1L)).thenReturn(new UserProfile());
+ AppExtCommand cmd = new AppExtCommand();
+ cmd.setReqUserId(1L);
+ cmd.setReqSysOrigin(ReqSysOrigin.of("YOLO", "YOLO"));
+ return cmd;
+ }
+
+ private void stubEmptyRegionOrigin() {
+ when(activeVoiceRoomService.listByRegion("YOLO", "AE", 50)).thenReturn(List.of());
+ when(roomRegionFilterCommon.filterActiveVoiceRoomsByRegion(List.of(), "AE"))
+ .thenReturn(List.of());
+ when(emptyRoomCacheReader.list("YOLO", "AE", false, 50)).thenReturn(List.of());
+ when(roomVoiceProfileCommon.toListRoomVoiceProfileCO(List.of())).thenReturn(List.of());
+ when(roomRegionFilterCommon.listCountryCodes(currentRegion)).thenReturn(List.of("AE", "SA"));
+ when(roomProfileManagerService.listSelectiveLimitByCountryCodes(
+ "YOLO", List.of("AE", "SA"), List.of(), 50
+ )).thenReturn(List.of());
+ when(roomRegionFilterCommon.filterRoomProfileManagersByRegion(List.of(), "AE"))
+ .thenReturn(List.of());
+ when(roomVoiceProfileCommon.listRoomVoiceProfilesV2(List.of())).thenReturn(List.of());
+ }
+ }
}
diff --git a/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/database/cache/service/other/EmptyRoomCacheReader.java b/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/database/cache/service/other/EmptyRoomCacheReader.java
new file mode 100644
index 00000000..fd9be954
--- /dev/null
+++ b/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/database/cache/service/other/EmptyRoomCacheReader.java
@@ -0,0 +1,21 @@
+package com.red.circle.other.infra.database.cache.service.other;
+
+import com.red.circle.other.infra.database.mongo.entity.live.ActiveVoiceRoom;
+import java.util.List;
+
+/**
+ * 按系统和区域读取发现页的空房间补充数据。
+ */
+public interface EmptyRoomCacheReader {
+
+ /**
+ * 从单个有界索引批量读取空房间,不允许退回全库 SCAN/KEYS。
+ *
+ * @param sysOrigin 业务系统
+ * @param region 当前区域
+ * @param allRegions 是否读取同系统的全区域聚合索引
+ * @param limit 最大返回数量
+ * @return 有效空房间快照
+ */
+ List list(String sysOrigin, String region, boolean allRegions, int limit);
+}
diff --git a/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/database/cache/service/other/impl/EmptyRoomCacheReaderImpl.java b/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/database/cache/service/other/impl/EmptyRoomCacheReaderImpl.java
new file mode 100644
index 00000000..ca66fb54
--- /dev/null
+++ b/rc-service/rc-service-other/other-infrastructure/src/main/java/com/red/circle/other/infra/database/cache/service/other/impl/EmptyRoomCacheReaderImpl.java
@@ -0,0 +1,176 @@
+package com.red.circle.other.infra.database.cache.service.other.impl;
+
+import com.alibaba.fastjson.JSON;
+import com.red.circle.component.redis.service.RedisService;
+import com.red.circle.other.infra.database.cache.service.other.EmptyRoomCacheReader;
+import com.red.circle.other.infra.database.mongo.entity.live.ActiveVoiceRoom;
+import com.red.circle.other.inner.model.cache.EmptyRoomCacheKeys;
+import com.red.circle.tool.core.collection.CollectionUtils;
+import com.red.circle.tool.core.text.StringUtils;
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+/**
+ * 基于 ZSet 到期索引批量加载空房间快照。
+ *
+ * 新 reader 不允许回退 legacy 全库 SCAN。滚动发布必须先全量升级 live writer,等待旧快照
+ * 十分钟窗口收敛后再升级 other,确保任何在途 legacy-only payload 都已过期。
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class EmptyRoomCacheReaderImpl implements EmptyRoomCacheReader {
+
+ private final RedisService redisService;
+
+ @Override
+ public List list(String sysOrigin, String region, boolean allRegions, int limit) {
+ if (StringUtils.isBlank(sysOrigin)
+ || (!allRegions && StringUtils.isBlank(region))
+ || limit <= 0) {
+ return new ArrayList<>();
+ }
+
+ String indexKey = allRegions
+ ? EmptyRoomCacheKeys.allRegionsIndex(sysOrigin)
+ : EmptyRoomCacheKeys.regionIndex(sysOrigin, region);
+ try {
+ long nowMillis = System.currentTimeMillis();
+ // 过期成员在读取前先删除,索引开销只与该系统/区域的空房数量相关。
+ redisService.zsetRemoveByScore(indexKey, 0D, nowMillis);
+ // score 越大代表快照剩余窗口越长;按 rank 在 Redis 端截断,网络与 MGET 都最多处理 limit 条。
+ Set