Redis原子性写入HASH结构数据并设置过期时间
Redis中提供了原子性命令SETEX或SET来写入STRING类型数据并设置Key的过期时间:
> SET key value EX 60 NX ok > SETEX key 60 value ok
但对于HASH结构则没有这样的命令,只能先写入数据然后设置过期时间:
> HSET key field value ok > EXPIRE key 60 ok
这样就带了一个问题:HSET命令执行成功而EXPIRE命令执行失败(如命令未能成功发送到Redis服务器),那么数据将不会过期。针对这个问题,本文提供了几种解决方案:
Lua脚本
向Redis中写入HASH结构的Lua脚本如下:
local fieldIndex=3 local valueIndex=4 local key=KEYS[1] local fieldCount=ARGV[1] local expired=ARGV[2] for i=1,fieldCount,1 do redis.pcall('HSET',key,ARGV[fieldIndex],ARGV[valueIndex]) fieldIndex=fieldIndex+2 valueIndex=valueIndex+2 end redis.pcall('EXPIRE',key,expired)
使用Redis命令行工具执行Lua脚本,需要将脚本内容单行化,并以分号间隔不同的命令:
> SCRIPT LOAD "local fieldIndex=3;local valueIndex=4;local key=KEYS[1];local fieldCount=ARGV[1];local expired=ARGV[2];for i=1,fieldCount,1 do redis.pcall('HSET',key,ARGV[fieldIndex],ARGV[valueIndex]) fieldIndex=fieldIndex+2 valueIndex=valueIndex+2 end;redis.pcall('EXPIRE',key,expired);" "e03e7868920b7669d1c8c8b16dcee86ebfac650d" > evalsha e03e7868920b7669d1c8c8b16dcee86ebfac650d 1 key 2 1000 field1 value1 field2 value2 nil
写入结果:

使用StackExchange.Redis执行Lua脚本:
public async Task WriteAsync(string key, IDictionary<string, string> valueDict, TimeSpan expiry) { async Task func() { if (valueDict.Empty()) { return; } var luaScriptPath = $"{AppDomain.CurrentDomain.BaseDirectory}/Lua/HSET.lua"; var script = File.ReadAllText(luaScriptPath); var seconds = (int)Math.Ceiling(expiry.TotalSeconds); var fieldCount = valueDict.Count; var redisValues = new RedisValue[fieldCount * 2 + 2]; redisValues[0] = fieldCount; redisValues[1] = seconds; var i = 2; foreach (var item in valueDict) { redisValues[i] = item.Key; redisValues[i + 1] = item.Value; i += 2; } //await Database.ScriptEvaluateAsync(script, new RedisKey[] { key, fieldCount.ToString(), seconds.ToString() }, redisValues); await Database.ScriptEvaluateAsync(script, new RedisKey[] { key }, redisValues); } await ExecuteCommandAsync(func, $"redisError:hashWrite:{key}"); }
事务
Redis官方文档在事务一节中指出:Redis命令只会在有语法错误或对Key使用了错误的数据类型时执行失败。因此,只要我们保证将正确的写数据和设置过期时间的命令作为一个整体发送到服务器端即可,使用Lua脚本正式基于此。
StackExchange.Redis官方文档中关于事务的说明,参见:Transactions
以下是代码实现:
public async Task<bool> WriteAsync(string key, IDictionary<string, string> valueDict, TimeSpan expiry) { var tranc = Database.CreateTransaction(); foreach (var item in valueDict) { tranc.HashSetAsync(key, item.Key, item.Value); } tranc.KeyExpireAsync(key, expiry); return await tranc.ExecuteAsync(); }
占位符
这种方案比较差,思路如下,共分为4步,每一步都有可能失败:
- 先写入一个特殊的值,如Nil表示无数据
- 若第一步操作成功,则Key被写入Redis。然后对Key设置过期时间。若第一步失败,则Key未写入Redis,设置过期时间会失败
- 若成功设置Key的过期时间则像Redis中写入有效数据
- 删除第一步中设置的特殊值
在读取Hash的值时,判断读到的field的值是否是Nil,若是则删除并忽略,若不是则处理。
代码如下:
namespace RedisClient.Imples { public class RedisHashOperator : RedisCommandExecutor, IRedisHashOperator { private readonly string KeyExpiryPlaceHolder = "expiryPlaceHolder"; public RedisHashOperator(ILogger<RedisHashOperator> logger, IRedisConnection redisConnection) : base(logger, redisConnection) { } public async Task WriteAsync(string key, IDictionary<string, string> valueDict, TimeSpan expiry) { async Task action() { if (valueDict.Empty()) { return; } var hashList = new List<HashEntry>(); foreach (var value in valueDict) { hashList.Add(new HashEntry(value.Key, value.Value)); } await Database.HashSetAsync(key, hashList.ToArray()); } async Task successed() { await ExecuteCommandAsync(action, $"redisEorror:hashWrite:{key}"); } await SetKeyExpireAsync(key, expiry, successed); } public async Task<RedisReadResult<IDictionary<string, string>>> ReadAllFieldsAsync(string key) { async Task<RedisReadResult<IDictionary<string, string>>> func() { var redisReadResult = new RedisReadResult<IDictionary<string, string>>(); if (Database.KeyExists(key) == false) { return redisReadResult.Failed(); } var resultList = await Database.HashGetAllAsync(key); if (resultList == null) { return redisReadResult.Failed(); } var dict = new Dictionary<string, string>(); if (resultList.Any()) { foreach (var result in resultList) { if (result.Name == KeyExpiryPlaceHolder || result.Value == KeyExpiryPlaceHolder) { await RemoveKeyExpiryPlaceHolderAsync(key); continue; } dict[result.Name] = result.Value; } } return redisReadResult.Success(dict); } return await ExecuteCommandAsync(func, $"redisError:hashReadAll:{key}"); } #region private /// <summary> /// 设置HASH结构KEY的过期时间 /// </summary> /// <param name="successed">设置过期时间成功之后的回调函数</param> private async Task SetKeyExpireAsync(string key, TimeSpan expiry, Func<Task> successed) { // 确保KEY的过期时间写入成功之后再执其它的操作 await Database.HashSetAsync(key, new HashEntry[] { new HashEntry(KeyExpiryPlaceHolder, KeyExpiryPlaceHolder) }); if (Database.KeyExpire(key, expiry)) { await successed(); } await Database.HashDeleteAsync(key, KeyExpiryPlaceHolder); } private async Task RemoveKeyExpiryPlaceHolderAsync(string key) { await Database.HashDeleteAsync(key, KeyExpiryPlaceHolder); } #endregion } }
文中多次出现的ExecuteCommandAsync方法主要目的是实现针对异常情况的统一处理,实现如下:
namespace RedisClient.Imples { public class RedisCommandExecutor { private readonly ILogger Logger; protected readonly IDatabase Database; public RedisCommandExecutor(ILogger<RedisCommandExecutor> logger, IRedisConnection redisConnection) { Logger = logger; Database = redisConnection.GetDatabase(); } protected async Task ExecuteCommandAsync(Func<Task> func, string errorMessage = null) { try { await func(); } catch (Exception ex) { if (string.IsNullOrEmpty(errorMessage)) { errorMessage = ex.Message; } Logger.LogError(errorMessage, ex); } } protected async Task<T> ExecuteCommandAsync<T>(Func<Task<T>> func, string errorMessage = null) { try { return await func(); } catch (Exception ex) { if (string.IsNullOrEmpty(errorMessage)) { errorMessage = ex.Message; } Logger.LogError(errorMessage, ex); return default(T); } } } }
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 10年+ .NET Coder 心语,封装的思维:从隐藏、稳定开始理解其本质意义
· .NET Core 中如何实现缓存的预热?
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· 阿里巴巴 QwQ-32B真的超越了 DeepSeek R-1吗?
· 【译】Visual Studio 中新的强大生产力特性
· 10年+ .NET Coder 心语 ── 封装的思维:从隐藏、稳定开始理解其本质意义
· 【设计模式】告别冗长if-else语句:使用策略模式优化代码结构
2016-07-19 Cookie中的几个概念