Redis In Action中公平信号量实现能否防范特定竞争条件?
问题描述
我正在研究《Redis实战》(Redis In Action)一书中的fair semaphore(公平信号量)实现,无法理解该实现如何防范特定的竞争条件。其实现代码如下:
def acquire_fair_semaphore(conn, semname, limit, timeout=10): identifier = str(uuid.uuid4()) czset = semname + ':owner' ctr = semname + ':counter' now = time.time() pipeline = conn.pipeline(True) pipeline.zremrangebyscore(semname, '-inf', now - timeout) pipeline.zinterstore(czset, {czset: 1, semname: 0}) pipeline.incr(ctr) counter = pipeline.execute()[-1] pipeline.zadd(semname, identifier, now) pipeline.zadd(czset, identifier, counter) pipeline.zrank(czset, identifier) if pipeline.execute()[-1] < limit: return identifier pipeline.zrem(semname, identifier) pipeline.zrem(czset, identifier) pipeline.execute() return None
该实现的核心特性(暂时忽略超时逻辑)是原子性地递增计数器,然后将新的UUID插入有序集合,使用刚递增得到的计数器值作为分数,再通过UUID在有序集合中的排名判断是否获取到信号量。
但我发现该实现存在两次pipeline.execute()调用,意味着使用了两个独立的事务,这会导致两个并发的信号量获取请求的操作可能发生交错。关键在于,计数器递增操作在第一个事务中,而UUID插入有序集合的操作在第二个事务中。因此可能出现如下交错操作序列:
- A递增计数器 -> B递增计数器 -> B插入有序集合 -> A插入有序集合
假设在该序列发生前,信号量仅剩1个可用许可,计数器值为100。那么A得到的计数器值为101,B得到的为102。当B插入其UUID时,由于A尚未插入,B的排名在许可限制内,认为已获取信号量;之后A插入其UUID,由于A的分数低于B,其排名与B之前的排名相同,也会认为已获取信号量。
请问该实现中是否存在防范这类竞争条件的机制?
解答
你提到的这个竞争场景是真实存在的,而原实现并没有针对这类竞争的防范机制——这是该实现的一处设计缺陷。
问题的根源在于代码把「计数器递增」和「UUID插入有序集合并检查排名」拆成了两个独立的Redis事务(两次pipeline.execute()调用),并发请求的操作序列完全可能出现你描述的交错情况,最终导致信号量许可被超额分配,突破设定的limit限制。
要解决这个问题,必须将「清理超时条目、递增计数器、插入UUID、检查排名」这些关键步骤合并为一个原子操作,最可靠的方式是使用Redis Lua脚本(因为Lua脚本在Redis中是原子执行的),避免中间步骤被其他请求打断。
比如可以改写为以下Lua脚本实现的逻辑:
local semname = KEYS[1] local limit = tonumber(ARGV[1]) local timeout = tonumber(ARGV[2]) local identifier = ARGV[3] local czset = semname .. ':owner' local ctr = semname .. ':counter' local now = tonumber(ARGV[4]) -- 清理超时的信号量持有者 redis.call('ZREMRANGEBYSCORE', semname, '-inf', now - timeout) redis.call('ZINTERSTORE', czset, 2, czset, semname, 'WEIGHTS', 1, 0) -- 递增计数器并记录值 local counter = redis.call('INCR', ctr) -- 将UUID插入两个有序集合 redis.call('ZADD', semname, now, identifier) redis.call('ZADD', czset, counter, identifier) -- 检查当前UUID的排名 local rank = redis.call('ZRANK', czset, identifier) if rank < limit then return identifier else -- 未获取到信号量,清理已插入的数据 redis.call('ZREM', semname, identifier) redis.call('ZREM', czset, identifier) return nil end
通过这种方式,所有关键操作都在一个原子执行的Lua脚本中完成,彻底避免了并发交错导致的竞争问题。
内容的提问来源于stack exchange,提问作者solarein

