目录
⑤synchronized关键字,和lock锁(只针对单体,分布式系统不行)
前言:
①本帖先选用JMeter作为测试并发的工具,讲解JMeter的并发配置,模拟请求成功
②开始抢票场景的模拟,编写数据库表、业务代码、模拟并发成功
③开始解决抢票过程中遇到的并发问题,并使其并发只有一个用户能够抢到票
④开始多种方案尝试
如图配置:
如上图检测到的结果能看到,并发的五个请求都成功请求到了。配置完毕。
下面我们开始模拟场景:
这里以抢票为例吧,一张票只能被一个人抢到。先设计表结构,编写业务。
票种和剩余票数关联关系表:
抢票记录表:
编写业务:
package com.hyj.services;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import com.hyj.domain.tb.TicketNum;
import com.hyj.domain.tb.TicketPurchaseLog;
import com.hyj.interfaces.ITicketNumService;
import com.hyj.mapper.TicketNumMapper;
import com.hyj.mapper.TicketPurchaseLogMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.Date;
@Service
@Slf4j
public class TicketNumServiceImpl implements ITicketNumService {
@Autowired
private TicketNumMapper numMapper;
@Autowired
private TicketPurchaseLogMapper purchaseLogMapper;
@Override
public Boolean buyTicket(Integer userId) {
//假设业务是,抢票成功即:更新票数,且插入成功,抢票失败即:更新失败,且插入失败。
//抢票前,需判断此票种是否还能抢票。
//在这里,模拟都抢ticked_id=1的票
try {
QueryWrapper queryWrapper = Wrappers.query();
queryWrapper.eq("ticked_id", 1);
TicketNum numInfo = numMapper.selectById(1);
if (numInfo == null || numInfo.getNum() < 1) {
log.warn("此票已被抢");
return false;
}
//减票数
numInfo.setNum(numInfo.getNum() - 1);
numMapper.updateById(numInfo);
//插入日志
TicketPurchaseLog model = new TicketPurchaseLog();
model.setTickedId(1);
model.setUserId(userId);
model.setCreateTime(new Date());
purchaseLogMapper.insert(model);
} catch (Exception e) {
log.error("抢票信息异常:{}", e.getStackTrace());
return false;
}
return true;
}
}
接口调试执行成功,插入一条数据并更新库存,从6更改为5。
清空表后,下面开始并发测试:
妈滴……只插入了一条数据,好像并没有并发问题????(当时是业务设计不完整,是单表操作。后面更改为两表操作后,不睡也会有问题) 有并发问题的话,应该插入5条。呵呵哒,傻了,是因为执行太快,并没有并发问题,所以再次假设,红框内执行较慢,让它睡5秒
再次并发测试,5个请求都返回true,且插入5条数据,成功模拟并发场景。
插入五条抢票记录,而库存却只减了一个。
这是错误日志:
所以,得出啥结论造吗……只要够快,就没有问题。。。所以呢,消息队列就可以引入了(如果业务场景允许)
下面开始——高并发解决方案:
①系统拆分
②缓存
③消息队列
④分库分表
⑤读写分离
⑥solrCloud
这里我们说②③④
一、缓存——数据最终一致性
高并发场景,利用缓存内存储的数据实现效率的提高,减轻数据库压力(以空间换时间),但只要
有两份数据存在,数据一致性问题就不可避免。
有几下几种解决方式:
1.加锁
当更新数据库的时候,同步更新缓存。
优点:数据一致性强,不会出现缓存雪崩的问题。
缺点:代码耦合度高,影响正常业务,增加一次网络开销。
适用环境:适用于数据一致性要求高的场景,比如银行业务,证券交易
上图我们就是用第一种方式实现的业务,耦合度很高,但是没有一致性问题。加锁的确会解决数据
不一致的问题,但是也有缺点:串行执行,效率极低。
分布式锁的实现方式
①数据库:乐观锁、悲观锁
②基于redis的实现
③基于zookeeper
分布式锁的那几种实现方式都搞上:悲观锁、乐观锁、缓存分布式锁、关键字锁之类的。能用乐观
锁就不要用悲观锁,悲观锁要等待,容易阻塞,体验很差劲。
下面开始实操:
场景:只剩一张车票,禁止卖超
①数据库悲观锁
它悲观地认为,所有操作都冲突
/**
* 悲观锁方式解决并发
* @param userId
* @return
*/
@Override
@Transactional
public Boolean bgBuyTicket(Integer userId) {
//假设业务是,抢票成功即:更新票数,且插入成功,抢票失败即:更新失败,且插入失败。
//抢票前,需判断此票种是否还能抢票。
//在这里,模拟都抢ticked_id=1的票
try {
//悲观锁锁住数据
// SELECT id,num,version,update_time from ticket_num where id = 1 for update
TicketNum numInfo = numMapper.bgSql();
if (numInfo == null || numInfo.getNum() < 1) {
log.warn("此票已被抢");
return false;
}
// Thread.sleep(5000);
//减票数
numInfo.setNum(numInfo.getNum() - 1);
numMapper.updateById(numInfo);
//插入日志
TicketPurchaseLog model = new TicketPurchaseLog();
model.setTickedId(1);
model.setUserId(userId);
model.setCreateTime(new Date());
purchaseLogMapper.insert(model);
} catch (Exception e) {
log.error("抢票信息异常:{}", e.getStackTrace());
return false;
}
return true;
}
结果:
下图为并发10次,只有6张票的情况:所以解决方案正确
这种是悲观锁的方式,解决高并发问题,注意一定要加@transactional事务注解,不加的话,会同
时插入5条,控制不住并发。but有but,这种方式能不用就不用。贼不好,要一直等待,所以又产
生了一个新的问题:死锁。华丽丽登场,这里我们先不说了,篇幅有限(假装我会但我不说)
注意:单纯加注释而不锁数据for update。并没有卵用。
上图是其中的一种解决方式,本质是mysql的悲观锁+事务。最后commit
②数据库乐观锁 √
乐观得认为,数据修改产生冲突的概率并不大,适合读多写少。
乐观锁的原理是:两个字段控制唯一一条数据。当在查询数据的时候并不知道成功与否,修改数据
的时候才知道
/**
* 乐观锁方式解决并发
*
* @param userId
* @return
*/
@Override
@Transactional
public Boolean lgBuyTicket(Integer userId) {
//假设业务是,抢票成功即:更新票数,且插入成功,抢票失败即:更新失败,且插入失败。
//抢票前,需判断此票种是否还能抢票。
//在这里,模拟都抢ticked_id=1的票
try {
//查出数据
QueryWrapper queryWrapper = Wrappers.query();
queryWrapper.eq("ticked_id", 1);
TicketNum numInfo = numMapper.selectById(1);
if (numInfo == null || numInfo.getNum() < 1) {
log.warn("此票已被抢");
return false;
}
//乐观锁,减票数
// UPDATE ticket_num set num = num-1,version = #{oldVersion}+1,update_time = now() where id = 1 and version = #{oldVersion}
Integer result = numMapper.lgSql(numInfo.getVersion());
if (result > 0) {
//插入日志
TicketPurchaseLog model = new TicketPurchaseLog();
model.setTickedId(1);
model.setUserId(userId);
model.setCreateTime(new Date());
purchaseLogMapper.insert(model);
}
} catch (Exception e) {
log.error("抢票信息异常:{}", e.getStackTrace());
return false;
}
return true;
}
这里模拟了只剩两张票,结果:
经尝试,不加事务注解对并发没什么影响,但是!还是加上吧,同时要注意拿到结果后才能继续下
一步
下图为并发10次,只有6张票的情况:所以解决方案正确
③redis锁实现 √
/**
* 缓存方式,解决并发问题
*
* @param userId
* @return
*/
@Override
@Transactional
public Boolean finalBuyTicket(Integer userId) {
//假设业务是,抢票成功即:更新票数,且插入成功,抢票失败即:更新失败,且插入失败。
//抢票前,需判断此票种是否还能抢票。
//在这里,模拟都抢ticked_id=1的票
try {
Integer num = 0;
if (redisUtil.exists("ticked_id_1")) {
num = (Integer) redisUtil.get("ticked_id_1");
} else {
QueryWrapper queryWrapper = Wrappers.query();
queryWrapper.eq("ticked_id", 1);
TicketNum numInfo = numMapper.selectById(1);
num = numInfo.getNum();
redisUtil.set("ticked_id_1", num);
}
if (num < 1) {
log.warn("此票已被抢,userId={}的没抢到", userId);
return false;
}
log.info("目前一共{}张票,userId={}抢到一张", num, userId);
//减票数
redisUtil.set("ticked_id_1", num - 1);
TicketNum numModel = new TicketNum();
numModel.setId(1);
numModel.setNum(num - 1);
numModel.setUpdateTime(new Date());
numMapper.updateById(numModel);
//插入日志
TicketPurchaseLog model = new TicketPurchaseLog();
model.setTickedId(1);
model.setUserId(userId);
model.setCreateTime(new Date());
purchaseLogMapper.insert(model);
} catch (Exception e) {
log.error("抢票信息异常:{}", e.getStackTrace());
return false;
}
return true;
}
不考虑并发的,缓存业务写法如上图。
成功的并发住了,出现问题了:
实际上,只是简单的读取并放入就会并发住:
系统初始化的时候,将票的库存加载到Redis 缓存中保存
下面用分布式锁解决问题,
③—①redis的setnx实现
如果key不存在,这种情况下等同SET命令。 当key存在时,什么也不做。
目前业务场景不支持用setnx,这里不说了
③—②redisson实现
解决方式是:在业务模块之前,redis加锁;在业务模块之后,redis解锁。
这里引入redission,redisson 是 Redis 官方的分布式锁组件。很成熟了,拿来就可以用,支持单
点模式、主从模式、哨兵模式、集群模式,也不用自己写锁之类的。
Redisson实现了分布式和可扩展的java数据结构,支持的数据结构有:List, Set, Map, Queue,
SortedSet, ConcureentMap, Lock, AtomicLong, CountDownLatch。并且是线程安全的,底层使用
Netty4实现网络通信。和jedis相比,功能比较简单,不支持排序,事务,管道,分区等redis特性,
可以认为是jedis的补充,不能替换jedis。
首先,加入pom依赖
<!--redisson -->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson-spring-boot-starter</artifactId>
<version>3.13.0</version>
</dependency>
加入依赖后,注解@Autowried引用类RedissonClient,这个类提供了方法的加锁及解锁。
编写:
/**
* redisson分布式锁方式,解决并发问题
*
* @param userId
* @return
*/
@Override
@Transactional
public Boolean redissonBuyTicket(Integer userId) {
//假设业务是,抢票成功即:更新票数,且插入成功,抢票失败即:更新失败,且插入失败。
//抢票前,需判断此票种是否还能抢票。
//在这里,模拟都抢ticked_id=1的票
Boolean result = true;
//获取分布式锁
RLock lock = redissonClient.getLock("ticked_id_1");
lock.lock();
try {
QueryWrapper queryWrapper = Wrappers.query();
queryWrapper.eq("ticked_id", 1);
TicketNum numInfo = numMapper.selectById(1);
Integer num = numInfo.getNum();
if (num < 1) {
log.warn("此票已被抢,userId={}的没抢到", userId);
return false;
}
log.info("目前一共{}张票,userId={}抢到一张", num, userId);
// Thread.sleep(5000);
//业务逻辑卸载try...catch中 ,finally最后一定要释放锁
//减票数
TicketNum numModel = new TicketNum();
numModel.setId(1);
numModel.setNum(num - 1);
numModel.setUpdateTime(new Date());
numMapper.updateById(numModel);
//插入日志
TicketPurchaseLog model = new TicketPurchaseLog();
model.setTickedId(1);
model.setUserId(userId);
model.setCreateTime(new Date());
purchaseLogMapper.insert(model);
} catch (Exception e) {
log.error("抢票信息异常:{}", e.getStackTrace());
result = false;
} finally {
lock.unlock();
}
return result;
}
这里注意,try catch finally之后一定要解锁。
运行结果:报错
注意,这里锁的key和库存key不要用一个!!!不要用redis已经存在的key。。。
再次运行,结果如下:
成功控制住并发!再次解决。分布式锁推荐此种方式
分布式锁问题解决完了,下面继续说,引入缓存解决高并发,在中间会遇到各种问题,比如:
缓存失效、缓存击穿、缓存雪崩等问题,这里的问题,参见我的xmind图,就不细说了。
但是要注意,极高并发数量的情况下,必须引入缓存或者消息队列,使其计算迅速,否则直接打到
数据库上,运行缓慢,即使加了事务注解,也会有异常。
亲测过100次并发条件下,数据都会不正确……更别提数据量上升了,即使加了事务注解也没用,
所以一定要加缓存或者消息队列,使其运行速度。
下面附图一张引入缓存解决高并发,满足上述业务的最终方案代码图:
/**
* 缓存方式,解决并发问题
*
* @param userId
* @return
*/
@Override
@Transactional
public Boolean finalBuyTicket(Integer userId) {
//假设业务是,抢票成功即:更新票数,且插入成功,抢票失败即:更新失败,且插入失败。
//抢票前,需判断此票种是否还能抢票。
//在这里,模拟都抢ticked_id=1的票
Boolean result = true;
//获取分布式锁
RLock lock = redissonClient.getLock("ticked");
lock.lock();
try {
Integer num = 0;
if (redisUtil.exists("ticked_id_1")) {
num = (Integer) redisUtil.get("ticked_id_1");
} else {
QueryWrapper queryWrapper = Wrappers.query();
queryWrapper.eq("ticked_id", 1);
TicketNum numInfo = numMapper.selectById(1);
num = numInfo.getNum();
redisUtil.set("ticked_id_1", num);
}
if (num < 1) {
log.warn("此票已被抢,userId={}的没抢到", userId);
return false;
}
log.info("目前一共{}张票,userId={}抢到一张", num, userId);
//减票数
redisUtil.set("ticked_id_1", num - 1);
TicketNum numModel = new TicketNum();
numModel.setId(1);
numModel.setNum(num - 1);
numModel.setUpdateTime(new Date());
numMapper.updateById(numModel);
//插入日志
TicketPurchaseLog model = new TicketPurchaseLog();
model.setTickedId(1);
model.setUserId(userId);
model.setCreateTime(new Date());
purchaseLogMapper.insert(model);
} catch (Exception e) {
log.error("抢票信息异常:{}", e.getStackTrace());
result = false;
} finally {
lock.unlock();
}
return result;
}
引入redis缓存,成功解决效率问题,用redis锁解决并发问题,使其控制数据在同一进程中。
redis的默认最大并发数是1万,所以,这里的并发数据以1万为上限。实际上当并发数量超过一万
的时候,Jemeter的http请求会报红。
④关于加唯一索引的想法
建议只是想想,太简单粗暴了,能没弊端吗(一不小心又死锁了)……能在上游拦截就拦截,加索
引也可以,上游一定要处理后再加。跟悲观锁一个道理
⑤synchronized关键字,和lock锁(只针对单体,分布式系统不行)
/**
* 关键字锁,解决并发问题
*
* @param userId
* @return
*/
@Override
@Transactional
public synchronized Boolean lockBuyTicket(Integer userId) {
//假设业务是,抢票成功即:更新票数,且插入成功,抢票失败即:更新失败,且插入失败。
//抢票前,需判断此票种是否还能抢票。
//在这里,模拟都抢ticked_id=1的票
try {
QueryWrapper queryWrapper = Wrappers.query();
queryWrapper.eq("ticked_id", 1);
TicketNum numInfo = numMapper.selectById(1);
if (numInfo == null || numInfo.getNum() < 1) {
log.warn("此票已被抢,userId={}的没抢到", userId);
return false;
}
log.info("目前一共{}张票,userId={}抢到一张", numInfo.getNum(),userId);
//Thread.sleep(5000);
//减票数
numInfo.setNum(numInfo.getNum() - 1);
numMapper.updateById(numInfo);
//插入日志
TicketPurchaseLog model = new TicketPurchaseLog();
model.setTickedId(1);
model.setUserId(userId);
model.setCreateTime(new Date());
purchaseLogMapper.insert(model);
} catch (Exception e) {
log.error("抢票信息异常:{}", e.getStackTrace());
return false;
}
return true;
}
成功解决问题。依旧不推荐,原理同悲观锁相同,都需要等待释放,很慢,吞吐量不行。而且,这
里还有比数据库悲观锁更不好的一点,关键字只能控制单体应用,并不能控制分布式服务。
2.异步写入缓存
当更新数据库的同时,异步去更新缓存,比如更新数据库后把一条消息发送到mq中去实现。
优点:与业务解耦,不影响正常业务
缺点:可能某个时间缓存一直不存在,导致数据都从数据库中获取
具体消息队列的引入,在第二部分会说
3.延迟双删
先删除缓存,更新完数据后,再sleep一段时间,然后再次删除缓存。
缓存问题解决完了,下面开始用其他方式解决高并发问题:
二、消息队列
异步,削峰,解耦。用起来用起来
用消息队列,解决缓存最终一致性问题,解决效率问题,更方便业务解耦。这里我选择rabbitmq。
主业务:
/**
* 异步消息队列,解决并发问题
* 解除锁方式
*
* @param userId
* @return
*/
@Override
@Transactional
public Boolean mqBuyTicket(Integer userId) {
//假设业务是,抢票成功即:更新票数,且插入成功,抢票失败即:更新失败,且插入失败。
//抢票前,需判断此票种是否还能抢票。
//在这里,模拟都抢ticked_id=1的票
try {
// 假设,余票库存放入缓存的步骤,在新建票种的时候,已经放入缓存,这里直接取
// 首先,查一下票,为了能及时的返回给用户信息
// 剩下的业务放入消息队列处理
Integer num = 0;
if (redisUtil.exists("ticked_id_1")) {
num = (Integer) redisUtil.get("ticked_id_1");
}
if (num < 1) {
log.warn("此票已被抢,userId={}的没抢到", userId);
return false;
}
log.info("抢票成功:userId={}", userId);
//放入消息队列
iFeignPushService.pushMsg(userId.toString());
} catch (Exception e) {
log.error("抢票异常:{}", e.getStackTrace());
return false;
}
return true;
}
上述描述的【放入消息队列】代码如下:
package com.hyj.interfaces;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
@FeignClient(name = "hahaha", path = "/feign", url = "localhost:6001")
public interface IFeignPushService {
@GetMapping("/pushMsg")
void pushMsg(@RequestParam("msg") String msg);
}
消费端代码如下:
/**
* 消费端,消费业务逻辑
*
* @param userId
* @return
*/
@Override
public Boolean reduceStocksConsumer(Integer userId) {
log.info("消费逻辑userId={}", userId);
try {
//减票数
Integer num = (Integer) redisUtil.get("ticked_id_1");
if (num < 1) {
log.warn("此票已被抢,userId={}的没抢到", userId);
return false;
}
redisUtil.set("ticked_id_1", num - 1);
TicketNum numModel = new TicketNum();
numModel.setId(1);
numModel.setNum(num - 1);
numModel.setUpdateTime(new Date());
numMapper.updateById(numModel);
//插入日志
TicketPurchaseLog model = new TicketPurchaseLog();
model.setTickedId(1);
model.setUserId(userId);
model.setCreateTime(new Date());
purchaseLogMapper.insert(model);
} catch (Exception e) {
log.error("消费端,消费业务逻辑异常:{}", e.getMessage());
return false;
}
return true;
}
此段代码是consumer微服务,监听生产消息,消费后,feign指过来的。具体实现如下:
这段是consumer的监听代码:
package com.hyj.service;
import com.hyj.interfaces.IFeignService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
@Slf4j
public class MsgService {
@Autowired
private IFeignService iFeignService;
@RabbitListener(queues = "directQueue")
public void GetMsg(String msg) {
try {
log.info("************消费开始************:{}", msg);
iFeignService.reduceStocksConsumer(msg);
log.info("************消费结束**********");
} catch (Exception e) {
log.error("消费异常:{}", e.getMessage());
}
}
}
上述调用的接口,如下:
package com.hyj.interfaces;
import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
/**
* 调用其他服务的方法
*/
@FeignClient(name = "hahaha",path = "/feign",url = "localhost:9999")
public interface IFeignService {
/**
* 库存减少,消费端处理
* @return
*/
@GetMapping("/reduceStocksConsumer")
Boolean reduceStocksConsumer(@RequestParam("msg") String msg);
}
总而言之,上述用消息队列异步,成功解决了系统并发问题。
然后有了新的问题,消息丢失、重复消费、消息堆积等问题