传统Synchronized的使用
话不多说我们先来看代码:
SeckillController
@Controller
@RequestMapping("/Seckill")
public class SeckillController {
@Autowired
private SeckillService seckillService;
/*
* 秒杀功能
* @param productId
* @return
* */
@GetMapping("/do_kill")
public String list(Model model, User user,
@RequestParam("productId")String productId) throws Exception {
model.addAttribute("user", user);
//判断用户是否登入
if (user == null) {
//返回登入页面
return "login";
}
// 调用秒杀
OrderMaster orderMaster = seckillService.querySecKillProductInfo(productId, user)
if (orderMaster == null) {
return "Seckill_fail";
} else {
return "Seckill_success";
}
}
}
然后就是核心的 SeckillServiceImpl
/**
* @Author: WH
* @Date: 2019/6/1 22:32
* @Version 1.0
*/
public class SeckillServiceImpl {
@Autowired
private ProductService productService;
@Autowired
private OrderService orderService;
@Transactional
public synchronized OrderMaster querySecKillProductInfo(String productId, User user) {
// 1. 判断商品库存
ProductInfoVO productInfoVO = productService.getProductsVOByProductInfoVoId(productId);
int stock = productInfoVO.getStockCount();
// 2. 如果商品库存为0,秒杀失败
if (stock <= 0) {
throw new SellException(100, "商品秒杀结束");
}
// 3. 判断是否重复秒杀
OrderMaster orderMaster = orderService.findOne(user.getId(), productId);
if (orderMaster != null) {
throw new SellException(101, "商品不能重复秒杀");
}
// 4. 下单
orderService.save(productId, user.getId());
// 5. 减库存
orderService.update(productId);
//返回订单完成详细信息
return orderMaster = orderService.findOne(user.getId(), productId);
}
}
总结:
使用 synchronized 能实现功能,但是不能做到细粒度的控制
只适合单机,不适合分布式锁
使用redis实现单节点分布式锁:
需要注意的是下面的实现仅仅是单节点的Redis分布式锁实现,而且不是最佳实践仍有很多问题,不考虑多节点数据同步问题,如果需要实现集群版本的Redis分布式锁建议使用Redisson实现
实现分布式锁需要满足的四个条件:
- 互斥性。在任意时刻,只有一个客户端能持有锁。
- 不会发生死锁。即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。
- 加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了。
- 具有容错性。只要大多数Redis节点正常运行,客户端就能够获取和释放锁(单节点无法保证)。
单节点Redis实现分布式锁主要靠setnx这个命令
我们来看看redis的这两个命令:
SETNX key value
: 将key设置值为value,如果key不存在,这种情况下等同SET命令。 当key存在时,什么也不做。SETNX是”SET if Not eXists”的简写.
例子:
redis> SETNX mykey "Hello"
(integer) 1
redis> SETNX mykey "World"
(integer) 0
redis> GET mykey
"Hello"
redis>
GETSET key value
: 自动将key对应到value并且返回原来key对应的value。如果key存在但是对应的value不是字符串,就返回错误
例子:
redis> INCR mycounter
(integer) 1
redis> GETSET mycounter "0"
"1"
redis> GET mycounter
"0"
redis>
现在我们不使用 Synchronized 加锁,使用redis实现分布式锁
加锁解锁的核心逻辑
RedisLock
@Component
@Log4j
public class RedisLock {
@Autowired
private StringRedisTemplate redisTemplate;
/*
* 加锁
* @param key
* @param value 当前时间 + 超时时间
* */
public boolean lock(String key, String value) {
//setex 相当于setIfAbsent 返回值为boolean。
if (redisTemplate.opsForValue().setIfAbsent( key, value)) {
return true;
}
// 取出的currentValue = A 这两个线程的vlaue 都是B 只会是其中一个线程拿到锁
String currentValue = redisTemplate.opsForValue().get(key);
//如果锁过期
if (!StringUtils.isEmpty(currentValue)
&& Long.parseLong(currentValue) < System.currentTimeMillis()) {
//获取上一个锁的时间,使用 getset命令
String oldValue = redisTemplate.opsForValue().getAndSet(key, value);
if (!StringUtils.isEmpty(oldValue) && oldValue.equals(currentValue)) {
return true;
}
}
return false;
}
//解锁
public void unlock (String key, String value){
try {
String currentValue = redisTemplate.opsForValue().get(key);
if (!StringUtils.isEmpty(currentValue) && currentValue.equals(value)) {
redisTemplate.opsForValue().getOperations().delete(key);
}
} catch (Exception e) {
log.error("[redis分布式锁解锁异常]");
}
}
}
这里的实现其实是有明显的问题的,这种方式肯定不是最佳实现,存在问题有如下:
- 单节点Reids如果挂掉导致整个服务获取不锁,使整个服务处于不可用状态
- 锁过期的时间设置多久合适,如果过短导致业务还没执行完成下一个线程又能获取到锁了,那如果过长呢?过长又会导致某个线程因异常未获取到锁导致其他线程长时间或不去不到锁。主要问题还是Redis不能自动释放锁。
- Redis分布式锁的最佳实践还是应该考入使用Redisson
- 不支持重入(可手动基于一个计数器来实现)
SeckillServiceImpl
/**
* @Author: WH
* @Date: 2019/6/1 22:32
* @Version 1.0
*/
public class SeckillServiceImpl {
private static final int TIMEOUT = 10 * 1000;
@Autowired
RedisLock redisLock;
@Autowired
private ProductService productService;
@Autowired
private OrderService orderService;
@Transactional
public synchronized OrderMaster querySecKillProductInfo(String productId, User user) {
//加锁
long time = System.currentTimeMillis() + TIMEOUT;
if (!redisLock.lock(productId, String.valueOf(time))) {
throw new SellException(103, "获取redis锁失败");
}
// 1. 判断商品库存
ProductInfoVO productInfoVO = productService.getProductsVOByProductInfoVoId(productId);
int stock = productInfoVO.getStockCount();
// 2. 如果商品库存为0,秒杀失败
if (stock <= 0) {
throw new SellException(100, "商品秒杀结束");
return null;
}
// 3. 判断是否重复秒杀
OrderMaster orderMaster = orderService.findOne(user.getId(), productId);
if (orderMaster != null) {
throw new SellException(101, "商品不能重复秒杀");
return null;
}
// 4. 下单
orderService.save(productId, user.getId());
// 5. 减库存
orderService.update(productId);
//返回订单完成详细信息
return orderMaster = orderService.findOne(user.getId(), productId);
//解锁
redisLock.unlock(productId, String.valueOf(time));
}
}
Redisson分布式锁最佳实践
Redisson解决的问题:
- 不可重入性问题
- Redis集群中主备切换导致加锁失败:为了保证 Redis 的可用性,一般采用主从方式部署。主从数据同步有异步和同步两种方式,Redis 将指令记录在本地内存 buffer 中,然后异步将 buffer 中的指令同步到从节点,从节点一边执行同步的指令流来达到和主节点一致的状态,一边向主节点反馈同步情况。在包含主从模式的集群部署方式中,当主节点挂掉时,从节点会取而代之,但客户端无明显感知。当客户端 A 成功加锁,指令还未同步,此时主节点挂掉,从节点提升为主节点,新的主节点没有锁的数据,当客户端 B 加锁时就会成功
- 集群脑裂:集群脑裂指因为网络问题,导致 Redis master 节点跟 slave 节点和 sentinel 集群处于不同的网络分区,因为 sentinel 集群无法感知到 master 的存在,所以将 slave 节点提升为 master 节点,此时存在两个不同的 master 节点。Redis Cluster 集群部署方式同理。当不同的客户端连接不同的 master 节点时,两个客户端可以同时拥有同一把锁
Redis作者 antirez基于分布式环境下提出了一种更高级的分布式锁的实现:Redlock算法
加锁步骤大致分为如下三步
- 客户端获取当前时间
- 客户端按顺序依次向N个Redis实例执行加锁操作
这里的加锁操作和在单实例上执行的加锁操作一样,使用 SET 命令,带上 NX,EX/PX 选项,以及带上客户端的唯一标识。当然,如果某个 Redis 实例发生故障了,为了保证在这种情况下,Redlock 算法能够继续运行,我们需要给加锁操作设置一个超时时间。
如果客户端在和一个 Redis 实例请求加锁时,一直到超时都没有成功,那么此时,客户端会和下一个 Redis 实例继续请求加锁。加锁操作的超时时间需要远远地小于锁的有效时间,一般也就是设置为几十毫秒。
- 一旦客户端完成了和所有Redis实例的加锁操作,客户端开始计算整个加锁的总耗时
客户端需要满足如下条件才能认为是加锁成功
- 客户端从超过半数(大于等于 N/2+1)的 Redis 实例上成功获取到了锁;
- 客户端获取锁的总耗时没有超过锁的有效时间
如果客户端在和所有实例执行完加锁操作后,没有满足这两个条件,客户端向所有Redis节点发起释放锁操作