多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

StackExchange.Redis 脚本编程实战指南:Lua EVAL/EVALSHA 与低分配 RespResult 结果读取

StackExchange.Redis 脚本编程实战指南:Lua EVAL/EVALSHA 与低分配 RespResult 结果读取 后端缓存数据库客户端消息队列【免费下载链接】StackExchange.RedisThe Redis client for .NET项目地址https://gitcode.com/gh_mirrors/st/StackExchange.Redis点击查看免费下载导读在 Redis 生态中Lua 脚本EVAL/EVALSHA是唯一能在服务端原子地执行多条命令、并将整个执行过程压缩为一次网络往返的手段。StackExchange.Redis 通过IDatabase.ScriptEvaluateResp(Async)及只读变体ScriptEvaluateReadOnlyResp(Async)对脚本执行提供了第一等公民支持并以IServer.ScriptLoad(Async)/ScriptExists(Async)/ScriptFlush(Async)覆盖脚本管理命令。本文以官方文档 docs/Scripting.md 为骨架结合仓库源码系统讲解脚本调用、自动缓存与 NOSCRIPT 重试、以及用RespResult替代RedisResult实现低分配结果读取的完整方案——读完你将能够写出高性能、协议无关RESP2/RESP3的脚本调用代码。一、API 全景脚本执行与脚本管理StackExchange.Redis 将 Lua 脚本能力拆成两组 APIAPI位置说明ScriptEvaluateResp/ScriptEvaluateRespAsyncIDatabase返回RespResult的低分配执行入口推荐新代码使用ScriptEvaluate/ScriptEvaluateAsyncIDatabase经典入口返回物化后的RedisResult树ScriptEvaluateReadOnlyResp(Async)IDatabase只读变体对应EVAL_RO/EVALSHA_ROScriptEvaluateReadOnly(Async)IDatabase只读变体的经典RedisResult形态ScriptLoad/ScriptExists/ScriptFlushIServer服务端脚本管理见 src/StackExchange.Redis/RedisServer.cs在 src/StackExchange.Redis/RedisDatabase.cs 中可以看到ScriptEvaluateResp的同步实现它根据脚本文本是否为合法 SHA1 决定发送EVALSHA还是EVAL并捕获RedisServerException当msg.IsScriptUnavailable时典型如NOSCRIPT透明地重发一次public RespResult ScriptEvaluateResp(string script, ReadOnlyMemoryRedisKey keys, ReadOnlyMemoryRedisValue values, CommandFlags flags CommandFlags.None) { var command ResultProcessor.ScriptLoadProcessor.IsSHA1(script) ? RedisCommand.EVALSHA : RedisCommand.EVAL; var msg new ScriptEvalMessage(Database, flags, command, script, keys, values, multiplexer?.RawConfig?.RequestBufferPool); try { return ExecuteSync(msg, ResultProcessor.RespResult)!; } catch (RedisServerException) when (msg.IsScriptUnavailable) { // could be a NOSCRIPT; for a sync call, we can re-issue that without problem return ExecuteSync(msg, ResultProcessor.RespResult)!; } }异步版本ScriptEvaluateRespAsyncRedisDatabase.cs采用完全相同的结构只是在任务交回调用方之前完成等待。二、基本用法Keys 与 Args 的分离设计ScriptEvaluateResp的签名刻意把脚本的KEYS与ARGV拆成两个独立的ReadOnlyMemory参数——脚本内部本来也分开索引KEYS[1]、ARGV[1]、……调用方把它们合并成一个集合没有任何收益using ConnectionMultiplexer conn /* init code */; var db conn.GetDatabase(); using RespResult result db.ScriptEvaluateResp( return redis.call(set, KEYS[1], ARGV[1]), new RedisKey[] { mykey }, new RedisValue[] { 123 });这里有两个容易被忽略、但至关重要的语义键参与集群路由与键前缀。从源码看ScriptEvaluateMessage.GetHashSlot直接基于 keys 计算哈希槽RedisDatabase.cs在键前缀模式下KeyPrefixedDatabase会调用ToInner(keys)把前缀应用到每个 key 上、而 values 原样透传KeyPrefixedDatabase.cs。也就是说keys 决定脚本发往哪个节点、是否加前缀values 不参与。这正是 Redis 集群对 Lua 脚本的要求——所有 key 必须位于同一哈希槽。只读变体。ScriptEvaluateReadOnlyResp(Async)与上面形状完全一致只是底层发送EVAL_RO/EVALSHA_RORedisDatabase.cs。只读脚本不能执行写命令因此有资格被路由到副本节点执行适合在读写分离场景下把脚本负载分摊给 replica。三、脚本缓存与 NOSCRIPT 透明重试的底层机制文档描述的自动缓存在源码中由ScriptEvaluateMessage.GetMessagesRedisDatabase.cs落地。它是一个IMultiMessage发送前会做如下决策若传入的是脚本文本而非 SHA1先从bridge.ServerEndPoint.GetScriptHash(script, command)查询该端点本地记录的哈希若哈希未知则先 yield 一个内部ScriptLoadMessage即SCRIPT LOAD把脚本载入并记录 SHA1随后再 yield 自身若哈希已知直接以EVALSHA发送服务端在SCRIPT FLUSH后报告NOSCRIPT时则由上一节展示的catch (RedisServerException) when (msg.IsScriptUnavailable)透明重试重新把完整脚本发给服务端。WriteImplRedisDatabase.cs进一步展示了三种写路径有十六进制哈希写EVALSHA、有 ASCII 哈希写EVALSHA、两者皆无才回退到完整脚本的EVAL当CanUseReadOnlyScripts且脚本只读时则改写EVALSHA_RO/EVAL_RO。需要指出一个细节文档所说的首次调用发送完整脚本文本EVAL在支持SCRIPT命令的现代服务端上实际体现为首次调用先走一次内部SCRIPT LOAD预载、再以EVALSHA发送仅当服务端不支持SCRIPT命令、或调用方显式指定CommandFlags.NoScriptCache时才会每次都发送完整EVAL文本。对调用方而言二者都是透明的不需要感知。四、读取结果RespResult与RedisResult的取舍ScriptEvaluateResp返回的是RespResult一个租用式leased、未解码的原始回复视图底层由池化缓冲区RefCountedBuffer来自RawConfig.RequestBufferPool承载而不是每次调用都分配全新对象。使用完用using归还缓冲区即可。与之对照经典的ScriptEvaluate无Resp后缀接收RedisKey[]?/RedisValue[]?数组返回RedisResult——一棵完全物化、便于强转(string)、(long)、(RedisValue[])等的通用树代价是必然分配每个节点一个包装对象、每个标量一份解码值。新代码应优先使用ScriptEvaluateResp尤其是结果为单个标量时这是最常见的情况尤其当标量是一个大 blob 载荷时。标量读取的三种方式各有适用场景using RespResult result db.ScriptEvaluateResp(return redis.call(get, KEYS[1]), new RedisKey[] { mykey }, default); if (!result.IsNull) { var reader result.ReadScalar(); // 最省直接拷入你已持有的缓冲区长度用 ScalarLength() 精确取得 byte[] buffer new byte[reader.ScalarLength()]; int written reader.CopyTo(buffer); // 或者拿一个自有、可回池的句柄——注意它通常直接共享回复缓冲区而不拷贝用完要及时 Dispose见下文 using Leasebyte? lease reader.ReadLease(); // 或者直接转成惯用的 RedisValue/string RedisValue value reader.ReadRedisValue(); }关于ReadLease是否拷贝取决于读取器来源而非调用方式从RespResult读取时回复缓冲区是引用计数的lease 直接指向其中、零拷贝在其它场景包括HashGetLease等返回Leasebyte的命令、以及你自己基于字节构建的RespReader得到的是独立拷贝。无论哪种lease 都归你所有、都必须释放因此调用方无需关心拿到的是哪一种。但区别会影响持有时长共享场景下回复会一直处于租用状态直到RespResult和从中取出的所有 lease 全部释放——从大回复中取一个短值会让整个回复一直活着。这正是该设计存在的原因大 blob 零拷贝拉回。所以lease 要及时释放如果你想把大回复的一小部分长期保留用CopyTo拷入自己的缓冲区而不是一直攥着 lease。RespResult永不为 C# 空引用一个RespResult本身永远不会是null——回复永远是一个真实对象IsNull才告诉你底层 RESP 回复是否为 null。线上存在三种不同的 null 编码RespResult完整保留你收到的是哪一种而不做坍缩RespResult.cs中对应三个单例RESP2 的$-1空 bulk string、*-1空数组、RESP3 的_见 src/StackExchange.Redis/RespResult.cs但日常判断请用IsNull这也为将来 null 回复附带 RESP3 属性元数据留下了空间。五、判断返回内容用类别测试而非 Prefix 比较判断回复形状时优先使用类别测试IsScalar/IsAggregate/IsNull/IsError而不是拿Prefix与某个具体RespPrefix比较。原因是类别是稳定的具体前缀不稳定——同一条命令在 RESP2 与 RESP3 下可能采用不同编码而走哪个协议取决于服务端版本与配置不由你的代码决定var reader result.Read(); if (reader.IsNull) { /* no value */ } else if (reader.IsScalar) { RedisValue value reader.ReadRedisValue(); } else if (reader.IsAggregate) { /* walk it - see below */ }这个差异在聚合类型上最致命RESP2 只有一种聚合编码*数组。因此 RESP2 下一切聚合形状都表现为ArrayRESP3 把它拆成多种HGETALL和CONFIG GET返回 map%SMEMBERS等返回集合的命令返回 set~pub/sub 投递表现为 push。写成Prefix RespPrefix.Array的代码在 RESP2 连接上一切正常一旦同一段代码切到 RESP3 就静默失效——而IsAggregate对它们全部为真。标量也有差异只是没那么剧烈RESP3 新增了,double、#boolean、(大整数、verbatim string——例如ZSCORE在 RESP2 下是 bulk string、RESP3 下是 double。null 同理RESP2 有$-1与*-1两种RESP3 只有_这正是IsNull而非前缀比较成为判断标准的原因。注意脚本本身也受此影响只要脚本回复透传了协议例如脚本内执行了redis.setresp(3)同样要遵循这套规则。Prefix仍然保留给确实关心线上字节编码的场景——区分 verbatim string 与 bulk string、或记录实际收到的编码——但这是例外情况把它当通用形状判断来用是写出协议升级即坏代码的常见姿势。六、树形结果物化、直接遍历与一次性投影如果脚本返回一棵树数组或随输入变化的混合形状RespResult.Read()给你一个定位在根节点的RespReader.ReadRedisResult()是便捷选项它把整棵树回退到熟悉的RedisResult物化代价正是本文前几节想避免的每节点一个包装对象式分配。效率敏感时不要用它using RespResult result db.ScriptEvaluateResp(return {1,2,three}, default, default); RedisResult tree result.Read().ReadRedisResult(); var values (RedisValue[])tree!;直接遍历AggregateChildren()零物化每个子元素本身也是一个RespReader所以常规标量访问器ReadRedisValue、ReadLease、CopyTo……逐元素可用using RespResult result db.ScriptEvaluateResp(return {1,2,three}, default, default); var parent result.Read(); var children parent.AggregateChildren(); while (children.MoveNext()) { // 注意优先使用 .Value 而非 .Current二者结果相同 RedisValue value children.Value.ReadRedisValue(); // use value ... } children.MovePast(out parent); // 把 parent 定位到聚合之后例如继续读取更大树中的兄弟数据一次性投影为类型化数组不想手写循环时RespReader.ReadPastArrayTResult或其非破坏性孪生ReadArrayTResult一次调用搞定scalar: true是进一步提示让它跳过更通用的子节点遍历机制——在本例中成立因为{1,2,three}的每个元素都是标量而非嵌套子树RedisValue[]? values parent.ReadPastArray(static (ref r) r.ReadRedisValue(), scalar: true);这与上面的手写循环等价只是省去了模板代码并把结果捕获为数组。七、热路径优化租用 keys 与 values 数组ScriptEvaluateResp的 keys/values 参数类型是ReadOnlyMemory因此在热路径上你可以租用数组而非每次分配一对并在调用返回后无条件归还var keys ArrayPoolRedisKey.Shared.Rent(1); var values ArrayPoolRedisValue.Shared.Rent(2); try { keys[0] mykey; values[0] 123; values[1] 456; using RespResult result db.ScriptEvaluateResp(script, keys.AsMemory(0, 1), values.AsMemory(0, 2)); // use result... } finally { ArrayPoolRedisKey.Shared.Return(keys, clearArray: true); ArrayPoolRedisValue.Shared.Return(values, clearArray: true); }为什么安全因为参数在调用返回前就已渲染进请求——异步形态下则是在 task 交回给调用方之前——此后无论调用成功、抛异常还是 fire-and-forget库都不会再读取你的数组。细节参见 docs/Execute.md 的 Leasing the argument buffer 一节异步与批处理形态以及那里记录的唯一一个注意事项RedisValue包装ReadOnlyMemorybyte时是按值渲染的——数组归你了但该 value 背后的字节并不归你。这是一个已知缺口预期在后续版本关闭。八、临时命令ExecuteRespIDatabase.ExecuteResp(Async)沿用了同样的RespResult模式但面向任意 Redis 命令不限于 Lua——参见 docs/Execute.md 的 Ad-hoc commands 一节。它的参数类型是ReadOnlyMemoryRedisKeyOrValue而不是分离的 keys/values原因正如文档所指出的与脚本固定的先KEYS后ARGV形状不同任意命令可以把键放在参数列表的任意位置。九、命名参数LuaScript旧方案在ScriptEvaluateResp出现之前向脚本传参的替代方案是LuaScript类它把脚本文本中的name风格占位符改写为 Redis 实际期望的KEYS/ARGV下标并通过反射遍历匿名对象的成员完成绑定const string Script redis.call(set, key, value); var prepared LuaScript.Prepare(Script); db.ScriptEvaluate(prepared, new { key (RedisKey)mykey, value 123 });这套机制至今仍可用ScriptEvaluate/ScriptEvaluateAsync以及通过LuaScript.Load(IServer)载入、只走EVALSHA的LoadedLuaScript相关实现见 src/StackExchange.Redis/LuaScript.cs但基于反射的参数绑定与name改写对大多数调用者来说过于笨重。新代码请优先使用ScriptEvaluateResp配合显式RedisKey/RedisValue参数只有当你确实想要命名参数的便利性时才考虑LuaScript。十、验证与进一步阅读仓库测试 tests/StackExchange.Redis.Tests/ScriptingTests.cs 大量使用ScriptFlush配合脚本测试是观察缓存失效后 NOSCRIPT 重试路径的现成样例RespResult的完整实现引用计数缓冲区、null 单例、Read/ReadScalar/Dispose语义见 src/StackExchange.Redis/RespResult.cs低分配读取扩展方法ReadRedisValue、ReadLease、ReadRedisResult等见 src/StackExchange.Redis/RespReaderExtensions.cs临时命令与参数缓冲区租赁的更多细节见 docs/Execute.md。实践要点回顾优先ScriptEvaluateResp与IsScalar/IsAggregate/IsNull类别测试标量结果用ReadScalar()配合CopyTo/ReadLease树形结果直接AggregateChildren()遍历热路径用ArrayPool租用 keys/values只读脚本用ScriptEvaluateReadOnlyResp走副本永远记得using释放RespResult与 lease。赞分享后端缓存数据库客户端消息队列【免费下载链接】StackExchange.RedisThe Redis client for .NET项目地址https://gitcode.com/gh_mirrors/st/StackExchange.Redis点击查看免费下载相关推荐Beamer高级技巧交叉引用、代码块与多媒体集成Beamer高级技巧交叉引用、代码块与多媒体集成 Beamer是一款强大的LaTeX文档类专为创建专业演示文稿和幻灯片设计。本文将分享三个提升演示效果的高级拖进就能出图ComfyUI-Workflows-ZHO50 中文工作流模板覆盖 20 类模型拖进就能出图ComfyUI Workflows ZHO50 中文工作流模板覆盖 20 类模型 ComfyUI Workflows ZHO 是中文 ComfStackExchange.Redis中的Lua脚本支持详解StackExchange.Redis中的Lua脚本支持详解 引言 在现代Redis应用中Lua脚本是一个强大的功能它允许开发者将多个Redis命令组合成一后端缓存数据库客户端消息队列上一篇Zotero PDF Translate打破语言壁垒让全球学术文献触手可及下一篇Windows热键冲突终结者Hotkey Detective 一键定位占用程序创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表