一、背景
在koala项目中, 很多处用到了redis锁, 比如ota升级、获取photo表的自增id、控制翻译加载等, 多数用法示例如下:
这种用法, 通过expire设置过期时间来防止未释放锁带来的问题, 但引来了其他问题:
- setnx 与 expire分开调用,不能保证原子性, 因此可能存在expire未成功调用且锁未成功释放的问题
二、调研
2.1、将setnx 与 expire组合成原子操作的方法
-
redis版本>=2.6.12后(我们用的是3.0.6版本),为SET命令增加了一系列选项SET key value [EX seconds] [PX milliseconds] [NX|XX] , 可以完美替代setnx+expire
- set命令官方文档:http://www.redis.cn/commands/set.html
- 关于锁的一些考虑点:http://redis.cn/topics/distlock.html
- python redis锁的规范实现https://github.com/SPSCommerce/redlock-py, 内部也是用的set命令, 且应保存一个随机token, 释放锁时判断token是否一致, 防止释放其他进程的锁(即自身锁已经过期)
- python 测试:
-
测试代码
# coding=utf-8 import sys, os sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) import time from app import create_app from app.foundation import redis create_app(False) def test_redis(): r = redis.db.set('setnx_test', "1", ex=10, nx=True) print ('test_redis....set..r:{0}'.format(r)) for i in range(5): r = redis.db.set('setnx_test', "1", ex=10, nx=True) print ('test_redis....set.i:{0}, r:{1}'.format(i, r)) r = redis.db.get('setnx_test') print ('test_redis.....get:{0}'.format(r)) time.sleep(10) r = redis.db.get('setnx_test') print ('test_redis.....get after sleep:{0}'.format(r)) r = redis.db.set('setnx_test', "1", ex=10, nx=True) print ('test_redis...set...r:{0}'.format(r)) test_redis()
- 测试结果输出:
-
-
- 结论: 可以完美替代setnx+expire, 且为原子操作, 更加安全和规范
2.2、如果setnx 与 expire之间, 存在其他判断逻辑, 如何保证原子性
- 参考http://redisdoc.com/script/eval.html
- Redis 使用单个 Lua 解释器去运行所有脚本,并且, Redis 也保证脚本会以原子性(atomic)的方式执行:当某个脚本正在运行的时候,不会有其他脚本或 Redis 命令被执行。这和使用 MULTI / EXEC 包围的事务很类似。在其他别的客户端看来,脚本的效果(effect)要么是不可见的(not visible),要么就是已完成的(already completed)。
- 用lua脚本实现限制并发数:
-
@system.route('/redis') def test_redis(): """ 逻辑: 限制并发数, 最大为5 """ key = 'sync_pad_sign' try: lua = """ local key = KEYS[1] local limit = tonumber(KEYS[2]) local curentLimit = tonumber(redis.call('get', key) or "0") if (curentLimit + 1 > limit) then return 0 else redis.call("INCRBY", key, 1) redis.call("EXPIRE", key, 20) return curentLimit + 1 end """ r = redis.db.eval(lua, 2, key, 5) logger.info('-----------r:{0}, type:{1}'.format(r, type(r))) if r == 0: from flask import make_response return make_response('', 304) except Exception, e: logger.info('sync pad get redis failed e:{0}'.format(e)) sleep(2) try: redis.db.decr(key) except Exception, e: logger.error('release redis key failed e:{0}'.format(e)) return success_result()
-
2.3、拓展一下:多个操作, 是否可以打包成一个原子操作
- lua脚本
2.4、官方限制请求量的例子
三、总结
- 使用set 代替setnx + expire命令
- value应设置一个随机token
- 释放时, 应判断token是否一致, 防止释放其他进程的锁
- 对于有复杂逻辑的, 采用lua脚本保证原子性