1. 全局唯一ID
在秒杀后生成的订单,订单ID的设计是值得考虑的。是采用数据库的自增?必然是不行的,首先若是一张订单表,其表的容量是有上限的,且订单的数据量巨大,若是采用多库多表进行存储,那么每个表自增ID都是从1开始,会造成订单ID的重复,且自增ID规律性强,容易被猜测,具有安全隐患。
1.1 ID生成策略
- 采用UUID
- 雪花算法
- 采用Redis的自增并且根据业务进行拼接
采用Redis的自增并且根据业务进行拼接:
第一位是符号位,永远为0,表示正数
2-31位是时间戳,可以定义一个起始时间,然后在获取下单时间,两者相减即可
后32位是序列号位,采用Redis的自增长
那么Redis自增长的key应该如何设计呢?
可以采用业务名:日期进行设计,如果不拼接日期的话,虽然Redis的自增长支持到2^64,但是我们设计的ID的序列号只有32位,所以如果不按照日期进行划分,业务量巨大的情况下,是会导致自增的数值超过序列号的位数。且以日期作为key的拼接,也方便做每天订单量的统计操作。
代码实现
/**
* 开始时间戳
*/
private static final long BEGIN_TIMESTAMP = 1640995200L;
/**
* 序列号的位数
*/
private static final int COUNT_BITS = 32;
private StringRedisTemplate stringRedisTemplate;
public RedisIdWorker(StringRedisTemplate stringRedisTemplate) {
this.stringRedisTemplate = stringRedisTemplate;
}
public long nextId(String keyPrefix) {
// 1.生成时间戳
LocalDateTime now = LocalDateTime.now();
long nowSecond = now.toEpochSecond(ZoneOffset.UTC);
long timestamp = nowSecond - BEGIN_TIMESTAMP;
// 2.生成序列号
// 2.1.获取当前日期,精确到天
String date = now.format(DateTimeFormatter.ofPattern("yyyy:MM:dd"));
// 2.2.自增长
long count = stringRedisTemplate.opsForValue().increment("icr:" + keyPrefix + ":" + date);
// 3.拼接并返回
return timestamp << COUNT_BITS | count;
}
2. 秒杀
声明,秒杀接口传入秒杀物品的ID,订单流程图如下
首先实现基本的下单功能
/**
* 获取秒杀优惠券的service
*/
@Autowired
private ISeckillVoucherService iSeckillVoucherService;
/**
* 全局ID的生成类
*/
@Autowired
private RedisIdWorker redisIdWorker;
/**
* 下单的mapper
*/
@Autowired
private VoucherOrderMapper voucherOrderMapper;
/**
* 秒杀
* @param voucherId
* @return
*/
@Transactional
@Override
public Result seckillVoucher(Long voucherId) {
// 1. 查询优惠券
SeckillVoucher voucher = iSeckillVoucherService.getById(voucherId);
// 2. 判断秒杀是否开始
LocalDateTime beginTime = voucher.getBeginTime();
if(beginTime.isAfter(LocalDateTime.now())){
return Result.fail("秒杀尚未开始");
}
// 3. 判断秒杀是否结束
LocalDateTime endTime = voucher.getEndTime();
if (endTime.isBefore(LocalDateTime.now())){
return Result.fail("秒杀已经结束");
}
// 4. 库存是否充足
if (voucher.getStock()<1){
return Result.fail("库存不足");
}
// 5.扣减库存
boolean flag = iSeckillVoucherService.update().setSql("stock = stock -1").eq("voucher_id", voucherId).update();
if (!flag){
return Result.fail("下单失败");
}
// 6. 创建订单
VoucherOrder order = new VoucherOrder();
// ID生成类
long orderId = redisIdWorker.nextId("order");
// 获取userId
Long userId = UserHolder.getUser().getId();
order.setId(orderId);
order.setVoucherId(voucherId);
order.setUserId(userId);
voucherOrderMapper.insert(order);
// 7. 返回订单ID
return Result.ok(orderId);
}
这种代码只是基本的业务代码,在高并发的情况下必然会出现超卖的情况
解决方式:使用锁
-
悲观锁
认为线程安全问题一定会发生,因此在操作数据之前先获取锁,确保线程串行执行。
例如Synchronized、Lock都属于悲观锁 -
乐观锁
认为线程安全问题不一定会发生,因此不加锁,只是在更新数据时去判断有没有其它线程对数据做了修改。
如果没有修改则认为是安全的,自己才更新数据。
如果已经被其它线程修改说明发生了安全问题,此时可以重试或异常
乐观锁,悲观锁不是锁,它是锁的一种思想
2.1 乐观锁
2.1.1 版本号法
数据库里保存version字段,在修改数据前先读取版本号,在修改数据时加上条件 version = 之前读取的version,如果条件不符合,则说明有线程修改过,则此次修改失败。每次修改成功后,version = version +1。
2.1.2 CAS法
CAS法和版本号法思想一致,只是版本号法使用新的字段version字段来辨别版本是否被修改过,CAS使用已有的字段,在上述场景中,可以使用库存代替版本号,在修改库存时,先读出库存,在修改时,stock = stock -1 where
stock = 读取的stock即可
代码实现CAS法
/**
* 获取秒杀优惠券的service
*/
@Autowired
private ISeckillVoucherService iSeckillVoucherService;
/**
* 全局ID的生成类
*/
@Autowired
private RedisIdWorker redisIdWorker;
/**
* 下单的mapper
*/
@Autowired
private VoucherOrderMapper voucherOrderMapper;
/**
* 秒杀
* @param voucherId
* @return
*/
@Transactional
@Override
public Result seckillVoucher(Long voucherId) {
// 1. 查询优惠券
SeckillVoucher voucher = iSeckillVoucherService.getById(voucherId);
// 2. 判断秒杀是否开始
LocalDateTime beginTime = voucher.getBeginTime();
if(beginTime.isAfter(LocalDateTime.now())){
return Result.fail("秒杀尚未开始");
}
// 3. 判断秒杀是否结束
LocalDateTime endTime = voucher.getEndTime();
if (endTime.isBefore(LocalDateTime.now())){
return Result.fail("秒杀已经结束");
}
// 4. 库存是否充足
if (voucher.getStock()<1){
return Result.fail("库存不足");
}
// 5.扣减库存
boolean flag = iSeckillVoucherService.update().setSql("stock = stock -1").eq("voucher_id", voucherId).eq("stock",voucher.getStock()).update();
if (!flag){
return Result.fail("下单失败");
}
// 6. 创建订单
VoucherOrder order = new VoucherOrder();
// ID生成类
long orderId = redisIdWorker.nextId("order");
// 获取userId
Long userId = UserHolder.getUser().getId();
order.setId(orderId);
order.setVoucherId(voucherId);
order.setUserId(userId);
voucherOrderMapper.insert(order);
// 7. 返回订单ID
return Result.ok(orderId);
}
2.2 乐观锁的缺点
场景:
如果10个优惠商品,100个人同时抢购,假如,100个线程同时读取到库存为10,100个线程同时去下单,只会有一个线程下单成功,其余的99个线程下单失败。此时10个商品只能卖出1个,会造成少卖的情况发生。从业务的角度考虑,10个商品,100个人抢购,是一定会抢购完的。
2.3 乐观锁的改进
我们只需要判断stock>0即可,无需考虑版本号的问题
@Transactional
@Override
public Result seckillVoucher(Long voucherId) {
// 1. 查询优惠券
SeckillVoucher voucher = iSeckillVoucherService.getById(voucherId);
// 2. 判断秒杀是否开始
LocalDateTime beginTime = voucher.getBeginTime();
if(beginTime.isAfter(LocalDateTime.now())){
return Result.fail("秒杀尚未开始");
}
// 3. 判断秒杀是否结束
LocalDateTime endTime = voucher.getEndTime();
if (endTime.isBefore(LocalDateTime.now())){
return Result.fail("秒杀已经结束");
}
// 4. 库存是否充足
if (voucher.getStock()<1){
return Result.fail("库存不足");
}
// 5.扣减库存
boolean flag = iSeckillVoucherService.update().setSql("stock = stock -1").eq("voucher_id", voucherId).gt("stock",0).update();
if (!flag){
return Result.fail("下单失败");
}
// 6. 创建订单
VoucherOrder order = new VoucherOrder();
// ID生成类
long orderId = redisIdWorker.nextId("order");
// 获取userId
Long userId = UserHolder.getUser().getId();
order.setId(orderId);
order.setVoucherId(voucherId);
order.setUserId(userId);
voucherOrderMapper.insert(order);
// 7. 返回订单ID
return Result.ok(orderId);
}
3. 拒绝牛牛
如何保证一人一单,防止黄牛?
在下单前查询数据库是否有对应的订单,如果有,则拒绝下单
上述流程如果不加锁,一定会出现牛牛现象。
代码实现(加锁)
/**
* 获取秒杀优惠券的service
*/
@Autowired
private ISeckillVoucherService iSeckillVoucherService;
/**
* 全局ID的生成类
*/
@Autowired
private RedisIdWorker redisIdWorker;
/**
* 下单的mapper
*/
@Autowired
private VoucherOrderMapper voucherOrderMapper;
/**
* 秒杀
* @param voucherId
* @return
*/
@Transactional
@Override
public Result seckillVoucher(Long voucherId) {
// 1. 查询优惠券
SeckillVoucher voucher = iSeckillVoucherService.getById(voucherId);
// 2. 判断秒杀是否开始
LocalDateTime beginTime = voucher.getBeginTime();
if(beginTime.isAfter(LocalDateTime.now())){
return Result.fail("秒杀尚未开始");
}
// 3. 判断秒杀是否结束
LocalDateTime endTime = voucher.getEndTime();
if (endTime.isBefore(LocalDateTime.now())){
return Result.fail("秒杀已经结束");
}
// 4. 库存是否充足
if (voucher.getStock()<1){
return Result.fail("库存不足");
}
return createOrder(voucherId);
}
private Result createOrder(Long voucherId) {
Long userId = UserHolder.getUser().getId();
synchronized (userId.toString().intern()) {
// 查询是否已经购买过商品
// 如果查询到已经有订单,则不允许下单
// 5.扣减库存
boolean flag = iSeckillVoucherService.update().setSql("stock = stock -1").eq("voucher_id", voucherId).gt("stock", 0).update();
if (!flag) {
return Result.fail("下单失败");
}
// 6. 创建订单
VoucherOrder order = new VoucherOrder();
// ID生成类
long orderId = redisIdWorker.nextId("order");
// 获取userId
order.setId(orderId);
order.setVoucherId(voucherId);
order.setUserId(userId);
voucherOrderMapper.insert(order);
// 7. 返回订单ID
return Result.ok(orderId);
}
}
将查询数据库是否有订单和下单封装为了一个方法,此方法加上了synchronized 来解决问题。
考虑:
-
为什么synchronized 的锁要用userid?
因为这只是方式一人下多单,如果使用当前对象的话,那么锁的范围就是全部的用户了 -
为什么要用userid.toString.intern()?
因为userid是long类型,long类型的toString方法底层是new String,所以如果不加intern方法的话,每次产生的对象不是同一个对象,达不到锁的要求,intern方法是如果字符串常量池里有字符,就不会创建一个新的字符串。
4. 利用Redis解决超卖
- 将秒杀商品的库存缓存到Redis
- 当开始秒杀时,请求去访问Redis的库存,然后进行value自减,如果自减成功,则下单
5. 集群模式下的问题
上述方式采用本地的锁,在单体架构中是没有问题的,但是在集群模式下,一个用户多次请求,会出现一个用户下单多次的情况。因为集群模式下,一个服务对应一个tomcat对应一个jvm,所以都能获取到锁。
解决方式:分布式锁
6. 分布式锁
分布式锁:满足分布式系统或集群模式下多进程可见并且互斥的锁。
分布式锁需要满足的条件:
- 多线程可见
- 互斥
- 高可用
- 高性能
- 安全性
- …
分布式锁的实现方式:
mysql | redis | zookeeper | |
---|---|---|---|
互斥 | 利用mysql的互斥锁 | 利用setnx这样的互斥命令 | 利用节点的唯一性和有序性实现互斥 |
高可用 | 好 | 好 | 好 |
高性能 | 一般 | 好 | 一般 |
安全性 | 断开连接,自动释放 | 删除或者利用锁超时时间,到期释放 | 临时节点,断开连接自动释放 |
7. 实现分布式锁
利用Redis实现分布式锁
获取锁:
setnx key value
释放锁:
del key
如果获取锁后服务宕机了,那么就无法释放锁,锁就一直释放不了,其余线程一直处于等待的状态,造成死锁。
所以在添加锁后给锁添加TTL
expire key seconds
如果在setnx成功了,但是expire的时候宕机了呢?所以需要Redis实现原子性
set key value ex seconds nx
上述语句实现了setnx和expire
在jvm中,如果线程获取不到锁有两种形式,1:阻塞式:即等待释放锁为止继续尝试获取锁 2:非阻塞式:拿不到锁就直接结束。
那么利用Redis实现分布式锁应该使用哪种呢?
接下来的实现使用非阻塞式:
public interface ILock {
/**
* 尝试获取锁
* @param timeoutSec 锁持有的超时时间,过期后自动释放
* @return true代表获取锁成功; false代表获取锁失败
*/
boolean tryLock(long timeoutSec);
/**
* 释放锁
*/
void unlock();
}
public class SimpleRedisLock implements ILock {
private StringRedisTemplate stringRedisTemplate;
/**
* key的前缀
*/
private static final String KEY_PREFIX = "lock:";
/**
* 业务名称
*/
private String name;
/**
* 构造器
* @param stringRedisTemplate
* @param name
*/
public SimpleRedisLock(StringRedisTemplate stringRedisTemplate, String name) {
this.stringRedisTemplate = stringRedisTemplate;
this.name = name;
}
@Override
public boolean tryLock(long timeoutSec) {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, String.valueOf(Thread.currentThread().getId()), timeoutSec, TimeUnit.SECONDS);
// 因为flag是包装类型,所以在拆箱的时候,如果flag是空指针,则有可能会产生异常,采取下面的方式来规避异常
return Boolean.TRUE.equals(flag);
}
@Override
public void unlock() {
stringRedisTemplate.delete(KEY_PREFIX+name);
}
}
8. 优化牛牛问题的分布式锁的问题
@Transactional
@Override
public Result seckillVoucher(Long voucherId) {
// 1. 查询优惠券
SeckillVoucher voucher = iSeckillVoucherService.getById(voucherId);
// 2. 判断秒杀是否开始
LocalDateTime beginTime = voucher.getBeginTime();
if (beginTime.isAfter(LocalDateTime.now())) {
return Result.fail("秒杀尚未开始");
}
// 3. 判断秒杀是否结束
LocalDateTime endTime = voucher.getEndTime();
if (endTime.isBefore(LocalDateTime.now())) {
return Result.fail("秒杀已经结束");
}
// 4. 库存是否充足
if (voucher.getStock() < 1) {
return Result.fail("库存不足");
}
return createOrder(voucherId);
}
private Result createOrder(Long voucherId) {
Long userId = UserHolder.getUser().getId();
SimpleRedisLock simpleRedisLock = new SimpleRedisLock(stringRedisTemplate, "order:"+userId);
boolean isLock = simpleRedisLock.tryLock(2);
if (!isLock){
// 获取锁失败,可以直接返回或者重试获取锁
return Result.fail("");
}
// 查询是否已经购买过商品
// 如果查询到已经有订单,则不允许下单
try{
// 5.扣减库存
boolean flag = iSeckillVoucherService.update().setSql("stock = stock -1").eq("voucher_id", voucherId).gt("stock", 0).update();
if (!flag) {
return Result.fail("下单失败");
}
// 6. 创建订单
VoucherOrder order = new VoucherOrder();
// ID生成类
long orderId = redisIdWorker.nextId("order");
// 获取userId
order.setId(orderId);
order.setVoucherId(voucherId);
order.setUserId(userId);
voucherOrderMapper.insert(order);
// 7. 返回订单ID
return Result.ok(orderId);
}finally {
simpleRedisLock.unlock();
}
}
上述代码已经实现了秒杀使用分布式锁的问题,但是在极端情况下还是会出现问题
如上图:
- 线程1获取到锁后,由于某种原因进行了阻塞,阻塞时间超过了锁的释放时间,所以当锁释放后,线程1还是处于可以完成业务的情况
- 当线程1的锁超时释放后,线程2获取到锁,执行业务的时候,线程1阻塞消失去执行业务,在线程2释放锁之前将线程2的锁释放掉
- 线程3在线程1释放掉线程2的锁后去尝试获取锁,获取成功执行业务代码
由上可见,三个线程在这种极端情况下都能下单成功,那么牛牛问题又出现了,同一个人可以秒杀多次并成功。
出现上述问题的主要原因是
- 线程释放的锁不是自己的锁
- 锁失效后线程还继续执行自己的业务,应该回滚才对
首先解决线程只释放自己的锁
public class SimpleRedisLock implements ILock {
private StringRedisTemplate stringRedisTemplate;
/**
* key的前缀
*/
private static final String KEY_PREFIX = "lock:";
public static final String ID_PREFIX = UUID.randomUUID().toString()+"-";
/**
* 业务名称
*/
private String name;
/**
* 构造器
* @param stringRedisTemplate
* @param name
*/
public SimpleRedisLock(StringRedisTemplate stringRedisTemplate, String name) {
this.stringRedisTemplate = stringRedisTemplate;
this.name = name;
}
@Override
public boolean tryLock(long timeoutSec) {
String threadId = ID_PREFIX+Thread.currentThread().getId();
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(KEY_PREFIX + name, threadId, timeoutSec, TimeUnit.SECONDS);
// 因为flag是包装类型,所以在拆箱的时候,如果flag是空指针,则有可能会产生异常,采取下面的方式来规避异常
return Boolean.TRUE.equals(flag);
}
@Override
public void unlock() {
// 获取线程标识
String threadId = ID_PREFIX+Thread.currentThread().getId();
String id = stringRedisTemplate.opsForValue().get(KEY_PREFIX + name);
if (!id.equals(threadId)){
return;
}
stringRedisTemplate.delete(KEY_PREFIX+name);
}
}
对redis里的value进行重新设置,采用UUID+线程号的方式来赋值,释放锁时做判断。
但是这样子还是会有极端问题出现
上图中
- 线程1执行业务,释放锁的时候,判断成功了,但是在释放锁的这一步阻塞,阻塞时间超过了锁的有效期
- 此时线程2获取到锁,执行业务,在执行业务的时候,线程1释放锁的阻塞消失了,然后去将线程2的锁释放掉
- 此时线程3获取锁成功,进入业务,所以此时有线程2和3同时执行业务代码
出现上述问题的原因是因为判断锁是不是自己的和释放锁两个操作之间出现了阻塞,所以两个操作必须是原子性的。可以使用LUA脚本来进行解决。
LUA脚本
Redis提供了Lua脚本功能,在一个脚本中编写多条Redis命令,确保多条命令执行时的原子性。Lua是一种编程语言,它的基本语法大家可以参考网站:https://www.runoob.com/lua/lua-tutorial.html
LUA脚本的基本命令
redis.call('命令名称','key','value',......)
比如在redis中的执行 set name mike,在脚本语言中就是
redis.call('set','name','mike')
如果是get name,在脚本语言中就是
local name = redis.call('get','name')
return name
那么如何执行脚本呢?
redis中给出了执行脚本的命令
执行 redis.call(‘set’, ‘name’, ‘jack’) 这个脚本,语法如下
EVAL "return redis.call('set','name','jack')" 0
0是key类型的参数个数,写为0就是不允许传参数
如果脚本中的key、value不想写死,可以作为参数传递。key类型参数会放入KEYS数组,其它参数会放入ARGV数组,在脚本中可以从KEYS和ARGV数组获取这些参数:
EVAL "return redis.call('set',KEYS[1],ARGV[1])" 1 name rose
Lua脚本的数组是从1开始
用Lua脚本解决上述问题
if(redis.call('get',KEYS[1]) == ARGV[1]) then
return redis.call('del',KEYS[1])
end
return 0
KEYS和ARGV是参数
脚本写好了,那么应该如何通过java来调用Lua脚本呢?
RedisTemplete提供了调用Lua脚本的函数,函数如下
public <T> T execute(RedisScript<T> script, List<K> keys, Object... args) {
return this.scriptExecutor.execute(script, keys, args);
}
重新实现释放锁的代码
// 这是execute的第一个参数,这里实例化他的子类,并且在静态代码块里赋值
private static final DefaultRedisScript<Long> UNLOCK_SCRIPT;
static{
UNLOCK_SCRIPT = new DefaultRedisScript<>();
// DefaultRedisScript的setLocation可以指定脚本的位置,传入Spring的ClassPathResource来指定位置
UNLOCK_SCRIPT.setLocation(new ClassPathResource("unlock.lua"));
UNLOCK_SCRIPT.setResultType(Long.class);
}
@Override
public void unlock() {
ArrayList<String> list = new ArrayList<>();
// key
list.add(KEY_PREFIX + name);
stringRedisTemplate.execute(UNLOCK_SCRIPT,list,ID_PREFIX + Thread.currentThread().getId());
}
if(redis.call('get',KEYS[1]) == ARGV[1]) then
return redis.call('del',KEYS[1])
end
return 0
优化到现在,其实分布式锁还是有优化空间,例如
- 不可重入:上述实现的分布式锁是不可重入的,如果A线程获取到锁,但是A线程调用B,B线程也需要获取锁,到B这里是不可能获取到锁的,就会出现死锁的现象
- 不可重试:线程获取不到锁就会return
- 超时释放:业务代码如果耗时过长,倒是锁自动被释放,会存在安全隐患
- 主从问题:如果是Redis集群的情况下,主节点写入锁,在同步给从节点时,主见点宕机,导致从节点没有备份到数据,所以其实线程获取到锁了,但是却没有写入锁,导致其他线程能够获取到锁
Redisson包含了许多分布式锁的实现,可以使用Redisson来实现分布式锁。
9. Redisson
Redisson实现了分布式锁和同步器:
- 可重入锁
- 公平锁
- 联锁
- 红锁
- 读写锁
- 信号量
- 可过期性信号量
- 闭锁
9.1 入门
- 引入依赖
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.13.6</version>
</dependency>
- 配置Redisson(可以使用yaml配置也可以使用Bean注入的方式进行配置)
@Component
public class RedisConfig {
@Bean
public RedissonClient redissonClient(){
// 配置类
Config config = new Config();
// 添加redis地址,这里添加了单点的地址,也可以使用config.useClusterServers()添加集群地址
config.useSingleServer().setAddress("redis://120.55.183.53:6379").setPassword("zjh");
return Redisson.create(config);
}
}
- 使用
@Autowired
private RedissonClient redissonClient;
RLock lock = redissonClient.getLock("order:" + userId);
// 尝试获取锁
boolean isLock = lock.tryLock(2, 10, TimeUnit.SECONDS);
if (!isLock){
return Result.fail("获取锁失败");
}
trylock方法的第一个参数等待时间,在此期间如果获取不到锁会重试,-1表示不重试,第二个参数是锁自动释放的时间
9.2 Redisson可重入锁原理
咱们上面实现的锁是不具备可重入性的,Redisson提供了可重入锁实现可重入性
Redisson的可重入锁和JDK的可重入锁思想上相似
获取锁:
- 获取锁(判断是否有锁)
- 锁是不是自己的?
- 是自己的就在计数器上+1(注意:Redis里没有计数器,但是可以使用Hash来存储,一个存入计数器,一个存入锁的标识)
- 重置锁的有效期
释放锁:
- 找到锁获取计数器
- 计数器-1
- 判断计数器是否为0
- 为0就释放锁
大概的思路如上
获取锁的Lua脚本
-- 从外部获取锁
local key = KEYS[1];
-- 从外部获取到线程的标识
local threadId = ARGV[1];
-- 从外部获取锁的释放时间
local releaseTime = ARGV[2];
if(redis.call('hexist',key) == 0) then
-- 不存在,设置锁,注意这里的1是计数器了
redis.call('hset',key,threadId,'1');
return 1;
end
-- 锁是自己的
if (redis.call('hexist',key) == 1) then
-- 计数器+1
redis.call('hincrby',key,threadId,'1');
-- 重置有效期
redis.call('expire',key,releaseTime);
return 1;
end
return 0;
释放锁的Lua脚本
-- 从外部获取锁
local key = KEYS[1];
-- 从外部获取到线程的标识
local threadId = ARGV[1];
-- 从外部获取锁的释放时间
local releaseTime = ARGV[2];
if(redis.call('hexist',key,threadId) == 0) then
return nil;
end
-- 过了上面的判断就说明锁是自己的,计数器-1
local count = redis.call('hincrby',key,threadId,'-1');
if (count > 0) then
-- 计数器>0 重置时间
redis.call('expire',key,releaseTime);
return nil;
else
-- 计数器=0,释放锁
redis.call('del',key);
return nil;
end