diff --git a/FastCache.Core/Attributes/CacheableAttribute.cs b/FastCache.Core/Attributes/CacheableAttribute.cs index 3302a91..7f8116a 100644 --- a/FastCache.Core/Attributes/CacheableAttribute.cs +++ b/FastCache.Core/Attributes/CacheableAttribute.cs @@ -42,7 +42,7 @@ public class CacheableAttribute : AbstractInterceptorAttribute { private readonly string _key; private readonly string _expression; - private readonly long _expire; + private readonly TimeSpan _expire; public sealed override int Order { get; set; } @@ -56,8 +56,20 @@ static CacheableAttribute() _taskResultMethod = typeof(Task).GetMethods() .First(p => p.Name == "FromResult" && p.ContainsGenericParameters); } + + // 支持秒数的构造器(保持向后兼容) + public CacheableAttribute(string prefix, string keyPattern, int expirationSeconds) + : this(prefix, keyPattern, TimeSpan.FromSeconds(expirationSeconds)) + { + } + + // 新增支持TimeSpan的构造器 + public CacheableAttribute(string prefix, string keyPattern, double expirationMinutes) + : this(prefix, keyPattern, TimeSpan.FromMinutes(expirationMinutes)) + { + } - public CacheableAttribute(string key, string expression, long expireSeconds = 0) + public CacheableAttribute(string key, string expression, TimeSpan expireSeconds = default) { _key = key; _expression = expression; @@ -134,11 +146,15 @@ public override async Task Invoke(AspectContext context, AspectDelegate next) var returnType = value?.GetType(); + var expire = 0l; + await cacheClient.Set(key, new CacheItem { Value = value, CreatedAt = DateTime.UtcNow.Ticks, - Expire = _expire > 0 ? DateTime.UtcNow.AddSeconds(_expire).Ticks : DateTime.UtcNow.AddYears(1).Ticks, + Expire = _expire > TimeSpan.Zero + ? DateTime.UtcNow.Add(_expire).Ticks + : DateTime.UtcNow.AddYears(1).Ticks, AssemblyName = returnType?.Assembly?.GetName()?.FullName ?? typeof(string).Assembly.FullName, Type = returnType?.FullName ?? string.Empty, }, _expire); diff --git a/FastCache.Core/Driver/ICacheClient.cs b/FastCache.Core/Driver/ICacheClient.cs index 7ccf5ea..f42fd32 100644 --- a/FastCache.Core/Driver/ICacheClient.cs +++ b/FastCache.Core/Driver/ICacheClient.cs @@ -1,3 +1,4 @@ +using System; using System.Threading.Tasks; using FastCache.Core.Entity; @@ -5,12 +6,12 @@ namespace FastCache.Core.Driver { public interface ICacheClient { - Task Set(string key, CacheItem cacheItem, long expire = 0); + Task Set(string key, CacheItem cacheItem, TimeSpan expire = default); Task Get(string key); Task Delete(string key, string prefix); - Task Delete(string key); + Task Delete(string key); } } \ No newline at end of file diff --git a/FastCache.Core/Driver/IRedisCache.cs b/FastCache.Core/Driver/IRedisCache.cs index c5118b2..42812bf 100644 --- a/FastCache.Core/Driver/IRedisCache.cs +++ b/FastCache.Core/Driver/IRedisCache.cs @@ -1,14 +1,34 @@ using System; +using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; +using FastCache.Core.Entity; +using RedLockNet.SERedis; +using StackExchange.Redis; namespace FastCache.Core.Driver { public interface IRedisCache : ICacheClient { - Task ExecuteWithRedisLockAsync(string lockKey, + // void Dispose(); + + ConnectionMultiplexer GetConnectionMultiplexer(); + + RedLockFactory GetRedLockFactory(); + + Task ExecuteWithRedisLockAsync(string lockKey, Func operation, - int msTimeout = 100, - int msExpire = 1000, - bool throwOnFailure = false); + DistributedLockOptions? options = null, + CancellationToken cancellationToken = default); + + Task> FuzzySearchAsync( + AdvancedSearchModel advancedSearchModel, + CancellationToken cancellationToken = default); + + Task BatchDeleteKeysWithPipelineAsync( + IEnumerable keys, + int batchSize = 200); + + Task TryRemove(string[] keys, int doubleDeleteDelayedMs = 0); } } \ No newline at end of file diff --git a/FastCache.Core/Entity/AdvancedSearchModel.cs b/FastCache.Core/Entity/AdvancedSearchModel.cs new file mode 100644 index 0000000..b2e090a --- /dev/null +++ b/FastCache.Core/Entity/AdvancedSearchModel.cs @@ -0,0 +1,20 @@ +namespace FastCache.Core.Entity +{ + public class AdvancedSearchModel + { + /// 匹配模式(支持*和?) + public string Pattern { get; set; } = "*"; + + /// 每批次扫描数量 + public int PageSize { get; set; } = 200; + + /// 最大返回结果数(0表示无限制) + public int MaxResults { get; set; } = 1000; + + /// 是否包含值内容(启用时会额外执行GET操作) + public bool IncludeValues { get; set; } + + /// 结果过滤条件(Lua脚本片段) + public string? FilterScript { get; set; } + } +} \ No newline at end of file diff --git a/FastCache.Core/Entity/DistributedLockOptions.cs b/FastCache.Core/Entity/DistributedLockOptions.cs new file mode 100644 index 0000000..7c2aab4 --- /dev/null +++ b/FastCache.Core/Entity/DistributedLockOptions.cs @@ -0,0 +1,39 @@ +using System; + +namespace FastCache.Core.Entity +{ + public class DistributedLockOptions + { + /// + /// 锁的自动释放时间(默认30秒) + /// + public TimeSpan ExpiryTime { get; set; } = TimeSpan.FromSeconds(30); + + /// + /// 最大等待获取锁时间(默认10秒) + /// + public TimeSpan WaitTime { get; set; } = TimeSpan.FromSeconds(10); + + /// + /// 重试间隔时间(默认200毫秒) + /// + public TimeSpan RetryInterval { get; set; } = TimeSpan.FromMilliseconds(200); + + /// + /// 获取锁失败时是否抛出异常(默认true) + /// + public bool ThrowOnLockFailure { get; set; } = true; + + /// + /// 业务操作失败时是否抛出异常(默认false) + /// + public bool ThrowOnOperationFailure { get; set; } = false; + + // 可扩展的Builder模式 + public DistributedLockOptions WithExpiry(TimeSpan expiry) + { + ExpiryTime = expiry; + return this; + } + } +} \ No newline at end of file diff --git a/FastCache.Core/Entity/DistributedLockResult.cs b/FastCache.Core/Entity/DistributedLockResult.cs new file mode 100644 index 0000000..f3249c6 --- /dev/null +++ b/FastCache.Core/Entity/DistributedLockResult.cs @@ -0,0 +1,12 @@ +using System; +using FastCache.Core.Enums; + +namespace FastCache.Core.Entity +{ + public class DistributedLockResult + { + public bool IsSuccess { get; set; } + public LockStatus Status { get; set; } + public Exception? Exception { get; set; } + } +} \ No newline at end of file diff --git a/FastCache.Core/Entity/MemoryCacheOptions.cs b/FastCache.Core/Entity/MemoryCacheOptions.cs new file mode 100644 index 0000000..26185b7 --- /dev/null +++ b/FastCache.Core/Entity/MemoryCacheOptions.cs @@ -0,0 +1,29 @@ +using FastCache.Core.Enums; + +namespace FastCache.Core.Entity +{ + public class MemoryCacheOptions + { + /// + /// 最大缓存项数量(默认:1,000,000) + /// + public int MaxCapacity { get; set; } = 1000000; + + /// + /// 内存淘汰策略(默认:LRU - 最近最少使用) + /// + public MaxMemoryPolicy MemoryPolicy { get; set; } = MaxMemoryPolicy.LRU; + + /// + /// 内存清理百分比(范围:1-100,默认:10%) + /// + public int CleanUpPercentage { get; set; } = 10; + + /// + /// 延迟秒数 + /// + public int DelaySeconds { get; set; } = 2; + + public uint Buckets { get; set; } = 5; + } +} \ No newline at end of file diff --git a/FastCache.Core/Entity/RedisCacheOptions.cs b/FastCache.Core/Entity/RedisCacheOptions.cs new file mode 100644 index 0000000..038d24f --- /dev/null +++ b/FastCache.Core/Entity/RedisCacheOptions.cs @@ -0,0 +1,27 @@ +using System; +using StackExchange.Redis; + +namespace FastCache.Core.Entity +{ + public class RedisCacheOptions + { + /// + /// Quorum 重试次数(默认: 3) + /// + public int QuorumRetryCount { get; set; } = 3; + + /// + /// Quorum 重试延迟基准值(默认: 400ms) + /// + public int QuorumRetryDelayMs { get; set; } = 400; + + /// + /// 全局延迟删除时间(ms) + /// + public int DoubleDeleteDelayedMs { get; set; } = 2000; + + public Action? ConnectionFailureHandler { get; set; } = null; + + public Action? ConnectionRestoredHandler { get; set; } = null; + } +} \ No newline at end of file diff --git a/FastCache.Core/Enums/LockStatus.cs b/FastCache.Core/Enums/LockStatus.cs new file mode 100644 index 0000000..6c37141 --- /dev/null +++ b/FastCache.Core/Enums/LockStatus.cs @@ -0,0 +1,9 @@ +namespace FastCache.Core.Enums +{ + public enum LockStatus + { + AcquiredAndCompleted, // 成功获取并执行 + LockNotAcquired, // 锁获取失败 + OperationFailed // 获取锁后执行失败 + } +} \ No newline at end of file diff --git a/FastCache.InMemory/Enum/MaxMemoryPolicy.cs b/FastCache.Core/Enums/MaxMemoryPolicy.cs similarity index 78% rename from FastCache.InMemory/Enum/MaxMemoryPolicy.cs rename to FastCache.Core/Enums/MaxMemoryPolicy.cs index 18d3a04..4d5ac40 100644 --- a/FastCache.InMemory/Enum/MaxMemoryPolicy.cs +++ b/FastCache.Core/Enums/MaxMemoryPolicy.cs @@ -1,4 +1,4 @@ -namespace FastCache.InMemory.Enum +namespace FastCache.Core.Enums { public enum MaxMemoryPolicy { @@ -6,5 +6,4 @@ public enum MaxMemoryPolicy RANDOM, // RANDOM TTL // Time To Live } -} - +} \ No newline at end of file diff --git a/FastCache.Core/Extensions/EnumerableExtensions.cs b/FastCache.Core/Extensions/EnumerableExtensions.cs new file mode 100644 index 0000000..664144f --- /dev/null +++ b/FastCache.Core/Extensions/EnumerableExtensions.cs @@ -0,0 +1,35 @@ +using System; +using System.Collections.Generic; + +namespace FastCache.Core.Extensions +{ + public static class EnumerableExtensions + { + /// + /// 将序列按指定大小分块(兼容 netstandard2.1) + /// + /// 元素类型 + /// 输入序列 + /// 每块大小 + /// 分块后的序列 + public static IEnumerable> Chunk(this IEnumerable source, int size) + { + if (source == null) throw new ArgumentNullException(nameof(source)); + if (size <= 0) throw new ArgumentOutOfRangeException(nameof(size)); + + using var enumerator = source.GetEnumerator(); + while (enumerator.MoveNext()) + { + yield return GetChunk(enumerator, size); + } + } + + private static IEnumerable GetChunk(IEnumerator enumerator, int size) + { + do + { + yield return enumerator.Current; + } while (--size > 0 && enumerator.MoveNext()); + } + } +} \ No newline at end of file diff --git a/FastCache.Core/FastCache.Core.csproj b/FastCache.Core/FastCache.Core.csproj index 36f68e5..7962dd1 100644 --- a/FastCache.Core/FastCache.Core.csproj +++ b/FastCache.Core/FastCache.Core.csproj @@ -16,6 +16,7 @@ + diff --git a/FastCache.InMemory/Drivers/MemoryCache.cs b/FastCache.InMemory/Drivers/MemoryCache.cs index 8b52a77..ff8a384 100644 --- a/FastCache.InMemory/Drivers/MemoryCache.cs +++ b/FastCache.InMemory/Drivers/MemoryCache.cs @@ -6,7 +6,7 @@ using System.Threading.Tasks; using FastCache.Core.Driver; using FastCache.Core.Entity; -using FastCache.InMemory.Enum; +using FastCache.Core.Enums; using FastCache.InMemory.Extension; using Newtonsoft.Json; @@ -31,9 +31,9 @@ public MemoryCache(int maxCapacity = 5000000, MaxMemoryPolicy maxMemoryPolicy = _dist = new ConcurrentDictionary(Environment.ProcessorCount * 2, _maxCapacity); } - public Task Set(string key, CacheItem cacheItem, long _ = 0) + public Task Set(string key, CacheItem cacheItem, TimeSpan expire = default) { - if (_dist.ContainsKey(key)) return Task.CompletedTask; + if (_dist.ContainsKey(key)) return Task.FromResult(true); if (_dist.Count >= _maxCapacity) { ReleaseCached(); @@ -46,7 +46,7 @@ public Task Set(string key, CacheItem cacheItem, long _ = 0) _dist.AddOrUpdate(key, cacheItem, (k, v) => cacheItem); - return Task.CompletedTask; + return Task.FromResult(true); } public Task Get(string key) @@ -118,10 +118,10 @@ public Task Delete(string key, string prefix = "") return Task.CompletedTask; } - public Task Delete(string key) + public Task Delete(string key) { _dist.TryRemove(key, out _, _delaySeconds); - return Task.CompletedTask; + return Task.FromResult(true); } private void ReleaseCached() diff --git a/FastCache.InMemory/Drivers/MultiBucketsMemoryCache.cs b/FastCache.InMemory/Drivers/MultiBucketsMemoryCache.cs index ca6361e..97030b5 100644 --- a/FastCache.InMemory/Drivers/MultiBucketsMemoryCache.cs +++ b/FastCache.InMemory/Drivers/MultiBucketsMemoryCache.cs @@ -8,7 +8,7 @@ using System.Threading.Tasks; using FastCache.Core.Driver; using FastCache.Core.Entity; -using FastCache.InMemory.Enum; +using FastCache.Core.Enums; using FastCache.InMemory.Extension; using Newtonsoft.Json; @@ -41,11 +41,11 @@ public MultiBucketsMemoryCache(uint buckets = 5, uint bucketMaxCapacity = 500000 InitBucket(_map, _buckets); } - public Task Set(string key, CacheItem cacheItem, long _ = 0) + public Task Set(string key, CacheItem cacheItem, TimeSpan timeSpan = default) { var bucket = GetBucket(HashKey(key)); - if (bucket.ContainsKey(key)) return Task.CompletedTask; + if (bucket.ContainsKey(key)) return Task.FromResult(true); if (bucket.Count >= _bucketMaxCapacity) { ReleaseCached(bucket); @@ -58,7 +58,7 @@ public Task Set(string key, CacheItem cacheItem, long _ = 0) bucket.AddOrUpdate(key, cacheItem, (k, v) => cacheItem); - return Task.CompletedTask; + return Task.FromResult(true); } public Task Get(string key) @@ -127,10 +127,11 @@ public Task Delete(string key, string prefix = "") return Task.CompletedTask; } - public Task Delete(string key) + public Task Delete(string key) { GetBucket(HashKey(key)).TryRemove(key, out _, _delaySeconds); - return Task.CompletedTask; + + return Task.FromResult(true); } private void InitBucket(Dictionary> map, uint buckets) diff --git a/FastCache.InMemory/Setup/Setup.cs b/FastCache.InMemory/Setup/Setup.cs index 0bf7fdc..ae53733 100644 --- a/FastCache.InMemory/Setup/Setup.cs +++ b/FastCache.InMemory/Setup/Setup.cs @@ -1,6 +1,7 @@ using FastCache.Core.Driver; +using FastCache.Core.Entity; +using FastCache.Core.Enums; using FastCache.InMemory.Drivers; -using FastCache.InMemory.Enum; using Microsoft.Extensions.DependencyInjection; namespace FastCache.InMemory.Setup @@ -9,14 +10,16 @@ public static class Setup { public static void AddInMemoryCache( this IServiceCollection services, - int maxCapacity = 1000000, - MaxMemoryPolicy maxMemoryPolicy = MaxMemoryPolicy.LRU, int cleanUpPercentage = 10, int delaySeconds = 2 + MemoryCacheOptions? memoryCacheOptions = null ) { + var option = memoryCacheOptions ?? new MemoryCacheOptions(); + services.AddSingleton( new MemoryCache( - maxCapacity, maxMemoryPolicy, cleanUpPercentage, - delaySeconds: delaySeconds + maxCapacity: option.MaxCapacity, maxMemoryPolicy: option.MemoryPolicy, + cleanUpPercentage: option.CleanUpPercentage, + delaySeconds: option.DelaySeconds ) ); } diff --git a/FastCache.MultiSource/Attributes/DistributedLockAttribute.cs b/FastCache.MultiSource/Attributes/DistributedLockAttribute.cs deleted file mode 100644 index 8b5c45c..0000000 --- a/FastCache.MultiSource/Attributes/DistributedLockAttribute.cs +++ /dev/null @@ -1,76 +0,0 @@ -using System; -using System.Linq; -using System.Security.Cryptography; -using System.Text; -using System.Threading.Tasks; -using AspectCore.DynamicProxy; -using FastCache.Core.Driver; -using Microsoft.Extensions.DependencyInjection; - -namespace FastCache.MultiSource.Attributes -{ - public class DistributedLockAttribute : AbstractInterceptorAttribute - { - private readonly string _prefix; - private readonly int _msTimeout; - private readonly int _msExpire; - private readonly bool _throwOnFailure; - private readonly bool _usePrefixToKey; - - public DistributedLockAttribute(string prefix, - int msTimeout = 600, - int msExpire = 3000, - bool throwOnFailure = false, - bool usePrefixToKey = true) - { - _prefix = prefix; - _msTimeout = msTimeout; - _msExpire = msExpire; - _throwOnFailure = throwOnFailure; - _usePrefixToKey = usePrefixToKey; - } - - public override async Task Invoke(AspectContext context, AspectDelegate next) - { - var cacheClient = context.ServiceProvider.GetService(); - - // 根据方法名和参数生成唯一的锁定键 - var lockKey = _usePrefixToKey ? $"{_prefix}:{context.ServiceMethod.Name}" : context.ServiceMethod.Name; - GenerateLockKey(context); - - await cacheClient.ExecuteWithRedisLockAsync( - lockKey, async () => { await next(context); }, _msTimeout, _msExpire, _throwOnFailure); - } - - private string GenerateLockKey(AspectContext context) - { - // 获取方法名 - var methodName = context.ServiceMethod.Name; - - // 获取方法参数并转换成字符串表示 - var arguments = context.Parameters.Select(p => p?.ToString() ?? "null").ToArray(); - - // 拼接方法名和参数作为输入 - var combined = $"{methodName}:{string.Join(":", arguments)}"; - - // 对拼接后的字符串进行哈希处理,得到简短唯一的Key - var hash = ComputeSha256Hash(combined); - - // 生成Key:前缀 + 哈希值 - var key = $"{_prefix}:{hash}"; - - return key; - } - - // 使用SHA-256哈希算法来确保唯一性并缩短Key长度 - private string ComputeSha256Hash(string rawData) - { - using var sha256 = SHA256.Create(); - // 计算哈希值 - var bytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(rawData)); - - // 将字节数组转换为十六进制字符串,并取前8个字节,确保短且唯一 - return BitConverter.ToString(bytes).Replace("-", "").Substring(0, 16); - } - } -} \ No newline at end of file diff --git a/FastCache.MultiSource/Attributes/MultiSourceCacheableAttribute.cs b/FastCache.MultiSource/Attributes/MultiSourceCacheableAttribute.cs index 5f59d16..25cc56a 100644 --- a/FastCache.MultiSource/Attributes/MultiSourceCacheableAttribute.cs +++ b/FastCache.MultiSource/Attributes/MultiSourceCacheableAttribute.cs @@ -44,7 +44,7 @@ public class MultiSourceCacheableAttribute : AbstractInterceptorAttribute private readonly string _key; private readonly string _expression; private readonly Target _target; - private readonly long _expire; + private readonly TimeSpan _expire; public sealed override int Order { get; set; } private static readonly ConcurrentDictionary @@ -58,12 +58,18 @@ static MultiSourceCacheableAttribute() .First(p => p.Name == "FromResult" && p.ContainsGenericParameters); } - public MultiSourceCacheableAttribute(string key, string expression, Target target, long expireSeconds = 0) + + public MultiSourceCacheableAttribute(string key, string expression, Target target, long expireSeconds = 0) : + this(key, expression, target, TimeSpan.FromSeconds(expireSeconds)) + { + } + + public MultiSourceCacheableAttribute(string key, string expression, Target target, TimeSpan expire = default) { _key = key; _expression = expression; _target = target; - _expire = expireSeconds; + _expire = expire; Order = 2; } @@ -155,7 +161,7 @@ public override async Task Invoke(AspectContext context, AspectDelegate next) Value = value, CreatedAt = DateTime.UtcNow.Ticks, Expire = - _expire > 0 ? DateTime.UtcNow.AddSeconds(_expire).Ticks : DateTime.UtcNow.AddYears(1).Ticks, + _expire > TimeSpan.Zero ? DateTime.UtcNow.Add(_expire).Ticks : DateTime.UtcNow.AddYears(1).Ticks, AssemblyName = returnType?.Assembly?.GetName()?.FullName ?? typeof(string).Assembly.FullName, Type = returnType?.FullName ?? string.Empty, }, _expire); diff --git a/FastCache.MultiSource/Attributes/MultiSourceEvictableAttribute.cs b/FastCache.MultiSource/Attributes/MultiSourceEvictableAttribute.cs index f541b45..7f7b622 100644 --- a/FastCache.MultiSource/Attributes/MultiSourceEvictableAttribute.cs +++ b/FastCache.MultiSource/Attributes/MultiSourceEvictableAttribute.cs @@ -42,16 +42,19 @@ public class MultiSourceEvictableAttribute : AbstractInterceptorAttribute private readonly string[] _keys; private readonly string[] _expression; private readonly Target _target; + private readonly int _doubleDeleteDelayedMs; public sealed override int Order { get; set; } public override bool AllowMultiple { get; } = true; - public MultiSourceEvictableAttribute(string[] keys, string[] expression, Target target) + public MultiSourceEvictableAttribute(string[] keys, string[] expression, Target target, + int doubleDeleteDelayedMs = 0) { _keys = keys; _expression = expression; _target = target; + _doubleDeleteDelayedMs = doubleDeleteDelayedMs; Order = 3; } diff --git a/FastCache.MultiSource/Setup/Setup.cs b/FastCache.MultiSource/Setup/Setup.cs index c6f01ff..593692f 100644 --- a/FastCache.MultiSource/Setup/Setup.cs +++ b/FastCache.MultiSource/Setup/Setup.cs @@ -1,23 +1,51 @@ +#nullable enable +using Autofac; using FastCache.Core.Driver; +using FastCache.Core.Entity; using FastCache.InMemory.Drivers; -using FastCache.InMemory.Enum; using FastCache.Redis.Driver; using Microsoft.Extensions.DependencyInjection; +using StackExchange.Redis; namespace FastCache.MultiSource.Setup { public static class Setup { - public static void AddMultiSourceCache( + public static void RegisterMultiSourceCache( this IServiceCollection services, - string connectionString, - bool canGetRedisClient = false, - int maxCapacity = 1000000, - MaxMemoryPolicy maxMemoryPolicy = MaxMemoryPolicy.LRU, int cleanUpPercentage = 10 + ConfigurationOptions configurationOptions, + RedisCacheOptions? redisCacheOptions = null, + MemoryCacheOptions? memoryCacheOptions = null ) { - services.AddSingleton(new RedisCache(connectionString, canGetRedisClient)); - services.AddSingleton(new MemoryCache(maxCapacity, maxMemoryPolicy, cleanUpPercentage)); + var memoryCacheOption = memoryCacheOptions ?? new MemoryCacheOptions(); + var redisCacheOption = redisCacheOptions ?? new RedisCacheOptions(); + + services.AddSingleton(new RedisCache(configurationOptions, redisCacheOption)); + services.AddSingleton(new MemoryCache(memoryCacheOption.MaxCapacity, + memoryCacheOption.MemoryPolicy, memoryCacheOption.CleanUpPercentage)); + } + + // 新增Autofac专用注册方式 + public static void RegisterMultiSourceCache( + this ContainerBuilder builder, + ConfigurationOptions configurationOptions, + RedisCacheOptions? redisCacheOptions = null, + MemoryCacheOptions? memoryCacheOptions = null) + { + var memoryOpts = memoryCacheOptions ?? new MemoryCacheOptions(); + var redisOpts = redisCacheOptions ?? new RedisCacheOptions(); + + builder.Register(ctx => new RedisCache(configurationOptions, redisOpts)) + .As() + .SingleInstance(); + + builder.Register(ctx => new MemoryCache( + memoryOpts.MaxCapacity, + memoryOpts.MemoryPolicy, + memoryOpts.CleanUpPercentage)) + .As() + .SingleInstance(); } } } \ No newline at end of file diff --git a/FastCache.Redis/Driver/RedisCache.BaseOperation.cs b/FastCache.Redis/Driver/RedisCache.BaseOperation.cs new file mode 100644 index 0000000..a0a950d --- /dev/null +++ b/FastCache.Redis/Driver/RedisCache.BaseOperation.cs @@ -0,0 +1,187 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Threading.Tasks; +using FastCache.Core.Entity; +using FastCache.Core.Extensions; +using Newtonsoft.Json; +using StackExchange.Redis; + +namespace FastCache.Redis.Driver +{ + public partial class RedisCache + { + public async Task Set(string key, CacheItem cacheItem, TimeSpan expire = default) + { + if (string.IsNullOrWhiteSpace(key)) + throw new ArgumentException("Key cannot be null or empty", nameof(key)); + + if (cacheItem == null) + throw new ArgumentNullException(nameof(cacheItem)); + + var db = _redisConnection.GetDatabase(); + + var hasKey = db.KeyExists(key); + + if (hasKey) return true; + + if (cacheItem.Value != null) + { + cacheItem.Value = JsonConvert.SerializeObject(cacheItem.Value); + } + + var value = JsonConvert.SerializeObject(cacheItem); + + if (expire != default) + { + return await db.StringSetAsync(key, value: value, expiry: expire, when: When.NotExists) + .ConfigureAwait(false); + } + + return await db.StringSetAsync(key, value: value, when: When.NotExists).ConfigureAwait(false); + } + + public async Task Get(string key) + { + if (string.IsNullOrWhiteSpace(key)) + throw new ArgumentException("Key cannot be null or empty", nameof(key)); + + var db = _redisConnection.GetDatabase(); + + var cache = await db.StringGetAsync(key); + + if (cache.IsNullOrEmpty) return new CacheItem(); + + var cacheItem = JsonConvert.DeserializeObject(cache); + + if (string.IsNullOrWhiteSpace(cacheItem?.AssemblyName) || string.IsNullOrWhiteSpace(cacheItem?.Type) || + cacheItem?.Value == null) return new CacheItem(); + + var assembly = Assembly.Load(cacheItem.AssemblyName); + var valueType = assembly.GetType(cacheItem.Type, true, true); + cacheItem.Value = JsonConvert.DeserializeObject(cacheItem.Value as string, valueType); + return cacheItem; + } + + public async Task TryRemove(string[] keys, int doubleDeleteDelayedMs = 0) + { + if (keys.Length == 0) return 0; + + var db = _redisConnection.GetDatabase(); + + var redisKeys = Array.ConvertAll(keys, key => (RedisKey)key); + + // 优先使用传入方法的参数,其次使用类字段的默认值 + var actualDelayMs = doubleDeleteDelayedMs > 0 ? doubleDeleteDelayedMs : _doubleDeleteDelayedMs; + + var keyDeleteResult = await db.KeyDeleteAsync(redisKeys).ConfigureAwait(false); + + // 如果第一次删除成功且设置了延迟时间,则安排延迟双删 + if (keyDeleteResult > 0 && actualDelayMs > 0) + { + // 使用后台任务执行延迟双删(不阻塞当前请求) + _ = Task.Run(async () => + { + await Task.Delay(actualDelayMs).ConfigureAwait(false); + await db.KeyDeleteAsync(redisKeys).ConfigureAwait(false); + // 可选:记录日志或监控指标 + }); + } + + return keyDeleteResult; + } + + public async Task Delete(string key) + { + if (string.IsNullOrWhiteSpace(key)) + throw new ArgumentException("Key cannot be null or empty", nameof(key)); + + var removeResult = await TryRemove(new[] { key }).ConfigureAwait(false); + + return removeResult > 0; + } + + public async Task BatchDeleteKeysWithPipelineAsync( + IEnumerable keys, + int batchSize = 200) + { + long totalDeleted = 0; + + var db = _redisConnection.GetDatabase(); + + foreach (var chunk in keys.Chunk(batchSize)) + { + // 1. 创建批处理对象(管道) + var batch = db.CreateBatch(); + + var enumerable = chunk as string[] ?? chunk.ToArray(); + // var redisKeys = Array.ConvertAll(enumerable, key => (RedisKey)key); + + // var keyDeleteResult = batch.KeyDeleteAsync(redisKeys); + + var keyDeleteResult = TryRemove(enumerable); + + // 3. 触发批量发送(所有命令一次性发往Redis) + batch.Execute(); + + var resultCount = await keyDeleteResult; + + totalDeleted += resultCount; + } + + return totalDeleted; + } + + public async Task Delete(string key, string prefix) + { + if (string.IsNullOrWhiteSpace(key)) + throw new ArgumentException("Key cannot be null or empty", nameof(key)); + + if (key.Contains('*')) + { + string[] list = { }; + if (key.First() == '*' || key.Last() == '*') + { + if (string.IsNullOrEmpty(prefix)) + { + list = (await FuzzySearchAsync(new AdvancedSearchModel() { Pattern = key, PageSize = 1000 })) + .ToArray(); + } + else + { + if (key.Length > 0 && key.First() == '*') + { + key = key[1..]; + } + + if (key.Length > 0 && key.Last() == '*') + { + key = key[..^1]; + } + + list = string.IsNullOrEmpty(key) + ? (await FuzzySearchAsync(new AdvancedSearchModel() + { Pattern = $"{prefix}*", PageSize = 1000 })).ToArray() + : (await FuzzySearchAsync(new AdvancedSearchModel() + { Pattern = $"{prefix}*", PageSize = 1000 })).Where(x => x.Contains(key)).ToArray(); + } + } + else + { + await Delete(key).ConfigureAwait(false); + } + + if (list?.Length > 0) + { + await BatchDeleteKeysWithPipelineAsync(list); + } + } + else + { + var removeKey = string.IsNullOrEmpty(prefix) ? key : $"{prefix}:{key}"; + await Delete(removeKey).ConfigureAwait(false); + } + } + } +} \ No newline at end of file diff --git a/FastCache.Redis/Driver/RedisCache.Lock.cs b/FastCache.Redis/Driver/RedisCache.Lock.cs index 2aa508e..c164d08 100644 --- a/FastCache.Redis/Driver/RedisCache.Lock.cs +++ b/FastCache.Redis/Driver/RedisCache.Lock.cs @@ -1,6 +1,8 @@ using System; +using System.Threading; using System.Threading.Tasks; -using NewLife.Caching; +using FastCache.Core.Entity; +using FastCache.Core.Enums; namespace FastCache.Redis.Driver { @@ -11,68 +13,56 @@ public partial class RedisCache /// /// 锁的键名 /// 需要执行的异步操作 - /// 重试次数,默认为3次 - /// 初始重试等待时间(毫秒),默认为100毫秒 - /// 最大重试等待时间(毫秒),默认为1000毫秒 - /// 锁的获取超时时间(毫秒),默认为100毫秒 - /// 锁的过期时间(毫秒),默认为1000毫秒 - /// 失败时是否抛出异常 + /// 控制参数 + /// /// 操作成功返回 true,否则返回 false - public async Task ExecuteWithRedisLockAsync(string lockKey, + public async Task ExecuteWithRedisLockAsync(string lockKey, Func operation, - // int retryCount = 3, - // int initialRetryDelayMs = 100, - // int maxRetryDelayMs = 1000, - int msTimeout = 600, - int msExpire = 3000, - bool throwOnFailure = false) + DistributedLockOptions? options = null, + CancellationToken cancellationToken = default) { - // int currentRetry = 0; - // int retryDelay = initialRetryDelayMs; + var opts = options ?? new DistributedLockOptions(); + + await using var redLock = await _redLockFactory + .CreateLockAsync(lockKey, opts.ExpiryTime, opts.WaitTime, opts.RetryInterval, cancellationToken) + .ConfigureAwait(false); - // while (currentRetry < retryCount) - // { - // 尝试获取分布式锁 - using var redisLock = _redisClient.AcquireLock(lockKey, msTimeout, msExpire, throwOnFailure); - - if (redisLock != null) + if (!redLock.IsAcquired) { - try - { - // 执行传入的操作 - await operation(); - return true; - } - catch (Exception ex) + return new DistributedLockResult { - // 记录操作异常 - // Console.WriteLine($"执行过程中发生错误: {ex.Message}"); - if (throwOnFailure) - { - throw; - } - - return false; - } + IsSuccess = false, + Status = LockStatus.LockNotAcquired, + Exception = opts.ThrowOnLockFailure + ? new InvalidOperationException( + "Failed to acquire the distributed lock after multiple attempts") + : null + }; } - // currentRetry++; - // - // if (currentRetry < retryCount) - // { - // // 指数退避策略:每次重试后增加等待时间 - // retryDelay = Math.Min(retryDelay * 2, maxRetryDelayMs); - // await Task.Delay(retryDelay); - // } - // } - - // 如果在所有重试中都未获取锁,则返回失败 - if (throwOnFailure) + try { - throw new InvalidOperationException("Failed to acquire the distributed lock after multiple attempts."); + await operation(); + return new DistributedLockResult + { + IsSuccess = true, + Status = LockStatus.AcquiredAndCompleted + }; } + catch (Exception ex) + { + if (opts.ThrowOnOperationFailure) + { + throw; + } - return false; + return new DistributedLockResult + { + IsSuccess = false, + Status = LockStatus.OperationFailed, + Exception = opts.ThrowOnOperationFailure ? ex : null + }; + } } } } \ No newline at end of file diff --git a/FastCache.Redis/Driver/RedisCache.Search.cs b/FastCache.Redis/Driver/RedisCache.Search.cs new file mode 100644 index 0000000..a235add --- /dev/null +++ b/FastCache.Redis/Driver/RedisCache.Search.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using FastCache.Core.Entity; + +namespace FastCache.Redis.Driver +{ + public partial class RedisCache + { + /// + /// 高级模糊搜索(支持集群模式和分页控制) + /// + /// + public async Task> FuzzySearchAsync( + AdvancedSearchModel advancedSearchModel, + CancellationToken cancellationToken = default) + { + if (advancedSearchModel == null || string.IsNullOrWhiteSpace(advancedSearchModel.Pattern)) + throw new ArgumentException(); + + // TODO 集群模式处理 + + var result = new List(); + + // 单节点模式 + await foreach (var key in NativeScanAsync(advancedSearchModel, cancellationToken)) + { + if (!string.IsNullOrWhiteSpace(key)) + { + result.Add(key); + } + } + + return result; + } + + private static bool CheckLimitReached(AdvancedSearchModel model, int currentCount) => + model.MaxResults > 0 && currentCount >= model.MaxResults; + + private async IAsyncEnumerable NativeScanAsync( + AdvancedSearchModel model, + [EnumeratorCancellation] CancellationToken cancellationToken) + { + // 单节点扫描 + var server = _redisConnection.GetServer(_redisConnection.GetEndPoints()[0]); + + var batchBuffer = new List(capacity: model.PageSize); + + var database = _configurationOptions.DefaultDatabase ?? 0; + + foreach (var redisKey in server.Keys( + database: database, + pattern: model.Pattern, + pageSize: model.PageSize)) + { + batchBuffer.Add(redisKey); + + if (cancellationToken.IsCancellationRequested) break; + + foreach (var item in batchBuffer) + yield return item; + + batchBuffer.Clear(); + await Task.Delay(1, cancellationToken); // 可控延迟 + } + } + } +} \ No newline at end of file diff --git a/FastCache.Redis/Driver/RedisCache.cs b/FastCache.Redis/Driver/RedisCache.cs index 3802722..9158aa9 100644 --- a/FastCache.Redis/Driver/RedisCache.cs +++ b/FastCache.Redis/Driver/RedisCache.cs @@ -1,117 +1,133 @@ +using System; +using System.Collections.Generic; using System.Linq; -using System.Reflection; -using System.Threading.Tasks; using FastCache.Core.Driver; using FastCache.Core.Entity; -using NewLife.Caching; -using Newtonsoft.Json; +using RedLockNet.SERedis; +using RedLockNet.SERedis.Configuration; +using StackExchange.Redis; namespace FastCache.Redis.Driver { public partial class RedisCache : IRedisCache { - private bool _canGetRedisClient = false; + private RedLockFactory _redLockFactory; - private readonly FullRedis _redisClient; + private readonly ConnectionMultiplexer _redisConnection; + private readonly IDatabase? _database; + private readonly ConfigurationOptions _configurationOptions; - public FullRedis? GetRedisClient() + private readonly List> _eventHandlers = + new List>(); + + // TODO 考虑增加version控制获取缓存的逻辑 + private readonly int _doubleDeleteDelayedMs; + + public ConnectionMultiplexer GetConnectionMultiplexer() { - return _canGetRedisClient ? _redisClient : null; + return _redisConnection; } - public RedisCache(string connectionString, bool canGetRedisClient = false) + public RedLockFactory GetRedLockFactory() { - _canGetRedisClient = canGetRedisClient; - _redisClient = new FullRedis(); - _redisClient.Init(connectionString); + return _redLockFactory; } - public Task Set(string key, CacheItem cacheItem, long expire = 0) + public RedisCache(ConfigurationOptions configuration, RedisCacheOptions? redisCacheOptions = null) { - var hasKey = _redisClient.ContainsKey(key); - if (hasKey) return Task.CompletedTask; + if (configuration == null) + throw new ArgumentNullException( + paramName: nameof(configuration), + message: "Redis configuration cannot be null. Please provide valid ConfigurationOptions"); - if (cacheItem.Value != null) + if (configuration.EndPoints.Count == 0) { - cacheItem.Value = JsonConvert.SerializeObject(cacheItem.Value); + throw new ArgumentException( + message: "At least one Redis endpoint must be configured", + paramName: nameof(configuration)); } - if (expire > 0) - { - _redisClient.Add(key, cacheItem, (int)expire); - } - else - { - _redisClient.Add(key, cacheItem); - } + var option = redisCacheOptions ?? new RedisCacheOptions(); + + _configurationOptions = configuration; + + _redisConnection = ConnectionMultiplexer.Connect(configuration); - return Task.CompletedTask; + if (_redisConnection == null) + throw new InvalidOperationException( + "Redis connection not initialized. Please call Initialize method first"); + + _database = _redisConnection.GetDatabase(configuration.DefaultDatabase ?? 0); + + if (_database == null) + throw new InvalidOperationException( + $"Failed to get database instance. Connection status: {_redisConnection?.IsConnected.ToString() ?? "null"}, Requested database number: {configuration.DefaultDatabase ?? 0}"); + + _doubleDeleteDelayedMs = option.DoubleDeleteDelayedMs; + + SetupRedisLockFactory(new List() { _redisConnection }, option); + + RegisterRedisConnectionFailure(_redisConnection, option.ConnectionFailureHandler); + + RegisterRedisConnectionRestoredHandler(_redisConnection, option.ConnectionRestoredHandler); } - public Task Get(string key) + private void RegisterRedisConnectionFailure(ConnectionMultiplexer connectionMultiplexer, + Action? customHandler = null) { - var cacheValue = _redisClient.Get(key); - if (string.IsNullOrWhiteSpace(cacheValue?.AssemblyName) || string.IsNullOrWhiteSpace(cacheValue?.Type) || - cacheValue?.Value == null) return Task.FromResult(new CacheItem()); - - var assembly = Assembly.Load(cacheValue.AssemblyName); - var valueType = assembly.GetType(cacheValue.Type, true, true); - cacheValue.Value = JsonConvert.DeserializeObject(cacheValue.Value as string, valueType); - return Task.FromResult(cacheValue); + EventHandler handler = (sender, args) => + { + var logMessage = $"[Redis连接失败] " + + $"类型: {args.FailureType}, " + + $"端点: {args.EndPoint}, " + + $"异常: {args.Exception?.GetBaseException().Message}"; + + Console.WriteLine(logMessage); + + customHandler?.Invoke(sender, args); + }; + + connectionMultiplexer.ConnectionFailed += handler; + + _eventHandlers.Add(handler); } - public Task Delete(string key) + private void RegisterRedisConnectionRestoredHandler(ConnectionMultiplexer connectionMultiplexer, + Action? customHandler = null) { - _redisClient.Remove(key); - return Task.CompletedTask; - } + EventHandler handler = (sender, args) => + { + Console.WriteLine($"[连接恢复]"); + + // 执行自定义处理 + customHandler?.Invoke(sender, args); + }; + connectionMultiplexer.ConnectionRestored += handler; - public Task Delete(string key, string prefix = "") + _eventHandlers.Add(handler); + } + + private void SetupRedisLockFactory(List connectionMultiplexers, + RedisCacheOptions redisCacheOptions) { - if (key.Contains('*')) - { - string[] list = { }; - if (key.First() == '*' || key.Last() == '*') - { - if (string.IsNullOrEmpty(prefix)) - { - list = _redisClient.Search(key, 1000).ToArray(); - } - else - { - if (key.Length > 0 && key.First() == '*') - { - key = key[1..]; - } - - if (key.Length > 0 && key.Last() == '*') - { - key = key[..^1]; - } - - list = string.IsNullOrEmpty(key) - ? _redisClient.Search($"{prefix}*", 1000).ToArray() - : _redisClient.Search($"{prefix}*", 1000).Where(x => x.Contains(key)).ToArray(); - } - } - else - { - _redisClient.Remove(key); - } - - if (list?.Length > 0) - { - _redisClient.Remove(list); - } - } - else - { - var removeKey = string.IsNullOrEmpty(prefix) ? key : $"{prefix}:{key}"; - _redisClient.Remove(removeKey); - } + var redLockMultiplexers = connectionMultiplexers + .Select(connectionMultiplexer => (RedLockMultiplexer)connectionMultiplexer).ToList(); - return Task.CompletedTask; + _redLockFactory = RedLockFactory.Create(redLockMultiplexers, + new RedLockRetryConfiguration(retryCount: redisCacheOptions.QuorumRetryCount, + retryDelayMs: redisCacheOptions.QuorumRetryDelayMs)); } + + // public void Dispose() + // { + // foreach (var handler in _eventHandlers) + // { + // _redisConnection.ConnectionFailed -= handler; + // } + // + // _redisConnection?.Dispose(); + // _redLockFactory?.Dispose(); + // } } } \ No newline at end of file diff --git a/FastCache.Redis/FastCache.Redis.csproj b/FastCache.Redis/FastCache.Redis.csproj index b634439..0b6cf08 100644 --- a/FastCache.Redis/FastCache.Redis.csproj +++ b/FastCache.Redis/FastCache.Redis.csproj @@ -16,6 +16,7 @@ + diff --git a/FastCache.Redis/Setup/Setup.cs b/FastCache.Redis/Setup/Setup.cs index 6d7dc24..9bcd421 100644 --- a/FastCache.Redis/Setup/Setup.cs +++ b/FastCache.Redis/Setup/Setup.cs @@ -1,6 +1,7 @@ using FastCache.Core.Driver; using FastCache.Redis.Driver; using Microsoft.Extensions.DependencyInjection; +using StackExchange.Redis; namespace FastCache.Redis.Setup { @@ -8,11 +9,10 @@ public static class Setup { public static void AddRedisCache( this IServiceCollection services, - string connectionString, - bool canGetRedisClient = false + ConfigurationOptions configurationOptions ) { - services.AddSingleton(new RedisCache(connectionString, canGetRedisClient)); + services.AddSingleton(new RedisCache(configurationOptions)); } } } \ No newline at end of file diff --git a/IntegrationTests/DistributedLockTests.cs b/IntegrationTests/DistributedLockTests.cs deleted file mode 100644 index 3d1c552..0000000 --- a/IntegrationTests/DistributedLockTests.cs +++ /dev/null @@ -1,141 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; -using System.Net; -using System.Net.Http; -using System.Net.Http.Json; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Mvc.Testing; -using Microsoft.Extensions.DependencyInjection; -using Newtonsoft.Json; -using TestApi.DB; -using TestApi.Entity; -using TestApi.Service; -using Xunit; -using Xunit.Abstractions; - -namespace IntegrationTests; - -[Collection("Sequential")] -public class DistributedLockTests : IClassFixture> -{ - private readonly ITestOutputHelper _testOutputHelper; - private readonly ILockUserService _lockUserService; - private readonly HttpClient _httpClient; - private readonly IServiceProvider _serviceProvider; - - public DistributedLockTests(WebApplicationFactory factory, ITestOutputHelper testOutputHelper) - { - _testOutputHelper = testOutputHelper; - _httpClient = factory.CreateClient(); - _serviceProvider = factory.Services; - - // 清空数据库 - using var scope = _serviceProvider.CreateScope(); - var memoryDbContext = scope.ServiceProvider.GetRequiredService(); - var list = memoryDbContext.Set().ToList(); - memoryDbContext.RemoveRange(list); - memoryDbContext.SaveChanges(); - } - - [Theory] - [InlineData("/UseLock")] - public async Task CheckCanLock(string baseUrl) - { - var tasks = new List(); - - async Task SortedSet(int index, int delayMs) - { - var stopwatch = new Stopwatch(); - stopwatch.Start(); - var responseMessage = await _httpClient.PostAsJsonAsync($"{baseUrl}?delayMs={delayMs}", - new User(DateTimeOffset.UtcNow) { Id = index.ToString(), Name = index.ToString() }); - stopwatch.Stop(); - - var message = await responseMessage.Content.ReadAsStringAsync(); - - _testOutputHelper.WriteLine($"{message} - {stopwatch.ElapsedMilliseconds}"); - } - - tasks.Add(SortedSet(0, 700)); - tasks.Add(SortedSet(1, 700)); - - await Task.WhenAll(tasks); - - // 验证数据库内容 - var resp = await _httpClient.GetAsync($"{baseUrl}/users?page=1"); - Assert.True(resp.StatusCode == HttpStatusCode.OK); - var message = await resp.Content.ReadAsStringAsync(); - var response = JsonConvert.DeserializeObject>(message); - - Assert.Single(response); - - await _httpClient.DeleteAsync($"{baseUrl}?id=0"); - await _httpClient.DeleteAsync($"{baseUrl}?id=1"); - } - - [Theory] - [InlineData("/UseLock")] - public async Task CheckCanLockAndCache(string baseUrl) - { - var tasks = new List(); - - async Task SortedSet(int index, int delayMs) - { - var stopwatch = new Stopwatch(); - stopwatch.Start(); - var response = await _httpClient.PostAsJsonAsync($"{baseUrl}/add-with-cache?delayMs={delayMs}", - new User(DateTimeOffset.UtcNow) { Id = index.ToString(), Name = index.ToString() }); - stopwatch.Stop(); - - var message = await response.Content.ReadAsStringAsync(); - - _testOutputHelper.WriteLine($"{message} - {stopwatch.ElapsedMilliseconds}"); - } - - tasks.Add(SortedSet(3, 800)); - tasks.Add(SortedSet(4, 700)); - - await Task.WhenAll(tasks); - - // 验证数据库内容 - var resp = await _httpClient.GetAsync($"{baseUrl}/users?page=1"); - Assert.True(resp.StatusCode == HttpStatusCode.OK); - var message = await resp.Content.ReadAsStringAsync(); - var response = JsonConvert.DeserializeObject>(message); - Assert.Single(response); - - var stopwatch = Stopwatch.StartNew(); - stopwatch.Start(); - - var resp2 = await _httpClient.GetAsync($"{baseUrl}?id={3}"); - stopwatch.Stop(); - var firstQuest = stopwatch.ElapsedMilliseconds; - if (resp2.StatusCode == HttpStatusCode.OK) - { - var message2 = await resp2.Content.ReadAsStringAsync(); - var response2 = JsonConvert.DeserializeObject(message2); - if (response2 != null) - { - Assert.True(firstQuest < 100); - } - } - - var resp3 = await _httpClient.GetAsync($"{baseUrl}?id={3}"); - stopwatch.Stop(); - var stopwatchElapsed = stopwatch.ElapsedMilliseconds; - if (resp3.StatusCode == HttpStatusCode.OK) - { - var message3 = await resp3.Content.ReadAsStringAsync(); - var response3 = JsonConvert.DeserializeObject(message3); - if (response3 != null) - { - Assert.True(stopwatchElapsed < 100); - } - } - - await _httpClient.DeleteAsync($"{baseUrl}?id=3"); - await _httpClient.DeleteAsync($"{baseUrl}?id=4"); - } -} \ No newline at end of file diff --git a/IntegrationTests/MultiSourceApiRequestCacheTests.cs b/IntegrationTests/MultiSourceApiRequestCacheTests.cs index 45db8fa..2200a89 100644 --- a/IntegrationTests/MultiSourceApiRequestCacheTests.cs +++ b/IntegrationTests/MultiSourceApiRequestCacheTests.cs @@ -64,7 +64,7 @@ public async void RequestCanCache(string baseUrl) var resp1 = await _httpClient.GetAsync($"{baseUrl}/?id=1"); stopwatch.Stop(); - Assert.True(resp1.StatusCode == HttpStatusCode.OK); + Assert.Equal(HttpStatusCode.OK, resp1.StatusCode); await resp1.Content.ReadAsStringAsync(); // 验证第一次请求花费的时间是否大于 1 秒(1000 毫秒) @@ -122,6 +122,14 @@ public async void CacheAndEvictOther(string baseUrl) Name = "anson5" }); + var resp0 = await _httpClient.GetAsync($"{baseUrl}?id=5"); + if (resp0.StatusCode == HttpStatusCode.OK) + { + var user0Entity = await resp0.Content.ReadFromJsonAsync(); + Assert.True(user0Entity != null); + Assert.Equal("5", user0Entity.Id); + } + var resp1 = await _httpClient.GetAsync($"{baseUrl}/users?page=1"); Assert.True(resp1.StatusCode == HttpStatusCode.OK); @@ -130,8 +138,6 @@ public async void CacheAndEvictOther(string baseUrl) var resp2 = await _httpClient.DeleteAsync($"{baseUrl}?id=1"); Assert.True(resp2.StatusCode == HttpStatusCode.OK); - await resp2.Content.ReadAsStringAsync(); - var resp3 = await _httpClient.GetAsync($"{baseUrl}/users?page=1"); Assert.True(resp3.StatusCode == HttpStatusCode.OK); @@ -150,6 +156,38 @@ public async void CacheAndEvictOther(string baseUrl) Assert.True(timeResult < 500000); } + [Fact] + public async void CacheAndRemoveWithRedis() + { + var baseUrl = "/MultiSource"; + await _httpClient.PostAsJsonAsync($"{baseUrl}", new User(DateTimeOffset.UtcNow) + { + Id = "5", + Name = "anson5" + }); + + var resp0 = await _httpClient.GetAsync($"{baseUrl}/getSingleOrDefaultAsync?id=5"); + var user0Entity = await resp0.Content.ReadFromJsonAsync(); + Assert.True(user0Entity != null); + Assert.Equal("5", user0Entity.Id); + + var resp11 = await _httpClient.GetAsync($"{baseUrl}/get/two?id=5&name=anson5"); + var user11Entity = await resp11.Content.ReadFromJsonAsync(); + Assert.True(user11Entity != null); + Assert.Equal("5", user11Entity.Id); + + var resp2 = await _httpClient.DeleteAsync($"{baseUrl}?id=5"); + Assert.Equal(HttpStatusCode.OK, resp2.StatusCode); + + await _httpClient.DeleteAsync($"{baseUrl}/cache/advanced/5"); + + var resp3 = await _httpClient.GetAsync($"{baseUrl}/getSingleOrDefaultAsync?id=5"); + Assert.Equal(HttpStatusCode.NoContent, resp3.StatusCode); + + var resp5 = await _httpClient.GetAsync($"{baseUrl}/get/two?id=5&name=anson5"); + Assert.Equal(HttpStatusCode.NoContent, resp5.StatusCode); + } + [Theory] [InlineData("/MultiSource")] public async Task TestUpdated(string baseUrl) diff --git a/TestApi/Controllers/MultiSourceController.cs b/TestApi/Controllers/MultiSourceController.cs index fa8e46d..52c1b44 100644 --- a/TestApi/Controllers/MultiSourceController.cs +++ b/TestApi/Controllers/MultiSourceController.cs @@ -23,7 +23,13 @@ public virtual async Task Get(string id) { return await _userService.Single(id); } - + + [HttpGet("getSingleOrDefaultAsync")] + public virtual async Task GetSingleOrDefaultAsync(string id) + { + return await _userService.SingleOrDefault(id); + } + [HttpGet("get/two")] public virtual async Task Get(string id, string name) { @@ -45,25 +51,32 @@ public virtual async Task Update(User user) } [HttpDelete] - [MultiSourceEvictable(new[] { "MultiSource-single", "MultiSources" }, ["{id}"], Target.Redis)] + [MultiSourceEvictable(["MultiSource-single", "MultiSources"], ["{id}"], Target.Redis)] public virtual bool Delete(string id) { return _userService.Delete(id); } + [HttpDelete("cache/advanced/{id}")] + [MultiSourceEvictable(["MultiSource-single"], ["{id}", "{id}*"], Target.Redis)] + public virtual bool EvictUserCacheWithPattern(string id) + { + return _userService.Delete(id); + } + [HttpGet("users")] [MultiSourceCacheable("MultiSources", "{page}", Target.Redis, 5)] public virtual IEnumerable Users(string page) { return _userService.List(page); } - + [HttpGet("get")] public virtual async Task TestResultNull(string id) { return await _userService.SingleOrDefault(id); } - + [HttpGet("get/name")] public virtual async Task SearchName(string name) { diff --git a/TestApi/Controllers/UseLockController.cs b/TestApi/Controllers/UseLockController.cs index 862ea81..57d0ffb 100644 --- a/TestApi/Controllers/UseLockController.cs +++ b/TestApi/Controllers/UseLockController.cs @@ -11,7 +11,6 @@ namespace TestApi.Controllers; public class UseLockController(ILockUserService lockUserService) { [HttpPost("add-with-cache")] - [DistributedLock("user-add")] [MultiSourceCacheable("MultiSource-single", "{user:id}", Target.Redis, 5)] [MultiSourceEvictable(new[] { "MultiSource-single", "MultiSources" }, ["{user:id}"], Target.Redis)] public virtual async Task AddWithCache(User user, int delayMs = 0) @@ -20,7 +19,6 @@ public virtual async Task AddWithCache(User user, int delayMs = 0) } [HttpPost] - [DistributedLock("user-add")] public virtual async Task Add(User user, int delayMs = 0) { return await lockUserService.Add(user, delayMs); diff --git a/TestApi/Program.cs b/TestApi/Program.cs index 955a584..1e636b0 100644 --- a/TestApi/Program.cs +++ b/TestApi/Program.cs @@ -1,6 +1,8 @@ using AspectCore.Extensions.DependencyInjection; +using FastCache.Core.Entity; using FastCache.InMemory.Setup; using FastCache.MultiSource.Setup; +using StackExchange.Redis; using TestApi.DB; using TestApi.Service; @@ -16,11 +18,28 @@ builder.Services.AddMvc().AddControllersAsServices(); -builder.Services.AddMultiSourceCache( - "server=localhost:6379;timeout=5000;MaxMessageSize=1024000;Expire=3600", // "Expire=3600" redis global timeout - true +builder.Services.RegisterMultiSourceCache( + new ConfigurationOptions() + { + EndPoints = { "localhost:6379" }, + ReconnectRetryPolicy = new ExponentialRetry( + deltaBackOffMilliseconds: 1000, // 初始延迟 1s + maxDeltaBackOffMilliseconds: 30000 // 最大延迟 30s + ), + DefaultDatabase = 12, + AbortOnConnectFail = false, + SyncTimeout = 5000, + ConnectTimeout = 5000, + ResponseTimeout = 5000 + }, + new RedisCacheOptions() + { + ConnectionRestoredHandler = (o, eventArgs) => { Console.WriteLine("[断开链接]"); }, + ConnectionFailureHandler = (o, eventArgs) => { Console.WriteLine("[重新链接]"); } + } ); + // builder.Services.AddMultiBucketsInMemoryCache(); builder.Services.AddInMemoryCache(); // builder.Services.AddRedisCache("server=localhost:6379;timeout=5000;MaxMessageSize=1024000;Expire=3600"); // "Expire=3600" redis global timeout diff --git a/TestApi/Service/MultiSourceService.cs b/TestApi/Service/MultiSourceService.cs index 63e96cc..a9c3ff8 100644 --- a/TestApi/Service/MultiSourceService.cs +++ b/TestApi/Service/MultiSourceService.cs @@ -1,4 +1,3 @@ -using FastCache.Core.Attributes; using FastCache.Core.Enums; using FastCache.MultiSource.Attributes; using Microsoft.EntityFrameworkCore; @@ -22,7 +21,7 @@ public virtual async Task Single(string id) return await dbContext.Set().SingleAsync(x => x.Id == id); } - [MultiSourceCacheable("MultiSource-single", "{id}:{name}", Target.Redis, 60)] + [MultiSourceCacheable("MultiSource-single", "{id}:{name}", Target.Redis, 900)] public virtual async Task SingleOrDefault(string id, string name, bool canChange) { return await dbContext.Set().SingleOrDefaultAsync(x => x.Id == id && x.Name == name); diff --git a/TestApi/Service/UserService.cs b/TestApi/Service/UserService.cs index e56cda5..2817a0a 100644 --- a/TestApi/Service/UserService.cs +++ b/TestApi/Service/UserService.cs @@ -28,7 +28,7 @@ public virtual async Task Single(string id, string name) return await _dbContext.Set().SingleAsync(x => x.Id == id && x.Name == name); } - [Cacheable("user-single", "{id}", 60 * 10)] + [Cacheable("user-single", "{id}", 600)] public virtual async Task Single(string id) { Thread.Sleep(TimeSpan.FromSeconds(1)); diff --git a/UnitTests/MemoryCacheTests.cs b/UnitTests/MemoryCacheTests.cs index f04fa81..e62fc5d 100644 --- a/UnitTests/MemoryCacheTests.cs +++ b/UnitTests/MemoryCacheTests.cs @@ -2,8 +2,8 @@ using System.Threading; using System.Threading.Tasks; using FastCache.Core.Entity; +using FastCache.Core.Enums; using FastCache.InMemory.Drivers; -using FastCache.InMemory.Enum; using Xunit; namespace UnitTests; @@ -105,6 +105,38 @@ public async void TestMemoryCacheCanDeleteByPattern(string prefix, string key, s var s = await _memoryCache.Get(key); Assert.Equal(s.Value, result); } + + [Theory] + [InlineData("", "anson555", "18", null)] + [InlineData("", "anson555555", "19", null)] + public async void TestMemoryCacheCanDeleteByFirstPatternWithDelayed(string prefix, string key, string value, + string result) + { + await _memoryCache.Set(key, new CacheItem() + { + Type = value.GetType().FullName, + AssemblyName = value.GetType().Assembly.FullName, + Value = value, + Expire = DateTime.UtcNow.AddSeconds(20).Ticks + }); + await _memoryCache.Delete("anson*", prefix); + var s = await _memoryCache.Get(key); + Assert.Equal(s.Value, result); + + await _memoryCache.Set(key, new CacheItem() + { + Type = value.GetType().FullName, + AssemblyName = value.GetType().Assembly.FullName, + Value = value, + Expire = DateTime.UtcNow.AddSeconds(20).Ticks + }); + var s2 = await _memoryCache.Get(key); + Assert.Equal(s2.Value, value); + + await Task.Delay(TimeSpan.FromSeconds(2)); + var s3 = await _memoryCache.Get(key); + Assert.Null(s3.Value); + } [Theory] [InlineData("anson", "anson1111", "18", null)] diff --git a/UnitTests/MulitBucketsMemoryCacheTests.cs b/UnitTests/MulitBucketsMemoryCacheTests.cs index a6aef07..86f3e8c 100644 --- a/UnitTests/MulitBucketsMemoryCacheTests.cs +++ b/UnitTests/MulitBucketsMemoryCacheTests.cs @@ -1,8 +1,9 @@ using System; using System.Threading; +using System.Threading.Tasks; using FastCache.Core.Entity; +using FastCache.Core.Enums; using FastCache.InMemory.Drivers; -using FastCache.InMemory.Enum; using Xunit; namespace UnitTests; @@ -99,11 +100,44 @@ public async void TestMemoryCacheCanDeleteByFirstPattern(string prefix, string k var s = await _memoryCache.Get(key); Assert.Equal(s.Value, result); } - + + [Theory] + [InlineData("", "anson555", "18", null)] + [InlineData("", "anson555555", "19", null)] + public async void TestMemoryCacheCanDeleteByFirstPatternWithDelayed(string prefix, string key, string value, + string result) + { + await _memoryCache.Set(key, new CacheItem() + { + Type = value.GetType().FullName, + AssemblyName = value.GetType().Assembly.FullName, + Value = value, + Expire = DateTime.UtcNow.AddSeconds(20).Ticks + }); + await _memoryCache.Delete("anson*", prefix); + var s = await _memoryCache.Get(key); + Assert.Equal(s.Value, result); + + await _memoryCache.Set(key, new CacheItem() + { + Type = value.GetType().FullName, + AssemblyName = value.GetType().Assembly.FullName, + Value = value, + Expire = DateTime.UtcNow.AddSeconds(20).Ticks + }); + var s2 = await _memoryCache.Get(key); + Assert.Equal(s2.Value, value); + + await Task.Delay(TimeSpan.FromSeconds(2)); + var s3 = await _memoryCache.Get(key); + Assert.Null(s3.Value); + } + [Theory] [InlineData("anson", "anson555", "18", null)] [InlineData("anson", "anson555555", "19", null)] - public async void TestMemoryCacheCanDeleteByFirstPatternWithPrefix(string prefix, string key, string value, string result) + public async void TestMemoryCacheCanDeleteByFirstPatternWithPrefix(string prefix, string key, string value, + string result) { var fullKey = $"{prefix}:{key}"; await _memoryCache.Set(fullKey, new CacheItem() @@ -118,7 +152,6 @@ public async void TestMemoryCacheCanDeleteByFirstPatternWithPrefix(string prefix Assert.Equal(s.Value, result); } - [Theory] [InlineData("", "555Joe", "18", null)] [InlineData("", "555555Joe", "19", null)] diff --git a/UnitTests/RedisCacheTests.Lock.cs b/UnitTests/RedisCacheTests.Lock.cs new file mode 100644 index 0000000..3335dc1 --- /dev/null +++ b/UnitTests/RedisCacheTests.Lock.cs @@ -0,0 +1,491 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using FastCache.Core.Entity; +using FastCache.Core.Enums; +using Xunit; + +namespace UnitTests; + +public partial class RedisCacheTests +{ + private const string TestLockKey = "test_lock"; + + // Mock长时间业务操作 + readonly Func _longRunningOperation = async () => + { + await Task.Delay(TimeSpan.FromSeconds(15)); // >默认expiryTime(30s) + }; + + // Mock快速业务操作 + private readonly Func _fastOperation = () => Task.CompletedTask; + + [Fact] + public async Task SingleClientShouldAcquireLock() + { + // Act & Assert (不应抛出异常) + await _redisClient.ExecuteWithRedisLockAsync( + $"{TestLockKey}:{nameof(SingleClientShouldAcquireLock)}", + () => Task.Delay(100), new DistributedLockOptions() + { + ThrowOnLockFailure = true + }); + } + + [Fact] + public async Task ConcurrentClientsOnlyOneShouldSucceed() + { + var successCount = 0; + var tasks = new List(); + + // Act (模拟5个并发客户端) + for (var i = 0; i < 5; i++) + { + tasks.Add(Task.Run(async () => + { + var distributedLockResult = await _redisClient.ExecuteWithRedisLockAsync( + $"{TestLockKey}:{nameof(ConcurrentClientsOnlyOneShouldSucceed)}", _longRunningOperation); + + if (distributedLockResult is { IsSuccess: true, Status: LockStatus.AcquiredAndCompleted }) + { + Interlocked.Increment(ref successCount); + } + })); + } + + await Task.WhenAll(tasks); + + // Assert + Assert.Equal(1, successCount); + } + + /// + /// 验证锁超时其他请求是否能否拿到锁,避免死锁 + /// + [Fact] + public async Task WhenLockExpiresOtherClientsCanAcquireLock() + { + // Arrange + var shortExpiry = TimeSpan.FromSeconds(2); + var key = $"{TestLockKey}:{nameof(WhenLockExpiresOtherClientsCanAcquireLock)}"; + + // Act - 第一个客户端获取锁并故意超时 + var firstClientTask = _redisClient.ExecuteWithRedisLockAsync( + key, + () => Task.Delay(3000), // 业务操作超过expiryTime + new DistributedLockOptions() + { + ExpiryTime = shortExpiry, + ThrowOnLockFailure = false + } + ); + + // 等待确保第一个客户端已触发锁超时(2秒+缓冲时间) + await Task.Delay(shortExpiry.Add(TimeSpan.FromSeconds(0.5))); + + // 第二个客户端尝试获取锁(应成功) + var secondClientResult = await _redisClient.ExecuteWithRedisLockAsync( + key, + _fastOperation, + new DistributedLockOptions() + { + WaitTime = TimeSpan.FromSeconds(1) + } + ); + + // Assert + Assert.True((await firstClientTask).IsSuccess); + Assert.Equal(LockStatus.AcquiredAndCompleted, secondClientResult.Status); // 第二个客户端应成功获得锁 + } + + /// + /// CancellationToken取消导致获取锁方法取消而获取不到锁 + /// + [Fact] + public async Task CancellationTokenShouldAbortWaiting() + { + var successCount = 0; + var key = $"{TestLockKey}:{nameof(CancellationTokenShouldAbortWaiting)}"; + // Arrange + using var cts = new CancellationTokenSource(); + cts.CancelAfter(100); //立即取消 + + // Act & Assert + var distributedLockResult = await _redisClient.ExecuteWithRedisLockAsync( + key, + async () => + { + await Task.Delay(TimeSpan.FromSeconds(1), cts.Token); + Interlocked.Increment(ref successCount); + }, + new DistributedLockOptions() + { + WaitTime = TimeSpan.FromSeconds(10), + }, + cancellationToken: cts.Token); + + Assert.Equal(LockStatus.OperationFailed, distributedLockResult.Status); + Assert.Equal(0, successCount); + } + + [Theory] + [InlineData(true, true)] + [InlineData(false, true)] + [InlineData(true, false)] + [InlineData(false, false)] + public async Task ThrowOnFailure_ControlsBehavior(bool shouldThrow, bool lockThrowOnOperationFailure) + { + Func lockAction = () => + { + if (shouldThrow) throw new InvalidOperationException("shouldThrow"); + return Task.CompletedTask; + }; + + if (shouldThrow && lockThrowOnOperationFailure) + { + await Assert.ThrowsAsync( + () => _redisClient.ExecuteWithRedisLockAsync("force_fail", lockAction, new DistributedLockOptions() + { + ThrowOnOperationFailure = lockThrowOnOperationFailure + })); + } + + if (!shouldThrow && lockThrowOnOperationFailure || shouldThrow && !lockThrowOnOperationFailure || + !shouldThrow && !lockThrowOnOperationFailure) + { + await _redisClient.ExecuteWithRedisLockAsync("force_fail", lockAction, new DistributedLockOptions() + { + ThrowOnOperationFailure = lockThrowOnOperationFailure + }); + } + } + + /// + /// 获取锁超时测试 + /// QuorumRetryCount = 3 redLock 默认值 + /// QuorumRetryDelayMs = 400ms redLock 默认值 + /// 该测试验证red lock在业务逻辑时间极短的情况下内默认配置下系无法锁住的 + /// + /// + /// + /// + /// + /// + [Theory(Skip = "只作为验证red lock分布式锁的实现逻辑的测试")] + [InlineData("LockTimeout_ShouldRespectWaitTime_case0", 100, 220, 200, 1)] + [InlineData("LockTimeout_ShouldRespectWaitTime_case1", 100, 200, 200, 1)] + [InlineData("LockTimeout_ShouldRespectWaitTime_case2", 100, 300, 200, 1)] + [InlineData("LockTimeout_ShouldRespectWaitTime_case3", 100, 400, 200, 1)] + [InlineData("LockTimeout_ShouldRespectWaitTime_case4", 100, 500, 200, 1)] + [InlineData("LockTimeout_ShouldRespectWaitTime_case5", 500, 200, 200, 2)] + [InlineData("LockTimeout_ShouldRespectWaitTime_case6", 100, 600, 200, 1)] + [InlineData("LockTimeout_ShouldRespectWaitTime_case7", 100, 700, 200, 1)] + [InlineData("LockTimeout_ShouldRespectWaitTime_case8", 0, 200, 200, 1)] + [InlineData("LockTimeout_ShouldRespectWaitTime_case9", 0, 200, 0, 1)] + [InlineData("LockTimeout_ShouldRespectWaitTime_case10", 0, 600, 0, 1)] + [InlineData("LockTimeout_ShouldRespectWaitTime_case11", 0, 700, 0, 1)] + [InlineData("LockTimeout_ShouldRespectWaitTime_case12", 100, 800, 200, 1)] + [InlineData("LockTimeout_ShouldRespectWaitTime_case13", 0, 800, 200, 1)] + [InlineData("LockTimeout_ShouldRespectWaitTime_case14", 0, 800, 0, 1)] + public async Task LockTimeoutShouldRespectWaitTime(string key, int waitMs, int operationMs, int retryMs, + int expectHasLock) + { + var options = new DistributedLockOptions + { WaitTime = TimeSpan.FromMilliseconds(waitMs), RetryInterval = TimeSpan.FromMilliseconds(retryMs) }; + + // Mock长耗时操作 + var operation = async () => await Task.Delay(TimeSpan.FromMilliseconds(operationMs)); + + var tasks = new List>(); + + for (int i = 0; i < 2; i++) + { + var task = _redisClient.ExecuteWithRedisLockAsync(key, operation, options); + tasks.Add(task); + } + + await Task.WhenAll(tasks); + + Assert.Equal(expectHasLock, tasks.Count(x => x.Result.Status is LockStatus.AcquiredAndCompleted)); + } + + /// + /// expectHasLock 只是一个大概的期待值,在线程以及网络抖动下,允许有误差+-1的范围 + /// QuorumRetryCount = 3 redLock 默认值 + /// QuorumRetryDelayMs = 400ms redLock 默认值 + /// + /// + /// + /// + /// + /// + /// + [Theory] + // 常规业务场景 1 等待时间大于业务执行时间,在1000并发模拟请求下大概会有5个成功 + [InlineData(nameof(ExecuteWithRedisLockAsyncWhenConcurrentRequestsShouldRespectLockSemantics) + ":default", 1000, + 500, + 300, 150, 5)] + // 常规业务场景 2 + [InlineData(nameof(ExecuteWithRedisLockAsyncWhenConcurrentRequestsShouldRespectLockSemantics) + ":default2", 1000, + 500, + 3000, 150, 1)] + // 常规业务场景 3 + [InlineData(nameof(ExecuteWithRedisLockAsyncWhenConcurrentRequestsShouldRespectLockSemantics) + ":default3", 1000, + 500, + 1000, 150, 2)] + // 常规业务场景 4 + [InlineData(nameof(ExecuteWithRedisLockAsyncWhenConcurrentRequestsShouldRespectLockSemantics) + ":default4", 1000, + 500, + 1500, 150, 1)] + // 高竞争场景 + [InlineData(nameof(ExecuteWithRedisLockAsyncWhenConcurrentRequestsShouldRespectLockSemantics) + ":HighContention1", + 5000, + 200, + 100, 50, 10)] + [InlineData(nameof(ExecuteWithRedisLockAsyncWhenConcurrentRequestsShouldRespectLockSemantics) + ":HighContention2", + 5000, + 200, + 1000, 50, 2)] + [InlineData(nameof(ExecuteWithRedisLockAsyncWhenConcurrentRequestsShouldRespectLockSemantics) + ":HighContention3", + 5000, + 200, + 1500, 50, 1)] + [InlineData(nameof(ExecuteWithRedisLockAsyncWhenConcurrentRequestsShouldRespectLockSemantics) + ":HighContention4", + 5000, + 200, + 1200, 50, 1)] + [InlineData(nameof(ExecuteWithRedisLockAsyncWhenConcurrentRequestsShouldRespectLockSemantics) + ":HighContention5", + 5000, + 500, + 1500, 200, 1)] + public async Task ExecuteWithRedisLockAsyncWhenConcurrentRequestsShouldRespectLockSemantics(string key, + int concurrencyLevel, int waitMs, int operationMs, int retryMs, + int expectHasLock) + { + var options = new DistributedLockOptions + { WaitTime = TimeSpan.FromMilliseconds(waitMs), RetryInterval = TimeSpan.FromMilliseconds(retryMs) }; + + // Mock长耗时操作 + var operation = async () => await Task.Delay(TimeSpan.FromMilliseconds(operationMs)); + + var tasks = new List>(); + + for (var i = 0; i < concurrencyLevel; i++) + { + var task = _redisClient.ExecuteWithRedisLockAsync(key, operation, options); + tasks.Add(task); + } + + await Task.WhenAll(tasks); + + var acquiredAndCompletedTaskCount = tasks.Count(x => x.Result.Status is LockStatus.AcquiredAndCompleted); + + testOutputHelper.WriteLine($""" + [RedisLock Test Report] + Scenario: {key} + Concurrency: {concurrencyLevel} requests + Lock Parameters: + - Wait Time: {waitMs}ms + - Operation Time: {operationMs}ms + - Retry Interval: {retryMs}ms + Results: + - Success Count: {acquiredAndCompletedTaskCount} + - LockNotAcquired Count: {tasks.Count(x => x.Result.Status is LockStatus.LockNotAcquired)} + - Error Count: {tasks.Count(x => x.Result.Exception != null)} + """); + + Assert.True(expectHasLock - 1 == acquiredAndCompletedTaskCount || + expectHasLock + 1 == acquiredAndCompletedTaskCount || + expectHasLock == acquiredAndCompletedTaskCount); + } + + /// + /// expectHasLock 只是一个大概的期待值,在线程以及网络抖动下,允许有误差+-1的范围 + /// QuorumRetryCount = 1 + /// QuorumRetryDelayMs = 400ms redLock 默认值 + /// + /// + /// + /// + /// + /// + /// + [Theory] + // 常规业务场景 + [InlineData(nameof(RedLockWithSingleRetryShouldShowHigherContention) + ":default1", 1000, + 500, + 300, 150, 2)] + [InlineData(nameof(RedLockWithSingleRetryShouldShowHigherContention) + ":default2", 1000, + 500, + 1000, 150, 1)] + [InlineData(nameof(RedLockWithSingleRetryShouldShowHigherContention) + ":default3", 1000, + 500, + 800, 150, 1)] + [InlineData(nameof(RedLockWithSingleRetryShouldShowHigherContention) + ":default4", 1000, + 500, + 600, 150, 1)] + [InlineData(nameof(RedLockWithSingleRetryShouldShowHigherContention) + ":default5", 1000, + 500, + 500, 150, 1)] + // 高竞争场景 + [InlineData(nameof(RedLockWithSingleRetryShouldShowHigherContention) + ":HighContention1", + 5000, + 500, + 400, 50, 2)] + [InlineData(nameof(RedLockWithSingleRetryShouldShowHigherContention) + ":HighContention2", + 5000, + 500, + 600, 50, 1)] + [InlineData(nameof(RedLockWithSingleRetryShouldShowHigherContention) + ":HighContention3", + 5000, + 500, + 700, 50, 1)] + [InlineData(nameof(RedLockWithSingleRetryShouldShowHigherContention) + ":HighContention4", + 5000, + 500, + 800, 50, 1)] + [InlineData(nameof(RedLockWithSingleRetryShouldShowHigherContention) + ":HighContention5", + 5000, + 500, + 900, 50, 1)] + [InlineData(nameof(RedLockWithSingleRetryShouldShowHigherContention) + ":HighContention6", + 5000, + 500, + 1000, 50, 1)] + public async Task RedLockWithSingleRetryShouldShowHigherContention(string key, + int concurrencyLevel, int waitMs, int operationMs, int retryMs, + int expectHasLock) + { + var options = new DistributedLockOptions + { WaitTime = TimeSpan.FromMilliseconds(waitMs), RetryInterval = TimeSpan.FromMilliseconds(retryMs) }; + + // Mock长耗时操作 + var operation = async () => await Task.Delay(TimeSpan.FromMilliseconds(operationMs)); + + var tasks = new List>(); + + for (var i = 0; i < concurrencyLevel; i++) + { + var task = _redisClient2.ExecuteWithRedisLockAsync(key, operation, options); + tasks.Add(task); + } + + await Task.WhenAll(tasks); + + var acquiredAndCompletedTaskCount = tasks.Count(x => x.Result.Status is LockStatus.AcquiredAndCompleted); + + testOutputHelper.WriteLine($""" + [RedisLock Test Report] + Scenario: {key} + Concurrency: {concurrencyLevel} requests + Lock Parameters: + - Wait Time: {waitMs}ms + - Operation Time: {operationMs}ms + - Retry Interval: {retryMs}ms + Results: + - Success Count: {acquiredAndCompletedTaskCount} + - LockNotAcquired Count: {tasks.Count(x => x.Result.Status is LockStatus.LockNotAcquired)} + - Error Count: {tasks.Count(x => x.Result.Exception != null)} + """); + + Assert.True(expectHasLock - 1 == acquiredAndCompletedTaskCount || + expectHasLock + 1 == acquiredAndCompletedTaskCount || + expectHasLock == acquiredAndCompletedTaskCount); + } + + /// + /// expectHasLock 只是一个大概的期待值,在线程以及网络抖动下,允许有误差+-1的范围 + /// QuorumRetryCount = 1 + /// QuorumRetryDelayMs = 200ms + /// + /// + /// + /// + /// + /// + /// + [Theory] + // 常规业务场景 + [InlineData(nameof(RedLockWithSingleRetryAndShortDelayShouldShowHighContention) + ":default1", 1000, + 500, + 300, 150, 2)] + [InlineData(nameof(RedLockWithSingleRetryAndShortDelayShouldShowHighContention) + ":default2", 1000, + 500, + 1000, 150, 1)] + [InlineData(nameof(RedLockWithSingleRetryAndShortDelayShouldShowHighContention) + ":default3", 1000, + 500, + 800, 150, 1)] + [InlineData(nameof(RedLockWithSingleRetryAndShortDelayShouldShowHighContention) + ":default4", 1000, + 500, + 600, 150, 1)] + [InlineData(nameof(RedLockWithSingleRetryAndShortDelayShouldShowHighContention) + ":default5", 1000, + 500, + 500, 150, 1)] + // 高竞争场景 + [InlineData(nameof(RedLockWithSingleRetryAndShortDelayShouldShowHighContention) + ":HighContention1", + 5000, + 500, + 400, 50, 2)] + [InlineData(nameof(RedLockWithSingleRetryAndShortDelayShouldShowHighContention) + ":HighContention2", + 5000, + 500, + 600, 50, 1)] + [InlineData(nameof(RedLockWithSingleRetryAndShortDelayShouldShowHighContention) + ":HighContention3", + 5000, + 500, + 700, 50, 1)] + [InlineData(nameof(RedLockWithSingleRetryAndShortDelayShouldShowHighContention) + ":HighContention4", + 5000, + 500, + 800, 50, 1)] + [InlineData(nameof(RedLockWithSingleRetryAndShortDelayShouldShowHighContention) + ":HighContention5", + 5000, + 500, + 900, 50, 1)] + [InlineData(nameof(RedLockWithSingleRetryAndShortDelayShouldShowHighContention) + ":HighContention6", + 5000, + 500, + 1000, 50, 1)] + public async Task RedLockWithSingleRetryAndShortDelayShouldShowHighContention(string key, + int concurrencyLevel, int waitMs, int operationMs, int retryMs, + int expectHasLock) + { + var options = new DistributedLockOptions + { WaitTime = TimeSpan.FromMilliseconds(waitMs), RetryInterval = TimeSpan.FromMilliseconds(retryMs) }; + + // Mock长耗时操作 + var operation = async () => await Task.Delay(TimeSpan.FromMilliseconds(operationMs)); + + var tasks = new List>(); + + for (var i = 0; i < concurrencyLevel; i++) + { + var task = _redisClient3.ExecuteWithRedisLockAsync(key, operation, options); + tasks.Add(task); + } + + await Task.WhenAll(tasks); + + var acquiredAndCompletedTaskCount = tasks.Count(x => x.Result.Status is LockStatus.AcquiredAndCompleted); + + testOutputHelper.WriteLine($""" + [RedisLock Test Report] + Scenario: {key} + Concurrency: {concurrencyLevel} requests + Lock Parameters: + - Wait Time: {waitMs}ms + - Operation Time: {operationMs}ms + - Retry Interval: {retryMs}ms + Results: + - Success Count: {acquiredAndCompletedTaskCount} + - LockNotAcquired Count: {tasks.Count(x => x.Result.Status is LockStatus.LockNotAcquired)} + - Error Count: {tasks.Count(x => x.Result.Exception != null)} + """); + + Assert.True(expectHasLock - 1 == acquiredAndCompletedTaskCount || + expectHasLock + 1 == acquiredAndCompletedTaskCount || + expectHasLock == acquiredAndCompletedTaskCount); + } +} \ No newline at end of file diff --git a/UnitTests/RedisCacheTests.Util.cs b/UnitTests/RedisCacheTests.Util.cs deleted file mode 100644 index fe2f37a..0000000 --- a/UnitTests/RedisCacheTests.Util.cs +++ /dev/null @@ -1,22 +0,0 @@ -using System; -using System.Text; - -namespace UnitTests; - -public partial class RedisCacheTests -{ - public string GenerateDataString(int sizeInKb) - { - int sizeInBytes = sizeInKb * 1024; - var random = new Random(); - var stringBuilder = new StringBuilder(sizeInBytes); - - for (int i = 0; i < sizeInBytes; i++) - { - // 随机生成字符,字符范围可以根据需求调整 - stringBuilder.Append((char)random.Next(33, 126)); // 生成ASCII字符从 '!' 到 '~' - } - - return stringBuilder.ToString(); - } -} \ No newline at end of file diff --git a/UnitTests/RedisCacheTests.cs b/UnitTests/RedisCacheTests.cs index 840d496..c548613 100644 --- a/UnitTests/RedisCacheTests.cs +++ b/UnitTests/RedisCacheTests.cs @@ -1,12 +1,10 @@ using System; -using System.Collections.Concurrent; using System.Collections.Generic; -using System.Diagnostics; using System.Linq; -using System.Threading; using System.Threading.Tasks; using FastCache.Core.Entity; using FastCache.Redis.Driver; +using StackExchange.Redis; using Xunit; using Xunit.Abstractions; @@ -14,8 +12,68 @@ namespace UnitTests; public partial class RedisCacheTests(ITestOutputHelper testOutputHelper) { - private readonly RedisCache _redisClient = - new("server=localhost:6379;timeout=5000;MaxMessageSize=1024000;Expire=3600", true); + private readonly RedisCache _redisClient = new(new ConfigurationOptions() + { + EndPoints = { "localhost:6379" }, + SyncTimeout = 5000, + ConnectTimeout = 5000, + ResponseTimeout = 5000, + }); + + private readonly RedisCache _redisClient2 = + new(new ConfigurationOptions() + { + EndPoints = { "localhost:6379" }, + SyncTimeout = 5000, + ConnectTimeout = 5000, + ResponseTimeout = 5000 + }, new RedisCacheOptions() + { + QuorumRetryCount = 1, + }); + + private readonly RedisCache _redisClient3 = + new(new ConfigurationOptions() + { + EndPoints = { "localhost:6379" }, + SyncTimeout = 5000, + ConnectTimeout = 5000, + ResponseTimeout = 5000 + }, new RedisCacheOptions() + { + QuorumRetryCount = 1, + QuorumRetryDelayMs = 200 + }); + + private class Setting + { + public string Name { get; set; } + } + + private class User + { + public string Name { get; set; } + + public List Settings { get; set; } + } + + [Fact] + public void ConstructorWhenConfigurationIsNullThrowsArgumentNullException() + { + Assert.Throws(() => { _ = new RedisCache(null); }); + } + + [Fact] + public void ConstructorWhenNoEndpointsConfiguredThrowsArgumentException() + { + Assert.Throws(() => + { + _ = new RedisCache(new ConfigurationOptions() + { + EndPoints = { } + }); + }); + } [Theory] [InlineData("anson", "18", "18")] @@ -27,11 +85,170 @@ public async void TestRedisCacheCanSet(string key, string value, string result) Value = value, AssemblyName = value.GetType().Assembly.GetName().Name, Type = value.GetType().FullName - }, 20); + }, TimeSpan.FromSeconds(20)); + var s = await _redisClient.Get(key); Assert.Equal(s.Value, result); } + [Fact] + public async Task GetAfterSettingComplexObjectShouldRetrieveOriginalValues() + { + var key = "TestRedisCacheCanGet"; + + var value = new User() + { + Name = "23131", + Settings = + [ + new Setting + { + Name = "fas231" + } + ] + }; + + await _redisClient.Set(key, new CacheItem() + { + Value = value, + AssemblyName = value.GetType().Assembly.GetName().Name, + Type = value.GetType().FullName + }, TimeSpan.FromSeconds(20)); + + var s = await _redisClient.Get(key); + + Assert.Equal(((User)s.Value).Name, value.Name); + Assert.Single(((User)s.Value).Settings); + Assert.Equal("fas231", ((User)s.Value).Settings.First().Name); + } + + [Fact] + public async Task TestFuzzySearchAsync() + { + var key = "TestFuzzySearchAsync"; + var value = "123456"; + + await _redisClient.Set(key, new CacheItem() + { + Value = value, + AssemblyName = value.GetType().Assembly.GetName().Name, + Type = value.GetType().FullName + }, TimeSpan.FromMinutes(20)); + + var result = await _redisClient.FuzzySearchAsync(new AdvancedSearchModel() + { + PageSize = 1000, + Pattern = "Test*" + }); + + Assert.True(result.Count > 0); + Assert.Contains(key, result); + + await _redisClient.Delete(key); + } + + /// + /// + /// + /// + /// + /// 期待的查找到的值,目前生成的key会有可能重复,在测试中会在进行一查找生成的匹配key的值 + [Theory] + // 基础匹配场景 + [InlineData(5, "Order_1*", 5)] // 前缀+数字通配 + [InlineData(500, "Product_*123", 5)] // 后缀固定值匹配 + [InlineData(200, "User_[0-9]??", 5)] // 字符集+占位符组合 + + // 新增关键测试场景(含特殊字符和边界情况) + [InlineData(50, "Temp\\*Key_*", 5)] // 转义字符测试 + [InlineData(300, "IDX_[A-F][0-9]", 5)] // 双字符集组合 + [InlineData(150, "Log_[1-9][a-z]", 5)] // 数字范围+字母范围 + [InlineData(5, "*", 5)] // 空前缀全通配 + [InlineData(80, "Test?_[A-Z]", 5)] // key本身含问号 + [InlineData(120, "[Demo]_*#*", 0)] // key含方括号 + [InlineData(120, "[Demo]_*#*", 5)] // key含方括号 + + // Redis实际业务典型场景 + [InlineData(1000, "Session:*:Token", 5)] // Redis常见会话模式 + [InlineData(600, "{Cache}:Item:*:Ver", 5)] // Hash tag模式 + [InlineData(400, "@Event@_*-Log", 5)] // 特殊符号包裹 + + // Unicode和多语言支持 + [InlineData(200, "用户_[张三李四]订单*", 0)] // UTF-8字符集 + [InlineData(70, "商品-[가-힣]*번호", 7)] // Hangul字母范围 + public async Task FuzzySearchWithParametersAsync( + int totalRecords, + string searchPattern, + int expectedMatches) + { + // Arrange - 准备带明确匹配规则的测试数据 + var insertedKeys = new List(); + var random = new Random(); + + var patternGeneratorUtil = new RedisKeyPatternGeneratorUtil(); + + // 生成可预测的匹配key(前N条为符合搜索条件的key) + for (var i = 0; i < totalRecords; i++) + { + var shouldMatch = i < expectedMatches; + + // 构造可匹配的key(根据searchPattern反向生成) + var key = shouldMatch + ? patternGeneratorUtil.GenerateRedisCompatibleKey(searchPattern, i) + : $"{nameof(FuzzySearchWithParametersAsync)}:not_match:{Guid.NewGuid()}"; // 不匹配的随机key + + var value = $"Value_{random.Next(1000)}"; + + var setSuccess = await _redisClient.Set(key, new CacheItem() + { + Value = value, + AssemblyName = value.GetType().Assembly.GetName().Name, + Type = value.GetType().FullName + }, TimeSpan.FromMinutes(20)); + + Assert.True(setSuccess); + + insertedKeys.Add(key); + } + + var insertedDictKeys = insertedKeys.Distinct().ToList(); + + var generateMatchedKeys = insertedDictKeys.Where(x => !x.Contains("not_match")).ToList(); + + try + { + // Act - 执行模糊查询 + var matchedKeys = await _redisClient.FuzzySearchAsync(new AdvancedSearchModel() + { + PageSize = totalRecords * 2, // 确保能返回所有可能结果 + Pattern = searchPattern + }); + + // Assert - 验证结果 + Assert.NotNull(matchedKeys); + + // 验证返回数量是否符合预期 + Assert.Equal(generateMatchedKeys.Count, matchedKeys.Count); + + foreach (var key in matchedKeys) + { + // 验证2:Key必须在我们插入的key集合中 + Assert.Contains(key, insertedKeys); + } + } + catch (Exception) + { + var deletedCount = await _redisClient.BatchDeleteKeysWithPipelineAsync(insertedKeys); + Assert.True(deletedCount == insertedKeys.Count); + throw; + } + + var deletedCountByNormalFlow = await _redisClient.BatchDeleteKeysWithPipelineAsync(insertedKeys); + Assert.True(deletedCountByNormalFlow == insertedKeys.Count); + + await Task.Delay(TimeSpan.FromSeconds(2)); + } + [Theory] [InlineData("key1", "18", null, 1)] [InlineData("key2", "19", null, 1)] @@ -42,7 +259,7 @@ public async void TestRedisCacheCanSetTimeout(string key, string value, string? Value = value, AssemblyName = value.GetType().Assembly.GetName().Name, Type = value.GetType().FullName - }, expire); + }, TimeSpan.FromSeconds(expire)); await Task.Delay(TimeSpan.FromSeconds(3)); @@ -76,16 +293,48 @@ public async void TestRedisCacheCanDeleteByLastPattern(string prefix, string key Value = value, AssemblyName = value.GetType().Assembly.GetName().Name, Type = value.GetType().FullName + }, TimeSpan.FromSeconds(10)); + await _redisClient.Delete("anson*", prefix); + var s = await _redisClient.Get(key); + Assert.Equal(s.Value, result); + } + + [Theory] + [InlineData("", "anson555", "18", null)] + [InlineData("", "anson555555", "19", null)] + public async void TestMemoryCacheCanDeleteByFirstPatternWithDelayed(string prefix, string key, string value, + string result) + { + await _redisClient.Set(key, new CacheItem() + { + Type = value.GetType().FullName, + AssemblyName = value.GetType().Assembly.FullName, + Value = value, + Expire = DateTime.UtcNow.AddSeconds(20).Ticks }); await _redisClient.Delete("anson*", prefix); var s = await _redisClient.Get(key); Assert.Equal(s.Value, result); + + await _redisClient.Set(key, new CacheItem() + { + Type = value.GetType().FullName, + AssemblyName = value.GetType().Assembly.FullName, + Value = value, + Expire = DateTime.UtcNow.AddSeconds(20).Ticks + }); + var s2 = await _redisClient.Get(key); + Assert.Equal(s2.Value, value); + + await Task.Delay(TimeSpan.FromSeconds(2)); + var s3 = await _redisClient.Get(key); + Assert.Null(s3.Value); } [Theory] - [InlineData("", "anson1111", "18", null)] - [InlineData("", "anson2222", "19", null)] - public async void TestRedisCacheCanDeleteByLastPatternByFullKey(string prefix, string key, string value, + [InlineData("anson1111", "18", null)] + [InlineData("anson2222", "19", null)] + public async void TestRedisCacheCanDeleteByLastPatternByFullKey(string key, string value, string? result) { await _redisClient.Set(key, new CacheItem() @@ -262,964 +511,121 @@ public async void TestLockRedisCacheCanDeleteByGeneralMatchPatternWithPrefix(str Assert.Equal(s.Value, result); } - /// - /// 测试 Redis 缓存添加方法在不同延迟和线性请求超时情况下的分布式锁获取情况。 - /// 该测试模拟了多个客户端争抢锁的情况,每个客户端请求之前有不同的延迟时间, - /// 以测试锁机制的有效性,以及在延迟和锁超时的情况下有多少个请求可以成功获得锁。 - /// - /// - /// - /// - /// - /// - /// - /// - [Theory] - [InlineData("Joe", "1111Joe", "88888888888888888888888888888888888888888888888888888888888888888", 100, 1000, 0, - 10)] - [InlineData("Joe", "1111Joe", "88888888888888888888888888888888888888888888888888888888888888888", 100, 1000, 100, - 5)] - [InlineData("Joe", "1111Joe", "88888888888888888888888888888888888888888888888888888888888888888", 100, 1000, 200, - 4)] - [InlineData("Joe", "1111Joe", "88888888888888888888888888888888888888888888888888888888888888888", 100, 1000, 300, - 3)] - [InlineData("Joe", "1111Joe", "88888888888888888888888888888888888888888888888888888888888888888", 100, 1000, 700, - 2)] - [InlineData("Joe", "1111Joe", "88888888888888888888888888888888888888888888888888888888888888888", 100, 1000, 900, - 1)] - public async void TestLockRedisCacheAddWithVaryingDelaysAndLinearTimeoutHandling(string prefix, string key, - string value, - int msTimeout, int msExpire, - int delayMs, int expect) + [Fact] + public void TestCanGetRedisClient() { - var fullKey = $"{prefix}:{key}"; - - var lockResult = new ConcurrentDictionary(); - - // 定义一个异步操作,用于模拟不同客户端争抢锁 - async Task TrySetAsyncLock(string v) - { - var stopwatch = new Stopwatch(); - stopwatch.Start(); - var isLock = await _redisClient.ExecuteWithRedisLockAsync( - $"lock:delete:{fullKey}", async () => - { - await Task.Delay(delayMs); - await _redisClient.Set(fullKey, new CacheItem() - { - Value = $"{value}-{v}", - AssemblyName = value.GetType().Assembly.GetName().Name, - Type = value.GetType().FullName - }); - }, msTimeout, msExpire); - - stopwatch.Stop(); - - testOutputHelper.WriteLine( - $"{v} - time : {stopwatch.ElapsedMilliseconds} lock: {isLock}, ticks: {DateTime.UtcNow.Ticks}"); - - lockResult.TryAdd(v, isLock); - - return isLock; - } - - var tasks = new List(); - - // 创建多个并发任务,模拟多个客户端同时争抢锁 - for (int i = 0; i < 10; i++) - { - var localIndex = i; - tasks.Add(TrySetAsyncLock(localIndex.ToString())); - } - - // 等待所有任务完成 - await Task.WhenAll(tasks); - - await _redisClient.Delete("*", prefix); - - Assert.True(lockResult.Count == 10); - - var hasLockRequests = lockResult.Count(x => x.Value); - - testOutputHelper.WriteLine( - $"hasLockRequests: {hasLockRequests} | delayMs: {delayMs}"); - - // 给予上下浮动1的范围 - Assert.True(hasLockRequests == expect || hasLockRequests == expect - 1 || hasLockRequests == expect + 1); + var redisClient = _redisClient.GetConnectionMultiplexer(); + Assert.NotNull(redisClient); } - /// - /// 测试 Redis 缓存删除方法在不同延迟和线性请求超时情况下的分布式锁获取情况。 - /// 该测试模拟了多个客户端争抢锁的情况,每个客户端请求删除缓存之前有不同的延迟时间, - /// 以测试锁机制的有效性,并且在延迟和锁超时的情况下,有多少个请求可以成功获得锁。 - /// - /// - /// - /// - /// - /// - /// - /// - /// - /// - [Theory] - [InlineData("Joe", "1111Joe", "*", "18", null, 100, 1000, 0, 10)] - [InlineData("Joe", "1111Joe", "*", "18", null, 100, 1000, 100, 5)] - [InlineData("Joe", "1111Joe", "*", "18", null, 100, 1000, 200, 4)] - [InlineData("Joe", "1111Joe", "*", "18", null, 100, 1000, 300, 3)] - [InlineData("Joe", "1111Joe", "*", "18", null, 100, 1000, 700, 2)] - [InlineData("Joe", "1111Joe", "*", "18", null, 100, 1000, 900, 1)] - public async void TestLockRedisCacheDeleteWithVaryingDelaysAndLinearTimeoutHandling(string prefix, string key, - string deleteKey, string value, string? result, int msTimeout, int msExpire, int delayMs, int hasLockCount) + [Fact] + public async void TestBatchDeleteKeysWithPipelineAsync() { - var fullKey = $"{prefix}:{key}"; - - await _redisClient.Set(fullKey, new CacheItem() + var key = "TestBatchDeleteKeysWithPipelineAsync1"; + var value = "1"; + await _redisClient.Set(key, new CacheItem() { Value = value, AssemblyName = value.GetType().Assembly.GetName().Name, Type = value.GetType().FullName }); - var lockResult = new ConcurrentDictionary(); - - // 定义一个异步操作,用于模拟不同客户端争抢锁 - async Task TrySetAsyncLock(string v) - { - var stopwatch = new Stopwatch(); - stopwatch.Start(); - var isLock = await _redisClient.ExecuteWithRedisLockAsync( - $"lock:delete:{fullKey}", async () => - { - await Task.Delay(delayMs); - await _redisClient.Delete(deleteKey, prefix); - }, msTimeout, msExpire); - - stopwatch.Stop(); - - testOutputHelper.WriteLine( - $"{v} - time : {stopwatch.ElapsedMilliseconds} lock: {isLock}, ticks: {DateTime.UtcNow.Ticks}"); - - lockResult.TryAdd(v, isLock); - - return isLock; - } - - var tasks = new List(); - - // 创建多个并发任务,模拟多个客户端同时争抢锁 - for (int i = 0; i < 10; i++) - { - var localIndex = i; - tasks.Add(TrySetAsyncLock(localIndex.ToString())); - } - - // 等待所有任务完成 - await Task.WhenAll(tasks); - - await _redisClient.Delete("*", prefix); - - Assert.True(lockResult.Count == 10); - - var hasLockRequests = lockResult.Count(x => x.Value); - - testOutputHelper.WriteLine( - $"hasLockRequests: {hasLockRequests} | delayMs: {delayMs}"); - - // 给予上下浮动1的范围 - Assert.True(hasLockRequests == hasLockCount || hasLockRequests == hasLockCount - 1 || - hasLockRequests == hasLockCount + 1); - - var s = await _redisClient.Get(key); - Assert.Equal(s.Value, result); - } - - /// - /// 测试 Redis 缓存添加方法在不同延迟和线性请求超时情况下的分布式锁自动释放以及抢锁情况。 - /// - /// - /// - /// - /// - /// - /// - /// - [Theory] - [InlineData("Joe", "1111Joe", "88888888888888888888888888888888888888888888888888888888888888888", 100, 200, 200, - 1)] - [InlineData("Joe", "1111Joe", "88888888888888888888888888888888888888888888888888888888888888888", 400, 200, 200, - 2)] - public async void TestLockAutoEvictByAdd(string prefix, string key, - string value, - int msTimeout, int msExpire, - int delayMs, int expect) - { - var fullKey = $"{prefix}:{key}"; - - var lockResult = new ConcurrentDictionary(); - - // 定义一个异步操作,用于模拟不同客户端争抢锁 - async Task TrySetAsyncLock(string v) + var key2 = "TestBatchDeleteKeysWithPipelineAsync2"; + var value2 = "1"; + await _redisClient.Set(key2, new CacheItem() { - var stopwatch = new Stopwatch(); - stopwatch.Start(); - var isLock = await _redisClient.ExecuteWithRedisLockAsync( - $"lock:delete:{fullKey}", async () => - { - await Task.Delay(delayMs); - await _redisClient.Set(fullKey, new CacheItem() - { - Value = $"{value}-{v}", - AssemblyName = value.GetType().Assembly.GetName().Name, - Type = value.GetType().FullName - }); - }, msTimeout, msExpire); - - stopwatch.Stop(); - - testOutputHelper.WriteLine( - $"{v} - time : {stopwatch.ElapsedMilliseconds} lock: {isLock}, ticks: {DateTime.UtcNow.Ticks}"); - - lockResult.TryAdd(v, isLock); - - return isLock; - } - - var tasks = new List(); - - // 创建多个并发任务,模拟多个客户端同时争抢锁 - for (int i = 0; i < 2; i++) - { - var localIndex = i; - tasks.Add(TrySetAsyncLock(localIndex.ToString())); - } - - // 等待所有任务完成 - await Task.WhenAll(tasks); - - await _redisClient.Delete("*", prefix); - - var hasLockRequests = lockResult.Count(x => x.Value); - - testOutputHelper.WriteLine( - $"hasLockRequests: {hasLockRequests} | delayMs: {delayMs}"); - - Assert.True(hasLockRequests == expect); - } - - /// - /// 测试 Redis 缓存删除方法在不同延迟和线性请求超时情况下的分布式锁自动释放以及抢锁情况。 - /// - /// - /// - /// - /// - /// - /// - /// - /// - /// - [Theory] - // 抢锁失败的情况 - [InlineData("Joe", "1111Joe", "*", "18", null, 100, 200, 200, 1)] - // 锁超时被抢 - [InlineData("Joe", "1111Joe", "*", "18", null, 400, 200, 200, 2)] - public async void TestLockAutoEvictByDelete(string prefix, string key, - string deleteKey, string value, string? result, int msTimeout, int msExpire, int delayMs, int hasLockCount) - { - var fullKey = $"{prefix}:{key}"; - - await _redisClient.Set(fullKey, new CacheItem() - { - Value = value, + Value = value2, AssemblyName = value.GetType().Assembly.GetName().Name, Type = value.GetType().FullName }); - var lockResult = new ConcurrentDictionary(); - - // 定义一个异步操作,用于模拟不同客户端争抢锁 - async Task TrySetAsyncLock(string v) - { - var stopwatch = new Stopwatch(); - stopwatch.Start(); - var isLock = await _redisClient.ExecuteWithRedisLockAsync( - $"lock:delete:{fullKey}", async () => - { - await Task.Delay(delayMs); - await _redisClient.Delete(deleteKey, prefix); - }, msTimeout, msExpire); - - stopwatch.Stop(); - - testOutputHelper.WriteLine( - $"{v} - time : {stopwatch.ElapsedMilliseconds} lock: {isLock}, ticks: {DateTime.UtcNow.Ticks}"); - - lockResult.TryAdd(v, isLock); - - return isLock; - } - - var tasks = new List(); - - // 创建多个并发任务,模拟多个客户端同时争抢锁 - for (int i = 0; i < 2; i++) - { - var localIndex = i; - tasks.Add(TrySetAsyncLock(localIndex.ToString())); - } - - // 等待所有任务完成 - await Task.WhenAll(tasks); - - await _redisClient.Delete("*", prefix); - - var hasLockRequests = lockResult.Count(x => x.Value); - - testOutputHelper.WriteLine( - $"hasLockRequests: {hasLockRequests} | delayMs: {delayMs}"); - - Assert.True(hasLockRequests == hasLockCount); - - var s = await _redisClient.Get(key); - Assert.Equal(s.Value, result); - } - - [Theory] - [InlineData("Joe", "1111Joe", "*", "18", null, 100, 1000, 100, 1000, 0.5)] // 模拟1000个请求并发 - [InlineData("Joe", "1111Joe", "*", "18", null, 100, 1000, 100, 2000, 0.5)] // 模拟2000个请求并发 - [InlineData("Joe", "1111Joe", "*", "18", null, 100, 1000, 700, 1000, 0.1)] // 模拟1000个请求并发 - [InlineData("Joe", "1111Joe", "*", "18", null, 100, 1000, 300, 1000, 0.3)] // 模拟1000个请求并发 - public async void PerformanceTestLockRedisCacheUnderHighConcurrency( - string prefix, string key, string deleteKey, string value, string? result, - int msTimeout, int msExpire, int delayMs, int numberOfRequests, decimal expectedSuccessRate) - { - var fullKey = $"{prefix}:{key}"; - - // 初始化缓存数据 - await _redisClient.Set(fullKey, new CacheItem() + var key3 = "TestBatchDeleteKeysWithPipelineAsync3"; + var value3 = "1"; + await _redisClient.Set(key3, new CacheItem() { - Value = value, + Value = value3, AssemblyName = value.GetType().Assembly.GetName().Name, Type = value.GetType().FullName }); - var lockResult = new ConcurrentDictionary(); - var responseTimes = new ConcurrentBag(); // 存储每个请求的响应时间 - - // 定义一个异步操作,用于模拟不同客户端争抢锁 - async Task TrySetAsyncLock(string v) - { - var stopwatch = new Stopwatch(); - stopwatch.Start(); - - var isLock = await _redisClient.ExecuteWithRedisLockAsync( - $"lock:delete:{fullKey}", async () => - { - await Task.Delay(delayMs); // 模拟主体操作前的延迟 - await _redisClient.Delete(deleteKey, prefix); - }, msTimeout, msExpire); - - stopwatch.Stop(); - - // _testOutputHelper.WriteLine( - // $"{v} - time : {stopwatch.ElapsedMilliseconds}ms lock: {isLock}, ticks: {DateTime.UtcNow.Ticks}"); - - lockResult.TryAdd(v, isLock); - responseTimes.Add(stopwatch.ElapsedMilliseconds); // 记录响应时间 - - return isLock; - } - - var tasks = new List(); - - // 创建高并发任务,模拟多个客户端同时争抢锁 - for (int i = 0; i < numberOfRequests; i++) - { - var localIndex = i; - tasks.Add(TrySetAsyncLock(localIndex.ToString())); - } - - // 等待所有任务完成 - await Task.WhenAll(tasks); - - // 删除缓存 - await _redisClient.Delete("*", prefix); - - Assert.True(lockResult.Count == numberOfRequests); - - var hasLockRequests = lockResult.Count(x => x.Value); - - testOutputHelper.WriteLine($"hasLockRequests: {hasLockRequests} | delayMs: {delayMs}"); - - // 验证最后缓存项的值 - var s = await _redisClient.Get(key); - Assert.Equal(s.Value, result); - - // 性能分析: 计算平均响应时间 - var averageResponseTime = responseTimes.Average(); - var maxResponseTime = responseTimes.Max(); - var minResponseTime = responseTimes.Min(); - - var expectedSuccessCount = (int)(numberOfRequests * expectedSuccessRate); - - // 打印性能测试结果 - testOutputHelper.WriteLine($"Performance Test Results:"); - testOutputHelper.WriteLine($"Total Requests: {numberOfRequests}"); - testOutputHelper.WriteLine($"Locks Acquired: {hasLockRequests}"); - testOutputHelper.WriteLine($"Average Response Time: {averageResponseTime}ms"); - testOutputHelper.WriteLine($"Max Response Time: {maxResponseTime}ms"); - testOutputHelper.WriteLine($"Min Response Time: {minResponseTime}ms"); - testOutputHelper.WriteLine($"Expected Success Count: {expectedSuccessCount} | rate: {expectedSuccessRate}"); - - // 对于高并发,确保大部分锁能在规定时间内被获取 - Assert.True(hasLockRequests >= expectedSuccessCount); + var batchDeletedResult = await _redisClient.BatchDeleteKeysWithPipelineAsync(new[] { key, key2, key3 }); + Assert.Equal(3, batchDeletedResult); } - /// - /// 测试 Redis 缓存添加方法在不同延迟和线性请求超时情况下的分布式锁获取情况。 - /// 该测试模拟了多个线程争抢锁的情况,每个线程请求之前有不同的延迟时间, - /// 以测试锁机制的有效性,以及在延迟和锁超时的情况下有多少个请求可以成功获得锁。 - /// - /// - /// - /// - /// - /// - /// - /// - /// - [Theory] - [InlineData("Joe", "1111Joe", "88888888888888888888888888888888888888888888888888888888888888888", 500, 1000, 200, - 10, - 0.1)] - [InlineData("Joe", "1111Joe", "88888888888888888888888888888888888888888888888888888888888888888", 500, 1000, 0, 10, - 0.4)] - [InlineData("Joe", "1111Joe", "88888888888888888888888888888888888888888888888888888888888888888", 300, 1000, 0, 10, - 0.1)] - [InlineData("Joe", "1111Joe", "88888888888888888888888888888888888888888888888888888888888888888", 100, 1000, 0, - 10, 0.1)] - [InlineData("Joe", "1111Joe", "88888888888888888888888888888888888888888888888888888888888888888", 100, 1000, 100, - 10, 0.1)] - [InlineData("Joe", "JoeKey", "SampleValue", 100, 1000, 200, 10, 0.1)] - public async void TestLockRedisCacheAddWithMultithreadingAndLockValidation(string prefix, string key, string value, - int msTimeout, int msExpire, int delayMs, int numberOfRequests, decimal expectedSuccessRate) + [Fact] + public async void TestBatchDeleteKeysWithPipelineAsyncWithDelayed() { - var fullKey = $"{prefix}:{key}"; - var lockResult = new ConcurrentDictionary(); - - // 初始化缓存数据 - await _redisClient.Set(fullKey, new CacheItem() + var key = "TestBatchDeleteKeysWithPipelineAsync1"; + var value = "1"; + await _redisClient.Set(key, new CacheItem() { Value = value, AssemblyName = value.GetType().Assembly.GetName().Name, Type = value.GetType().FullName }); - // 创建一个 ManualResetEventSlim 用于控制并发的起始时间 - var startEvent = new ManualResetEventSlim(false); - - // 线程安全的响应时间容器 - var responseTimes = new ConcurrentBag(); - - // 定义一个异步操作,用于模拟不同线程争抢锁 - async Task TrySetAsyncLock(string threadId) - { - try - { - // 等待所有线程就绪 - startEvent.Wait(); - - var stopwatch = Stopwatch.StartNew(); - - // 执行带锁操作 - var isLock = await _redisClient.ExecuteWithRedisLockAsync( - $"lock:delete:{fullKey}", async () => - { - await Task.Delay(delayMs); // 模拟处理延迟 - - // 假设此处可能发生异常,需记录日志 - await _redisClient.Set(fullKey, new CacheItem() - { - Value = $"{value}-{threadId}", - AssemblyName = value.GetType().Assembly.GetName().Name, - Type = value.GetType().FullName - }); - }, msTimeout, msExpire); - - stopwatch.Stop(); - - testOutputHelper.WriteLine( - $"threadId: {threadId} | responseTimes: {stopwatch.ElapsedMilliseconds}ms | isLock: {isLock} | startWatch: {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss.fff}"); - - responseTimes.Add(stopwatch.ElapsedMilliseconds); - - // 添加锁结果 - lockResult.TryAdd(threadId, isLock); - } - catch (Exception ex) - { - // 捕获并记录所有可能的异常 - testOutputHelper.WriteLine($"Error in threadId: {threadId} - {ex.Message}"); - } - } - - var tasks = new List(); - var lockObject = new object(); - - // 使用 Parallel.For 来模拟多线程并发 - Parallel.For(0, 10, i => + var key2 = "TestBatchDeleteKeysWithPipelineAsync2"; + var value2 = "1"; + await _redisClient.Set(key2, new CacheItem() { - var index = i; - var task = Task.Run(async () => { await TrySetAsyncLock(index.ToString()); }); - - lock (lockObject) - { - tasks.Add(task); - } + Value = value2, + AssemblyName = value.GetType().Assembly.GetName().Name, + Type = value.GetType().FullName }); - // 手动触发所有线程开始执行 - startEvent.Set(); - - // 等待所有线程完成任务 - await Task.WhenAll(tasks); - - Assert.True(!tasks.Any(x => x == null)); - - Assert.True(tasks.Count > 0); - - // 删除缓存 - await _redisClient.Delete("*", prefix); - - Assert.True(lockResult.Count == numberOfRequests); - - var expectedSuccessCount = (int)(numberOfRequests * expectedSuccessRate); - - // 计算获取到锁的请求数 - var hasLockRequests = lockResult.Count(x => x.Value); - - testOutputHelper.WriteLine($"hasLockRequests: {hasLockRequests} | delayMs: {delayMs}"); - - // 给予上下浮动1的范围,验证实际锁获取数是否符合预期 - Assert.True(hasLockRequests >= expectedSuccessCount); - - // 打印性能测试结果 - var averageResponseTime = responseTimes.Average(); - testOutputHelper.WriteLine($"Average Response Time: {averageResponseTime}ms"); - } - - - /// - /// 测试 Redis 缓存添加方法在不同延迟和线性请求超时情况下的分布式锁获取情况。 - /// 该测试模拟了多个线程争抢锁的情况,每个线程请求之前有不同的延迟时间, - /// 以测试锁机制的有效性,以及在延迟和锁超时的情况下有多少个请求可以成功获得锁。 - /// - /// - /// - /// - /// - /// - /// - /// - /// - /// - [Theory] - [InlineData("Joe", "1111Joe", "*", "88888888888888888888888888888888888888888888888888888888888888888", 500, 1000, - 200, 10, - 0.1)] - [InlineData("Joe", "1111Joe", "*", "88888888888888888888888888888888888888888888888888888888888888888", 500, 1000, - 0, 10, - 0.3)] - [InlineData("Joe", "1111Joe", "*", "88888888888888888888888888888888888888888888888888888888888888888", 300, 1000, - 0, 10, - 0.2)] - [InlineData("Joe", "1111Joe", "*", "88888888888888888888888888888888888888888888888888888888888888888", 100, 1000, - 0, - 10, 0.1)] - [InlineData("Joe", "1111Joe", "*", "88888888888888888888888888888888888888888888888888888888888888888", 100, 1000, - 100, - 10, 0.1)] - [InlineData("Joe", "JoeKey", "*", "SampleValue", 100, 1000, 200, 10, 0.1)] - public async void TestLockRedisCacheDeleteWithMultithreadingAndLockValidation(string prefix, string key, - string deleteKey, string value, - int msTimeout, int msExpire, int delayMs, int numberOfRequests, decimal expectedSuccessRate) - { - var fullKey = $"{prefix}:{key}"; - var lockResult = new ConcurrentDictionary(); - - // 初始化缓存数据 - await _redisClient.Set(fullKey, new CacheItem() + var key3 = "TestBatchDeleteKeysWithPipelineAsync3"; + var value3 = "1"; + await _redisClient.Set(key3, new CacheItem() { - Value = value, + Value = value3, AssemblyName = value.GetType().Assembly.GetName().Name, Type = value.GetType().FullName }); - // 创建一个 ManualResetEventSlim 用于控制并发的起始时间 - var startEvent = new ManualResetEventSlim(false); - - // 线程安全的响应时间容器 - var responseTimes = new ConcurrentBag(); - - // 定义一个异步操作,用于模拟不同线程争抢锁 - async Task TrySetAsyncLock(string threadId) - { - try - { - // 等待所有线程就绪 - startEvent.Wait(); - - var stopwatch = Stopwatch.StartNew(); - - // 执行带锁操作 - var isLock = await _redisClient.ExecuteWithRedisLockAsync( - $"lock:delete:{fullKey}", async () => - { - await Task.Delay(delayMs); // 模拟处理延迟 - - await _redisClient.Delete(deleteKey, prefix); - - // 假设此处可能发生异常,需记录日志 - await _redisClient.Set(fullKey, new CacheItem() - { - Value = $"{value}-{threadId}", - AssemblyName = value.GetType().Assembly.GetName().Name, - Type = value.GetType().FullName - }); - }, msTimeout, msExpire); - - stopwatch.Stop(); - - testOutputHelper.WriteLine( - $"threadId: {threadId} | responseTimes: {stopwatch.ElapsedMilliseconds}ms | isLock: {isLock} | startWatch: {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss.fff}"); - - responseTimes.Add(stopwatch.ElapsedMilliseconds); - - // 添加锁结果 - lockResult.TryAdd(threadId, isLock); - } - catch (Exception ex) - { - // 捕获并记录所有可能的异常 - testOutputHelper.WriteLine($"Error in threadId: {threadId} - {ex.Message}"); - } - } - - var tasks = new List(); - var lockObject = new object(); + var batchDeletedResult = await _redisClient.BatchDeleteKeysWithPipelineAsync(new[] { key, key2, key3 }); + Assert.Equal(3, batchDeletedResult); - // 使用 Parallel.For 来模拟多线程并发 - Parallel.For(0, 10, i => + await _redisClient.Set(key3, new CacheItem() { - var index = i; - var task = Task.Run(async () => { await TrySetAsyncLock(index.ToString()); }); - - lock (lockObject) - { - tasks.Add(task); - } + Value = value3, + AssemblyName = value.GetType().Assembly.GetName().Name, + Type = value.GetType().FullName }); + var cacheItemBySet = await _redisClient.Get(key3); + Assert.Equal(cacheItemBySet.Value, value); - // 手动触发所有线程开始执行 - startEvent.Set(); - - Assert.True(tasks.Count > 0); - - // 等待所有线程完成任务 - await Task.WhenAll(tasks); - - // 删除缓存 - await _redisClient.Delete("*", prefix); - - testOutputHelper.WriteLine($"lockResult count: {lockResult.Count}"); - - Assert.True(lockResult.Count == numberOfRequests); - - var expectedSuccessCount = (int)(numberOfRequests * expectedSuccessRate); - - // 计算获取到锁的请求数 - var hasLockRequests = lockResult.Count(x => x.Value); - - testOutputHelper.WriteLine($"hasLockRequests: {hasLockRequests} | delayMs: {delayMs}"); - - // 给予上下浮动1的范围,验证实际锁获取数是否符合预期 - Assert.True(hasLockRequests >= expectedSuccessCount); + await Task.Delay(TimeSpan.FromSeconds(2)); - // 打印性能测试结果 - var averageResponseTime = responseTimes.Average(); - testOutputHelper.WriteLine($"Average Response Time: {averageResponseTime}ms"); + var cacheItem = await _redisClient.Get(key3); + Assert.Null(cacheItem.Value); } - /// - /// 测试 Redis 缓存添加方法在不同延迟和线性请求超时情况下的分布式锁获取情况。 - /// 该测试模拟了多个线程争抢锁的情况,每个线程请求之前有不同的延迟时间, - /// 以测试锁机制的有效性,以及在延迟和锁超时的情况下有多少个请求可以成功获得锁。 - /// - /// - /// - /// - /// - /// - /// - /// - /// - /// - [Theory] - [InlineData("Joe", "1111Joe", "88888888888888888888888888888888888888888888888888888888888888888", 500, 1000, 200, - 10, 5, 0.2)] - [InlineData("Joe", "1111Joe", "88888888888888888888888888888888888888888888888888888888888888888", 500, 1000, 0, 10, - 5, 0.4)] - public async void TestLockRedisCacheAddWithMultipleRequestsAndMultithreading(string prefix, string key, - string value, - int msTimeout, int msExpire, int delayMs, int numberOfThreads, int requestsPerThread, - decimal expectedSuccessRate) + [Fact] + public async void TestCanDelayedRemove() { - var fullKey = $"{prefix}:{key}"; - var lockResult = new ConcurrentDictionary(); - - // 初始化缓存数据 - await _redisClient.Set(fullKey, new CacheItem() + var key = "TestCanDelayedRemove"; + var value = "1"; + await _redisClient.Set(key, new CacheItem() { Value = value, AssemblyName = value.GetType().Assembly.GetName().Name, Type = value.GetType().FullName }); - // 创建一个 ManualResetEventSlim 用于控制并发的起始时间 - var startEvent = new ManualResetEventSlim(false); - - // 线程安全的响应时间容器 - var responseTimes = new ConcurrentBag(); - - // 定义一个异步操作,用于模拟不同线程争抢锁,每个线程内发起多个请求 - async Task TrySetAsyncLock(string threadId) - { - try - { - // 等待所有线程就绪 - startEvent.Wait(); - - for (int i = 0; i < requestsPerThread; i++) - { - var stopwatch = Stopwatch.StartNew(); - - // 执行带锁操作 - var isLock = await _redisClient.ExecuteWithRedisLockAsync( - $"lock:delete:{fullKey}", async () => - { - await Task.Delay(delayMs); // 模拟处理延迟 - - // 假设此处可能发生异常,需记录日志 - await _redisClient.Set(fullKey, new CacheItem() - { - Value = $"{value}-{threadId}-{i}", - AssemblyName = value.GetType().Assembly.GetName().Name, - Type = value.GetType().FullName - }); - }, msTimeout, msExpire); - - stopwatch.Stop(); - - testOutputHelper.WriteLine( - $"threadId: {threadId} | request: {i} | responseTimes: {stopwatch.ElapsedMilliseconds}ms | isLock: {isLock} | time: {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss.fff}"); - - responseTimes.Add(stopwatch.ElapsedMilliseconds); - - // 添加锁结果 - lockResult.TryAdd($"{threadId}-{i}", isLock); - } - } - catch (Exception ex) - { - // 捕获并记录所有可能的异常 - testOutputHelper.WriteLine($"Error in threadId: {threadId} - {ex.Message}"); - } - } - - var tasks = new List(); - var lockObject = new object(); - - // 使用 Parallel.For 来模拟多线程并发 - Parallel.For(0, numberOfThreads, i => - { - var index = i; - var task = Task.Run(async () => { await TrySetAsyncLock(index.ToString()); }); - - lock (lockObject) - { - tasks.Add(task); - } - }); - - // 手动触发所有线程开始执行 - startEvent.Set(); - - // 等待所有线程完成任务 - await Task.WhenAll(tasks); - - // 删除缓存 - await _redisClient.Delete("*", prefix); - - testOutputHelper.WriteLine($"lockResult count: {lockResult.Count}"); - - var totalRequests = numberOfThreads * requestsPerThread; - Assert.True(lockResult.Count == totalRequests); - - var expectedSuccessCount = (int)(totalRequests * expectedSuccessRate); - - // 计算获取到锁的请求数 - var hasLockRequests = lockResult.Count(x => x.Value); - - testOutputHelper.WriteLine($"hasLockRequests: {hasLockRequests} | delayMs: {delayMs}"); - - // 给予上下浮动1的范围,验证实际锁获取数是否符合预期 - Assert.True(hasLockRequests >= expectedSuccessCount); - - // 打印性能测试结果 - var averageResponseTime = responseTimes.Average(); - testOutputHelper.WriteLine($"Average Response Time: {averageResponseTime}ms"); - } - - /// - /// 测试 Redis 缓存删除方法在不同延迟和线性请求超时情况下的分布式锁获取情况。 - /// 该测试模拟了多个线程争抢锁的情况,每个线程请求之前有不同的延迟时间, - /// 以测试锁机制的有效性,以及在延迟和锁超时的情况下有多少个请求可以成功获得锁。 - /// - /// - /// - /// - /// - /// - /// - /// - /// - /// - /// - [Theory] - [InlineData("Joe", "1111Joe", "*", "88888888888888888888888888888888888888888888888888888888888888888", 500, 1000, - 200, - 10, 5, 0.15)] - [InlineData("Joe", "1111Joe", "*", "88888888888888888888888888888888888888888888888888888888888888888", 500, 1000, - 0, 10, - 5, 0.4)] - public async void TestLockRedisCacheDeleteWithMultipleRequestsAndMultithreading(string prefix, string key, - string deleteKey, - string value, - int msTimeout, int msExpire, int delayMs, int numberOfThreads, int requestsPerThread, - decimal expectedSuccessRate) - { - var fullKey = $"{prefix}:{key}"; - var lockResult = new ConcurrentDictionary(); + await _redisClient.Delete(key); - // 初始化缓存数据 - await _redisClient.Set(fullKey, new CacheItem() + await _redisClient.Set(key, new CacheItem() { Value = value, AssemblyName = value.GetType().Assembly.GetName().Name, Type = value.GetType().FullName }); + var cacheItemBySet = await _redisClient.Get(key); + Assert.Equal(cacheItemBySet.Value, value); - // 创建一个 ManualResetEventSlim 用于控制并发的起始时间 - var startEvent = new ManualResetEventSlim(false); - - // 线程安全的响应时间容器 - var responseTimes = new ConcurrentBag(); + await Task.Delay(TimeSpan.FromSeconds(5)); - // 定义一个异步操作,用于模拟不同线程争抢锁,每个线程内发起多个请求 - async Task TrySetAsyncLock(string threadId) - { - try - { - // 等待所有线程就绪 - startEvent.Wait(); - - for (int i = 0; i < requestsPerThread; i++) - { - var stopwatch = Stopwatch.StartNew(); - - // 执行带锁操作 - var isLock = await _redisClient.ExecuteWithRedisLockAsync( - $"lock:delete:{fullKey}", async () => - { - await Task.Delay(delayMs); // 模拟处理延迟 - - await _redisClient.Delete(deleteKey, prefix); - - // 假设此处可能发生异常,需记录日志 - await _redisClient.Set(fullKey, new CacheItem() - { - Value = $"{value}-{threadId}-{i}", - AssemblyName = value.GetType().Assembly.GetName().Name, - Type = value.GetType().FullName - }); - }, msTimeout, msExpire); - - stopwatch.Stop(); - - testOutputHelper.WriteLine( - $"threadId: {threadId} | request: {i} | responseTimes: {stopwatch.ElapsedMilliseconds}ms | isLock: {isLock} | time: {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss.fff}"); - - responseTimes.Add(stopwatch.ElapsedMilliseconds); - - // 添加锁结果 - lockResult.TryAdd($"{threadId}-{i}", isLock); - } - } - catch (Exception ex) - { - // 捕获并记录所有可能的异常 - testOutputHelper.WriteLine($"Error in threadId: {threadId} - {ex.Message}"); - } - } - - var tasks = new List(); - var lockObject = new object(); - - // 使用 Parallel.For 来模拟多线程并发 - Parallel.For(0, numberOfThreads, i => - { - var index = i; - var task = Task.Run(async () => { await TrySetAsyncLock(index.ToString()); }); - - lock (lockObject) - { - tasks.Add(task); - } - }); - - // 手动触发所有线程开始执行 - startEvent.Set(); - - // 等待所有线程完成任务 - await Task.WhenAll(tasks); - - // 删除缓存 - await _redisClient.Delete("*", prefix); - - var totalRequests = numberOfThreads * requestsPerThread; - Assert.True(lockResult.Count == totalRequests); - - var expectedSuccessCount = (int)(totalRequests * expectedSuccessRate); - - // 计算获取到锁的请求数 - var hasLockRequests = lockResult.Count(x => x.Value); - - testOutputHelper.WriteLine($"hasLockRequests: {hasLockRequests} | delayMs: {delayMs}"); - - // 给予上下浮动1的范围,验证实际锁获取数是否符合预期 - Assert.True(hasLockRequests >= expectedSuccessCount); - - // 打印性能测试结果 - var averageResponseTime = responseTimes.Average(); - testOutputHelper.WriteLine($"Average Response Time: {averageResponseTime}ms"); - } - - - [Fact] - public void TestCanGetRedisClient() - { - var redisClient = _redisClient.GetRedisClient(); - Assert.NotNull(redisClient); + var cacheItem = await _redisClient.Get(key); + Assert.Null(cacheItem.Value); } } \ No newline at end of file diff --git a/UnitTests/RedisKeyPatternGeneratorUtil.cs b/UnitTests/RedisKeyPatternGeneratorUtil.cs new file mode 100644 index 0000000..a4bf882 --- /dev/null +++ b/UnitTests/RedisKeyPatternGeneratorUtil.cs @@ -0,0 +1,215 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading; + +namespace UnitTests; + +public class RedisKeyPatternGeneratorUtil +{ + private readonly Random _rnd = new Random(); + private static readonly Regex CharSetRegex = new Regex(@"\[([^\]]+)\]"); + private static readonly Regex RangeRegex = new Regex(@"\[(\d+)-(\d+)\]"); + + public string GenerateRedisCompatibleKey(string pattern, int index) + { + if (string.IsNullOrWhiteSpace(pattern)) + return $"key_{index}_{Guid.NewGuid().ToString("N")[..8]}"; + + var sb = new StringBuilder(); + bool isEscape = false; + bool inBrackets = false; + string bracketContent = string.Empty; + + for (int i = 0; i < pattern.Length; i++) + { + var c = pattern[i]; + + if (isEscape) + { + sb.Append(c); + isEscape = false; + continue; + } + + switch (c) + { + case '\\': + isEscape = true; + break; + + case '*': + sb.Append(inBrackets ? c.ToString() : GenerateWildcardReplacement(index)); + break; + + case '?': + sb.Append(inBrackets ? c.ToString() : _rnd.Next(0, 10).ToString()); + break; + + case '[': + if (!inBrackets) + { + inBrackets = true; + bracketContent = string.Empty; + } + else + { + bracketContent += c; + } + + break; + + case ']': + if (inBrackets) + { + var result = ProcessBracketContent(bracketContent); + sb.Append(result); + inBrackets = false; + } + else + { + sb.Append(c); + } + + break; + + default: + if (inBrackets) + bracketContent += c; + else + sb.Append(c); + break; + } + } + + // 处理未闭合的方括号 + if (inBrackets) + sb.Append('[').Append(bracketContent); + + // 确保键名符合Redis规范 + return NormalizeRedisKey(sb.ToString()); + } + + private string ProcessBracketContent(string content) + { + // 空内容 [] + if (string.IsNullOrEmpty(content)) + return string.Empty; + + // 中文字符集特殊处理(如[张三李四]) + if (ContainsChineseCharacters(content)) + { + // 直接返回原内容(去掉方括号) + return content; + } + + // 范围模式 [1-9], [a-z] + if (content.Length == 3 && content[1] == '-') + { + char start = content[0]; + char end = content[2]; + + if (char.IsDigit(start) && char.IsDigit(end)) + return _rnd.Next(start - '0', end - '0' + 1).ToString(); + + if (char.IsLetter(start) && char.IsLetter(end)) + return ((char)_rnd.Next(start, end + 1)).ToString(); + } + + // 普通字符集 [abc] + if (content.All(char.IsLetterOrDigit) && content.Length > 1) + return content[_rnd.Next(0, content.Length)].ToString(); + + // 默认情况:保留原始内容(去掉方括号) + return EscapeRedisSpecialChars(content); + } + + private bool ContainsChineseCharacters(string input) + { + foreach (char c in input) + { + // Unicode中文字符范围:0x4E00-0x9FFF + if (c >= '\u4e00' && c <= '\u9fff') + return true; + + // 扩展判断:全角符号等 + if (c >= '\u3000' && c <= '\u303f') + return true; + } + + return false; + } + + private string EscapeRedisSpecialChars(string input) + { + // Redis特殊字符: * ? [ ] + var sb = new StringBuilder(); + foreach (char c in input) + { + if ("*?[]".Contains(c)) + sb.Append('\\').Append(c); + else + sb.Append(c); + } + + return sb.ToString(); + } + + private string NormalizeRedisKey(string key) + { + // 替换空格为下划线 + key = key.Replace(' ', '_'); + + // 限制长度 + const int maxLength = 256; + if (key.Length > maxLength) + key = key.Substring(0, maxLength); + + return key; + } + + private string GenerateWildcardReplacement(int index) + { + // 使用线程安全的随机数生成 + var rndValue = ThreadLocalRandom.Next(0, 100); + + // 根据不同的权重选择生成策略 + switch (rndValue % 7) // 7种组合方式减少重复 + { + case 0: + return $"{index}_{DateTime.UtcNow:HHmmss}"; + + case 1: + return $"{index:X4}{ThreadLocalRandom.Next(100):D2}"; + + case 2: + return $"{DateTime.UtcNow.Ticks % 1000000}_{index}"; + + case 3: + return $"{(char)('A' + index % 26)}{ThreadLocalRandom.Next(1000, 9999)}"; + + case 4: + return $"{Guid.NewGuid():N}".Substring(0, 8 + index % 5); + + case 5: + return Convert.ToBase64String(BitConverter.GetBytes(DateTime.UtcNow.Ticks)) + .Replace("=", "")[..8]; + + default: + return $"{index}{DateTime.UtcNow:MMdd}{ThreadLocalRandom.Next(100):D2}"; + } + } + + // 线程安全的随机数生成器 + private static class ThreadLocalRandom + { + private static readonly ThreadLocal Random = new(() => new Random(Interlocked.Increment(ref _seed))); + + private static int _seed = Environment.TickCount; + + public static int Next(int min, int max) => Random.Value!.Next(min, max); + public static int Next(int max) => Random.Value!.Next(max); + } +} \ No newline at end of file