司机接单
1.需求
- 乘客下单之后,新订单信息已经在司机临时队列
- 下面司机可以开始进行接单了
首先,司机登录,认证(身份证、驾驶证、创建人脸模型),司机登录信息包含司机认证状态
第二,司机进行人脸识别(每天司机接单之前都需要进行人脸识别)
第三,司机开始接单了,更新司机接单状态
第四,当司机开始接单之后,删除司机之前存储到Redis里面位置信息
第五,当司机开始接单之后,清空司机临时队列新订单信息
2.查找司机端当前订单
司机端接单也一样,只要有执行中的订单,没有结束,那么司机是不可以接单的,页面会弹出层,进入执行中的订单。
当前我们必须把这个接口绕过去,不然我们不能接单,因此我们模拟一下这个接口,告诉它没有执行中的订单,后续在订单流程中我们完善这个业务。
OrderController
@Operation(summary = "查找司机端当前订单")
@GuiGuLogin
@GetMapping("/searchDriverCurrentOrder")
public Result<CurrentOrderInfoVo> searchDriverCurrentOrder() {
CurrentOrderInfoVo currentOrderInfoVo = new CurrentOrderInfoVo();
//TODO 后续完善
currentOrderInfoVo.setIsHasCurrentOrder(false);
return Result.ok(currentOrderInfoVo);
}
3.判断司机当日是否已做人脸识别
- 首先,找到数据库表,存储司机当日认证信息数据
- 实现过程:根据司机id和当日日期,两个条件进行查询,根据查询结果判断
service-driver
DriverInfoController
@Operation(summary = "判断司机当日是否进行过人脸识别")
@GetMapping("/isFaceRecognition/{driverId}")
Result<Boolean> isFaceRecognition(@PathVariable("driverId") Long driverId) {
return Result.ok(driverInfoService.isFaceRecognition(driverId));
}
DriverInfoServiceImpl
@Autowired
private DriverFaceRecognitionMapper driverFaceRecognitionMapper;
//判断司机当日是否进行过人脸识别
@Override
public Boolean isFaceRecognition(Long driverId) {
// 根据司机id + 当日日期进行查询
LambdaQueryWrapper<DriverFaceRecognition> queryWrapper = new LambdaQueryWrapper();
queryWrapper.eq(DriverFaceRecognition::getDriverId, driverId);
// 年-月-日 格式
queryWrapper.eq(DriverFaceRecognition::getFaceDate, new DateTime().toString("yyyy-MM-dd"));
// 调用mapper方法
long count = driverFaceRecognitionMapper.selectCount(queryWrapper);
return count != 0;
}
远程调用定义 service-driver-client
DriverInfoFeignClient
/**
* 判断司机当日是否进行过人脸识别
* @param driverId
* @return
*/
@GetMapping("/driver/info/isFaceRecognition/{driverId}")
Result<Boolean> isFaceRecognition(@PathVariable("driverId") Long driverId);
web-driver
DriverController
@Operation(summary = "判断司机当日是否进行过人脸识别")
@GuiGuLogin
@GetMapping("/isFaceRecognition")
Result<Boolean> isFaceRecognition() {
Long driverId = AuthContextHolder.getUserId();
return Result.ok(driverService.isFaceRecognition(driverId));
}
DriverServiceImpl
// 判断司机当日是否进行过人脸识别
@Override
public Boolean isFaceRecognition(Long driverId) {
return driverInfoFeignClient.isFaceRecognition(driverId).getData();
}
4.人脸识别
前面我们已经做了人员库人员信息录入,现在我们要做人脸验证,为了防止司机作弊,我们还要对司机上传的人脸信息做人脸静态活体检测。
人脸静态活体检测可用于对用户上传的静态图片进行防翻拍活体检测,以判断是否是翻拍图片。
service-driver
DriverInfoController
@Operation(summary = "验证司机人脸")
@PostMapping("/verifyDriverFace")
public Result<Boolean> verifyDriverFace(@RequestBody DriverFaceModelForm driverFaceModelForm) {
return Result.ok(driverInfoService.verifyDriverFace(driverFaceModelForm));
}
DriverInfoServiceImpl
//人脸识别
@Override
public Boolean verifyDriverFace(DriverFaceModelForm driverFaceModelForm) {
//1 照片比对
try{
// 实例化一个认证对象,入参需要传入腾讯云账户 SecretId 和 SecretKey,此处还需注意密钥对的保密
// 代码泄露可能会导致 SecretId 和 SecretKey 泄露,并威胁账号下所有资源的安全性。以下代码示例仅供参考,建议采用更安全的方式来使用密钥,请参见:https://cloud.tencent.com/document/product/1278/85305
// 密钥可前往官网控制台 https://console.cloud.tencent.com/cam/capi 进行获取
Credential cred = new Credential(tencentCloudProperties.getSecretId(),
tencentCloudProperties.getSecretKey());
// 实例化一个http选项,可选的,没有特殊需求可以跳过
HttpProfile httpProfile = new HttpProfile();
httpProfile.setEndpoint("iai.tencentcloudapi.com");
// 实例化一个client选项,可选的,没有特殊需求可以跳过
ClientProfile clientProfile = new ClientProfile();
clientProfile.setHttpProfile(httpProfile);
// 实例化要请求产品的client对象,clientProfile是可选的
IaiClient client = new IaiClient(cred,
tencentCloudProperties.getRegion(),
clientProfile);
// 实例化一个请求对象,每个接口都会对应一个request对象
VerifyFaceRequest req = new VerifyFaceRequest();
//设置相关参数
req.setImage(driverFaceModelForm.getImageBase64());
req.setPersonId(String.valueOf(driverFaceModelForm.getDriverId()));
// 返回的resp是一个VerifyFaceResponse的实例,与请求对象对应
VerifyFaceResponse resp = client.VerifyFace(req);
// 输出json格式的字符串回包
System.out.println(AbstractModel.toJsonString(resp));
if(resp.getIsMatch()) { //照片比对成功
//2 如果照片比对成功,静态活体检测
Boolean isSuccess = this.
detectLiveFace(driverFaceModelForm.getImageBase64());
if(isSuccess) {//3 如果静态活体检测通过,添加数据到认证表里面
DriverFaceRecognition driverFaceRecognition = new DriverFaceRecognition();
driverFaceRecognition.setDriverId(driverFaceModelForm.getDriverId());
driverFaceRecognition.setFaceDate(new Date());
driverFaceRecognitionMapper.insert(driverFaceRecognition);
return true;
}
}
} catch (TencentCloudSDKException e) {
System.out.println(e.toString());
}
throw new GuiguException(ResultCodeEnum.DATA_ERROR);
}
//人脸静态活体检测
private Boolean detectLiveFace(String imageBase64) {
try{
// 实例化一个认证对象,入参需要传入腾讯云账户 SecretId 和 SecretKey,此处还需注意密钥对的保密
// 代码泄露可能会导致 SecretId 和 SecretKey 泄露,并威胁账号下所有资源的安全性。以下代码示例仅供参考,建议采用更安全的方式来使用密钥,请参见:https://cloud.tencent.com/document/product/1278/85305
// 密钥可前往官网控制台 https://console.cloud.tencent.com/cam/capi 进行获取
Credential cred = new Credential(tencentCloudProperties.getSecretId(),
tencentCloudProperties.getSecretKey());
// 实例化一个http选项,可选的,没有特殊需求可以跳过
HttpProfile httpProfile = new HttpProfile();
httpProfile.setEndpoint("iai.tencentcloudapi.com");
// 实例化一个client选项,可选的,没有特殊需求可以跳过
ClientProfile clientProfile = new ClientProfile();
clientProfile.setHttpProfile(httpProfile);
// 实例化要请求产品的client对象,clientProfile是可选的
IaiClient client = new IaiClient(cred, tencentCloudProperties.getRegion(),
clientProfile);
// 实例化一个请求对象,每个接口都会对应一个request对象
DetectLiveFaceRequest req = new DetectLiveFaceRequest();
req.setImage(imageBase64);
// 返回的resp是一个DetectLiveFaceResponse的实例,与请求对象对应
DetectLiveFaceResponse resp = client.DetectLiveFace(req);
// 输出json格式的字符串回包
System.out.println(DetectLiveFaceResponse.toJsonString(resp));
if(resp.getIsLiveness()) {
return true;
}
} catch (TencentCloudSDKException e) {
System.out.println(e.toString());
}
return false;
}
service-driver-client
DriverInfoFeignClient
/**
* 验证司机人脸
* @param driverFaceModelForm
* @return
*/
@PostMapping("/driver/info/verifyDriverFace")
Result<Boolean> verifyDriverFace(@RequestBody DriverFaceModelForm driverFaceModelForm);
web-driver
driverController
@Operation(summary = "验证司机人脸")
@GuiguLogin
@PostMapping("/verifyDriverFace")
public Result<Boolean> verifyDriverFace(@RequestBody DriverFaceModelForm driverFaceModelForm) {
driverFaceModelForm.setDriverId(AuthContextHolder.getUserId());
return Result.ok(driverService.verifyDriverFace(driverFaceModelForm));
}
DriverServiceImpl
//人脸识别
@Override
public Boolean verifyDriverFace(DriverFaceModelForm driverFaceModelForm) {
return driverInfoFeignClient.verifyDriverFace(driverFaceModelForm).getData();
}
5.更新司机接单状态
操作数据库表driver_set
service-driver
DriverInfoController
@Operation(summary = "更新接单状态")
@GetMapping("/updateServiceStatus/{driverId}/{status}")
public Result<Boolean> updateServiceStatus(@PathVariable Long driverId, @PathVariable Integer status) {
return Result.ok(driverInfoService.updateServiceStatus(driverId, status));
}
DriverInfoServiceImpl
//更新接单状态
// update driver_set set status=? where driver_id=?
@Override
public Boolean updateServiceStatus(Long driverId, Integer status) {
LambdaQueryWrapper<DriverSet> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(DriverSet::getDriverId, driverId);
DriverSet driverSet = new DriverSet();
driverSet.setServiceStatus(status);
driverSetMapper.update(driverSet, queryWrapper);
return true;
}
远程调用定义 service-driver-client
DriverInfoFeignClient
/**
* 更新接单状态
* @param driverId
* @param status
* @return
*/
@GetMapping("/driver/info/updateServiceStatus/{driverId}/{status}")
Result<Boolean> updateServiceStatus(@PathVariable("driverId") Long driverId, @PathVariable("status") Integer status);
6.开启接单服务web接口
司机要开启接单后,上传位置信息到redis的geo,这样才能被任务调度搜索到司机信息,才能抢单。
web-driver
DriverController
@Operation(summary = "开始接单服务")
@GuiguLogin
@GetMapping("/startService")
public Result<Boolean> startService() {
Long driverId = AuthContextHolder.getUserId();
return Result.ok(driverService.startService(driverId));
}
DriverServiceImpl
@Autowired
private LocationFeignClient locationFeignClient;
@Autowired
private NewOrderFeignClient newOrderFeignClient;
//开始接单服务
@Override
public Boolean startService(Long driverId) {
//1 判断完成认证
DriverLoginVo driverLoginVo = driverInfoFeignClient.getDriverLoginInfo(driverId).getData();
if(driverLoginVo.getAuthStatus()!=2) {
throw new GuiguException(ResultCodeEnum.AUTH_ERROR);
}
//2 判断当日是否人脸识别
Boolean isFace = driverInfoFeignClient.isFaceRecognition(driverId).getData();
if(!isFace) {
throw new GuiguException(ResultCodeEnum.FACE_ERROR);
}
//3 更新订单状态 1 开始接单
driverInfoFeignClient.updateServiceStatus(driverId,1);
//4 删除redis司机位置信息
locationFeignClient.removeDriverLocation(driverId);
//5 清空司机临时队列数据
newOrderFeignClient.clearNewOrderQueueData(driverId);
return true;
}
7.停止接单服务web接口
司机成功抢单后,就要关闭接单服务
web-driver
DriverController
@Operation(summary = "停止接单服务")
@GuiguLogin
@GetMapping("/stopService")
public Result<Boolean> stopService() {
Long driverId = AuthContextHolder.getUserId();
return Result.ok(driverService.stopService(driverId));
}
DriverServiceImpl
//停止接单服务
@Override
public Boolean stopService(Long driverId) {
//更新司机的接单状态 0
driverInfoFeignClient.updateServiceStatus(driverId,0);
//删除司机位置信息
locationFeignClient.removeDriverLocation(driverId);
//清空司机临时队列
newOrderFeignClient.clearNewOrderQueueData(driverId);
return true;
}
8.功能测试
- 启动后端服务
- XXL-JOB调度中心服务
- 启动微信小程序(微信开发者工具)
– 有小问题:因为我们现在有两端,司机端和乘客端,可能造成第二次扫描登录之后,第一次扫描失效,如果发现效果不正确,多扫几次码;每次重新启动服务器之后,扫码登录失效
– 可以这样测试:准备两台电脑,准备两个微信
乘客端和司机端分别显示如下界面说明测试成功,要注意的是,司机端弹出的界面很快就没有了,是因为前端设计的倒计时5秒,没有看到的话可以重新呼叫代驾查看
- 对于不想进行认证的同学把人脸识别那些方法的返回值直接改为true就可以了
司机抢单
1.需求
当前司机已经开启接单服务了,实时轮流司机服务器端临时队列,只要有合适的新订单产生,那么就会轮回获取新订单数据,进行语音播放,如果司机对这个订单感兴趣就可以抢单,大家注意,同一个新订单会放入满足条件的所有司机的临时队列,谁先抢到就是谁的。
2.司机抢单接口
service-order
OrderInfoController
@Operation(summary = "司机抢单")
@GetMapping("/robNewOrder/{driverId}/{orderId}")
public Result<Boolean> robNewOrder(@PathVariable Long driverId, @PathVariable Long orderId) {
return Result.ok(orderInfoService.robNewOrder(driverId, orderId));
}
OrderInfoServiceImpl
这里需要修改保存订单的代码,在下单的时候添加标识,判断订单是否处在接单状态
//乘客下单
@Override
public Long saveOrderInfo(OrderInfoForm orderInfoForm) {
//order_info添加订单数据
OrderInfo orderInfo = new OrderInfo();
BeanUtils.copyProperties(orderInfoForm,orderInfo);
//订单号
String orderNo = UUID.randomUUID().toString().replaceAll("-","");
orderInfo.setOrderNo(orderNo);
//订单状态
orderInfo.setStatus(OrderStatus.WAITING_ACCEPT.getStatus());
orderInfoMapper.insert(orderInfo);
//记录日志
this.log(orderInfo.getId(),orderInfo.getStatus());
//向redis添加标识
//接单标识,标识不存在了说明不在等待接单状态了
redisTemplate.opsForValue().set(RedisConstant.ORDER_ACCEPT_MARK,
"0", RedisConstant.ORDER_ACCEPT_MARK_EXPIRES_TIME, TimeUnit.MINUTES);
return orderInfo.getId();
}
这里判断订单是否存在的标识我觉得应该加上订单id更好一点,因为抢单只是抢同一个订单,这里有其他订单存在的时候也会继续向下查询数据库
//司机抢单
@Override
public Boolean robNewOrder(Long driverId, Long orderId) {
//判断订单是否存在,通过Redis,减少数据库压力
if(!redisTemplate.hasKey(RedisConstant.ORDER_ACCEPT_MARK)) {
//抢单失败
throw new GuiguException(ResultCodeEnum.COB_NEW_ORDER_FAIL);
}
//司机抢单
//修改order_info表订单状态值2:已经接单 + 司机id + 司机接单时间
//修改条件:根据订单id
LambdaQueryWrapper<OrderInfo> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(OrderInfo::getId,orderId);
OrderInfo orderInfo = orderInfoMapper.selectOne(wrapper);
//设置
orderInfo.setStatus(OrderStatus.ACCEPTED.getStatus());
orderInfo.setDriverId(driverId);
orderInfo.setAcceptTime(new Date());
//调用方法修改
int rows = orderInfoMapper.updateById(orderInfo);
if(rows != 1) {
//抢单失败
throw new GuiguException(ResultCodeEnum.COB_NEW_ORDER_FAIL);
}
//删除抢单标识
redisTemplate.delete(RedisConstant.ORDER_ACCEPT_MARK);
return true;
}
远程调用定义service-order-client
OrderInfoFeignClient
/**
* 司机抢单
* @param driverId
* @param orderId
* @return
*/
@GetMapping("/order/info/robNewOrder/{driverId}/{orderId}")
Result<Boolean> robNewOrder(@PathVariable("driverId") Long driverId, @PathVariable("orderId") Long orderId);
司机web端接口web-driver
OrderController
@Operation(summary = "司机抢单")
@GuiguLogin
@GetMapping("/robNewOrder/{orderId}")
public Result<Boolean> robNewOrder(@PathVariable Long orderId) {
Long driverId = AuthContextHolder.getUserId();
return Result.ok(orderService.robNewOrder(driverId, orderId));
}
OrderServiceImpl
@Override
public Boolean robNewOrder(Long driverId, Long orderId) {
return orderInfoFeignClient.robNewOrder(driverId,orderId).getData();
}
司机抢单优化方案
对于秒杀业务这块的内容,大家可以去看黑马点评这个项目,里面的秒杀讲解的很详细,只用看秒杀内容就可以了,其他的不用看
- 刚才抢单接口里面,并没有考虑并发的问题,所以,产生问题,类似于电商里面超卖问题
- 超卖问题
- 解决方案
1.超卖问题
商品秒杀过程中经常会出现超售的现象,超售就是卖出了超过预期数量的商品。比如说A商品库存是100个,但是秒杀的过程中,一共卖出去500个A商品。对于卖家来说,这就是超售。
上面的这个流程代表,A顾客抢购商品,电商系统先去判断,商品有没有库存,假设现在某个商品库存为1,是可以抢购的,于是电商程序,开启事物,生成UPDATE语句,但是也许是线程被挂起的原因,或者网络延迟的原因,反正就是没有把UPDATE交给数据库执行。
这时候B顾客来抢购商品,电商系统也是先去判断有没有库存,因为A顾客抢购商品的SQL语句并没有执行,但是B顾客抢购商品执行的很顺利,电商系统开启了事务,然后生成库存减1的UPDATE语句,并且提交给数据库运行,事务提交之后,商品库存变成0。这时候A顾客的抢购商品的UPDATE语句,传递给数据库执行,于是数据库对库存又减1,这就形成了超售现象。原本库存只有1件商品,却卖出去两份订单,你说这个商品发货给谁呢?
2.解决方案
- 设置数据库事务的隔离级别,设置为Serializable
这个事物隔离级别非常严格,因为多个事物并发执行,对同一条记录修改,就会出现超售现象。所以干脆,咱们就禁止事物的并发执行吧。Serializable就是让数据库,串行执行事物,一个事物执行完,才能执行下一个事物,这种办法确实解决了超售的问题。
但是SQL语句对于数据的修改最终是要反应到磁盘上的,磁盘的IO速度比内存和CPU慢很多。当有成百上千万的用户一起买东西的时候串行化执行事务,排在最后的人就不知道要等到什么时候了。
- 使用乐观锁解决,通过版本号进行控制
数据表设置乐观锁。我们在数据表上面添加一个乐观锁字段,数据类型是整数的,用来记录数据更新的版本号,这个跟SVN机制很像。乐观锁是一种逻辑锁,他是通过版本号来判定有没有更新冲突出现。比如说,现在A商品的乐观锁版本号是0,现在有事务1来抢购商品了。事务1记录下版本号是0,等到执行修改库存的时候,就把乐观锁的版本号设置成1。但是事务1在执行的过程中,还没来得及执行UPDATE语句修改库存。这个时候事务2进来了,他执行的很快,直接把库存修改成99,然后把版本号变成了1。这时候,事务1开始执行UPDATE语句,但是发现乐观锁的版本号变成了1,这说明,肯定有人抢在事务1之前,更改了库存,所以事务1就不能更新,否则就会出现超售现象。
- 加锁解决
我们学习过synchronized 及lock锁,但是这些锁是本地锁,在微服务环境中就不可以使用了,那么怎么办呢?我可以使用分布式锁,分布式的实现方式多种多样,常见的分布式说可以基于以下集中方式实现:
1、基于 Redis 做分布式锁
基于 REDIS 的 SETNX()、EXPIRE() 方法做分布式锁
(1)setnx(lockkey, 1) 如果返回 0,则说明占位失败;如果返回 1,则说明占位成功
(2)expire() 命令对 lockkey 设置超时时间,为的是避免死锁问题。
(3)执行完业务代码后,可以通过 delete 命令删除 key
2、基于 REDISSON 做分布式锁
redisson 是 redis 官方的分布式锁组件。
3、基于 ZooKeeper 做分布式锁
基于临时顺序节点实现
每一种分布式锁解决方案都有各自的优缺点
基于乐观锁解决司机抢单
1.基本实现思路
前面司机抢单时的sql语句为:
update order_info set status = 2, driver_id = #{driverId}, accept_time = now() where id = #{id}
只要接单标识还没删除,无数个线程都可以执行上面的更新sql,这样就不是先抢先得了,后抢的会覆盖前面的操作。
更改抢单sql:
update order_info set status = 2, driver_id = #{driverId}, accept_time = now() where id = #{id} and status = 1
或者
update order_info set status = 2, driver_id = #{driverId}, accept_time = now() where id = #{id} and driver_id is null
where语句后面相当于加了一个乐观锁,后面的线程执行时更新记录为0,那么我就可以返回抢单失败
2.代码修改
//司机抢单:乐观锁方案解决并发问题
public Boolean robNewOrder1(Long driverId, Long orderId) {
//判断订单是否存在,通过Redis,减少数据库压力
if(!redisTemplate.hasKey(RedisConstant.ORDER_ACCEPT_MARK)) {
//抢单失败
throw new GuiguException(ResultCodeEnum.COB_NEW_ORDER_FAIL);
}
//司机抢单
//update order_info set status =2 ,driver_id = ?,accept_time = ?
// where id=? and status = 1
LambdaQueryWrapper<OrderInfo> wrapper = new LambdaQueryWrapper<>();
wrapper.eq(OrderInfo::getId,orderId);
wrapper.eq(OrderInfo::getStatus,OrderStatus.WAITING_ACCEPT.getStatus());
//修改值
OrderInfo orderInfo = new OrderInfo();
orderInfo.setStatus(OrderStatus.ACCEPTED.getStatus());
orderInfo.setDriverId(driverId);
orderInfo.setAcceptTime(new Date());
//调用方法修改
int rows = orderInfoMapper.update(orderInfo,wrapper);
if(rows != 1) {
//抢单失败
throw new GuiguException(ResultCodeEnum.COB_NEW_ORDER_FAIL);
}
//删除抢单标识
redisTemplate.delete(RedisConstant.ORDER_ACCEPT_MARK);
return true;
}
分布式锁解决司机抢单
1. 本地锁的局限性
之前,我们学习过synchronized 及lock锁,这些锁都是本地锁。接下来写一个案例,演示本地锁的问题
1.1. 编写测试代码
在service-order
中新建TestController中添加测试方法
package com.atguigu.daijia.order.controller;
import com.atguigu.daijia.common.result.Result;
import com.atguigu.daijia.order.service.TestService;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@Tag(name = "测试接口")
@RestController
@RequestMapping("/order/test")
public class TestController {
@Autowired
private TestService testService;
@GetMapping("testLock")
public Result testLock() {
testService.testLock();
return Result.ok();
}
}
业务接口
package com.atguigu.daijia.order.service;
public interface TestService {
void testLock();
}
业务实现类
package com.atguigu.daijia.order.service.impl;
import com.atguigu.daijia.order.service.TestService;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
@Service
public class TestServiceImpl implements TestService {
@Autowired
private StringRedisTemplate redisTemplate;
@Override
public void testLock() {
// 查询Redis中的num值
String value = (String)this.redisTemplate.opsForValue().get("num");
// 没有该值return
if (StringUtils.isBlank(value)){
return ;
}
// 有值就转成成int
int num = Integer.parseInt(value);
// 把Redis中的num值+1
this.redisTemplate.opsForValue().set("num", String.valueOf(++num));
}
}
说明:通过reids客户端设置num=0
1.2. 使用本地锁
@Override
public synchronized void testLock() {
// 查询Redis中的num值
String value = (String)this.redisTemplate.opsForValue().get("num");
// 没有该值return
if (StringUtils.isBlank(value)){
return ;
}
// 有值就转成成int
int num = Integer.parseInt(value);
// 把Redis中的num值+1
this.redisTemplate.opsForValue().set("num", String.valueOf(++num));
}
1.3. 本地锁问题演示锁
接下来启动8205 8215 8225 三个运行实例,运行多个service-order
实例:
server.port=8215
server.port=8225
注意:bootstrap.properties 添加一个server.port = 8205; 将nacos的配置注释掉!
以上测试,可以发现:
本地锁只能锁住同一工程内的资源,在分布式系统里面都存在局限性。
此时需要分布式锁。
2. 分布式锁实现的解决方案
分布式锁主流的实现方案:
- 基于数据库实现分布式锁
- 基于缓存( Redis等)
- 基于Zookeeper
每一种分布式锁解决方案都有各自的优缺点:
- 性能:Redis最高
- 可靠性:zookeeper最高
因为Redis具备高性能、高可用、高并发的特性,这里,我们就基于Redis实现分布式锁。
分布式锁的关键是多进程共享的内存标记(锁),因此只要我们在Redis中放置一个这样的标记(数据)就可以了。不过在实现过程中,不要忘了我们需要实现下列目标:
-
多进程可见:多进程可见,否则就无法实现分布式效果
-
避免死锁:死锁的情况有很多,我们要思考各种异常导致死锁的情况,保证锁可以被释放
尝试获取锁
成功:执行业务代码 执行业务 try(){业务代码-宕机} catch() finally{ 释放锁}
失败:等待;失效;下次
-
排它:同一时刻,只能有一个进程获得锁
-
高可用:避免锁服务宕机或处理好宕机的补救措施(redis集群架构:1.主从复制 2.哨兵 3.cluster集群)
3.使用Redis实现分布式锁
- 多个客户端同时获取锁(setnx)
- 获取成功,执行业务逻辑{从db获取数据,放入缓存,执行完成释放锁(del)
- 其他客户端等待重试
3.1. 编写代码
/**
* 采用SpringDataRedis实现分布式锁
* 原理:执行业务方法前先尝试获取锁(setnx存入key val),如果获取锁成功再执行业务代码,业务执行完毕后将锁释放(del key)
*/
@Override
public void testLock() {
//0.先尝试获取锁 setnx key val
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent("lock", "lock");
if(flag){
//获取锁成功,执行业务代码
//1.先从redis中通过key num获取值 key提前手动设置 num 初始值:0
String value = stringRedisTemplate.opsForValue().get("num");
//2.如果值为空则非法直接返回即可
if (StringUtils.isBlank(value)) {
return;
}
//3.对num值进行自增加一
int num = Integer.parseInt(value);
stringRedisTemplate.opsForValue().set("num", String.valueOf(++num));
//4.将锁释放
stringRedisTemplate.delete("lock");
}else{
try {
Thread.sleep(100);
this.testLock();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
重启,服务集群,通过网关压力测试:
ab -n 5000 -c 100 http://192.168.200.1/order/test/testLock
查看Redis中num的值:
基本实现。
问题:setnx刚好获取到锁,业务逻辑出现异常,导致锁无法释放
解决:设置过期时间,自动释放锁。
3.2 优化之设置锁的过期时间
设置过期时间有两种方式:
- 首先想到通过expire设置过期时间(缺乏原子性:如果在setnx和expire之间出现异常,锁也无法释放)
- 在set时指定过期时间(推荐)
设置过期时间:
压力测试肯定也没有问题。自行测试
问题:可能会释放其他服务器的锁。
场景:如果业务逻辑的执行时间是7s。执行流程如下
-
index1业务逻辑没执行完,3秒后锁被自动释放。
-
index2获取到锁,执行业务逻辑,3秒后锁被自动释放。
-
index3获取到锁,执行业务逻辑
. index1业务逻辑执行完成,开始调用del释放锁,这时释放的是index3的锁, 导致index3的业务只执行1s就被别人释放。
最终等于没锁的情况。
解决:setnx获取锁时,设置一个指定的唯一值(例如:uuid);释放前获取这个值,判断是否自己的锁
3.3 优化之UUID防误删
问题:删除操作缺乏原子性。
场景:
- index1执行删除时,查询到的lock值确实和uuid相等
- index1执行删除前,lock刚好过期时间已到,被Redis自动释放
在Redis中没有了锁。
- index2获取了lock,index2线程获取到了cpu的资源,开始执行方法
- index1执行删除,此时会把index2的lock删除
index1 因为已经在方法中了,所以不需要重新上锁。index1有执行的权限。index1已经比较完成了,这个时候,开始执行
删除的index2的锁!
3.4 优化之LUA脚本保证删除的原子性
/**
* 采用SpringDataRedis实现分布式锁
* 原理:执行业务方法前先尝试获取锁(setnx存入key val),如果获取锁成功再执行业务代码,业务执行完毕后将锁释放(del key)
*/
@Override
public void testLock() {
//0.先尝试获取锁 setnx key val
//问题:锁可能存在线程间相互释放
//Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent("lock", "lock", 10, TimeUnit.SECONDS);
//解决:锁值设置为uuid
String uuid = UUID.randomUUID().toString();
Boolean flag = stringRedisTemplate.opsForValue().setIfAbsent("lock", uuid, 10, TimeUnit.SECONDS);
if(flag){
//获取锁成功,执行业务代码
//1.先从redis中通过key num获取值 key提前手动设置 num 初始值:0
String value = stringRedisTemplate.opsForValue().get("num");
//2.如果值为空则非法直接返回即可
if (StringUtils.isBlank(value)) {
return;
}
//3.对num值进行自增加一
int num = Integer.parseInt(value);
stringRedisTemplate.opsForValue().set("num", String.valueOf(++num));
//4.将锁释放 判断uuid
//问题:删除操作缺乏原子性。
//if(uuid.equals(stringRedisTemplate.opsForValue().get("lock"))){ //线程一:判断是满足是当前线程锁的值
// //条件满足,此时锁正好到期,redis锁自动释放了线程2获取锁成功,线程1将线程2的锁删除
// stringRedisTemplate.delete("lock");
//}
//解决:redis执行lua脚本保证原子,lua脚本执行会作为一个整体执行
//执行脚本参数 参数1:脚本对象封装lua脚本,参数二:lua脚本中需要key参数(KEYS[i]) 参数三:lua脚本中需要参数值 ARGV[i]
//4.1 先创建脚本对象 DefaultRedisScript泛型脚本语言返回值类型 Long 0:失败 1:成功
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
//4.2设置脚本文本
String script = "if redis.call(\"get\",KEYS[1]) == ARGV[1]\n" +
"then\n" +
" return redis.call(\"del\",KEYS[1])\n" +
"else\n" +
" return 0\n" +
"end";
redisScript.setScriptText(script);
//4.3 设置响应类型
redisScript.setResultType(Long.class);
stringRedisTemplate.execute(redisScript, Arrays.asList("lock"), uuid);
}else{
try {
//睡眠
Thread.sleep(100);
//自旋重试
this.testLock();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
3.5 总结
1、加锁
// 1. 从Redis中获取锁,set k1 v1 px 20000 nx
String uuid = UUID.randomUUID().toString();
Boolean lock = this.redisTemplate.opsForValue()
.setIfAbsent("lock", uuid, 2, TimeUnit.SECONDS);
2、使用lua释放锁
// 2. 释放锁 del
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
// 设置lua脚本返回的数据类型
DefaultRedisScript<Long> redisScript = new DefaultRedisScript<>();
// 设置lua脚本返回类型为Long
redisScript.setResultType(Long.class);
redisScript.setScriptText(script);
redisTemplate.execute(redisScript, Arrays.asList("lock"),uuid);
3、重试
Thread.sleep(500);
testLock();
为了确保分布式锁可用,我们至少要确保锁的实现同时满足以下四个条件:
- 互斥性。在任意时刻,只有一个客户端能持有锁。
- 不会发生死锁。即使有一个客户端在持有锁的期间崩溃而没有主动解锁,也能保证后续其他客户端能加锁。
- 解铃还须系铃人。加锁和解锁必须是同一个客户端,客户端自己不能把别人加的锁给解了。
- 加锁和解锁必须具有原子性
4.使用Redisson 解决分布式锁
Redisson是一个在Redis的基础上实现的Java驻内存数据网格(In-Memory Data Grid)。它不仅提供了一系列的分布式的Java常用对象,还提供了许多分布式服务。其中包括(BitSet, Set, Multimap, SortedSet, Map, List, Queue, BlockingQueue, Deque, BlockingDeque, Semaphore, Lock, AtomicLong, CountDownLatch, Publish / Subscribe, Bloom filter, Remote service, Spring cache, Executor service, Live Object service, Scheduler service) Redisson提供了使用Redis的最简单和最便捷的方法。Redisson的宗旨是促进使用者对Redis的关注分离(Separation of Concern),从而让使用者能够将精力更集中地放在处理业务逻辑上。
官方文档地址:https://github.com/Redisson/Redisson/wiki
Github 地址:https://github.com/Redisson/Redisson
4.1 实现代码
4.1.1引入依赖
父模块已经做了版本管理,直接引用
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
</dependency>
4.1.2 配置RedissonClient
package com.atguigu.daijia.common.config.redssion;
import lombok.Data;
import org.redisson.Redisson;
import org.redisson.api.RedissonClient;
import org.redisson.config.Config;
import org.redisson.config.SingleServerConfig;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.util.StringUtils;
/**
* redisson配置信息
*/
@Data
@Configuration
@ConfigurationProperties("spring.data.redis")
public class RedissonConfig {
private String host;
private String password;
private String port;
private int timeout = 3000;
private static String ADDRESS_PREFIX = "redis://";
/**
* 自动装配
*
*/
@Bean
RedissonClient redissonSingle() {
Config config = new Config();
if(!StringUtils.hasText(host)){
throw new RuntimeException("host is empty");
}
SingleServerConfig serverConfig = config.useSingleServer()
.setAddress(ADDRESS_PREFIX + this.host + ":" + port)
.setTimeout(this.timeout);
if(StringUtils.hasText(this.password)) {
serverConfig.setPassword(this.password);
}
return Redisson.create(config);
}
}
注意:这里读取了一个名为RedisProperties的属性,因为我们引入了SpringDataRedis,Spring已经自动加载了RedisProperties,并且读取了配置文件中的Redis信息。
4.1.3 修改实现类
@Autowired
private RedissonClient redissonClient;
/**
* 使用Redison实现分布式锁
* 开发步骤:
* 1.使用RedissonClient客户端对象 创建锁对象
* 2.调用获取锁方法
* 3.执行业务逻辑
* 4.将锁释放
*
*/
public void testLock() {
//0.创建锁对象
RLock lock = redissonClient.getLock("lock1");
//0.1 尝试加锁
//0.1.1 lock() 阻塞等待一直到获取锁,默认锁有效期30s
lock.lock();
//1.先从redis中通过key num获取值 key提前手动设置 num 初始值:0
String value = stringRedisTemplate.opsForValue().get("num");
//2.如果值为空则非法直接返回即可
if (StringUtils.isBlank(value)) {
return;
}
//3.对num值进行自增加一
int num = Integer.parseInt(value);
stringRedisTemplate.opsForValue().set("num", String.valueOf(++num));
//4.将锁释放
lock.unlock();
}
基于Redis的Redisson分布式可重入锁RLock Java对象实现了java.util.concurrent.locks.Lock接口。
大家都知道,如果负责储存这个分布式锁的Redisson节点宕机以后,而且这个锁正好处于锁住的状态时,这个锁会出现锁死的状态。为了避免这种情况的发生,Redisson内部提供了一个监控锁的看门狗,它的作用是在Redisson实例被关闭前,不断的延长锁的有效期。默认情况下,看门狗的检查锁的超时时间是30秒钟,也可以通过修改Config.lockWatchdogTimeout来另行指定。
另外Redisson还通过加锁的方法提供了leaseTime的参数来指定加锁的时间。超过这个时间后锁便自动解开了。
看门狗原理:
只要线程一加锁成功,就会启动一个watch dog
看门狗,它是一个后台线程,会每隔10
秒检查一下,如果线程一还持有锁,那么就会不断的延长锁key
的生存时间。因此,Redisson
就是使用Redisson
解决了锁过期释放,业务没执行完问题。
1、如果我们指定了锁的超时时间,就发送给Redis执行脚本,进行占锁,默认超时就是我们制定的时间,不会自动续期;
2、如果我们未指定锁的超时时间,就使用lockWatchdogTimeout = 30 * 1000
【看门狗默认时间】】
基于Redisson分布式解决司机抢单
操作模块:service-util,我们把redisson配置在这里,后续其他微服务模块可以直接使用
代码实现
在业务方法编写加锁和解锁
OrderInfoServiceImpl
@Autowired
private RedissonClient redissonClient;
//Redisson实现
@Override
public void testLock() {
//1 通过redisson创建锁对象
RLock lock = redissonClient.getLock("lock1");
//2 尝试获取锁
//(1) 阻塞一直等待直到获取到,获取锁之后,默认过期时间30s
lock.lock();
//(2) 获取到锁,锁过期时间10s
// lock.lock(10,TimeUnit.SECONDS);
//(3) 第一个参数获取锁等待时间
// 第二个参数获取到锁,锁过期时间
// try {
// // true
// boolean tryLock = lock.tryLock(30, 10, TimeUnit.SECONDS);
// } catch (InterruptedException e) {
// throw new RuntimeException(e);
// }
//3 编写业务代码
//1.先从redis中通过key num获取值 key提前手动设置 num 初始值:0
String value = redisTemplate.opsForValue().get("num");
//2.如果值为空则非法直接返回即可
if (StringUtils.isBlank(value)) {
return;
}
//3.对num值进行自增加一
int num = Integer.parseInt(value);
redisTemplate.opsForValue().set("num", String.valueOf(++num));
//4 释放锁
lock.unlock();
}