Java主流分布式解决方案多场景设计与实战
xia栽の地止:
lexuecode.com/7381.html
Java主流分布式设计与实战 基于Redis手撸分布式锁
if (balance - amount < 0) { throw new XXException("余额不足,扣减失败"); } 但是此时由于 ③ 处使用快照读,读到是个旧值,未读到最新值,导致这层校验失效,从而代码继续往下运行,执行了数据更新。
更新语句又采用如下写法:
UPDATE account set balance=balance-1000 WHERE id =1; 这条更新语句又必须是在这条记录的最新值的基础做更新,更新语句执行结束,这条记录就变成了 id=1,balance=-1000。
之前有朋友疑惑 t12 更新之后,再次进行快照读,结果会是多少。
上图执行结果 ④ 可以看到结果为 id=1,balance=-1000,可以看到已经查询最新的结果记录。
这行数据最新版本由于是事务 2 自己更新的,自身事务更新永远对自己可见。
另外这次问题上本质上因为 Java 层与数据库层数据不一致导致,有的朋友留言提出,可以在更新余额时加一层判断:
UPDATE account set balance=balance-1000 WHERE id =1 and balance>0; 然后更新完成,Java 层判断更新有效行数是否大于 0。这种做法确实能规避这个问题。
最后这位朋友留言总结的挺好,粘贴一下:
先赞后看,微信搜索「程序通事」,关注就完事了
手撸分布式锁 现在切回正文,这篇文章本来是准备写下 Mysql 查询左匹配的问题,但是还没研究出来。那就先写下最近在鼓捣一个东西,使用 Redis 实现可重入分布锁。
看到这里,有的朋友可能会提出来使用 redisson 不香吗,为什么还要自己实现?
哎,redisson 真的很香,但是现有项目中没办法使用,只好自己手撸一个可重入的分布式锁了。
虽然用不了 redisson,但是我可以研究其源码,最后实现的可重入分布锁参考了 redisson 实现方式。
分布式锁 分布式锁特性就要在于排他性,同一时间内多个调用方加锁竞争,只能有一个调用方加锁成功。
Redis 由于内部单线程的执行,内部按照请求先后顺序执行,没有并发冲突,所以只会有一个调用方才会成功获取锁。
而且 Redis 基于内存操作,加解锁速度性能高,另外我们还可以使用集群部署增强 Redis 可用性。
加锁 使用 Redis 实现一个简单的分布式锁,非常简单,可以直接使用 SETNX 命令。
SETNX 是『SET if Not eXists』,如果不存在,才会设置,使用方法如下:
不过直接使用 SETNX 有一个缺陷,我们没办法对其设置过期时间,如果加锁客户端宕机了,这就导致这把锁获取不了了。
有的同学可能会提出,执行 SETNX 之后,再执行 EXPIRE 命令,主动设置过期时间,伪码如下:
var result = setnx lock "client" if(result==1){ // 有效期 30 s expire lock 30 } 不过这样还是存在缺陷,加锁代码并不能原子执行,如果调用加锁语句,还没来得及设置过期时间,应用就宕机了,还是会存在锁过期不了的问题。
不过这个问题在 Redis 2.6.12 版本 就可以被完美解决。这个版本增强了 SET 命令,可以通过带上 NX,EX 命令原子执行加锁操作,解决上述问题。参数含义如下:
EX second :设置键的过期时间,单位为秒 NX 当键不存在时,进行设置操作,等同与 SETNX 操作 使用 SET 命令实现分布式锁只需要一行代码:
SET lock_name anystring NX EX lock_time
Java主流分布式设计与实战 MySql锁解决库存超卖问题
由秒杀引发的一个问题
- 秒杀最大的一个问题就是解决超卖的问题。其中一种解决超卖如下方式:
update goods set num = num - 1 WHERE id = 1001 and num > 0
我们假设现在商品只剩下一件了,此时数据库中 num = 1;
但有100个线程同时读取到了这个 num = 1,所以100个线程都开始减库存了。
但你会最终会发觉,其实只有一个线程减库存成功,其他99个线程全部失败。
为何?
这就是MySQL中的排他锁起了作用。
排他锁又称为写锁,简称X锁,顾名思义,排他锁就是不能与其他所并存,如一个事务获取了一个数据行的排他锁,其他事务就不能再获取该行的其他锁,包括共享锁和排他锁,但是获取排他锁的事务是可以对数据就行读取和修改。
就是类似于我在执行update操作的时候,这一行是一个事务 (默认加了排他锁)。这一行不能被任何其他线程修改和读写
- 第二种解决超卖的方式如下
select version from goods WHERE id= 1001;
update goods set num = num - 1, version = version + 1 WHERE id= 1001 AND num > 0 AND version = @version(上面查到的version);
update goods set num = num - 1, version = version + 1 WHERE id= 1001 AND version = @version(上面查到的version);
而且还应该在执行该sql语句前增加一个num数目是否大于0的业务逻辑判断。
在mysql中,这里实际上还是会加排它锁的,但是采用版本号也是解决超卖的一种方式,只不过用version的方式代替掉了数据库中num>0这语句的作用,将num>0的判断放置在了业务逻辑中进行。
实际上,这两种方式解决超卖的方式也有细微的一点区别。考虑两个线程,当库存数量为2时,如果是第一种方式,那么两个线程都能成功执行。如果为第二种方式,如果在第一个线程提交事务之前,第二个线程也执行了相同的sql拿到了version值(也就是线程1和线程2拿到了相同的version值),那么这两个线程之间将只有一个线程能够让库存数目减一成功执行。最终库存数目不为0,而为1。
这种方式采用了版本号的方式,其实也就是CAS的原理。
假设此时version = 100, num = 1; 100个线程进入到了这里,同时他们select出来版本号都是version = 100。
然后直接update的时候,只有其中一个先update了,同时更新了版本号。
那么其他99个在更新的时候,会发觉version并不等于上次select的version,就说明version被其他线程修改过了。那么我就放弃这次update
- 第三种解决超卖的方式如下
利用redis的单线程预减库存。
比如商品有100件。那么我在redis存储一个k,v。例如 <gs1001, 100>
每一个用户线程进来,key值就减1,等减到0的时候,全部拒绝剩下的请求。
那么也就是只有100个线程会进入到后续操作。所以一定不会出现超卖的现象。
在众多抢购活动中,在有限的商品数量的限制下如何保证抢购到商品的用户数不能大于商品数量,也就是不能出现超卖的问题;还有就是抢购时会出现大量用户的访问,如何提高用户体验效果也是一个问题,也就是要解决秒杀系统的性能问题。
本文主要介绍基于redis 实现商品秒杀功能。先来跟大家讲下大概思路。总体思路就是要减少对数据库的访问,尽可能将数据缓存到Redis缓存中,从缓存中获取数据。
- 在系统初始化时,将商品的库存数量加载到Redis缓存中,并不是需要先请求一次才能缓存
- 接收到秒杀请求时,在Redis中进行预减库存,当Redis中的库存不足时,直接返回秒杀失败,减少对数据库的访问。否则继续进行第3步;
- 将请求放入异步队列(RabbitMQ) 中,立即给前端返回一个值,表示正在排队中。
- 服务端异步队列将请求出队,出队成功的请求可以然后进行秒杀逻辑,减库存–>下订单–>写入秒杀订单,成功了就返回成功。
- 当后台订单创建成功之后可以通过
websocket向用户发送一个秒杀成功通知。前端以此来判断是否秒杀成功,秒杀成功则进入秒杀订单详情,否则秒杀失败。
- 系统初始化的时候将秒杀商品库存放入
redis缓存
//首先我们需要实现InitializingBean接口,InitializingBean接口为bean提供了初始化方法的方式,它就包括afterPropertiesSet方法,凡是继承该接口的类,在初始化bean的时候会执行该方法。
@Component
public class WebListener implements InitializingBean{
@Autowired
private RedisTemplate redistemplate;
@Override
public void afterPropertiesSet() throws Exception{
List<GoodsVo> goodsList = goodsService.listGoodsVo();
if(goodsList == null) {
return;
}
for(GoodsVo goods : goodsList) {
redistemplate.set(GoodsKey.getMiaoshaGoodsStock, ""+goods.getId(), goods.getStockCount());
localOverMap.put(goods.getId(), false);//先初始化 每个商品都是false 就是还有库存
}
}
}
//这就实现了我们系统启动就把所有缓存加载完毕,然后我们通过操作redis来实现预减库存
- 预减库存 请求放到异步队列
//然后当我们的并发量够大,redis的压力页很大,然后我们可以通过map集合标记缓存,减少redis服务器的压力
// 1、生成一个map,并在初始化的时候,将所有商品的id为键,标记false 存入map中。
// 2、在预减库存之前,从map中取标记,若标记为false,说明库存,还有,
// 3、预减库存,当遇到库存不足的时候,将该商品的标记置为true,表示该商品的库存不足。
这样,下面的所有请求,将被拦截,无需访问redis进行预减库存。
//系统启动时会对其初始化,将所有秒杀商品id存入map,库存为0是为true
private Map<Long,Boolean> localOverMap = new HashMap<Long,Boolean>();
//====================================================================================
@RequestMapping(value="/{path}/do_miaosha", method=RequestMethod.POST)
@ResponseBody
public Result<Integer> miaosha(HttpServletRequest request, HttpServletResponse response,
Model model,MiaoshaUser user,
@RequestParam("goodsId")long goodsId,
@PathVariable("path") String path) {
model.addAttribute("user", user);
//如果用户为空,则返回至登录页面
if(user == null) {
return Result.error(CodeMsg.SESSION_ERROR);
}
//验证path
boolean check = miaoshaService.checkPath(user, goodsId, path);
if(!check){
return Result.error(CodeMsg.REQUEST_ILLEGAL);
}
//内存标记,从map取值判断,减少redis访问
boolean over = localOverMap.get(goodsId);
if(over) {
return Result.error(CodeMsg.MIAO_SHA_OVER);
}
//预减库存 这里的减库存是原子性的操作
long stock = redisService.decr(GoodsKey.getMiaoshaGoodsStock, ""+goodsId);//10
if(stock < 0) {
localOverMap.put(goodsId, true);
return Result.error(CodeMsg.MIAO_SHA_OVER);
}
//判断是否已经秒杀到了
MiaoshaOrder order = orderService.getMiaoshaOrderByUserIdGoodsId(user.getId(), goodsId);
if(order != null) {
return Result.error(CodeMsg.REPEATE_MIAOSHA);
}
//入队
MiaoshaMessage mm = new MiaoshaMessage();
mm.setUser(user);
mm.setGoodsId(goodsId);
sender.sendMiaoshaMessage(mm);
//返回0代表排队中
return Result.success(0);
}
// redis给数据库减轻压力,利用map标记库存给redis减轻压力