perf: 收敛工作流状态与高 IO 节点开销

- 落地 Redis 版本状态、触发租约和定义缓存

- 优化数据批写、插件请求、文件下载与审计日志

- 补齐循环范围校验、轮询兼容和专项测试
This commit is contained in:
2026-07-29 00:47:48 +08:00
parent 5ee6065017
commit 1ae8a22afe
103 changed files with 15600 additions and 532 deletions

View File

@@ -0,0 +1,296 @@
package tech.easyflow.common.cache;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Duration;
import java.util.Collections;
import java.util.HexFormat;
import java.util.Objects;
import java.util.UUID;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicBoolean;
/**
* 基于 Redis owner token 的通用幂等执行器。
*
* <p>该执行器用于降低持久化触发器重复投递造成的副作用重复执行窗口。处理中凭证和完成
* 凭证均有界过期,异常时仅允许当前 owner 释放凭证。</p>
*/
@Component
public class RedisIdempotencyExecutor {
private static final Logger log =
LoggerFactory.getLogger(RedisIdempotencyExecutor.class);
private static final String KEY_PREFIX = "idempotency:";
private static final String PROCESSING_PREFIX = "P:";
private static final String COMPLETED_PREFIX = "C:";
private static final Duration DEFAULT_PROCESSING_TTL =
Duration.ofMinutes(5);
private static final Duration DEFAULT_COMPLETED_TTL =
Duration.ofDays(3);
private static final ScheduledExecutorService LEASE_RENEWER =
Executors.newSingleThreadScheduledExecutor(runnable -> {
Thread thread = new Thread(
runnable, "redis-idempotency-lease-renewer");
thread.setDaemon(true);
return thread;
});
private static final DefaultRedisScript<Long> CLAIM_SCRIPT = script(
"local current = redis.call('get', KEYS[1]); "
+ "if current == ARGV[3] then return 2 end; "
+ "if current and string.sub(current, 3, 66) ~= ARGV[4] then return -1 end; "
+ "if current then return 0 end; "
+ "redis.call('psetex', KEYS[1], ARGV[2], ARGV[1]); return 1");
private static final DefaultRedisScript<Long> COMPLETE_SCRIPT = script(
"if redis.call('get', KEYS[1]) == ARGV[1] then "
+ "redis.call('psetex', KEYS[1], ARGV[2], ARGV[3]); return 1 "
+ "else return 0 end");
private static final DefaultRedisScript<Long> RELEASE_SCRIPT = script(
"if redis.call('get', KEYS[1]) == ARGV[1] then "
+ "return redis.call('del', KEYS[1]) else return 0 end");
private static final DefaultRedisScript<Long> RENEW_SCRIPT = script(
"if redis.call('get', KEYS[1]) == ARGV[1] then "
+ "return redis.call('pexpire', KEYS[1], ARGV[2]) "
+ "else return 0 end");
private final StringRedisTemplate redisTemplate;
private final Duration processingTtl;
private final Duration completedTtl;
/**
* 创建 Redis 幂等执行器。
*
* @param redisTemplate Redis 字符串模板
*/
@Autowired
public RedisIdempotencyExecutor(StringRedisTemplate redisTemplate) {
this(
redisTemplate,
DEFAULT_PROCESSING_TTL,
DEFAULT_COMPLETED_TTL);
}
/**
* 创建可指定租约周期的 Redis 幂等执行器。
*
* @param redisTemplate Redis 字符串模板
* @param processingTtl 处理中 owner 租约周期
* @param completedTtl 完成凭证保留周期
*/
RedisIdempotencyExecutor(
StringRedisTemplate redisTemplate,
Duration processingTtl,
Duration completedTtl) {
this.redisTemplate = Objects.requireNonNull(
redisTemplate, "redisTemplate must not be null");
this.processingTtl = requirePositive(
processingTtl, "processingTtl");
this.completedTtl = requirePositive(
completedTtl, "completedTtl");
}
/**
* 以稳定幂等键至多执行一次当前可观测操作。
*
* <p>返回 {@code false} 表示该键已有成功记录。另一个 owner 仍在执行时抛出明确异常,
* 由上层持久化触发器稍后重试,避免提前返回虚假成功。</p>
*
* @param idempotencyKey 稳定业务幂等键
* @param action 副作用操作
* @return 本次实际执行操作时为 {@code true},已有成功记录时为 {@code false}
* @throws IdempotentOperationInProgressException 同一操作仍由其他 owner 执行时抛出
* @throws IllegalStateException 操作完成后无法确认幂等凭证时抛出
*/
public boolean executeOnce(String idempotencyKey, Runnable action) {
return executeOnce(idempotencyKey, sha256(""), action);
}
/**
* 以稳定幂等键和负载摘要至多执行一次当前可观测操作。
*
* @param idempotencyKey 稳定业务幂等键
* @param payloadHash 负载摘要,用于拒绝同键不同数据
* @param action 副作用操作
* @return 本次实际执行操作时为 {@code true},已有成功记录时为 {@code false}
* @throws IdempotentOperationInProgressException 同一操作仍在执行
* @throws IdempotencyPayloadMismatchException 同一幂等键对应不同负载
*/
public boolean executeOnce(String idempotencyKey, String payloadHash, Runnable action) {
if (idempotencyKey == null || idempotencyKey.isBlank()) {
Objects.requireNonNull(action, "action must not be null").run();
return true;
}
Objects.requireNonNull(action, "action must not be null");
String normalizedPayloadHash = Objects.requireNonNull(
payloadHash, "payloadHash must not be null");
String key = KEY_PREFIX + sha256(idempotencyKey);
String ownerToken = UUID.randomUUID().toString();
String processingValue = PROCESSING_PREFIX + normalizedPayloadHash + ":" + ownerToken;
String completedValue = COMPLETED_PREFIX + normalizedPayloadHash;
Long claim = redisTemplate.execute(
CLAIM_SCRIPT,
Collections.singletonList(key),
processingValue,
String.valueOf(processingTtl.toMillis()),
completedValue,
normalizedPayloadHash);
if (Long.valueOf(2L).equals(claim)) {
return false;
}
if (Long.valueOf(-1L).equals(claim)) {
throw new IdempotencyPayloadMismatchException(idempotencyKey);
}
if (!Long.valueOf(1L).equals(claim)) {
throw new IdempotentOperationInProgressException(idempotencyKey);
}
AtomicBoolean ownershipLost = new AtomicBoolean();
long renewIntervalMillis = Math.max(
1L, processingTtl.toMillis() / 3L);
ScheduledFuture<?> renewal = LEASE_RENEWER.scheduleAtFixedRate(
() -> renewLease(
key, processingValue, ownershipLost),
renewIntervalMillis,
renewIntervalMillis,
TimeUnit.MILLISECONDS);
try {
action.run();
} catch (RuntimeException | Error error) {
renewal.cancel(false);
redisTemplate.execute(
RELEASE_SCRIPT, Collections.singletonList(key), processingValue);
throw error;
}
renewal.cancel(false);
if (ownershipLost.get()) {
throw new IllegalStateException(
"Idempotency ownership lost while operation was running");
}
Long completed = redisTemplate.execute(
COMPLETE_SCRIPT,
Collections.singletonList(key),
processingValue,
String.valueOf(completedTtl.toMillis()),
completedValue);
if (!Long.valueOf(1L).equals(completed)) {
// 副作用已经完成时保留处理中凭证,避免确认异常立即打开重复执行窗口。
throw new IllegalStateException(
"Idempotency ownership lost after operation completed");
}
return true;
}
/**
* 续期仍由当前 owner 持有的处理中凭证。
*
* @param key Redis 幂等键
* @param processingValue 当前 owner 完整凭证
* @param ownershipLost owner 丢失标记
*/
private void renewLease(
String key,
String processingValue,
AtomicBoolean ownershipLost) {
try {
Long renewed = redisTemplate.execute(
RENEW_SCRIPT,
Collections.singletonList(key),
processingValue,
String.valueOf(processingTtl.toMillis()));
if (!Long.valueOf(1L).equals(renewed)) {
ownershipLost.set(true);
}
} catch (RuntimeException error) {
// 短暂 Redis 故障由后续周期继续续期;最终完成脚本仍会校验 owner token。
log.warn("Redis idempotency lease renewal failed", error);
}
}
/**
* 校验正数时长配置。
*
* @param duration 配置值
* @param name 配置名
* @return 原配置值
*/
private Duration requirePositive(
Duration duration, String name) {
Duration value = Objects.requireNonNull(
duration, name + " must not be null");
if (value.isZero() || value.isNegative()) {
throw new IllegalArgumentException(
name + " must be positive");
}
return value;
}
/**
* 计算有界 Redis key 摘要。
*
* @param value 原始幂等键
* @return SHA-256 十六进制摘要
*/
private String sha256(String value) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(digest.digest(
value.getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException error) {
throw new IllegalStateException("SHA-256 is unavailable", error);
}
}
/**
* 创建返回 Long 的 Redis Lua 脚本。
*
* @param text 脚本文本
* @return Redis 脚本
*/
private static DefaultRedisScript<Long> script(String text) {
DefaultRedisScript<Long> script = new DefaultRedisScript<>();
script.setScriptText(text);
script.setResultType(Long.class);
return script;
}
/**
* 同一幂等操作仍在执行时的冲突异常。
*/
public static final class IdempotentOperationInProgressException extends IllegalStateException {
/**
* 创建幂等执行冲突异常。
*
* @param idempotencyKey 冲突的业务幂等键
*/
public IdempotentOperationInProgressException(String idempotencyKey) {
super("Idempotent operation is already in progress: " + idempotencyKey);
}
}
/**
* 同一幂等键被不同负载复用时的冲突异常。
*/
public static final class IdempotencyPayloadMismatchException extends IllegalStateException {
/**
* 创建负载冲突异常。
*
* @param idempotencyKey 冲突的业务幂等键
*/
public IdempotencyPayloadMismatchException(String idempotencyKey) {
super("Idempotency payload mismatch: " + idempotencyKey);
}
}
}

View File

@@ -24,6 +24,8 @@ public class RedisLockExecutor {
private static final DefaultRedisScript<Long> RELEASE_LOCK_SCRIPT;
private static final DefaultRedisScript<Long> RENEW_LOCK_SCRIPT;
private static final DefaultRedisScript<Long> NEXT_FENCING_TOKEN_SCRIPT;
private static final DefaultRedisScript<Long> ACQUIRE_FENCED_LOCK_SCRIPT;
static {
RELEASE_LOCK_SCRIPT = new DefaultRedisScript<>();
@@ -40,6 +42,19 @@ public class RedisLockExecutor {
"else return 0 end"
);
RENEW_LOCK_SCRIPT.setResultType(Long.class);
NEXT_FENCING_TOKEN_SCRIPT = new DefaultRedisScript<>();
NEXT_FENCING_TOKEN_SCRIPT.setScriptText(
"local token = redis.call('hincrby', KEYS[1], 'version', 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); return token"
);
NEXT_FENCING_TOKEN_SCRIPT.setResultType(Long.class);
ACQUIRE_FENCED_LOCK_SCRIPT = new DefaultRedisScript<>();
ACQUIRE_FENCED_LOCK_SCRIPT.setScriptText(
"if redis.call('exists', KEYS[1]) == 1 then return 0 end; "
+ "redis.call('psetex', KEYS[1], ARGV[2], ARGV[1]); "
+ "local token = redis.call('hincrby', KEYS[2], 'version', 1); "
+ "redis.call('pexpire', KEYS[2], ARGV[3]); return token");
ACQUIRE_FENCED_LOCK_SCRIPT.setResultType(Long.class);
}
@Autowired
@@ -132,7 +147,53 @@ public class RedisLockExecutor {
if (!acquired) {
return null;
}
return new LockHandle(lockKey, lockValue, leaseTimeout);
return new LockHandle(lockKey, lockValue, leaseTimeout, 0L);
}
/**
* 原子获取互斥锁并分配 fencing token。
*
* <p>锁键和 fencing 键必须位于同一 Redis Cluster slot。成功的 SET 与 token
* 递增在同一 Lua 脚本内完成,消除新 owner 已取得锁但 token 尚未推进的窗口。</p>
*
* @param lockKey 互斥锁键
* @param fenceKey fencing token 哈希键
* @param waitTimeout 等待时间
* @param leaseTimeout 锁租约时间
* @param fenceTtl fencing token 有效期
* @return 获取成功时返回带 token 的锁句柄,否则返回 {@code null}
*/
public LockHandle tryAcquireFenced(
String lockKey,
String fenceKey,
Duration waitTimeout,
Duration leaseTimeout,
Duration fenceTtl) {
String lockValue = UUID.randomUUID().toString();
long deadline = System.nanoTime() + waitTimeout.toNanos();
try {
do {
Long token = stringRedisTemplate.execute(
ACQUIRE_FENCED_LOCK_SCRIPT,
java.util.List.of(lockKey, fenceKey),
lockValue,
String.valueOf(Math.max(1L, leaseTimeout.toMillis())),
String.valueOf(Math.max(1L, fenceTtl.toMillis())));
if (token != null && token > 0L) {
return new LockHandle(
lockKey, lockValue, leaseTimeout, token);
}
if (System.nanoTime() >= deadline) {
return null;
}
Thread.sleep(RETRY_INTERVAL_MILLIS);
} while (System.nanoTime() <= deadline);
} catch (InterruptedException error) {
Thread.currentThread().interrupt();
throw new IllegalStateException(
"等待 fenced 分布式锁被中断lockKey=" + lockKey, error);
}
return null;
}
/**
@@ -168,6 +229,25 @@ public class RedisLockExecutor {
}
}
/**
* 为已经取得互斥锁的实例分配单调递增 fencing token。
*
* @param fenceKey fencing token 哈希键
* @param ttl token 有效期
* @return 大于零的 fencing token
* @throws IllegalStateException Redis 未返回有效 token 时抛出
*/
public long nextFencingToken(String fenceKey, Duration ttl) {
Long token = stringRedisTemplate.execute(
NEXT_FENCING_TOKEN_SCRIPT,
Collections.singletonList(fenceKey),
String.valueOf(Math.max(1L, ttl.toMillis())));
if (token == null || token <= 0L) {
throw new IllegalStateException("分配 fencing token 失败fenceKey=" + fenceKey);
}
return token;
}
/**
* 显式分布式锁句柄。
*/
@@ -176,12 +256,27 @@ public class RedisLockExecutor {
private final String lockKey;
private final String lockValue;
private final Duration leaseTimeout;
private final long fencingToken;
private volatile boolean released;
private LockHandle(String lockKey, String lockValue, Duration leaseTimeout) {
private LockHandle(
String lockKey,
String lockValue,
Duration leaseTimeout,
long fencingToken) {
this.lockKey = lockKey;
this.lockValue = lockValue;
this.leaseTimeout = leaseTimeout;
this.fencingToken = fencingToken;
}
/**
* 获取原子分配的 fencing token。
*
* @return 普通锁为 {@code 0}fenced 锁为正数
*/
public long getFencingToken() {
return fencingToken;
}
/**

View File

@@ -0,0 +1,870 @@
package tech.easyflow.common.cache;
import com.alicp.jetcache.support.JavaValueEncoder;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.ReturnType;
import org.springframework.stereotype.Component;
import java.io.Serializable;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* 基于 Redis Hash 与 Lua 的原子版本对象存储。
*
* <p>对象继续使用项目既有的 Java 序列化协议,避免状态内多态值在迁移后改变类型。
* payload 与 version 保存在同一 Redis Hash 中,创建、版本比较、写入和 TTL 刷新均由
* 单次 Lua 脚本原子完成。</p>
*/
@Component
public class RedisVersionedObjectStore implements VersionedObjectStore {
private static final byte[] PAYLOAD_FIELD = bytes("payload");
private static final byte[] CREATE_SCRIPT = bytes(
"if redis.call('exists', KEYS[1]) == 1 then return 0 end; "
+ "redis.call('hset', KEYS[1], 'version', ARGV[1], 'payload', ARGV[2]); "
+ "redis.call('pexpire', KEYS[1], ARGV[3]); return 1");
private static final byte[] GUARDED_CREATE_SCRIPT = bytes(
"local guard = redis.call('hget', KEYS[2], 'version'); "
+ "if not guard or guard ~= ARGV[2] then return -1 end; "
+ "if redis.call('exists', KEYS[1]) == 1 then return 0 end; "
+ "redis.call('hset', KEYS[1], 'version', ARGV[1], 'payload', ARGV[3]); "
+ "redis.call('pexpire', KEYS[1], ARGV[4]); return 1");
private static final byte[] DOUBLE_GUARDED_CREATE_SCRIPT = bytes(
"local guard = redis.call('hget', KEYS[2], 'version'); "
+ "local secondary = redis.call('hget', KEYS[3], 'version'); "
+ "if not guard or guard ~= ARGV[2] "
+ "or not secondary or secondary ~= ARGV[3] then return -1 end; "
+ "if redis.call('exists', KEYS[1]) == 1 then return 0 end; "
+ "redis.call('hset', KEYS[1], 'version', ARGV[1], 'payload', ARGV[4]); "
+ "redis.call('pexpire', KEYS[1], ARGV[5]); return 1");
private static final byte[] CAS_SCRIPT = bytes(
"local current = redis.call('hget', KEYS[1], 'version'); "
+ "if not current then return -1 end; "
+ "if current ~= ARGV[1] then return 0 end; "
+ "redis.call('hset', KEYS[1], 'version', ARGV[2], 'payload', ARGV[3]); "
+ "redis.call('pexpire', KEYS[1], ARGV[4]); return 1");
private static final byte[] GUARDED_CAS_SCRIPT = bytes(
"local guard = redis.call('hget', KEYS[2], 'version'); "
+ "if not guard or guard ~= ARGV[3] then return -2 end; "
+ "local current = redis.call('hget', KEYS[1], 'version'); "
+ "if not current then return -1 end; "
+ "if current ~= ARGV[1] then return 0 end; "
+ "redis.call('hset', KEYS[1], 'version', ARGV[2], 'payload', ARGV[4]); "
+ "redis.call('pexpire', KEYS[1], ARGV[5]); return 1");
private static final byte[] DOUBLE_GUARDED_CAS_SCRIPT = bytes(
"local guard = redis.call('hget', KEYS[2], 'version'); "
+ "local secondary = redis.call('hget', KEYS[3], 'version'); "
+ "if not guard or guard ~= ARGV[3] "
+ "or not secondary or secondary ~= ARGV[4] then return -2 end; "
+ "local current = redis.call('hget', KEYS[1], 'version'); "
+ "if not current then return -1 end; "
+ "if current ~= ARGV[1] then return 0 end; "
+ "redis.call('hset', KEYS[1], 'version', ARGV[2], 'payload', ARGV[5]); "
+ "redis.call('pexpire', KEYS[1], ARGV[6]); return 1");
private static final byte[] DOUBLE_GUARDED_DELETE_ALL_SCRIPT = bytes(
"local guard = redis.call('hget', KEYS[#KEYS - 1], 'version'); "
+ "local secondary = redis.call('hget', KEYS[#KEYS], 'version'); "
+ "if not guard or guard ~= ARGV[1] "
+ "or not secondary or secondary ~= ARGV[2] then return -1 end; "
+ "for i = 1, #KEYS - 2 do redis.call('del', KEYS[i]); end; "
+ "return 1");
private static final byte[] CREATE_FIELDS_SCRIPT = bytes(
"if redis.call('exists', KEYS[1]) == 1 then return 0 end; "
+ "redis.call('hset', KEYS[1], 'version', ARGV[1]); "
+ "for i = 2, #ARGV - 1, 2 do redis.call('hset', KEYS[1], ARGV[i], ARGV[i + 1]); end; "
+ "redis.call('pexpire', KEYS[1], ARGV[#ARGV]); return 1");
private static final byte[] GUARDED_CREATE_FIELDS_SCRIPT = bytes(
"local guard = redis.call('hget', KEYS[2], 'version'); "
+ "if not guard or guard ~= ARGV[2] then return -1 end; "
+ "if redis.call('exists', KEYS[1]) == 1 then return 0 end; "
+ "redis.call('hset', KEYS[1], 'version', ARGV[1]); "
+ "for i = 3, #ARGV - 1, 2 do redis.call('hset', KEYS[1], ARGV[i], ARGV[i + 1]); end; "
+ "redis.call('pexpire', KEYS[1], ARGV[#ARGV]); return 1");
private static final byte[] DOUBLE_GUARDED_CREATE_FIELDS_SCRIPT = bytes(
"local guard = redis.call('hget', KEYS[2], 'version'); "
+ "local secondary = redis.call('hget', KEYS[3], 'version'); "
+ "if not guard or guard ~= ARGV[2] "
+ "or not secondary or secondary ~= ARGV[3] then return -1 end; "
+ "if redis.call('exists', KEYS[1]) == 1 then return 0 end; "
+ "redis.call('hset', KEYS[1], 'version', ARGV[1]); "
+ "for i = 4, #ARGV - 1, 2 do "
+ "redis.call('hset', KEYS[1], ARGV[i], ARGV[i + 1]); end; "
+ "redis.call('pexpire', KEYS[1], ARGV[#ARGV]); return 1");
private static final byte[] TRIPLE_GUARDED_CREATE_FIELDS_SCRIPT = bytes(
"local guard = redis.call('hget', KEYS[2], 'version'); "
+ "local secondary = redis.call('hget', KEYS[3], 'version'); "
+ "local tertiary = redis.call('hget', KEYS[4], 'version'); "
+ "if not guard or guard ~= ARGV[2] "
+ "or not secondary or secondary ~= ARGV[3] "
+ "or not tertiary or tertiary ~= ARGV[4] then return -1 end; "
+ "if redis.call('exists', KEYS[1]) == 1 then return 0 end; "
+ "redis.call('hset', KEYS[1], 'version', ARGV[1]); "
+ "for i = 5, #ARGV - 1, 2 do "
+ "redis.call('hset', KEYS[1], ARGV[i], ARGV[i + 1]); end; "
+ "redis.call('pexpire', KEYS[1], ARGV[#ARGV]); return 1");
private static final byte[] CAS_FIELDS_SCRIPT = bytes(
"local current = redis.call('hget', KEYS[1], 'version'); "
+ "if not current then return -1 end; "
+ "if current ~= ARGV[1] then return 0 end; "
+ "redis.call('hset', KEYS[1], 'version', ARGV[2]); "
+ "for i = 3, #ARGV - 1, 2 do redis.call('hset', KEYS[1], ARGV[i], ARGV[i + 1]); end; "
+ "redis.call('pexpire', KEYS[1], ARGV[#ARGV]); return 1");
private static final byte[] GUARDED_CAS_FIELDS_SCRIPT = bytes(
"local guard = redis.call('hget', KEYS[2], 'version'); "
+ "if not guard or guard ~= ARGV[3] then return -2 end; "
+ "local current = redis.call('hget', KEYS[1], 'version'); "
+ "if not current then return -1 end; "
+ "if current ~= ARGV[1] then return 0 end; "
+ "redis.call('hset', KEYS[1], 'version', ARGV[2]); "
+ "for i = 4, #ARGV - 1, 2 do redis.call('hset', KEYS[1], ARGV[i], ARGV[i + 1]); end; "
+ "redis.call('pexpire', KEYS[1], ARGV[#ARGV]); return 1");
private static final byte[] DOUBLE_GUARDED_CAS_FIELDS_SCRIPT = bytes(
"local guard = redis.call('hget', KEYS[2], 'version'); "
+ "local secondary = redis.call('hget', KEYS[3], 'version'); "
+ "if not guard or guard ~= ARGV[3] "
+ "or not secondary or secondary ~= ARGV[4] then return -2 end; "
+ "local current = redis.call('hget', KEYS[1], 'version'); "
+ "if not current then return -1 end; "
+ "if current ~= ARGV[1] then return 0 end; "
+ "redis.call('hset', KEYS[1], 'version', ARGV[2]); "
+ "for i = 5, #ARGV - 1, 2 do "
+ "redis.call('hset', KEYS[1], ARGV[i], ARGV[i + 1]); end; "
+ "redis.call('pexpire', KEYS[1], ARGV[#ARGV]); return 1");
private static final byte[] TRIPLE_GUARDED_CAS_FIELDS_SCRIPT = bytes(
"local guard = redis.call('hget', KEYS[2], 'version'); "
+ "local secondary = redis.call('hget', KEYS[3], 'version'); "
+ "local tertiary = redis.call('hget', KEYS[4], 'version'); "
+ "if not guard or guard ~= ARGV[3] "
+ "or not secondary or secondary ~= ARGV[4] "
+ "or not tertiary or tertiary ~= ARGV[5] then return -2 end; "
+ "local current = redis.call('hget', KEYS[1], 'version'); "
+ "if not current then return -1 end; "
+ "if current ~= ARGV[1] then return 0 end; "
+ "redis.call('hset', KEYS[1], 'version', ARGV[2]); "
+ "for i = 6, #ARGV - 1, 2 do "
+ "redis.call('hset', KEYS[1], ARGV[i], ARGV[i + 1]); end; "
+ "redis.call('pexpire', KEYS[1], ARGV[#ARGV]); return 1");
private static final byte[] DOUBLE_GUARDED_CAS_FIELDS_REFRESH_SCRIPT = bytes(
"local guard = redis.call('hget', KEYS[2], 'version'); "
+ "local secondary = redis.call('hget', KEYS[3], 'version'); "
+ "if not guard or guard ~= ARGV[3] "
+ "or not secondary or secondary ~= ARGV[4] then return -2 end; "
+ "local current = redis.call('hget', KEYS[1], 'version'); "
+ "if not current then return -1 end; "
+ "if current ~= ARGV[1] then return 0 end; "
+ "redis.call('hset', KEYS[1], 'version', ARGV[2]); "
+ "for i = 5, #ARGV - 2, 2 do "
+ "redis.call('hset', KEYS[1], ARGV[i], ARGV[i + 1]); end; "
+ "redis.call('pexpire', KEYS[1], ARGV[#ARGV - 1]); "
+ "if redis.call('exists', KEYS[4]) == 0 then "
+ "redis.call('hset', KEYS[4], 'version', '0'); end; "
+ "redis.call('pexpire', KEYS[4], ARGV[#ARGV]); return 1");
private static final byte[] REWRITE_FIELDS_SCRIPT = bytes(
"local current = redis.call('hget', KEYS[1], 'version'); "
+ "if not current or current ~= ARGV[1] then return 0 end; "
+ "redis.call('del', KEYS[1]); "
+ "redis.call('hset', KEYS[1], 'version', ARGV[1]); "
+ "for i = 2, #ARGV - 1, 2 do redis.call('hset', KEYS[1], ARGV[i], ARGV[i + 1]); end; "
+ "redis.call('pexpire', KEYS[1], ARGV[#ARGV]); return 1");
private final RedisConnectionFactory connectionFactory;
private final ApplicationClassLoaderJavaValueDecoder valueDecoder =
new ApplicationClassLoaderJavaValueDecoder();
/**
* 创建 Redis 版本对象存储。
*
* @param connectionFactory Redis 连接工厂
*/
public RedisVersionedObjectStore(RedisConnectionFactory connectionFactory) {
this.connectionFactory = Objects.requireNonNull(
connectionFactory, "connectionFactory must not be null");
}
/**
* {@inheritDoc}
*/
@Override
public <T> T load(String key, Class<T> type) {
requireKey(key);
Objects.requireNonNull(type, "type must not be null");
try (RedisConnection connection = connectionFactory.getConnection()) {
byte[] payload = connection.hashCommands().hGet(bytes(key), PAYLOAD_FIELD);
if (payload == null) {
return null;
}
return type.cast(valueDecoder.apply(payload));
}
}
/**
* {@inheritDoc}
*/
@Override
public <T> List<T> loadAll(List<String> keys, Class<T> type) {
Objects.requireNonNull(keys, "keys must not be null");
Objects.requireNonNull(type, "type must not be null");
if (keys.isEmpty()) {
return List.of();
}
try (RedisConnection connection = connectionFactory.getConnection()) {
connection.openPipeline();
for (String key : keys) {
connection.hashCommands().hGet(bytes(requireKey(key)), PAYLOAD_FIELD);
}
List<Object> encodedValues = connection.closePipeline();
List<T> values = new ArrayList<>(keys.size());
for (int index = 0; index < keys.size(); index++) {
Object encoded = encodedValues != null && index < encodedValues.size()
? encodedValues.get(index)
: null;
values.add(encoded instanceof byte[]
? type.cast(valueDecoder.apply((byte[]) encoded))
: null);
}
return values;
}
}
/**
* {@inheritDoc}
*/
@Override
public void deleteAll(List<String> keys) {
Objects.requireNonNull(keys, "keys must not be null");
if (keys.isEmpty()) {
return;
}
byte[][] encodedKeys = new byte[keys.size()][];
for (int index = 0; index < keys.size(); index++) {
encodedKeys[index] = bytes(requireKey(keys.get(index)));
}
try (RedisConnection connection = connectionFactory.getConnection()) {
connection.keyCommands().del(encodedKeys);
}
}
/**
* {@inheritDoc}
*/
@Override
public boolean deleteAll(
List<String> keys,
String guardKey,
long guardVersion,
String secondaryGuardKey,
long secondaryGuardVersion) {
Objects.requireNonNull(keys, "keys must not be null");
if (keys.isEmpty()) {
return true;
}
List<byte[]> arguments = new ArrayList<>(keys.size() + 4);
for (String key : keys) {
arguments.add(bytes(requireKey(key)));
}
arguments.add(bytes(requireKey(guardKey)));
arguments.add(bytes(requireKey(secondaryGuardKey)));
arguments.add(bytes(guardVersion));
arguments.add(bytes(secondaryGuardVersion));
Long result = eval(
DOUBLE_GUARDED_DELETE_ALL_SCRIPT,
keys.size() + 2,
arguments.toArray(new byte[0][]));
return Long.valueOf(1L).equals(result);
}
/**
* {@inheritDoc}
*/
@Override
public void refreshExpirations(List<String> keys, Duration ttl) {
Objects.requireNonNull(keys, "keys must not be null");
if (keys.isEmpty()) {
return;
}
long ttlMillis = ttlMillis(ttl);
try (RedisConnection connection = connectionFactory.getConnection()) {
connection.openPipeline();
for (String key : keys) {
connection.keyCommands().pExpire(
bytes(requireKey(key)), ttlMillis);
}
connection.closePipeline();
}
}
/**
* {@inheritDoc}
*/
@Override
public boolean createIfAbsent(String key,
Serializable value,
long version,
Duration ttl) {
Long result = eval(
CREATE_SCRIPT,
1,
bytes(requireKey(key)),
bytes(version),
encode(value),
bytes(ttlMillis(ttl)));
return Long.valueOf(1L).equals(result);
}
/**
* {@inheritDoc}
*/
@Override
public boolean createIfAbsent(
String key,
Serializable value,
long version,
String guardKey,
long guardVersion,
String secondaryGuardKey,
long secondaryGuardVersion,
Duration ttl) {
Long result = eval(
DOUBLE_GUARDED_CREATE_SCRIPT,
3,
bytes(requireKey(key)),
bytes(requireKey(guardKey)),
bytes(requireKey(secondaryGuardKey)),
bytes(version),
bytes(guardVersion),
bytes(secondaryGuardVersion),
encode(value),
bytes(ttlMillis(ttl)));
return Long.valueOf(1L).equals(result);
}
/**
* {@inheritDoc}
*/
@Override
public boolean createIfAbsent(String key,
Serializable value,
long version,
String guardKey,
long guardVersion,
Duration ttl) {
Long result = eval(
GUARDED_CREATE_SCRIPT,
2,
bytes(requireKey(key)),
bytes(requireKey(guardKey)),
bytes(version),
bytes(guardVersion),
encode(value),
bytes(ttlMillis(ttl)));
return Long.valueOf(1L).equals(result);
}
/**
* {@inheritDoc}
*/
@Override
public boolean compareAndSet(String key,
long expectedVersion,
Serializable value,
long newVersion,
Duration ttl) {
Long result = eval(
CAS_SCRIPT,
1,
bytes(requireKey(key)),
bytes(expectedVersion),
bytes(newVersion),
encode(value),
bytes(ttlMillis(ttl)));
return Long.valueOf(1L).equals(result);
}
/**
* {@inheritDoc}
*/
@Override
public VersionedFields loadFields(String key) {
requireKey(key);
try (RedisConnection connection = connectionFactory.getConnection()) {
Map<byte[], byte[]> encoded = connection.hashCommands().hGetAll(bytes(key));
if (encoded == null || encoded.isEmpty()) {
return null;
}
Long version = null;
Map<String, Object> fields = new LinkedHashMap<>();
for (Map.Entry<byte[], byte[]> entry : encoded.entrySet()) {
String fieldName = new String(entry.getKey(), StandardCharsets.UTF_8);
if ("version".equals(fieldName)) {
version = Long.parseLong(new String(entry.getValue(), StandardCharsets.UTF_8));
continue;
}
Object value = valueDecoder.apply(entry.getValue());
fields.put(fieldName, value == NullFieldValue.INSTANCE ? null : value);
}
return version == null ? null : new VersionedFields(version, fields);
}
}
/**
* {@inheritDoc}
*/
@Override
public Long loadVersion(String key) {
requireKey(key);
try (RedisConnection connection = connectionFactory.getConnection()) {
byte[] encoded = connection.hashCommands().hGet(bytes(key), bytes("version"));
return encoded == null
? null
: Long.parseLong(new String(encoded, StandardCharsets.UTF_8));
}
}
/**
* {@inheritDoc}
*/
@Override
public boolean createFieldsIfAbsent(String key,
Map<String, ? extends Serializable> fields,
long version,
Duration ttl) {
Long result = eval(
CREATE_FIELDS_SCRIPT,
1,
fieldArguments(
List.of(bytes(requireKey(key)), bytes(version)),
fields,
ttl));
return Long.valueOf(1L).equals(result);
}
/**
* {@inheritDoc}
*/
@Override
public boolean createFieldsIfAbsent(
String key,
Map<String, ? extends Serializable> fields,
long version,
String guardKey,
long guardVersion,
String secondaryGuardKey,
long secondaryGuardVersion,
Duration ttl) {
Long result = eval(
DOUBLE_GUARDED_CREATE_FIELDS_SCRIPT,
3,
fieldArguments(
List.of(
bytes(requireKey(key)),
bytes(requireKey(guardKey)),
bytes(requireKey(secondaryGuardKey)),
bytes(version),
bytes(guardVersion),
bytes(secondaryGuardVersion)),
fields,
ttl));
return Long.valueOf(1L).equals(result);
}
/**
* {@inheritDoc}
*/
@Override
public boolean createFieldsIfAbsent(
String key,
Map<String, ? extends Serializable> fields,
long version,
String guardKey,
long guardVersion,
String secondaryGuardKey,
long secondaryGuardVersion,
String tertiaryGuardKey,
long tertiaryGuardVersion,
Duration ttl) {
Long result = eval(
TRIPLE_GUARDED_CREATE_FIELDS_SCRIPT,
4,
fieldArguments(
List.of(
bytes(requireKey(key)),
bytes(requireKey(guardKey)),
bytes(requireKey(secondaryGuardKey)),
bytes(requireKey(tertiaryGuardKey)),
bytes(version),
bytes(guardVersion),
bytes(secondaryGuardVersion),
bytes(tertiaryGuardVersion)),
fields,
ttl));
return Long.valueOf(1L).equals(result);
}
/**
* {@inheritDoc}
*/
@Override
public boolean createFieldsIfAbsent(String key,
Map<String, ? extends Serializable> fields,
long version,
String guardKey,
long guardVersion,
Duration ttl) {
Long result = eval(
GUARDED_CREATE_FIELDS_SCRIPT,
2,
fieldArguments(
List.of(
bytes(requireKey(key)),
bytes(requireKey(guardKey)),
bytes(version),
bytes(guardVersion)),
fields,
ttl));
return Long.valueOf(1L).equals(result);
}
/**
* {@inheritDoc}
*/
@Override
public boolean compareAndSetFields(String key,
long expectedVersion,
Map<String, ? extends Serializable> fields,
long newVersion,
Duration ttl) {
Long result = eval(
CAS_FIELDS_SCRIPT,
1,
fieldArguments(
List.of(
bytes(requireKey(key)),
bytes(expectedVersion),
bytes(newVersion)),
fields,
ttl));
return Long.valueOf(1L).equals(result);
}
/**
* {@inheritDoc}
*/
@Override
public boolean compareAndSetFields(
String key,
long expectedVersion,
Map<String, ? extends Serializable> fields,
long newVersion,
String guardKey,
long guardVersion,
String secondaryGuardKey,
long secondaryGuardVersion,
Duration ttl) {
Long result = eval(
DOUBLE_GUARDED_CAS_FIELDS_SCRIPT,
3,
fieldArguments(
List.of(
bytes(requireKey(key)),
bytes(requireKey(guardKey)),
bytes(requireKey(secondaryGuardKey)),
bytes(expectedVersion),
bytes(newVersion),
bytes(guardVersion),
bytes(secondaryGuardVersion)),
fields,
ttl));
return Long.valueOf(1L).equals(result);
}
/**
* {@inheritDoc}
*/
@Override
public boolean compareAndSetFields(
String key,
long expectedVersion,
Map<String, ? extends Serializable> fields,
long newVersion,
String guardKey,
long guardVersion,
String secondaryGuardKey,
long secondaryGuardVersion,
String tertiaryGuardKey,
long tertiaryGuardVersion,
Duration ttl) {
Long result = eval(
TRIPLE_GUARDED_CAS_FIELDS_SCRIPT,
4,
fieldArguments(
List.of(
bytes(requireKey(key)),
bytes(requireKey(guardKey)),
bytes(requireKey(secondaryGuardKey)),
bytes(requireKey(tertiaryGuardKey)),
bytes(expectedVersion),
bytes(newVersion),
bytes(guardVersion),
bytes(secondaryGuardVersion),
bytes(tertiaryGuardVersion)),
fields,
ttl));
return Long.valueOf(1L).equals(result);
}
/**
* {@inheritDoc}
*/
@Override
public boolean compareAndSetFieldsAndRefresh(
String key,
long expectedVersion,
Map<String, ? extends Serializable> fields,
long newVersion,
String guardKey,
long guardVersion,
String secondaryGuardKey,
long secondaryGuardVersion,
Duration ttl,
String refreshKey,
Duration refreshTtl) {
Long result = eval(
DOUBLE_GUARDED_CAS_FIELDS_REFRESH_SCRIPT,
4,
fieldArgumentsWithRefreshTtl(
List.of(
bytes(requireKey(key)),
bytes(requireKey(guardKey)),
bytes(requireKey(secondaryGuardKey)),
bytes(requireKey(refreshKey)),
bytes(expectedVersion),
bytes(newVersion),
bytes(guardVersion),
bytes(secondaryGuardVersion)),
fields,
ttl,
refreshTtl));
return Long.valueOf(1L).equals(result);
}
/**
* {@inheritDoc}
*/
@Override
public boolean compareAndSetFields(String key,
long expectedVersion,
Map<String, ? extends Serializable> fields,
long newVersion,
String guardKey,
long guardVersion,
Duration ttl) {
Long result = eval(
GUARDED_CAS_FIELDS_SCRIPT,
2,
fieldArguments(
List.of(
bytes(requireKey(key)),
bytes(requireKey(guardKey)),
bytes(expectedVersion),
bytes(newVersion),
bytes(guardVersion)),
fields,
ttl));
return Long.valueOf(1L).equals(result);
}
/**
* {@inheritDoc}
*/
@Override
public boolean rewriteAsFields(String key,
long expectedVersion,
Map<String, ? extends Serializable> fields,
Duration ttl) {
Long result = eval(
REWRITE_FIELDS_SCRIPT,
1,
fieldArguments(
List.of(bytes(requireKey(key)), bytes(expectedVersion)),
fields,
ttl));
return Long.valueOf(1L).equals(result);
}
/**
* {@inheritDoc}
*/
@Override
public boolean compareAndSet(String key,
long expectedVersion,
Serializable value,
long newVersion,
String guardKey,
long guardVersion,
Duration ttl) {
Long result = eval(
GUARDED_CAS_SCRIPT,
2,
bytes(requireKey(key)),
bytes(requireKey(guardKey)),
bytes(expectedVersion),
bytes(newVersion),
bytes(guardVersion),
encode(value),
bytes(ttlMillis(ttl)));
return Long.valueOf(1L).equals(result);
}
/**
* {@inheritDoc}
*/
@Override
public boolean compareAndSet(
String key,
long expectedVersion,
Serializable value,
long newVersion,
String guardKey,
long guardVersion,
String secondaryGuardKey,
long secondaryGuardVersion,
Duration ttl) {
Long result = eval(
DOUBLE_GUARDED_CAS_SCRIPT,
3,
bytes(requireKey(key)),
bytes(requireKey(guardKey)),
bytes(requireKey(secondaryGuardKey)),
bytes(expectedVersion),
bytes(newVersion),
bytes(guardVersion),
bytes(secondaryGuardVersion),
encode(value),
bytes(ttlMillis(ttl)));
return Long.valueOf(1L).equals(result);
}
/**
* 执行返回整数的 Redis Lua 脚本。
*
* @param script Lua 脚本
* @param keyCount 参数中的 Redis key 数量
* @param keysAndArgs key 与脚本参数
* @return Redis 整数结果
*/
private Long eval(byte[] script, int keyCount, byte[]... keysAndArgs) {
try (RedisConnection connection = connectionFactory.getConnection()) {
return connection.scriptingCommands().eval(
script, ReturnType.INTEGER, keyCount, keysAndArgs);
}
}
/**
* 使用项目既有 Java 协议编码对象。
*
* @param value 待编码对象
* @return 编码后的二进制 payload
*/
private byte[] encode(Serializable value) {
return JavaValueEncoder.INSTANCE.apply(
Objects.requireNonNull(value, "value must not be null"));
}
/**
* 组装动态字段脚本参数,每个字段只编码一次。
*
* @param prefix Redis key 与固定参数
* @param fields 待写字段
* @param ttl 有效期
* @return EVAL 的完整二进制参数
*/
private byte[][] fieldArguments(List<byte[]> prefix,
Map<String, ? extends Serializable> fields,
Duration ttl) {
Objects.requireNonNull(fields, "fields must not be null");
List<byte[]> arguments = new ArrayList<>(prefix.size() + fields.size() * 2 + 1);
arguments.addAll(prefix);
for (Map.Entry<String, ? extends Serializable> entry : fields.entrySet()) {
String fieldName = requireKey(entry.getKey());
if ("version".equals(fieldName)) {
throw new IllegalArgumentException("version is a reserved field");
}
Serializable value = entry.getValue() == null ? NullFieldValue.INSTANCE : entry.getValue();
arguments.add(bytes(fieldName));
arguments.add(encode(value));
}
arguments.add(bytes(ttlMillis(ttl)));
return arguments.toArray(new byte[0][]);
}
/**
* 组装需要同时刷新关联键 TTL 的字段脚本参数。
*
* @param prefix Redis key 与固定参数
* @param fields 待写字段
* @param ttl 主对象有效期
* @param refreshTtl 关联键有效期
* @return EVAL 的完整二进制参数
*/
private byte[][] fieldArgumentsWithRefreshTtl(
List<byte[]> prefix,
Map<String, ? extends Serializable> fields,
Duration ttl,
Duration refreshTtl) {
byte[][] base = fieldArguments(prefix, fields, ttl);
byte[][] arguments = java.util.Arrays.copyOf(base, base.length + 1);
arguments[base.length] = bytes(ttlMillis(refreshTtl));
return arguments;
}
/**
* 校验并换算有效期。
*
* @param ttl 有效期
* @return 至少一毫秒的有效期
*/
private long ttlMillis(Duration ttl) {
Objects.requireNonNull(ttl, "ttl must not be null");
if (ttl.isNegative() || ttl.isZero()) {
throw new IllegalArgumentException("ttl must be positive");
}
return Math.max(1L, ttl.toMillis());
}
/**
* 校验 Redis key。
*
* @param key Redis key
* @return 原 key
*/
private String requireKey(String key) {
if (key == null || key.isBlank()) {
throw new IllegalArgumentException("key must not be blank");
}
return key;
}
/**
* 将文本转为 Redis 二进制参数。
*
* @param value 文本
* @return UTF-8 字节
*/
private static byte[] bytes(Object value) {
return String.valueOf(value).getBytes(StandardCharsets.UTF_8);
}
/**
* Redis Hash 中显式保存的空字段占位。
*/
private enum NullFieldValue {
INSTANCE
}
}

View File

@@ -0,0 +1,45 @@
package tech.easyflow.common.cache;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* Redis 字段化版本状态快照。
*/
public final class VersionedFields {
private final long version;
private final Map<String, Object> fields;
/**
* 创建字段化状态快照。
*
* @param version 当前版本
* @param fields 字段值
*/
public VersionedFields(long version, Map<String, Object> fields) {
this.version = version;
this.fields = fields == null
? Collections.emptyMap()
: Collections.unmodifiableMap(new LinkedHashMap<>(fields));
}
/**
* 获取状态版本。
*
* @return 当前版本
*/
public long getVersion() {
return version;
}
/**
* 获取字段快照。
*
* @return 不可变字段映射
*/
public Map<String, Object> getFields() {
return fields;
}
}

View File

@@ -0,0 +1,479 @@
package tech.easyflow.common.cache;
import java.io.Serializable;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
/**
* 支持原子版本比较的对象存储。
*/
public interface VersionedObjectStore {
/**
* 加载对象。
*
* @param key 存储键
* @param type 对象类型
* @param <T> 对象类型
* @return 已存在的对象;不存在时返回 {@code null}
*/
<T> T load(String key, Class<T> type);
/**
* 批量加载对象并保持输入键顺序。
*
* @param keys 存储键
* @param type 对象类型
* @param <T> 对象类型
* @return 与输入键等长的对象列表;缺失位置为 {@code null}
*/
default <T> List<T> loadAll(List<String> keys, Class<T> type) {
List<T> values = new ArrayList<>(keys.size());
for (String key : keys) {
values.add(load(key, type));
}
return values;
}
/**
* 批量删除对象。
*
* @param keys 存储键
*/
default void deleteAll(List<String> keys) {
throw new UnsupportedOperationException("Batch delete is not supported");
}
/**
* 两个守卫版本均匹配时原子批量删除对象。
*
* @param keys 待删除对象键
* @param guardKey 第一守卫键
* @param guardVersion 第一守卫版本
* @param secondaryGuardKey 第二守卫键
* @param secondaryGuardVersion 第二守卫版本
* @return 守卫匹配并执行删除时为 {@code true}
*/
default boolean deleteAll(
List<String> keys,
String guardKey,
long guardVersion,
String secondaryGuardKey,
long secondaryGuardVersion) {
throw new UnsupportedOperationException(
"Guarded batch delete is not supported");
}
/**
* 批量刷新对象有效期。
*
* @param keys 存储键
* @param ttl 新有效期
*/
default void refreshExpirations(List<String> keys, Duration ttl) {
throw new UnsupportedOperationException("Expiration refresh is not supported");
}
/**
* 原子创建对象。
*
* @param key 存储键
* @param value 初始对象
* @param version 初始版本
* @param ttl 有效期
* @return 创建成功时为 {@code true},对象已存在时为 {@code false}
*/
boolean createIfAbsent(String key, Serializable value, long version, Duration ttl);
/**
* 在守卫对象版本匹配时原子创建对象。
*
* @param key 存储键
* @param value 初始对象
* @param version 初始版本
* @param guardKey 守卫对象键
* @param guardVersion 期望的守卫版本
* @param ttl 有效期
* @return 创建成功时为 {@code true},对象已存在或守卫版本冲突时为 {@code false}
*/
boolean createIfAbsent(String key,
Serializable value,
long version,
String guardKey,
long guardVersion,
Duration ttl);
/**
* 两个守卫版本均匹配时原子创建对象。
*
* @param key 存储键
* @param value 初始对象
* @param version 初始版本
* @param guardKey 第一守卫键
* @param guardVersion 第一守卫版本
* @param secondaryGuardKey 第二守卫键
* @param secondaryGuardVersion 第二守卫版本
* @param ttl 有效期
* @return 创建成功时为 {@code true}
*/
default boolean createIfAbsent(
String key,
Serializable value,
long version,
String guardKey,
long guardVersion,
String secondaryGuardKey,
long secondaryGuardVersion,
Duration ttl) {
return createIfAbsent(key, value, version, guardKey, guardVersion, ttl);
}
/**
* 原子比较并更新对象。
*
* @param key 存储键
* @param expectedVersion 期望的当前版本
* @param value 新对象
* @param newVersion 新版本
* @param ttl 有效期
* @return 更新成功时为 {@code true},对象缺失或版本冲突时为 {@code false}
*/
boolean compareAndSet(String key,
long expectedVersion,
Serializable value,
long newVersion,
Duration ttl);
/**
* 在守卫对象版本匹配时原子比较并更新对象。
*
* @param key 存储键
* @param expectedVersion 期望的当前版本
* @param value 新对象
* @param newVersion 新版本
* @param guardKey 守卫对象键
* @param guardVersion 期望的守卫版本
* @param ttl 有效期
* @return 更新成功时为 {@code true},对象缺失或任一版本冲突时为 {@code false}
*/
boolean compareAndSet(String key,
long expectedVersion,
Serializable value,
long newVersion,
String guardKey,
long guardVersion,
Duration ttl);
/**
* 两个守卫版本均匹配时原子比较并更新对象。
*
* @param key 存储键
* @param expectedVersion 期望的当前版本
* @param value 新对象
* @param newVersion 新版本
* @param guardKey 第一守卫键
* @param guardVersion 第一守卫版本
* @param secondaryGuardKey 第二守卫键
* @param secondaryGuardVersion 第二守卫版本
* @param ttl 有效期
* @return 更新成功时为 {@code true}
*/
default boolean compareAndSet(
String key,
long expectedVersion,
Serializable value,
long newVersion,
String guardKey,
long guardVersion,
String secondaryGuardKey,
long secondaryGuardVersion,
Duration ttl) {
return compareAndSet(
key, expectedVersion, value, newVersion, guardKey, guardVersion, ttl);
}
/**
* 一次读取字段化状态及其版本。
*
* @param key 存储键
* @return 字段化状态;不存在时返回 {@code null}
*/
default VersionedFields loadFields(String key) {
throw new UnsupportedOperationException("Field storage is not supported");
}
/**
* 只读取对象版本。
*
* @param key 存储键
* @return 当前版本;对象不存在或缺少版本时返回 {@code null}
*/
default Long loadVersion(String key) {
VersionedFields fields = loadFields(key);
return fields == null ? null : fields.getVersion();
}
/**
* 原子创建字段化状态。
*
* @param key 存储键
* @param fields 初始字段
* @param version 初始版本
* @param ttl 有效期
* @return 创建成功时为 {@code true}
*/
default boolean createFieldsIfAbsent(String key,
Map<String, ? extends Serializable> fields,
long version,
Duration ttl) {
throw new UnsupportedOperationException("Field storage is not supported");
}
/**
* 守卫版本匹配时原子创建字段化状态。
*
* @param key 存储键
* @param fields 初始字段
* @param version 初始版本
* @param guardKey 守卫状态键
* @param guardVersion 期望守卫版本
* @param ttl 有效期
* @return 创建成功时为 {@code true}
*/
default boolean createFieldsIfAbsent(String key,
Map<String, ? extends Serializable> fields,
long version,
String guardKey,
long guardVersion,
Duration ttl) {
throw new UnsupportedOperationException("Field storage is not supported");
}
/**
* 两个守卫版本均匹配时原子创建字段化状态。
*
* @param key 存储键
* @param fields 初始字段
* @param version 初始版本
* @param guardKey 第一守卫键
* @param guardVersion 第一守卫版本
* @param secondaryGuardKey 第二守卫键
* @param secondaryGuardVersion 第二守卫版本
* @param ttl 有效期
* @return 创建成功时为 {@code true}
*/
default boolean createFieldsIfAbsent(
String key,
Map<String, ? extends Serializable> fields,
long version,
String guardKey,
long guardVersion,
String secondaryGuardKey,
long secondaryGuardVersion,
Duration ttl) {
return createFieldsIfAbsent(key, fields, version, guardKey, guardVersion, ttl);
}
/**
* 三个守卫版本均匹配时原子创建字段化状态。
*
* @param key 存储键
* @param fields 初始字段
* @param version 初始版本
* @param guardKey 第一守卫键
* @param guardVersion 第一守卫版本
* @param secondaryGuardKey 第二守卫键
* @param secondaryGuardVersion 第二守卫版本
* @param tertiaryGuardKey 第三守卫键
* @param tertiaryGuardVersion 第三守卫版本
* @param ttl 有效期
* @return 创建成功时为 {@code true}
*/
default boolean createFieldsIfAbsent(
String key,
Map<String, ? extends Serializable> fields,
long version,
String guardKey,
long guardVersion,
String secondaryGuardKey,
long secondaryGuardVersion,
String tertiaryGuardKey,
long tertiaryGuardVersion,
Duration ttl) {
return createFieldsIfAbsent(
key,
fields,
version,
guardKey,
guardVersion,
secondaryGuardKey,
secondaryGuardVersion,
ttl);
}
/**
* 原子比较版本并仅提交变化字段。
*
* @param key 存储键
* @param expectedVersion 期望版本
* @param fields 变化字段
* @param newVersion 新版本
* @param ttl 有效期
* @return 更新成功时为 {@code true}
*/
default boolean compareAndSetFields(String key,
long expectedVersion,
Map<String, ? extends Serializable> fields,
long newVersion,
Duration ttl) {
throw new UnsupportedOperationException("Field storage is not supported");
}
/**
* 两个守卫版本均匹配时原子提交变化字段。
*
* @param key 存储键
* @param expectedVersion 当前对象期望版本
* @param fields 变化字段
* @param newVersion 新版本
* @param guardKey 第一守卫键
* @param guardVersion 第一守卫版本
* @param secondaryGuardKey 第二守卫键
* @param secondaryGuardVersion 第二守卫版本
* @param ttl 有效期
* @return 提交成功时为 {@code true}
*/
default boolean compareAndSetFields(
String key,
long expectedVersion,
Map<String, ? extends Serializable> fields,
long newVersion,
String guardKey,
long guardVersion,
String secondaryGuardKey,
long secondaryGuardVersion,
Duration ttl) {
return compareAndSetFields(
key, expectedVersion, fields, newVersion, guardKey, guardVersion, ttl);
}
/**
* 三个守卫版本均匹配时原子提交变化字段。
*
* @param key 存储键
* @param expectedVersion 当前对象期望版本
* @param fields 变化字段
* @param newVersion 新版本
* @param guardKey 第一守卫键
* @param guardVersion 第一守卫版本
* @param secondaryGuardKey 第二守卫键
* @param secondaryGuardVersion 第二守卫版本
* @param tertiaryGuardKey 第三守卫键
* @param tertiaryGuardVersion 第三守卫版本
* @param ttl 有效期
* @return 提交成功时为 {@code true}
*/
default boolean compareAndSetFields(
String key,
long expectedVersion,
Map<String, ? extends Serializable> fields,
long newVersion,
String guardKey,
long guardVersion,
String secondaryGuardKey,
long secondaryGuardVersion,
String tertiaryGuardKey,
long tertiaryGuardVersion,
Duration ttl) {
return compareAndSetFields(
key,
expectedVersion,
fields,
newVersion,
guardKey,
guardVersion,
secondaryGuardKey,
secondaryGuardVersion,
ttl);
}
/**
* 两个守卫版本均匹配时提交字段,并在同一原子操作中刷新关联键有效期。
*
* @param key 存储键
* @param expectedVersion 当前对象期望版本
* @param fields 变化字段
* @param newVersion 新版本
* @param guardKey 第一守卫键
* @param guardVersion 第一守卫版本
* @param secondaryGuardKey 第二守卫键
* @param secondaryGuardVersion 第二守卫版本
* @param ttl 有效期
* @param refreshKey 需要刷新有效期的关联键
* @param refreshTtl 关联键有效期
* @return 提交成功时为 {@code true}
*/
default boolean compareAndSetFieldsAndRefresh(
String key,
long expectedVersion,
Map<String, ? extends Serializable> fields,
long newVersion,
String guardKey,
long guardVersion,
String secondaryGuardKey,
long secondaryGuardVersion,
Duration ttl,
String refreshKey,
Duration refreshTtl) {
return compareAndSetFields(
key,
expectedVersion,
fields,
newVersion,
guardKey,
guardVersion,
secondaryGuardKey,
secondaryGuardVersion,
ttl);
}
/**
* 守卫版本匹配时原子比较版本并仅提交变化字段。
*
* @param key 存储键
* @param expectedVersion 期望版本
* @param fields 变化字段
* @param newVersion 新版本
* @param guardKey 守卫状态键
* @param guardVersion 期望守卫版本
* @param ttl 有效期
* @return 更新成功时为 {@code true}
*/
default boolean compareAndSetFields(String key,
long expectedVersion,
Map<String, ? extends Serializable> fields,
long newVersion,
String guardKey,
long guardVersion,
Duration ttl) {
throw new UnsupportedOperationException("Field storage is not supported");
}
/**
* 将同版本完整对象原子改写为字段化状态。
*
* @param key 存储键
* @param expectedVersion 期望版本
* @param fields 完整字段
* @param ttl 有效期
* @return 改写成功时为 {@code true}
*/
default boolean rewriteAsFields(String key,
long expectedVersion,
Map<String, ? extends Serializable> fields,
Duration ttl) {
throw new UnsupportedOperationException("Field storage is not supported");
}
}

View File

@@ -0,0 +1,185 @@
package tech.easyflow.common.cache;
import org.junit.Assert;
import org.junit.Test;
import org.mockito.ArgumentMatchers;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.data.redis.core.script.RedisScript;
import java.lang.reflect.Constructor;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
/**
* {@link RedisIdempotencyExecutor} 状态转换回归测试。
*/
public class RedisIdempotencyExecutorTest {
/**
* 验证存在测试构造器时生产构造器仍能被 Spring 明确选择。
*
* @throws Exception 生产构造器不存在时抛出
*/
@Test
public void productionConstructorShouldBeAutowired()
throws Exception {
Constructor<RedisIdempotencyExecutor> constructor =
RedisIdempotencyExecutor.class.getConstructor(
StringRedisTemplate.class);
Assert.assertNotNull(
constructor.getAnnotation(Autowired.class));
}
/**
* 验证首次认领会执行操作并写入完成凭证。
*/
@Test
public void executeOnceShouldRunAndCompleteForNewKey() {
StringRedisTemplate redisTemplate = redisTemplateReturning(1L, 1L);
RedisIdempotencyExecutor executor = new RedisIdempotencyExecutor(redisTemplate);
AtomicInteger executions = new AtomicInteger();
boolean executed = executor.executeOnce(
"workflow:instance:node:trigger", executions::incrementAndGet);
Assert.assertTrue(executed);
Assert.assertEquals(1, executions.get());
Mockito.verify(redisTemplate).execute(
ArgumentMatchers.<RedisScript<Long>>any(),
ArgumentMatchers.<List<String>>any(),
ArgumentMatchers.anyString(),
ArgumentMatchers.anyString(),
ArgumentMatchers.anyString(),
ArgumentMatchers.anyString());
Mockito.verify(redisTemplate).execute(
ArgumentMatchers.<RedisScript<Long>>any(),
ArgumentMatchers.<List<String>>any(),
ArgumentMatchers.anyString(),
ArgumentMatchers.anyString(),
ArgumentMatchers.anyString());
}
/**
* 验证已有完成凭证时直接跳过副作用操作。
*/
@Test
public void executeOnceShouldSkipCompletedKey() {
StringRedisTemplate redisTemplate = redisTemplateReturning(2L);
RedisIdempotencyExecutor executor = new RedisIdempotencyExecutor(redisTemplate);
AtomicInteger executions = new AtomicInteger();
boolean executed = executor.executeOnce(
"workflow:instance:node:trigger", executions::incrementAndGet);
Assert.assertFalse(executed);
Assert.assertEquals(0, executions.get());
}
/**
* 验证其他 owner 仍在处理时返回明确冲突。
*/
@Test
public void executeOnceShouldRejectInProgressKey() {
StringRedisTemplate redisTemplate = redisTemplateReturning(0L);
RedisIdempotencyExecutor executor = new RedisIdempotencyExecutor(redisTemplate);
try {
executor.executeOnce("workflow:instance:node:trigger", () -> {
});
Assert.fail("in-progress operation must be rejected");
} catch (RedisIdempotencyExecutor.IdempotentOperationInProgressException expected) {
Assert.assertTrue(expected.getMessage().contains("workflow:instance"));
}
}
/**
* 验证同一幂等键绑定不同负载时明确拒绝,避免把不同业务写入误判为已完成。
*/
@Test
public void executeOnceShouldRejectPayloadMismatch() {
StringRedisTemplate redisTemplate = redisTemplateReturning(-1L);
RedisIdempotencyExecutor executor = new RedisIdempotencyExecutor(redisTemplate);
try {
executor.executeOnce(
"workflow:instance:node:trigger",
"different-payload-hash",
() -> {
});
Assert.fail("payload mismatch must be rejected");
} catch (RedisIdempotencyExecutor.IdempotencyPayloadMismatchException expected) {
Assert.assertTrue(expected.getMessage().contains("workflow:instance"));
}
}
/**
* 验证长操作会在处理中凭证到期前持续续期。
*
* @throws Exception 测试等待被中断
*/
@Test
public void executeOnceShouldRenewLeaseForLongOperation()
throws Exception {
StringRedisTemplate redisTemplate =
redisTemplateReturning(1L, 1L);
Mockito.when(redisTemplate.execute(
ArgumentMatchers.<RedisScript<Long>>any(),
ArgumentMatchers.<List<String>>any(),
ArgumentMatchers.anyString(),
ArgumentMatchers.anyString()))
.thenReturn(1L);
RedisIdempotencyExecutor executor =
new RedisIdempotencyExecutor(
redisTemplate,
Duration.ofMillis(60L),
Duration.ofMinutes(1L));
Assert.assertTrue(executor.executeOnce(
"workflow:long-operation",
() -> {
try {
Thread.sleep(90L);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new IllegalStateException(
"test interrupted", exception);
}
}));
Mockito.verify(redisTemplate, Mockito.atLeastOnce()).execute(
ArgumentMatchers.<RedisScript<Long>>any(),
ArgumentMatchers.<List<String>>any(),
ArgumentMatchers.anyString(),
ArgumentMatchers.anyString());
}
/**
* 创建按顺序返回脚本结果的 Redis 模板。
*
* @param results 脚本返回值
* @return Redis 模板
*/
private StringRedisTemplate redisTemplateReturning(Long... results) {
StringRedisTemplate redisTemplate = Mockito.mock(StringRedisTemplate.class);
Mockito.when(redisTemplate.execute(
ArgumentMatchers.<RedisScript<Long>>any(),
ArgumentMatchers.<List<String>>any(),
ArgumentMatchers.anyString(),
ArgumentMatchers.anyString(),
ArgumentMatchers.anyString(),
ArgumentMatchers.anyString()
)).thenReturn(results[0]);
Mockito.when(redisTemplate.execute(
ArgumentMatchers.<RedisScript<Long>>any(),
ArgumentMatchers.<List<String>>any(),
ArgumentMatchers.anyString(),
ArgumentMatchers.anyString(),
ArgumentMatchers.anyString()
)).thenReturn(results[Math.min(1, results.length - 1)]);
return redisTemplate;
}
}

View File

@@ -79,6 +79,74 @@ public class RedisLockExecutorTest {
);
}
/**
* 验证 fencing token 通过 Redis 原子脚本分配并返回。
*
* @throws Exception 反射注入异常
*/
@Test
public void nextFencingTokenShouldReturnAtomicSequenceValue() throws Exception {
StringRedisTemplate redisTemplate = Mockito.mock(StringRedisTemplate.class);
Mockito.when(redisTemplate.execute(
ArgumentMatchers.<RedisScript<Long>>any(),
ArgumentMatchers.<List<String>>any(),
ArgumentMatchers.<Object[]>any()
)).thenReturn(9L);
RedisLockExecutor executor = new RedisLockExecutor();
setRedisTemplate(executor, redisTemplate);
long token = executor.nextFencingToken(
"workflowState:{instance}:fence",
Duration.ofDays(4));
Assert.assertEquals(9L, token);
Mockito.verify(redisTemplate).execute(
ArgumentMatchers.<RedisScript<Long>>any(),
ArgumentMatchers.eq(List.of("workflowState:{instance}:fence")),
ArgumentMatchers.eq(String.valueOf(Duration.ofDays(4).toMillis()))
);
}
/**
* 验证互斥锁与 fencing token 通过同一个 Redis 脚本原子获取。
*
* @throws Exception 反射注入异常
*/
@Test
public void tryAcquireFencedShouldSetLockAndAdvanceTokenAtomically() throws Exception {
StringRedisTemplate redisTemplate = Mockito.mock(StringRedisTemplate.class);
Mockito.when(redisTemplate.execute(
ArgumentMatchers.<RedisScript<Long>>any(),
ArgumentMatchers.<List<String>>any(),
ArgumentMatchers.anyString(),
ArgumentMatchers.anyString(),
ArgumentMatchers.anyString()
)).thenReturn(17L);
RedisLockExecutor executor = new RedisLockExecutor();
setRedisTemplate(executor, redisTemplate);
RedisLockExecutor.LockHandle handle = executor.tryAcquireFenced(
"chainLock:{instance}",
"workflowState:{instance}:fence",
Duration.ZERO,
Duration.ofSeconds(30),
Duration.ofDays(4));
Assert.assertNotNull(handle);
Assert.assertEquals(17L, handle.getFencingToken());
Mockito.verify(redisTemplate).execute(
ArgumentMatchers.<RedisScript<Long>>any(),
ArgumentMatchers.eq(List.of(
"chainLock:{instance}",
"workflowState:{instance}:fence")),
ArgumentMatchers.anyString(),
ArgumentMatchers.eq("30000"),
ArgumentMatchers.eq(
String.valueOf(Duration.ofDays(4).toMillis())));
}
@SuppressWarnings("unchecked")
private ValueOperations<String, String> mockValueOperations(boolean acquired) {
ValueOperations<String, String> valueOperations = Mockito.mock(ValueOperations.class);

View File

@@ -0,0 +1,141 @@
package tech.easyflow.common.cache;
import com.alicp.jetcache.support.JavaValueEncoder;
import org.junit.Assert;
import org.junit.Test;
import org.mockito.ArgumentMatchers;
import org.mockito.Mockito;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.connection.RedisConnectionFactory;
import org.springframework.data.redis.connection.RedisHashCommands;
import org.springframework.data.redis.connection.RedisScriptingCommands;
import org.springframework.data.redis.connection.ReturnType;
import java.time.Duration;
import java.util.Map;
/**
* {@link RedisVersionedObjectStore} 二进制协议与脚本调用回归测试。
*/
public class RedisVersionedObjectStoreTest {
/**
* 验证状态对象继续按项目 Java 序列化协议读取。
*/
@Test
public void loadShouldDecodeExistingJavaPayload() {
RedisConnectionFactory connectionFactory = Mockito.mock(RedisConnectionFactory.class);
RedisConnection connection = Mockito.mock(RedisConnection.class);
RedisHashCommands hashCommands = Mockito.mock(RedisHashCommands.class);
Mockito.when(connectionFactory.getConnection()).thenReturn(connection);
Mockito.when(connection.hashCommands()).thenReturn(hashCommands);
Mockito.when(hashCommands.hGet(
ArgumentMatchers.any(byte[].class),
ArgumentMatchers.any(byte[].class)
)).thenReturn(JavaValueEncoder.INSTANCE.apply("state-value"));
RedisVersionedObjectStore store = new RedisVersionedObjectStore(connectionFactory);
String loaded = store.load("workflowState:{instance}:chain", String.class);
Assert.assertEquals("state-value", loaded);
Mockito.verify(connection).close();
}
/**
* 验证 CAS 更新通过单次 Redis Lua 调用完成。
*/
@Test
public void compareAndSetShouldUseSingleAtomicScript() {
RedisConnectionFactory connectionFactory = Mockito.mock(RedisConnectionFactory.class);
RedisConnection connection = Mockito.mock(RedisConnection.class);
RedisScriptingCommands scriptingCommands = Mockito.mock(RedisScriptingCommands.class);
Mockito.when(connectionFactory.getConnection()).thenReturn(connection);
Mockito.when(connection.scriptingCommands()).thenReturn(scriptingCommands);
Mockito.when(scriptingCommands.eval(
ArgumentMatchers.any(byte[].class),
ArgumentMatchers.eq(ReturnType.INTEGER),
ArgumentMatchers.eq(1),
ArgumentMatchers.any(byte[].class),
ArgumentMatchers.any(byte[].class),
ArgumentMatchers.any(byte[].class),
ArgumentMatchers.any(byte[].class),
ArgumentMatchers.any(byte[].class)
)).thenReturn(1L);
RedisVersionedObjectStore store = new RedisVersionedObjectStore(connectionFactory);
boolean updated = store.compareAndSet(
"workflowState:{instance}:chain",
3L,
"state-value",
4L,
Duration.ofDays(3));
Assert.assertTrue(updated);
Mockito.verify(scriptingCommands, Mockito.times(1)).eval(
ArgumentMatchers.any(byte[].class),
ArgumentMatchers.eq(ReturnType.INTEGER),
ArgumentMatchers.eq(1),
ArgumentMatchers.any(byte[].class),
ArgumentMatchers.any(byte[].class),
ArgumentMatchers.any(byte[].class),
ArgumentMatchers.any(byte[].class),
ArgumentMatchers.any(byte[].class));
Mockito.verify(connection).close();
}
/**
* 验证节点状态更新在同一脚本内校验链版本和 fencing token。
*/
@Test
public void compareAndSetFieldsShouldUseDoubleGuardedAtomicScript() {
RedisConnectionFactory connectionFactory = Mockito.mock(RedisConnectionFactory.class);
RedisConnection connection = Mockito.mock(RedisConnection.class);
RedisScriptingCommands scriptingCommands = Mockito.mock(RedisScriptingCommands.class);
Mockito.when(connectionFactory.getConnection()).thenReturn(connection);
Mockito.when(connection.scriptingCommands()).thenReturn(scriptingCommands);
Mockito.when(scriptingCommands.eval(
ArgumentMatchers.any(byte[].class),
ArgumentMatchers.eq(ReturnType.INTEGER),
ArgumentMatchers.eq(3),
ArgumentMatchers.any(byte[].class),
ArgumentMatchers.any(byte[].class),
ArgumentMatchers.any(byte[].class),
ArgumentMatchers.any(byte[].class),
ArgumentMatchers.any(byte[].class),
ArgumentMatchers.any(byte[].class),
ArgumentMatchers.any(byte[].class),
ArgumentMatchers.any(byte[].class),
ArgumentMatchers.any(byte[].class),
ArgumentMatchers.any(byte[].class)
)).thenReturn(1L);
RedisVersionedObjectStore store = new RedisVersionedObjectStore(connectionFactory);
boolean updated = store.compareAndSetFields(
"workflowState:{instance}:node:node-1",
2L,
Map.of("status", "RUNNING"),
3L,
"workflowState:{instance}:chain",
8L,
"workflowState:{instance}:fence",
12L,
Duration.ofDays(3));
Assert.assertTrue(updated);
Mockito.verify(scriptingCommands).eval(
ArgumentMatchers.any(byte[].class),
ArgumentMatchers.eq(ReturnType.INTEGER),
ArgumentMatchers.eq(3),
ArgumentMatchers.any(byte[].class),
ArgumentMatchers.any(byte[].class),
ArgumentMatchers.any(byte[].class),
ArgumentMatchers.any(byte[].class),
ArgumentMatchers.any(byte[].class),
ArgumentMatchers.any(byte[].class),
ArgumentMatchers.any(byte[].class),
ArgumentMatchers.any(byte[].class),
ArgumentMatchers.any(byte[].class),
ArgumentMatchers.any(byte[].class));
Mockito.verify(connection).close();
}
}