bigywater.tech
Redis · 32

Lua 脚本原子性

用20个线程并发压测同一个GET+计算+SET的递增逻辑,对比客户端两次round-trip和整段Lua脚本原子执行两种实现方式,看丢失更新是怎么发生又怎么被消除的。

丢924次
非原子GET+SET丢失的更新次数(共1000次)
0次丢失
原子Lua脚本一次更新都没丢
反而更快
原子版本比非原子版本快约2倍

做的事:同一个"读出来+1再写回去"的逻辑,两种不同的实现方式

一种是客户端自己做两次round-trip(GET,算,SET),一种是整个逻辑写进一段Lua脚本让Redis自己做。

代码# 非原子版本:20个线程,每个跑50次 def naive_worker(): for _ in range(50): val = int(rr.get("naive_counter")) time.sleep(0.0005) # 模拟应用层在GET和SET之间做了点计算 rr.set("naive_counter", val + 1) # 原子版本:同样的逻辑,整段交给一个Lua脚本 script = """ local v = tonumber(redis.call('GET', KEYS[1])) v = v + 1 redis.call('SET', KEYS[1], v) return v """

验证:非原子版本丢了924次更新

20线程×50次,理论上最终值应该是1000。

结果naive GET-then-SET: expected=1000, actual=76, lost_updates=924, time=0.202s

丢失率高达92.4%——因为GET和SET是两条独立的命令,中间隔着一次客户端本地的sleep(0.0005秒),这段时间足够让另外19个线程都执行完自己的GET,拿到同一个旧值,最后20个线程几乎都在往同一个"过时的值"上加1再写回去,前面写入的结果被后面的覆盖掉了——这正是经典的丢失更新问题(lost update),根源是"读"和"写"之间存在一个客户端能感知到的时间窗口,别人能在这个窗口里插进来。

验证:Lua脚本版本一次更新都没丢

同样20线程,同样50次,同样的最终目标1000。

结果atomic Lua script: expected=1000, actual=1000, lost_updates=0, time=0.099s
差距的根源不是Lua"更聪明",而是Redis保证了一个Lua脚本从开始到结束,中间不会被服务器插入执行任何别的客户端的命令——脚本内部的redis.call('GET',...)和redis.call('SET',...)这两次调用,从Redis单线程事件循环的视角看,是发生在同一个不可分割的执行单元内部,不存在"读完之后、写之前,被别的客户端插队"这个的时间窗口。这也解释了为什么原子版本反而更快(0.099秒 vs 0.202秒):非原子版本每次GET和SET都要走一次完整的客户端-服务器网络round-trip,Lua版本把两次操作合并成一次网络请求,脚本内部的GET/SET调用是纯粹的服务器端函数调用,不需要经过网络。

分步演示:924次丢失更新是怎么消失的

每一步都是本文上面用多线程并发压测跑出来的原始结果。

Lua脚本把两次round-trip压成一次不可分割的执行
这一步新验证的实现方式

    本文用到的工具

    • 本机 Redis 8.10.1
    • Python redis-py 8.0.1 + threading:并发压测非原子GET+SET和原子Lua脚本两种递增方式
    京ICP备2026067502号-1京公网安备11010502063487号