用20个线程并发压测同一个GET+计算+SET的递增逻辑,对比客户端两次round-trip和整段Lua脚本原子执行两种实现方式,看丢失更新是怎么发生又怎么被消除的。
一种是客户端自己做两次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 """
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),根源是"读"和"写"之间存在一个客户端能感知到的时间窗口,别人能在这个窗口里插进来。
同样20线程,同样50次,同样的最终目标1000。
结果atomic Lua script: expected=1000, actual=1000, lost_updates=0, time=0.099s
redis.call('GET',...)和redis.call('SET',...)这两次调用,从Redis单线程事件循环的视角看,是发生在同一个不可分割的执行单元内部,不存在"读完之后、写之前,被别的客户端插队"这个的时间窗口。这也解释了为什么原子版本反而更快(0.099秒 vs 0.202秒):非原子版本每次GET和SET都要走一次完整的客户端-服务器网络round-trip,Lua版本把两次操作合并成一次网络请求,脚本内部的GET/SET调用是纯粹的服务器端函数调用,不需要经过网络。
每一步都是本文上面用多线程并发压测跑出来的原始结果。