会员办理月卡或签到累积的积分,可以在指定时间段内兑换商场优惠券,由于数量有限,时间有限,兑换操作相当集中,如果按正常流程处理的话,肯定会出现超兑的情况。比如只有 5000 张券,结果兑换出 8000 张,这对商场来说是一笔经济损失。
为防止超兑,自然做法是按总量一个接一个兑换,至到兑换完,但多并发的情况下如何保证还一个一个兑换呢?自然而然就会想到锁上面来。提及锁,你脑海是不是出现了一堆关于锁的场景:死锁、互斥锁、乐观锁、悲观锁等等,本节介绍分布式锁,它主要应用于分布式系统下面,单体应用基本不会涉及。
常见的实现方法分布式锁可以基于数据库、Redis、Zookeeper 等第三方工具来实现,各种不同实现方式需要引入第三方,截止目前 MySQL 及 Redis 已经引入到实战中,为降低系统复杂度,我们想办法基于这两个机制进行分布式锁实现。
但此做法存在漏洞,操作 key 后,指令发起方挂掉的话,这个 key 就永远不能被操作了。稍做改进,给 key 设置失效时间,这样就可以到期自动释放,供其它操作。但依旧有漏洞,在 setnx 后,发起 expire 前服务挂了,这种方式依旧与第一处方式类似。
细查 Redis 官方指令后,发现 set 指令后还跟有 [EX seconds|PX milliseconds] [NX|XX] [KEEPTTL]
等选项,可以针对第二种方式用此方式进一步改进。在单实例 Redis 的情况下,在实例可用的情况下,取锁、释放锁操作已经基本可用。
Redis 另外提供了一种 Redlock 算法来实现分面式锁,有兴趣的朋友可看原文(中文版本),在单实例无法保证可用的情况下,通过集群中多实例来有效防止单点故障导致锁不可用。大致意思是同某一时刻,向所有实例发起加锁请求,如果获取到 N/2+1 个锁表示成功,否则失败并自动解锁所有实例,到达锁失效期后同样去解锁所有实例。
如果是自己去实现这一套算法的话,想必还是比较复杂的,庆幸的是有非常好的成品,已经帮我们完成了。这就是本篇要提到的 Redission 客户端,里面有 Redlock 分布式锁的完整实现。
Redis 的三大 Java 客户端之一,其它两个是:Jedis 和 Lettuce(SpringBoot 2.x 之后就将默认集成的 Jedis 客户端替换成 Lettuce)。不仅提供了一系列的分布式的 Java 常用对象,还提供了许多分布式服务。Redisson 的宗旨是促进使用者对 Redis 的关注分离(Separation of Concern),从而让使用者能够将精力更集中地放在处理业务逻辑上。
更多介绍参见官网:redisson。
由于我们使用的框架是 Spring Boot 搭建的,这里同样采用 starter 的方式引入(不再需要 spring-data-redis 模块):
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.11.6</version>
</dependency>
配置文件采用 redis 的默认配置方式,可以兼容:
#redis config
spring.redis.database=2
spring.redis.host=localhost
spring.redis.port=16479
#default redis password is empty
spring.redis.password=zxcvbnm,./
spring.redis.timeout=60000
spring.redis.pool.max-active=1000
spring.redis.pool.max-wait=-1
spring.redis.pool.max-idle=10
spring.redis.pool.min-idle=5
这里编写了一个启动类,将本次兑换优惠券总可兑换数量写入缓存,每次采用原子操作进行减少。
@Component
@Order(0)
public class StartupApplicatonRunner implements ApplicationRunner {
@Autowired
Redisson redisson;
@Override
public void run(ApplicationArguments args) throws Exception {
RAtomicLong atomicLong = redisson.getAtomicLong(ParkingConstant.cache.grouponCodeAmtKey);
atomicLong.set(ParkingConstant.cache.grouponCodeAmt);
}
}
在兑换逻辑中,判断优惠券可用数量,兑换结束后数量减 1:
@Autowired
Redisson redisson;
@Override
public int createExchange(String json) throws BusinessException {
Exchange exchange = JSONObject.parseObject(json, Exchange.class);
int rtn = 0;
// 兑换类型有两部分,0 是商场优惠券,1 是洗车券,这是作了简单区分
if (exchange.getCtype() == 0) {
RAtomicLong atomicLong = redisson.getAtomicLong(ParkingConstant.cache.grouponCodeAmtKey);
// 获取锁
RLock rLock = redisson.getLock(ParkingConstant.lock.exchangeCouponLock);
// 锁定,默认 10s 不主动解锁的话,自动解锁,防止出现死锁的情况。正常情况下可基于 redisson 获取 redLock 处理,更加安全,本测试基于单机 redis 测试。
rLock.lock(1000, TimeUnit.SECONDS);
log.info("lock it when release ...");
// 判定可兑换数量,如果有就兑换,兑换结束数量减一
if (atomicLong.get() > 0) {
rtn = exchangeMapper.insertSelective(exchange);
atomicLong.decrementAndGet();
}
// 释放锁
rLock.unlock();
log.info("exchage coupon ended ...");
} else {
rtn = exchangeMapper.insertSelective(exchange);
}
log.debug("create exchage ok = " + exchange.getId());
return rtn;
}
简单测试,将 lock 时间设置个较长时间,利用断点来测试(也可以采用前面介绍到的 Postman 的方式进行并发测试)。
_lock it when release …_
,而是一直在等待。这样就达到分布锁的目标,实际应用中锁定时间肯定比较短,否则服务会被拖垮,很类似秒杀的场景,但杀场景更复杂,还需要其它辅助手段,不能如此简单处理。文中只提到 Redission 的这一种锁的用法,文后留个小作业吧,你再研究下 Redission 还有没有其它场景下的用法。