好久没有写博客了,这是在公司进行技术分享时写的一篇科普性质的文章,在公司CRUD久了人容易失去对技术的向往,从而失去在如今大环境恶劣中的竞争力,大家一定要保持对技术的热爱,对生活和工作亦是如此!
小立,刚入职某电商商城,第一周分到的需求就是公司商品秒杀,在了解完需求后就开始着手设计方案,整体的方案是Redis 缓存+异步同步数据到数据库。
实现思路
1秒杀前 将商品库存信息从数据库同步到redis
2.依靠redis来保证原子性
3.根据对应的返回结果,将订单数据放入redis订阅中 或者MQ中进行投递 ,防止服务器等问题还可以 开一个定时器去扫描这个库存信息和订单信息 进行一个补偿
4.消费者收到数据后,持久到数据库中
/**
*weng@
*/
@RestController
public class GoodController
{
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Value("${server.port}")
private String serverPort;
@GetMapping("/buy_goods")
public String buy_Goods()
{
String result = stringRedisTemplate.opsForValue().get("goods:001");
int goodsNumber = result == null ? 0 : Integer.parseInt(result);
if(goodsNumber > 0)
{
int realNumber = goodsNumber - 1;
stringRedisTemplate.opsForValue().set("goods:001",realNumber + "");
System.out.println("你已经成功秒杀商品,此时还剩余:" + realNumber + "件"+"\t 服务器端口:"+serverPort);
return "你已经成功秒杀商品,此时还剩余:" + realNumber + "件"+"\t 服务器端口:"+serverPort;
写完发现没有加锁,在生产环境下发生了超卖问题,a b用户同时抢到了同一个商品,直接挨屌。
想到学过JUC 加上锁就能防止2个用户同时获取同一个商品问题,这个时候就考虑是使用 synchronized 还是 ReetranLock了 2者都能实现锁功能但要选择哪种这个就犯难了 ,精细控制下还是 选择r更好 synchronized 必须要释放锁 或者出现异常被动释放锁 在高并发情况下应该等得到就等 等不到就不等(1不见不散 , 2过时不候 )
synchronized执行完同步方法或者代码块,才会释放锁 并发性下降。
reetranLock if (lock.tryLock()) 或者 if (lock.tryLock(2L,TimeUnit.SECONDS)) 拿得到就执行 ,拿不到就走 提高并发。
对比之下使用ReetranLock会更好。
/**
*weng@
*/
@RestController
public class GoodController
{
@Autowired
private StringRedisTemplate stringRedisTemplate;
private final Lock lock = new ReentrantLock();
@GetMapping("/buy_goods")
public String buyGoods() throws InterruptedException
{
/*synchronized (this)
{
String number = stringRedisTemplate.opsForValue().get("goods:001");
int realNumber = number == null ? 0 : Integer.parseInt(number);
if(realNumber > 0)
{
realNumber = realNumber - 1;
stringRedisTemplate.opsForValue().set("goods:001",String.valueOf(realNumber));
return "你已经成功秒杀商品,此时还剩余:" + realNumber + "件";
}
}*/
//if (lock.tryLock(2L,TimeUnit.SECONDS))
if (lock.tryLock())
{
try
{
String number = stringRedisTemplate.opsForValue().get("goods:001");
int realNumber = number == null ? 0 : Integer.parseInt(number);
if(realNumber > 0)
{
realNumber = realNumber - 1;
stringRedisTemplate.opsForValue().set("goods:001",String.valueOf(realNumber));
return "你已经成功秒杀商品,此时还剩余:" + realNumber + "件";
}
}finally {
lock.unlock();
}
}
return "商品售罄/活动结束,欢迎下次光临";
}
}
这样编写完成后,上线之后没有发生类似 a b 同时抢同一件商品的情况了,不久后公司技术架构从传统单体项目升级到微服务架构,从单个JVM虚拟机变成了分布式 不同虚拟机内,这也导致单机的线程锁机制不在起作用 ,资源在不同的服务器之间共享,那么这个时候就要引出“分布式锁”。
分布式锁本质上要实现的目标就是在占一个“茅坑”,当别的进程也要来占 时,发现已经有人蹲在那里了,就只好放弃或者稍后再试。 占坑一般是只允许被一个客户端占坑。先来先占, 用完了,再调用 del 指令释放茅坑。
常见实现分布式锁的方式有1通过redis , 2.通过MySQL(基本没有使用) 3.通过zk , 3.mysql 通过 悲观锁和乐观锁,悲观锁 select where for update 锁表 乐观锁 cas 思想 update version zk 通过不断创建临时节点实现 性能 没有redis 性能强 目前最主流的redis 来实现 分布式锁
分布式锁需要具备的条件和刚需:
- 独占性
OnlyOne,任何时刻只能有且仅有一个线程持有
2.高可用
若redis集群环境下,不能因为某一个节点挂了而出现获取锁和释放锁失败的情况
3.防死锁
杜绝死锁,必须有超时控制机制或者撤销操作,有个兜底终止跳出方案
4.不乱抢
防止张冠李戴,不能私下unlock别人的锁,只能自己加锁自己释放
5.重入性
同一个节点的同一个线程如果获得锁之后,它也可以再次获取这个锁
分布式锁:
set key value [ex seconds] [px milliseconds] [nx|xx]
ex key在多少秒之后过期
px key在多少毫秒之后过期
nx 当key 不存在时才创建key 效果等同于setnx
xx 当key 存在时覆盖 key
setnx key value + expire 存在不安全 2条命令非原子性操作
小立了解后开始对原来的代码进行升级支持分布式环境下使用
@RestController
public class GoodController
{
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Value("${server.port}")
private String serverPort;
@GetMapping("/buy_goods")
public String buy_Goods()
{
String key = "wengRedisLock";
String value = UUID.randomUUID().toString()+Thread.currentThread().getName();
Boolean flagLock = stringRedisTemplate.opsForValue().setIfAbsent(key, value);
if(!flagLock)
{
return "抢夺锁失败";
}
String result = stringRedisTemplate.opsForValue().get("goods:001");
int goodsNumber = result == null ? 0 : Integer.parseInt(result);
if(goodsNumber > 0)
{
int realNumber = goodsNumber - 1;
stringRedisTemplate.opsForValue().set("goods:001",realNumber + "");
stringRedisTemplate.delete(key);
System.out.println("你已经成功秒杀商品,此时还剩余:" + realNumber + "件"+"\t 服务器端口:"+serverPort);
return "你已经成功秒杀商品,此时还剩余:" +realNumber + "件"+"\t 服务器端口:"+serverPort);
return "你已经成功秒杀商品,此时还剩余:" + realNumber + "件"+"\t 服务器端口:"+serverPort;
}else{
System.out.println("商品已经售罄/活动结束/调用超时,欢迎下次光临"+"\t 服务器端口:"+serverPort);
}
return "商品已经售罄/活动结束/调用超时,欢迎下次光临"+"\t 服务器端口:"+serverPort;
}
}
突然有一天咋线上加锁之后的业务代码发生异常,但分布式锁没有解放导致迟迟不能执行其他的业务方法
@RestController
public class GoodController
{
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Value("${server.port}")
private String serverPort;
@GetMapping("/buy_goods")
public String buy_Goods()
{
String key = "wengRedisLock";
String value = UUID.randomUUID().toString()+Thread.currentThread().getName();
try {
Boolean flagLock = stringRedisTemplate.opsForValue().setIfAbsent(key, value);
if(!flagLock)
{
return "抢锁失败";
}
String result = stringRedisTemplate.opsForValue().get("goods:001");
int goodsNumber = result == null ? 0 : Integer.parseInt(result);
if(goodsNumber > 0)
{
int realNumber = goodsNumber - 1;
stringRedisTemplate.opsForValue().set("goods:001",realNumber + "");
System.out.println("你已经成功秒杀商品,此时还剩余:" + realNumber + "件"+"\t 服务器端口:"+serverPort);
return "你已经成功秒杀商品,此时还剩余:" + realNumber + "件"+"\t 服务器端口:"+serverPort;
}else{
System.out.println("商品已经售罄/活动结束/调用超时,欢迎下次光临"+"\t 服务器端口:"+serverPort);
}
return "商品已经售罄/活动结束/调用超时,欢迎下次光临"+"\t 服务器端口:"+serverPort;
} finally {
stringRedisTemplate.delete(key);
}
}
}
我靠突然有一天公司的服务器上的微服务jar 包 突然挂了代码层面根本没有走到finally这块,没办法保证解锁,这个key没有被删除
要解决这个问题必须要在redis分布式锁加入对应的过期时间,放在出现类似这种情况 没办法保证解锁,这个key没有被删除,需要加入一个过期时间限定key
@RestController
public class GoodController
{
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Value("${server.port}")
private String serverPort;
@GetMapping("/buy_goods")
public String buy_Goods()
{
String key = "wengRedisLock";
String value = UUID.randomUUID().toString()+Thread.currentThread().getName();
try {
Boolean flagLock = stringRedisTemplate.opsForValue().setIfAbsent(key, value);
stringRedisTemplate.expire(key,10L,TimeUnit.SECONDS);
if(!flagLock)
{
return "抢锁失败";
}
String result = stringRedisTemplate.opsForValue().get("goods:001");
int goodsNumber = result == null ? 0 : Integer.parseInt(result);
if(goodsNumber > 0)
{
int realNumber = goodsNumber - 1;
stringRedisTemplate.opsForValue().set("goods:001",realNumber + "");
System.out.println("你已经成功秒杀商品,此时还剩余:" + realNumber + "件"+"\t 服务器端口:"+serverPort);
return "你已经成功秒杀商品,此时还剩余:" + realNumber + "件"+"\t 服务器端口:"+serverPort;
}else{
System.out.println("商品已经售罄/活动结束/调用超时,欢迎下次光临"+"\t 服务器端口:"+serverPort);
}
return "商品已经售罄/活动结束/调用超时,欢迎下次光临"+"\t 服务器端口:"+serverPort;
} finally {
stringRedisTemplate.delete(key);
}
}
}
突然在公司周年庆时流量QPS 提高很多,在购买商品时发生在一个时刻商品明明有货但用户不能购买被别人占有有,结果发现是设置key +过期不是在同一个原子操作中
解决上面的这个问题,将设置key和过期时间放到一个命令,保证原子性
原子操作就是: 不可中断的一个或者一系列操作, 也就是不会被线程调度机制打断的操作, 运行期间不会有任何的上下文切换(context switch)
@RestController
public class GoodController
{
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Value("${server.port}")
private String serverPort;
@GetMapping("/buy_goods")
public String buy_Goods()
{
String key = "wymRedisLock";
String value = UUID.randomUUID().toString()+Thread.currentThread().getName();
try {
Boolean flagLock = stringRedisTemplate.opsForValue().setIfAbsent(key,value,10L,TimeUnit.SECONDS);
if(!flagLock)
{
return "抢锁失败,o(╥﹏╥)o";
}
String result =stringRedisTemplate.opsForValue().get("goods:001");
int goodsNumber = result == null ? 0 : Integer.parseInt(result);
if(goodsNumber > 0)
{
int realNumber = goodsNumber - 1;
stringRedisTemplate.opsForValue().set("goods:001",realNumber + "");
System.out.println("你已经成功秒杀商品,此时还剩余:" + realNumber + "件"+"\t 服务器端口:"+serverPort);
return "你已经成功秒杀商品,此时还剩余:" + realNumber + "件"+"\t 服务器端口:"+serverPort;
}else{
System.out.println("商品已经售罄/活动结束/调用超时,欢迎下次光临"+"\t 服务器端口:"+serverPort);
}
return "商品已经售罄/活动结束/调用超时,欢迎下次光临"+"\t 服务器端口:"+serverPort;
} finally {
stringRedisTemplate.delete(key);
}
}
}
上线一段时间后发现 可能在设置的过期时间业务还未完成,锁已经被删了,然后finally块中就可能会删除别的服务创建的锁,张冠李戴
解决上面的问题 只能删除自己创建的锁,不能动别人的
@RestController
public class GoodController
{
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Value("${server.port}")
private String serverPort;
@GetMapping("/buy_goods")
public String buy_Goods()
{
String key = "wengRedisLock";
String value = UUID.randomUUID().toString()+Thread.currentThread().getName();
try {
Boolean flagLock = stringRedisTemplate.opsForValue().setIfAbsent(key,value,10L,TimeUnit.SECONDS);
if(!flagLock)
{
return "抢锁失败,o(╥﹏╥)o";
}
String result = stringRedisTemplate.opsForValue().get("goods:001");
int goodsNumber = result == null ? 0 : Integer.parseInt(result);
if(goodsNumber > 0)
{
int realNumber = goodsNumber - 1;
stringRedisTemplate.opsForValue().set("goods:001",realNumber + "");
System.out.println("你已经成功秒杀商品,此时还剩余:" + realNumber + "件"+"\t 服务器端口:"+serverPort);
return "你已经成功秒杀商品,此时还剩余:" + realNumber + "件"+"\t 服务器端口:"+serverPort;
}else{
System.out.println("商品已经售罄/活动结束/调用超时,欢迎下次光临"+"\t 服务器端口:"+serverPort);
}
return "商品已经售罄/活动结束/调用超时,欢迎下次光临"+"\t 服务器端口:"+serverPort;
} finally {
if (stringRedisTemplate.opsForValue().get(key).equals(value)) {
stringRedisTemplate.delete(key);
}
}
}
}
在高qps 情况下 finally块的判断+del删除操作不是原子性的,会发现商品明明在却不能购买的情况
使用Lua脚本Redis调用Lua脚本通过eval命令保证代码执行的原子性
使用jdeis
@RestController
public class GoodController
{
public static final String REDIS_LOCK_KEY = "redisLockPay";
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Value("${server.port}")
private String serverPort;
@GetMapping("/buy_goods")
public String buy_Goods()
{
String value = UUID.randomUUID().toString()+Thread.currentThread().getName();
try {
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent(REDIS_LOCK_KEY, value,30L,TimeUnit.SECONDS);
if(!flag)
{
return "抢夺锁失败,请下次尝试";
}
String result = stringRedisTemplate.opsForValue().get("goods:001");
int goodsNumber = result == null ? 0 : Integer.parseInt(result);
if(goodsNumber > 0)
{
int realNumber = goodsNumber - 1;
stringRedisTemplate.opsForValue().set("goods:001",realNumber + "");
System.out.println("你已经成功秒杀商品,此时还剩余:" + realNumber + "件"+"\t 服务器端口:"+serverPort);
return "你已经成功秒杀商品,此时还剩余:" + realNumber + "件"+"\t 服务器端口:"+serverPort;
}else{
System.out.println("商品已经售罄/活动结束/调用超时,欢迎下次光临"+"\t 服务器端口:"+serverPort);
}
return "商品已经售罄/活动结束/调用超时,欢迎下次光临"+"\t 服务器端口:"+serverPort;
} finally {
Jedis jedis = RedisUtils.getJedis();
String script = "if redis.call('get', KEYS[1]) == ARGV[1] " +
"then " +
"return redis.call('del', KEYS[1]) " +== ARGV[1] " +
"then " +
"return redis.call('del', KEYS[1]) " +
"else " +
" return 0 " +
"end";
try {
Object result = jedis.eval(script, Collections.singletonList(REDIS_LOCK_KEY), Collections.singletonList(value));
if ("1".equals(result.toString())) {
System.out.println("------del REDIS_LOCK_KEY success");
}else{
System.out.println("------del REDIS_LOCK_KEY error");
}
} finally {
if(null != jedis) {
jedis.close();
}
}
}
}
}
终于一系列的迭代之后基于Redis实现分布式锁达到了分布式要具备的独占性,防死锁,不乱抢。
随着公司业务的发展,技术是服务业务的,redis 架构也演进到了集群环境 ,随着而来在之前的单节点实现的redis 分布式锁也暴露出了redisLock过期时间小于业务执行时间的问题 ,redis分布式锁续费的问题,在集群环境下redis 异步复制造成锁丢失问题, 比如:主节点没来的及把刚刚set进来这条数据给从节点,master就挂了,从机上位但从机上无该数据 … redis 环境下 自己写的也不ok 要考虑的东西太多了,直接上 RedLock 的Redisson落地 。
@RestController
public class GoodController
{
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Value("${server.port}")
private String serverPort;
@Autowired
private Redisson redisson;
@GetMapping("/buy_goods")
public String buy_Goods(){
String key = "wengRedisLock";
RLock redissonLock = redisson.getLock(key);
redissonLock.lock();
try{
String result = stringRedisTemplate.opsForValue().get("goods:001");
int goodsNumber = result == null ? 0 : Integer.parseInt(result);
if(goodsNumber > 0) {
int realNumber = goodsNumber - 1;
stringRedisTemplate.opsForValue().set("goods:001",realNumber + "");
System.out.println("你已经成功秒杀商品,此时还剩余:" + realNumber + "件"+"\t 服务器端口:"+serverPort);
return "你已经成功秒杀商品,此时还剩余:" + realNumber + "件"+"\t 服务器端口:"+serverPort;
}else{
System.out.println("商品已经售罄/活动结束/调用超时,欢迎下次光临"+"\t 服务器端口:"+serverPort);
}
return "商品已经售罄/活动结束/调用超时,欢迎下次光临"+"\t 服务器端口:"+serverPort;
}finally {
redissonLock.unlock();
}
}
}
按照之前单机版的 可能解锁了别服务创建的锁,所以解锁时需要判断当前锁是否是自己创建的那个,避免张冠李戴
@RestController
public class GoodController
{
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Value("${server.port}")
private String serverPort;
@Autowired
private Redisson redisson;
@GetMapping("/buy_goods")
public String buy_Goods()
{
String key = "wengRedisLock";
RLock redissonLock = redisson.getLock(key);
redissonLock.lock();
try
{
String result = stringRedisTemplate.opsForValue().get("goods:001");
int goodsNumber = result == null ? 0 : Integer.parseInt(result);
if(goodsNumber > 0)
{
int realNumber = goodsNumber - 1;
stringRedisTemplate.opsForValue().set("goods:001",realNumber + "");
System.out.println("你已经成功秒杀商品,此时还剩余:" + realNumber + "件"+"\t 服务器端口:"+serverPort);
return "你已经成功秒杀商品,此时还剩余:" + realNumber + "件"+"\t 服务器端口:"+serverPort;
}else{
System.out.println("商品已经售罄/活动结束/调用超时,欢迎下次光临"+"\t 服务器端口:"+serverPort);
}
return "商品已经售罄/活动结束/调用超时,欢迎下次光临"+"\t 服务器端口:"+serverPort;
}finally {
if(redissonLock.isLocked() && redissonLock.isHeldByCurrentThread())
{
redissonLock.unlock();
}
}
}
}
1setnx分布式锁缺点
严禁出现2个以上的请求线程拿到锁。危险的
使用到的 就是Redlock (红锁)算法 大概就是客户端发送setnx指令,同时向多个redis节点发信息,超过半数redis节点加锁成功,才会返回成功 具体的落地实现是redisson客户端工具。锁变量由多个实例维护,即使有实例发生了故障,锁变量仍然是存在的,客户端还是可以完成锁操作。Redlock算法是实现高可靠分布式锁的一种有效解决方案,可以在实际开发中使用。
在使用 这套理论时 该方案为了解决数据不一致的问题,直接舍弃了异步复制只使用 master 节点,同时由于舍弃了 slave,为了保证可用性,引入了 N 个节点
N = 2X + 1 (N是最终部署机器数,X是容错机器数)
失败了多少个机器实例后我还是可以容忍的,所谓的容忍就是数据一致性还是可以Ok的,CP数据一致性还是可以满足
加入在集群环境中,redis失败1台,可接受。2X+1 = 2 * 1+1 =3,部署3台,死了1个剩下2个可以正常工作,那就部署3台。
加入在集群环境中,redis失败2台,可接受。2X+1 = 2 * 2+1 =5,部署5台,死了2个剩下3个可以正常工作,那就部署5台。
@RestController
@Slf4j
public class RedLockController {
public static final String CACHE_KEY_REDLOCK = "ZZYY_REDLOCK";
@Autowired
RedissonClient redissonClient1;
@Autowired
RedissonClient redissonClient2;
@Autowired
RedissonClient redissonClient3;
@GetMapping(value = "/redlock")
public void getlock() {
//CACHE_KEY_REDLOCK为redis 分布式锁的key
RLock lock1 = redissonClient1.getLock(CACHE_KEY_REDLOCK);
RLock lock2 = redissonClient2.getLock(CACHE_KEY_REDLOCK);
RLock lock3 = redissonClient3.getLock(CACHE_KEY_REDLOCK);
RedissonRedLock redLock = new RedissonRedLock(lock1, lock2, lock3);
boolean isLock;
try {
//waitTime 锁的等待时间处理,正常情况下 等5s
//leaseTime就是redis key的过期时间,正常情况下等5分钟。
isLock = redLock.tryLock(5, 300, TimeUnit.SECONDS);
log.info("线程{},是否拿到锁:{} ",Thread.currentThread().getName(),isLock);
if (isLock) {
//TODO if get lock success, do something;
//暂停20秒钟线程
try { TimeUnit.SECONDS.sleep(20); } catch (InterruptedException e) { e.printStackTrace(); }
}
} catch (Exception e) {
log.error("redlock exception ",e);
} finally {
// 无论如何, 最后都要解锁
redLock.unlock();
System.out.println(Thread.currentThread().getName()+"\t"+"redLock.unlock()");
}
}
}
这样就通过调用redisson-api实现高可用 集群分布式锁
看门狗 守护线程“缓存续命” 额外起一个线程,定期检查线程是否还持有锁,如果有则延长过期时间,Redisson 里面就实现了这个方案使用“看门狗”定期检查(每1/3的锁时间检查1次),如果线程还持有锁,则刷新过期时间;在获取锁成功后,给锁加一个 watchdog,watchdog 会起一个定时任务,在锁没有被释放且快要过期的时候会续期,具体的源码可以 百度下周阳老师的redis课程。