SafeRedisCacheService.java

package org.darkroomlibrary.infrastructure.cache;

import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;

import java.time.Duration;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicLong;

@Slf4j
@Service
public class SafeRedisCacheService implements CacheService {

    private static final DefaultRedisScript<Long> COMPARE_AND_DELETE_SCRIPT =
            new DefaultRedisScript<>(
                    "if redis.call('get', KEYS[1]) == ARGV[1] "
                            + "then return redis.call('del', KEYS[1]) else return 0 end",
                    Long.class
            );
    private static final DefaultRedisScript<String> GET_AND_DELETE_SCRIPT =
            new DefaultRedisScript<>(
                    "local value = redis.call('get', KEYS[1]); "
                            + "if value then redis.call('del', KEYS[1]); end; "
                            + "return value",
                    String.class
            );
    private static final DefaultRedisScript<Long> INCREMENT_WITH_TTL_SCRIPT =
            new DefaultRedisScript<>(
                    "local value = redis.call('incr', KEYS[1]); "
                            + "if value == 1 then redis.call('pexpire', KEYS[1], ARGV[1]); end; "
                            + "return value",
                    Long.class
            );
    private static final DefaultRedisScript<Long> TOKEN_BUCKET_SCRIPT =
            new DefaultRedisScript<>(
                    "local time = redis.call('TIME'); "
                            + "local now = time[1] * 1000 + math.floor(time[2] / 1000); "
                            + "local capacity = tonumber(ARGV[1]); "
                            + "local refillPeriod = math.max(1, tonumber(ARGV[2])); "
                            + "local refillRate = capacity / refillPeriod; "
                            + "local values = redis.call('HMGET', KEYS[1], 'tokens', 'timestamp'); "
                            + "local tokens = tonumber(values[1]); "
                            + "local timestamp = tonumber(values[2]); "
                            + "if tokens == nil or timestamp == nil then "
                            + "  tokens = capacity; timestamp = now; "
                            + "else "
                            + "  tokens = math.min(capacity, tokens + math.max(0, now - timestamp) * refillRate); "
                            + "  timestamp = now; "
                            + "end; "
                            + "local allowed = 0; "
                            + "if tokens >= 1 then tokens = tokens - 1; allowed = 1; end; "
                            + "redis.call('HSET', KEYS[1], 'tokens', tokens, 'timestamp', timestamp); "
                            + "redis.call('PEXPIRE', KEYS[1], math.ceil(refillPeriod * 2)); "
                            + "return allowed",
                    Long.class
            );

    @Value("${middleware.redis.enabled:false}")
    private boolean enabled;

    @Value("${middleware.redis.recovery-interval-ms:30000}")
    private long recoveryIntervalMs;

    @Autowired
    private StringRedisTemplate redisTemplate;

    private final AtomicBoolean warned = new AtomicBoolean(false);
    private final AtomicLong unavailableUntil = new AtomicLong(0);
    private final Set<String> pendingDeletes = ConcurrentHashMap.newKeySet();
    private final Object recoveryLock = new Object();

    @Override
    public Optional<String> getString(String key) {
        if (!prepareForOperation()) {
            return Optional.empty();
        }
        try {
            Optional<String> result = Optional.ofNullable(redisTemplate.opsForValue().get(key));
            markAvailable();
            return result;
        } catch (Exception e) {
            markUnavailable(e);
            return Optional.empty();
        }
    }

    @Override
    public Optional<String> getAndDelete(String key) {
        if (!prepareForOperation()) {
            return Optional.empty();
        }
        try {
            Optional<String> result = Optional.ofNullable(redisTemplate.execute(
                    GET_AND_DELETE_SCRIPT,
                    List.of(key)
            ));
            markAvailable();
            return result;
        } catch (Exception e) {
            markUnavailable(e);
            return Optional.empty();
        }
    }

    @Override
    public Optional<Boolean> compareAndDelete(String key, String expectedValue) {
        if (!prepareForOperation()) {
            return Optional.empty();
        }
        try {
            Long deleted = redisTemplate.execute(
                    COMPARE_AND_DELETE_SCRIPT,
                    List.of(key),
                    expectedValue
            );
            Optional<Boolean> result = Optional.of(deleted != null && deleted > 0);
            markAvailable();
            return result;
        } catch (Exception e) {
            markUnavailable(e);
            return Optional.empty();
        }
    }

    @Override
    public boolean setString(String key, String value, Duration ttl) {
        if (!prepareForOperation()) {
            return false;
        }
        try {
            redisTemplate.opsForValue().set(key, value, ttl);
            markAvailable();
            return true;
        } catch (Exception e) {
            markUnavailable(e);
            return false;
        }
    }

    @Override
    public Optional<Boolean> setIfAbsent(String key, String value, Duration ttl) {
        if (!prepareForOperation()) {
            return Optional.empty();
        }
        try {
            Boolean stored = redisTemplate.opsForValue().setIfAbsent(key, value, ttl);
            Optional<Boolean> result = Optional.of(Boolean.TRUE.equals(stored));
            markAvailable();
            return result;
        } catch (Exception e) {
            markUnavailable(e);
            return Optional.empty();
        }
    }

    @Override
    public boolean delete(String key) {
        if (!enabled) {
            return false;
        }
        pendingDeletes.add(key);
        if (!prepareForOperation()) {
            return false;
        }
        return !pendingDeletes.contains(key);
    }

    private boolean deleteNow(String key) {
        try {
            redisTemplate.delete(key);
            markAvailable();
            return true;
        } catch (Exception e) {
            markUnavailable(e);
            return false;
        }
    }

    @Override
    public Optional<Long> increment(String key, Duration ttl) {
        if (!prepareForOperation()) {
            return Optional.empty();
        }
        try {
            long ttlMillis = ttl == null ? 1L : Math.max(1L, ttl.toMillis());
            Long value = redisTemplate.execute(
                    INCREMENT_WITH_TTL_SCRIPT,
                    List.of(key),
                    String.valueOf(ttlMillis)
            );
            Optional<Long> result = Optional.ofNullable(value);
            markAvailable();
            return result;
        } catch (Exception e) {
            markUnavailable(e);
            return Optional.empty();
        }
    }

    @Override
    public Optional<Boolean> tryConsumeToken(String key, int capacity, Duration refillPeriod) {
        if (!prepareForOperation()) {
            return Optional.empty();
        }
        try {
            int normalizedCapacity = Math.max(1, capacity);
            long refillPeriodMillis = refillPeriod == null
                    ? 1L
                    : Math.max(1L, refillPeriod.toMillis());
            Long allowed = redisTemplate.execute(
                    TOKEN_BUCKET_SCRIPT,
                    List.of(key),
                    String.valueOf(normalizedCapacity),
                    String.valueOf(refillPeriodMillis)
            );
            Optional<Boolean> result = Optional.of(allowed != null && allowed == 1L);
            markAvailable();
            return result;
        } catch (Exception e) {
            markUnavailable(e);
            return Optional.empty();
        }
    }

    private boolean shouldAttempt() {
        return enabled && System.currentTimeMillis() >= unavailableUntil.get();
    }

    private boolean prepareForOperation() {
        if (!shouldAttempt()) {
            return false;
        }
        if (pendingDeletes.isEmpty()) {
            return true;
        }
        synchronized (recoveryLock) {
            if (!shouldAttempt()) {
                return false;
            }
            for (String key : List.copyOf(pendingDeletes)) {
                if (!deleteNow(key)) {
                    return false;
                }
                pendingDeletes.remove(key);
            }
        }
        return true;
    }

    private void markAvailable() {
        unavailableUntil.set(0);
        warned.set(false);
    }

    private void markUnavailable(Exception e) {
        unavailableUntil.set(System.currentTimeMillis() + Math.max(1L, recoveryIntervalMs));
        if (warned.compareAndSet(false, true)) {
            log.warn("Redis不可用,已自动降级到本地/数据库兜底: {}", e.getMessage());
        }
    }
}