目录
1、前言
在互联网应用日益普及的今天,抽奖活动已经成为吸引用户参与、提升互动性和用户粘性的常用手段。一个精心设计的抽奖系统能提升用户留存,让用户更多的时间停留在自己的产品上。
2、分布式部署服务高可用
想象一下在一个大型直播活动,数以万计或更大流量的用户同时参与抽奖。如何保证用户请求公平的获取锁,避免资源竞争引起的库存数据错误,保证服务的高可用。
随着参与人数的急剧增加和技术架构向分布式的转变,传统的单机抽奖机制已经难以满足大规模并发访问的需求。我们知道传统的单机存在性能瓶颈,不支持大流量下的高并发,不支持高可用,主机宕机服务会挂掉,无法避免数据丢失等严重问题。
分布式部署下,应用程序将被拆成多个服务实例,通过负载均衡使得用户的请求均匀的打在不同的实例上,横向提升了服务流量瓶颈问题。单个实例挂掉,其他服务仍然可用。数据库使用主从复制、数据异地容灾等方案解决数据同步、数据安全问题。使用分布式锁解决分布式场景下并发安全问题。
3、分布式锁
分布式锁是在分布式系统中用于协调多个节点之间对共享资源访问的一种机制。确保在任何时刻,只有一个客户端能够获取到锁,从而对该共享资源进行操作。
3.1 基于数据库的锁
- 实现原理
使用数据库的唯一索引或者乐观锁(版本号)来实现 - 优点
(1)简单易用
(2)适合小型应用或低并发场景 - 缺点
(1)数据库性能瓶颈
(2)不适合高并发场景
(3)不是强一致性,会出现死锁问题
3.2 Redis 分布式锁
- 实现原理
使用 Redis 的 SETNX (SET if Not eXists) 和 EXPIRE 指令来设置键值对,并指定过期时间。
或者使用 Redlock 算法,该算法通过多个 Redis 实例来提高可靠性。 - 优点
(1)高性能,Redis 是内存级存储,响应速度快。
(2)支持自动过期释放锁,避免死锁问题。
(3)提供原子性操作,保证了锁的安全性。 - 缺点
(1)单点故障风险,如果使用单个 Redis 实例,可能会导致锁服务不可用。
(2)网络分区情况下可能出现不一致的问题。
3.3 ZooKeeper 分布式锁
- 实现原理
利用 ZooKeeper 的临时顺序节点特性,在创建时生成一个唯一的序号,通过比较序号来决定是否获得锁。 - 优点
(1)强一致性,ZooKeeper 提供了严格的线性化特性。
(2)支持会话超时机制,防止进程崩溃后遗留的锁未释放。 - 缺点
(1)相对复杂,需要引入额外的服务组件。
(2)写操作性能较低,不适合高并发写场景。
3.4 Consul 分布式锁
- 实现原理
Consul 提供了 KV 存储功能,可以通过 CAS (Check-And-Set) 操作来实现分布式锁。 - 优点
(1)具备健康检查和监控能力,适合微服务架构。
(2)支持多数据中心部署,具有良好的扩展性和容错性。 - 缺点
(1)相对于 Redis 和 ZooKeeper,社区活跃度较低,文档和工具支持较少。
3.5 Etcd 分布式锁
- 实现原理
类似于 ZooKeeper,但基于 Raft 协议实现的一致性存储,提供更强的一致性和可用性保障。 - 优点
(1)更加现代化的一致性协议,相比 ZooKeeper 更高效稳定。
(2)内置了自动快照和恢复功能,简化运维。 - 缺点
(1)上手难度较大,配置相对复杂。
3.6 基于消息队列的锁
- 实现原理
将锁请求放入消息队列中,按照先进先出的原则处理,只有当上一个任务完成之后才会开始下一个任务。 - 优点
(1)可以很好地处理异步任务,适用于某些特定场景下的分布式锁需求。 - 缺点
(1)并不是真正的分布式锁,而是通过排队的方式间接实现了互斥访问。
(2)实现较为复杂,且不易管理。
4.Redis 分布式锁
4.1 分布式锁方案选择
选择哪种分布式锁取决于具体的应用场景和技术栈。
例如:
如果追求高性能并且容忍一定的一致性延迟,可以选择 Redis 分布式锁。
如果需要强一致性,则可以选择 ZooKeeper 或 Etcd。
对于微服务架构,Consul 是一个不错的选择,因为它还提供了服务发现等功能。
4.2 Redis
Redis 作为一种高性能的内存数据结构存储,不仅提供了丰富的数据类型支持,还以其简单易用的 API 和出色的性能赢得了广大开发者的青睐。更重要的是,Redis 可以作为构建分布式锁的理想工具。它通过其原子命令(如 SETNX, EVAL)实现了高效的锁获取与释放机制,并且借助于 Redis 的持久性和过期策略,可以有效防止死锁的发生。
4.3 Redis 分布式锁基本原理
利用了 Redis 的原子性和持久化特性来确保在分布式系统中对共享资源的互斥访问。
4.3.1 实现方式一:SETNX 和 EXPIRE 指令
- SETNX (SET if Not eXists):只有当键不存在时才设置键值。
- EXPIRE:为键设置一个过期时间,防止死锁(即某个客户端获取锁后崩溃,导致其他客户端永远无法获取锁)。
4.3.2 实现方式二:Redlock 算法
Redlock 是 Redis 官方推荐的一种更复杂的分布式锁算法,通过多个 Redis 实例来提高可靠性。其基本思想是:
- 在多个独立的 Redis 实例上尝试获取锁。
- 如果大多数实例都成功返回锁,则认为获得了全局锁。
- 锁的持有时间需要减去获取锁所花费的时间,以保证锁的有效性。
4.4 Redis 分布式锁实现步骤
4.4.1、获取锁
使用 SET 命令,同时指定 NX(如果键不存在则设置)和 PX(设置过期时间,单位毫秒)选项。
4.4.2、释放锁
通过 Lua 脚本确保删除操作的原子性,仅当锁是由当前客户端创建时才允许删除。
4.4.3、实现代码
3.1 RedisConfig.java
Redis配置类
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class RedisConfig {
// 127.0.0.1
@Value("${spring.redis.host}")
private String host;
// 6379
@Value("${spring.redis.port}")
private String port;
@Bean(name = "redissonClient")
public RedissonClient redissonClient() {
Config config = new Config();
config.useSingleServer()
.setAddress("redis://" + host + ":" + port)
.setSubscriptionsPerConnection(10)
.setSubscriptionConnectionPoolSize(100);
return Redisson.create(config);
}
}
3.2 ErrorCode.java
错误码枚举类
public enum ErrorCode {
GET_REDISSON_LOCK_ERROR(20100, "get redisson lock error"),
LOTTERY_COUNT_LIMIT(70001, "lottery count limit")
;
private int code;
private String message;
public int getCode() {
return code;
}
public String getMessage() {
return message;
}
ErrorCode(int code, String message) {
this.code = code;
this.message = message;
}
public static ErrorCode getErrorCodeByCode(int code) {
ErrorCode[] errorCodes = ErrorCode.values();
for (ErrorCode errorCode : errorCodes) {
if (errorCode.getCode() == code) {
return errorCode;
}
}
return null;
}
}
3.3 LotteryServiceImpl.java
中奖业务实现类
@Slf4j
@Service("lotteryService")
public class LotteryServiceImpl extends BaseService implements LotteryService {
// 库存缓存key
public static final String LOTTERY_LIST_PACKAGE_NAME = "lottery:list:%s:%s";
// 分布式锁key
public static final String LOTTERY_LIST_LOCK_PACKAGE_NAME = "lottery:list:lock:%s:%s";
@Resource
private BibleLotteryDao bibleLotteryDao;
@Resource
private StringRedisTemplate stringRedisTemplate;
@Resource(name = "redissonClient")
private RedissonClient redissonClient;
/**
* @param request 请求body
* @param userId 用户ID
* @param count Apollo配置的奖品总库存数
* @throws BusinessException
*/
@Override
public void winLottery(LotteryWinRequest request, String userId, Integer count) throws BusinessException {
// 奖品编码
String code = request.getCode();
String packageName = request.getPackageName();
String redisLockKey = String.format(LOTTERY_LIST_LOCK_PACKAGE_NAME, packageName, code);
String key = String.format(LOTTERY_LIST_PACKAGE_NAME, packageName, code);
// 1、获取锁实例
RLock lock = redissonClient.getLock(redisLockKey);
try {
// 2、尝试获取锁
boolean lockAcquired = lock.tryLock(100, 10000, TimeUnit.MILLISECONDS);
if (!lockAcquired) {
// 3、处理获取锁失败的情况
log.info("Lottery Win Lock not acquired redisKey", key);
throw new BusinessRuntimeException(ErrorCode.GET_REDISSON_LOCK_ERROR);
}
Long startTime = DateUtils.getCurrentTime();
String redisValue = stringRedisTemplate.opsForValue().get(key);
Integer nowAvail = 0;
if (StringUtils.isEmpty(redisValue)) {
Integer nowCount = bibleLotteryDao.selectCountByCode(packageName, code);
nowAvail = count - nowCount;
} else {
nowAvail = Integer.valueOf(redisValue);
}
boolean win = nowAvail > 0;
if (win) {
--nowAvail;
}
// 插入中奖记录
BibleLotteryPO bibleLotteryPO = new BibleLotteryPO();
bibleLotteryPO.setUserId(userId);
bibleLotteryPO.setClientId(request.getClientId());
bibleLotteryPO.setPackageName(request.getPackageName());
bibleLotteryPO.setCode(code);
bibleLotteryPO.setTotalCount(count);
bibleLotteryPO.setAvailableCount(nowAvail);
bibleLotteryPO.setIsWin(win ? 1 : 0);
bibleLotteryPO.setCreateTime(startTime);
bibleLotteryPO.setUpdateTime(DateUtils.getCurrentTime());
bibleLotteryDao.insert(bibleLotteryPO);
if (win) {
stringRedisTemplate.opsForValue().set(key, JSON.toJSONString(nowAvail), 1, TimeUnit.HOURS);
} else {
throw new BusinessException(ErrorCode.LOTTERY_COUNT_LIMIT);
}
} catch (InterruptedException e) {
//4、捕获中断异常
log.error("Lottery Win Lock get InterruptedException redisKey:{},e:{}", key, e);
throw new BusinessRuntimeException(ErrorCode.GET_REDISSON_LOCK_ERROR);
} finally {
//5、释放锁
if (lock.isLocked() && lock.isHeldByCurrentThread()) {
lock.unlock();
}
log.info("Lottery Win Lock get finally unlock redisKey:{}" + key);
}
}
}
4.4.4、代码实现说明
(1)通过 Redisson 客户端获取一个名为 redisLockKey 的分布式锁实例。
RLock lock = redissonClient.getLock(redisLockKey);
(2)尝试获取锁
boolean lockAcquired = lock.tryLock(100, 10000, TimeUnit.MILLISECONDS);
这里调用了 tryLock 方法,它有两个参数:等待时间和锁的持有时间。
- 第一个参数 100 表示线程最多等待 100 毫秒尝试获取锁。
- 第二个参数 10000 表示如果成功获取锁,则锁的有效期为 10 秒(10000 毫秒)。
- 返回值是一个布尔值,表示是否成功获取到锁。
(3)处理获取锁失败的情况:
如果未能获取到锁,则记录日志并抛出业务异常。
(4)捕获中断异常:
在尝试获取锁的过程中,如果线程被中断,则捕获 InterruptedException 并记录错误日志,同时抛出业务异常。
(5)释放锁:
finally 块确保无论是否成功获取锁,都会执行解锁操作。
仅当锁已被锁定且当前线程持有该锁时才会解锁,避免了误解锁其他线程持有的锁。
if (lock.isLocked() && lock.isHeldByCurrentThread()) {
lock.unlock();
}
4.4.5、注意事项
- Redis集群高可用:Redis搭建集群做负载均衡,避免单机挂掉导致分布式锁不可用或死锁等问题。如果使用多个 Redis 实例或集群模式,确保 Redisson 配置正确,以便在所有节点间保持一致性
- 锁的自动续期:Redisson 提供了自动续期功能,即如果锁的持有时间快到期而线程仍在处理任务,Redisson 会自动延长锁的有效期。这有助于防止因长时间任务导致的锁提前过期问题。
- 公平锁:Redisson 支持公平锁(FairLock),可以保证请求锁的顺序,避免饥饿现象。如果你需要保证锁的公平性,可以考虑使用 RReadWriteLock 或 RFairLock
- 锁的超时:设置合理的锁超时时间,避免死锁问题。
- 异常处理:在实际应用中,应添加更多的异常处理逻辑,例如网络故障、Redis 服务不可用等情况
- 告警监控:对于业务异常或服务健康检测,应该做轮询监控和告警通知,当服务挂掉或者需要重点关注的异常时,及时通过邮件、微信、电话等方式通知到服务的负责人。保证及时处理。
- 锁的唯一性:每个客户端应该生成唯一的 requestId,以便正确地识别哪个客户端持有锁。
Powered by niaonao