1.项目及启动说明
gulimall-search可以不看,后台管理用户名密码admin/admin,商城密码admin/123456
高并发总结:缓存,异步,队列
- docker部署以下工具
工具 | 版本号 | 下载 |
Mysql | 5.7 | |
Redis | 7.0 | |
RabbitMQ | 3.8.5 | |
Nginx | 1.1.6 |
- 修改本机的host文件,映射域名端口
192.168.77.130 gulimall.com
192.168.77.130 search.gulimall.com
192.168.77.130 item.gulimall.com
192.168.77.130 auth.gulimall.com
192.168.77.130 cart.gulimall.com
192.168.77.130 order.gulimall.com
192.168.77.130 member.gulimall.com
192.168.77.130 seckill.gulimall.com
以上端口换成自己Linux的ip地址
- 修改Linux中Nginx的配置文件
1、在nginx.conf中添加负载均衡的配置
upstream gulimall {
server 192.168.43.182:88;
}
2、在gulimall.conf中添加如下配置
server {
listen 80;
server_name gulimall.com *.gulimall.com;
#charset koi8-r;
#access_log /var/log/nginx/log/host.access.log main;
#配置静态资源的动态分离
location /static/ {
root /usr/share/nginx/html;
}
#支付异步回调的一个配置
location /payed/ {
proxy_set_header Host order.gulimall.com; #不让请求头丢失
proxy_pass http://gulimall;
}
location / {
#root /usr/share/nginx/html;
#index index.html index.htm;
proxy_set_header Host $host; #不让请求头丢失
proxy_pass http://gulimall;
}
2.分布式架构
SpringCloud - Gateway:API 网关
SpringCloud - Feign:声明式HTTP 客户端(调用远程服务)
SpringCloud - Sleuth:调用链监控
SpringCloud Alibaba - Nacos:注册中心(服务发现/注册)/ 配置中心(动态配置管理)
SpringCloud Alibaba - Sentinel:服务容错(限流、降级、熔断)
SpringCloud Alibaba - Seata:原Fescar,即分布式事务解决方案
外网部署前端项目,请求经过nginx反向代理到spirngcloud-gateway,网关根据请求路径转发到不同服务
SpringCloud Alibaba-Nacos作为服务注册中心,并进行动态配置管理
服务之间互相调用使用spirngcloud-feign,登录系统使用基于OAuth2.0的认证中心
缓存使用Redis集群(分片+哨兵集群),数据库使用MySQL,读写分离和分库分表
服务和服务之间使用RabbitMQ,来完成订单关闭,库存解锁,保证分布式事务的一致性
对象存储使用阿里云的对象存储服务OSS
常见的负载均衡算法:
**轮询:**为第一个请求选择健康池中的第一个后端服务器,然后按顺序往后依次选择,直到最后一个,然后循环。
**最小连接:**优先选择连接数最少,也就是压力最小的后端服务器,在会话较长的情况下可以考虑采取这种方式。
**散列:**根据请求源的IP的散列(hash)来选择要转发的服务器。这种方式可以一定程度上保证特定用户能连接到相同的服务器。
3.Docker使用
以安装mysql为例
docker pull mysql:5.7
# --name指定容器名字 -v目录挂载 -p指定端口映射 -e设置mysql参数 -d后台运行*
docker run -p 3306:3306 --name mysql \
-v /mydata/mysql/log:/var/log/mysql \
-v /mydata/mysql/data:/var/lib/mysql \
-v /mydata/mysql/conf:/etc/mysql \
-e MYSQL_ROOT_PASSWORD=root \
-d mysql:5.7
容器相关
docker ps #查看运行中的容器
docker exec -it mysql bin/bash #进入容器
4.SpringCloud组件使用
nacos注册中心
1.依赖
2.yml配置
spring:
application:
name: gulimall-coupon
cloud:
nacos:
config:
server-addr: 47.96.3.126:8848
3.启动类@EnableDiscoveryClient
4.启动nacos
nacos配置中心
1.依赖
2.创建bootstrap.properties,这个文件是springboot里规定的,优先级别比application.properties高
spring.application.name=服务名
spring.cloud.nacos.config.server-addr=127.0.0.1:8848
spring.cloud.nacos.config.namespace=命名空间的ID #切换配置环境
spring.cloud.nacos.config.group=组名 #切换配置组
#拆分配置
spring.cloud.nacos.config.extension-configs[0].data-id=datasource.yml
spring.cloud.nacos.config.extension-configs[0].group=dev
spring.cloud.nacos.config.extension-configs[0].refresh=true
spring.cloud.nacos.config.extension-configs[1].data-id=mybatis.yml
spring.cloud.nacos.config.extension-configs[1].group=dev
spring.cloud.nacos.config.extension-configs[1].refresh=true
spring.cloud.nacos.config.extension-configs[2].data-id=other.yml
spring.cloud.nacos.config.extension-configs[2].group=dev
spring.cloud.nacos.config.extension-configs[2].refresh=true
3.启动nacos,创建服务名-开发环境名.properties,添加任何配置
4.获取配置使用@RefreshScope和@Value,如果配置内容相同,优先使用配置中心
最终方案:每个微服务创建自己的命名空间,然后使用配置分组区分环境(dev/test/prod)
feign远程调用
1.依赖
2.创建feign接口,声明要调用其他服务中的哪些服务(直接复制方法定义),加注解@FeignClient(“服务名”)
@FeignClient("gulimall-coupon")
public interface CouponFeignService {
@RequestMapping("/coupon/coupon/member/list")
public R membercoupons();
}
3.启动类上加注解@EnableFeignClients(“feign接口全类名”)
@EnableFeignClients(basePackages="com.atguigu.gulimall.member.feign")
gateway网关
spring:
gateway:
routes:
- id: test_root
uri: https://www.baidu.com
predicates:
- Query=url,baidu #有url=baidu这个参数跳转
- id: third_party_route
uri: lb://gulimall-third-party
predicates:
- Path=/api/thirdparty/**
filters:
- RewritePath=/api/thirdparty/(?<segment>/?.*),/$\{segment} #把/api/thirdparty去掉
- id: admin_route
uri: lb://renren-fast
predicates:
- Path=/api/**
filters:
- RewritePath=/api/(?<segment>/?.*), /renren-fast/$\{segment}
#验证码网关默认转发http://localhost:88/api/captcha.jpg->http://localhost:8080/api/captcha.jpg
#需要路径重写为http://localhost:88/api/captcha.jpg->http://localhost:8080/renren-fast/captcha.jpg
5.后台管理开发
商品服务主要表结构
pms_category:商品三级分类表
分类id,分类名称,父分类id,菜单等级,是否显示,排序,图标地址,计量单位,商品数量
225 手机 34 3 。。。
pms_category_brand_relation:品牌分类关联表
主键id,品牌id,分类id,品牌名,分类名称
每个三级分类都可以关联多个品牌
pms_attr_group:属性分组表
分组id,组名,排序,描述,组图标,所属分类id
每个三级分类都可以关联多个属性,比如主芯片,主体等
pms_attr_attrgroup_relation:属性和属性分组关联表
id,属性id,属性分组id,属性组内排序
即一个属性分组可以有多个属性,如主芯片可以关联芯片上市时间,芯片型号
pms_attr:商品属性表
属性id,属性名,是否需要检索,可否多个值,属性图标,可选值列表,属性类型(销售属性,基本属性),启用状态,所属分类,是否展示在介绍上
芯片上市时间,芯片型号就是对应的这里一条记录
pms_product_attr_value:SPU规格参数表
id,spuId,基本属性id,基本属性名,基本属性值
一条数据对应一个spu的一个属性,一个spu可以有多个属性,通过属性id和属性表关联
spu商品聚合信息的最小单位,比如iphone14就是一个spu,属性都一样,比如重量240g,上市年份2022年
pms_spu_info:SPU信息表
spuId,spu名称,spu描述,所属分类id,品牌id,上架状态
pms_spu_images:SPU图片集表
id,spuId,图片名称,图片地址,顺序,是否默认
一条记录对应一张图片
pms_sku_sale_attr_value:SKU销售属性表
id,skuId,销售属性Id,销售属性名,销售属性值
sku商品不可再分的最小单位,比如iphone14 256G 紫色,通过属性id和属性表关联
pms_sku_info:SKU信息表
skuId,spuId,sku名称,sku描述,所属分类id,品牌id,标题,价格,销量
pms_sku_images:SKU图片集表
id,skuId,图片名称,图片地址,顺序,是否默认
一条记录对应一张图片
库存服务主要表结构
wms_purchase_detail:采购需求表
采购单id,采购商品id,采购数量,采购金额,仓库id,状态[0]
状态:新建,已分配,正在采购,已完成,采购失败
wms_purchase:采购单表
采购单id,采购人id,采购人名,联系方式,优先级,状态,仓库id,总金额
状态:新建,已分配,已领取,已完成,采购失败
wms_ware_info:仓库信息
id,仓库名,仓库地址,区域编码,仓库信息
后管功能模块开发
目前请求都是通过前端发送到网关,网关进行转发,没有nginx
分类维护,品牌管理,商品属性,商品维护属于商品系统菜单
仓库维护,库存工作单,商品库存,采购单维护属于库存系统菜单
每日秒杀属于优惠营销菜单
分类维护菜单
1.分类菜单CRUD
product/category/list/tree 获取所有分类以及子分类,并返回json树形结构
product/category/delete
如果为第一级菜单,父分类id为0,菜单等级为1
三级分类实体类,复习组合模式
@Data
@TableName("pms_category")
public class CategoryEntity implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 分类id
*/
@TableId
private Long catId;
/**
* 分类名称
*/
private String name;
/**
* 父分类id
*/
private Long parentCid;
/**
* 层级
*/
private Integer catLevel;
/**
* 所有子分类
*/
@JsonInclude(JsonInclude.Include.NON_EMPTY)
@TableField(exist = false)
private List<CategoryEntity> children;
}
mybatis逻辑删除配置
mybatis-plus:
global-config:
db-config:
#逻辑删除
logic-delete-value: 1
logic-not-delete-value: 0
@TableLogic(value = "1",delval = "0")
private Integer showStatus;
品牌管理菜单
1.品牌管理CRUD
product/categorybrandrelation/save
一个品牌可以和多个分类关联
JSR303数据校验
@RequestMapping("/save")
public R save(@Validated({AddGroup.class}) @RequestBody BrandEntity brand, //BindingResult result){
// Map<String,String> map = new HashMap<>();
//
// if (result.hasErrors()) {
// //获取效验错误结果
// result.getFieldErrors().forEach((item)-> {
// //获取到错误提示
// String message = item.getDefaultMessage();
// //获取错误的属性的名字
// String field = item.getField();
// map.put(field,message);
// });
// return R.error(400,"提交的数据不合法").put("data",map);
// } else {
//
// }
brandService.save(brand);
return R.ok();
}
// 配合实体类注解
@NotNull(message = "修改必须指定品牌id",groups = {UpdateGroup.class})
@Null(message = "新增不能指定id",groups = {AddGroup.class})
@TableId
private Long brandId;
商品属性菜单
1.属性分组CRUD
product/attrgroup/list/{catelogId}
一个分类有多个属性分组,每个属性分组可以关联多个属性
2.属性分组关联属性CRUD
/product/attrgroup/{attrgroupId}/attr/relation
3.规格参数CRUD
product/attr/{attrType}/list/{catelogId}
规格参数就是SPU基本属性,attrType = base
4.销售属性CRUD
product/attr/{attrType}/list/{catelogId}
销售属性就是SKU销售属性,attrType = sale
商品维护菜单
1.SPU管理CRUD
product/spuinfo/list 查pms_spu_info
product/attr/base/listforspu/{spuId} 查pms_product_attr_value
展示SPU列表,点进单个SPU可以获取/修改规格参数
2.发布商品
product/spuinfo/save
前端:输入SPU基本信息->输入SPU规格参数->输入SKU规格参数->排列组合生成所有SKU,输入信息->发布商品
1、保存spu基本信息:pms_spu_info
2、保存spu的图片集:pms_spu_images
3、保存spu的规格参数(基本属性):pms_product_attr_value
4、远程保存spu的积分信息:gulimall_sms–>sms_spu_bounds
5、保存当前spu对应的所有sku信息:pms_sku_info
sku的基本信息:pms_sku_info
sku的图片信息:pms_sku_images
sku的销售属性:pms_sku_sale_attr_value
sku的阶梯价格、满减、会员价格等信息:gulimall_sms–>sms_sku_ladder、sms_sku_full_reduction、sms_member_price
OOS对象存储
商品图片使用阿里云对象存储,前端上传文件地址要修改http://gulimail-xxh.oss-cn-hangzhou.aliyuncs.com
接口地址
/oss/policy
返回实例
{"accessid":"LTAI4G4W1RA4JXz2QhoDwHhi","policy":"eyJleHBpcmF0aW9uIjoiMjAyMC0wNC0yOVQwMjo1ODowNy41NzhaIiwiY29uZGl0aW9ucyI6W1siY29udGVudC1sZW5ndGgtcmFuZ2UiLDAsMTA0ODU3NjAwMF0sWyJzdGFydHMtd2l0aCIsIiRrZXkiLCIyMDIwLTA0LTI5LyJdXX0=","signature":"s42iRxtxGFmHyG40StM3d9vOfFk=","dir":"2020-04-29/","host":"https://gulimall-images.oss-cn-shanghai.aliyuncs.com","expire":"1588129087"}
- 用户发送上传Policy请求到应用服务器。
- 应用服务器返回上传Policy和签名给用户。
- 用户直接上传数据到OSS
3.SKU管理CRUD
product/skuinfo/list
展示SKU列表,可根据分类,品牌,价格,标题检索
仓库维护菜单
1.仓库信息CRUD
/ware/waresku/list
采购单维护菜单
1.采购需求CRUD
/ware/purchasedetail/list 查询采购需求列表
/ware/purchase/merge 合并采购需求
创建一个/多个采购需求后,可以单个/批量分配给某个采购单,分配后,这个采购需求状态就变成已分配
如果没有选择采购单,将创建新采购单进行合并
通过采购需求中的采购单id关联到某个采购单
2.采购单维护CRUD
ware/purchase/list 查询采购单列表
ware/purchase/received 领取采购单
ware/purchase/done 完成采购单
采购单生成后可以分配给某个用户,分配后,采购单状态就变成已分配
这个用户领取后采购单状态就变成已领取,采购项状态变成正在采购
采购完成后采购单状态变成已完成,采购项状态变成已完成,商品入库
每日秒杀菜单
1.秒杀活动场次CRUD及关联商品
coupon/seckillsession/list 查询所有秒杀活动场次
秒杀活动有开始时间和结束时间,实际上这里应该是每日秒杀活动,对应的是每日开始时间和结束时间,但是本项目中这个菜单被当成普通的秒杀活动用了,开始和结束时间都是带有日期的
/coupon/seckillskurelation/list 根据场次id查出关联的商品
一个场次可以关联很多商品
6.商城首页开发
gulimall-search商品上架和检索使用elasticsearch,跳过
请求流程:浏览器请求gulimall.com,根据本机配置的hosts被解析为虚拟机ip,虚拟机的80端口(nginx)接收到请求后,根据proxy_pass http://gulimall,将请求转发到项目ip(本机)的88端口,即网关,网关根据host负载均衡到对应的微服务,然后该服务响应请求,返回响应结果
# 这个配置在前面,根据路径中带api路由
- id: product_route
uri: lb://gulimall-product
predicates:
- Path=/api/product/**
filters:
- RewritePath=/api/(?<segment>/?.*),/$\{segment}
# 配置在后面,优先级低,按照域名路由
- id: gulimall_host_route
uri: lb://gulimall-product
predicates:
- Host=gulimall.com,item.gulimall.com
动静分离:
指定所有static的访问,都是到“/usr/share/nginx/html”路径下寻找
location /static/ {
root /usr/share/nginx/html;
}
1.商城首页
1.访问首页渲染一级分类菜单
@GetMapping(value = {"/","index.html"})
private String indexPage(Model model) {
// 查出所有的一级分类
List<CategoryEntity> categoryEntities = categoryService.getLevel1Categorys();
model.addAttribute("categories",categoryEntities);
// thymeleaf默认后缀html
return "index";
}
通过下面缓存的一系列演变过程,在原来方法基础上添加注解
/**
* 该注解代表当前方法的结果需要缓存,如果缓存中有,方法都不用调用,如果缓存中没有,会调用方法。最后将方法的结果放入缓存
* 在redis中体现为category文件夹下,key的值是category::getLevel1Categorys,value的值是JSON格式
*/
@Cacheable(value = {"category"},key = "#root.method.name",sync = true)
@Override
public List<CategoryEntity> getLevel1Categorys() {
System.out.println("getLevel1Categorys........");
long l = System.currentTimeMillis();
List<CategoryEntity> categoryEntities = this.baseMapper.selectList(
new QueryWrapper<CategoryEntity>().eq("parent_cid", 0));
System.out.println("消耗时间:"+ (System.currentTimeMillis() - l));
return categoryEntities;
}
访问gulimail.com返回gulimall-product\src\main\resources\templates\index.html
2.渲染二级三级分类数据
@GetMapping(value = "/index/catalog.json")
@ResponseBody
public Map<String, List<Catelog2Vo>> getCatalogJson() {
Map<String, List<Catelog2Vo>> catalogJson = categoryService.getCatalogJson();
return catalogJson;
}
一次性查询出所有的分类数据,减少对于数据库的访问次数,后面的数据操作并不是到数据库中查询,而是从内存中操作
@Override
public Map<String, List<Catelog2Vo>> getCatalogJson() {
// 将数据库的多次查询变为一次
List<CategoryEntity> selectList = this.baseMapper.selectList(null);
// 封装数据,省略
Map<String, List<Catelog2Vo>> parentCid = selectList.stream();
return parentCid;
}
通过下面缓存的一系列演变过程,最终解决方案
@Cacheable(value = "category",key = "#root.methodName")
@Override
public Map<String, List<Catelog2Vo>> getCatalogJson() {
// 将数据库的多次查询变为一次
List<CategoryEntity> selectList = this.baseMapper.selectList(null);
// 封装数据,省略
Map<String, List<Catelog2Vo>> parentCid = selectList.stream();
return parentCid;
}
2.分类数据缓存及有关问题解决
本项目使用Jedis作为操作Resdis的客户端,StringRedisTemplate是Springboot对Jedis相关API的进一步封装
一致性要求不高,读多写少的数据适合放入缓存,如果分类列表,商品列表,下面将数据库中获取的分类数据放入缓存
public Map<String, List<Catelog2Vo>> getCatalogJsonFromCache() {
// 加入缓存逻辑,缓存中存的数据是json字符串
ValueOperations<String, String> ops = stringRedisTemplate.opsForValue();
String catalogJson = ops.get("catalogJson");
if (StringUtils.isEmpty(catalogJson)) {
System.out.println("缓存不命中...查询数据库...");
// 缓存中没有数据,查询数据库
Map<String, List<Catelog2Vo>> catalogJsonFromDb = getDataFromDb();
return catalogJsonFromDb;
}
System.out.println("缓存命中...直接返回...");
// 转为指定的对象
Map<String, List<Catelog2Vo>> result = JSON.parseObject(catalogJson,new TypeReference<Map<String, List<Catelog2Vo>>>(){});
return result;
}
/**
* 从数据库中查询并放入缓存,parentCid最终的结构是key是一级分类的id,value是二级分类的list,每个二级分类又包含一个三级分类list,每个分类的实体包含父分类id,自己的id和名字
*/
private Map<String, List<Catelog2Vo>> getDataFromDb() {
// 将数据库的多次查询变为一次
List<CategoryEntity> selectList = this.baseMapper.selectList(null);
// 封装数据,省略
Map<String, List<Catelog2Vo>> parentCid;
// 将查到的数据转为Json放入缓存
String valueJson = JSON.toJSONString(parentCid);
stringRedisTemplate.opsForValue().set("catalogJson", valueJson, 1, TimeUnit.DAYS);
return parentCid;
}
但是在分布式缓存中,还是存在缓存穿透,击穿,雪崩的问题
缓存穿透是指查询一个永不存在的数据;缓存雪崩是值大面积key同时失效问题;缓存击穿是指高频key失效问题
- 空结果缓存,解决缓存穿透
- 设置过期时间(随机加值),解决缓存雪崩
- 加锁,解决缓存击穿
下面通过加本地锁尝试解决缓存击穿问题
public Map<String, List<Catelog2Vo>> getCatalogJsonFromDbWithLocalLock() {
synchronized (this) {
// 使用了双重检查锁定模式来控制线程的并发访问数据库,一个线程进入到临界区之前,判断缓存中是否有数据,进入到临界区后,再次判断缓存中是否有数据
// 锁之后还要进行一次检查是因为如果多个线程同时查缓存不命中,就会多次查库
String catalogJson = stringRedisTemplate.opsForValue().get("catalogJson");
if (!StringUtils.isEmpty(catalogJson)) {
// 缓存不为空直接返回
Map<String, List<Catelog2Vo>> result = JSON.parseObject(catalogJson, new TypeReference<Map<String, List<Catelog2Vo>>>() {
});
return result;
}
return getDataFromDb();
}
}
2.1.分布式锁
本地锁只能锁住当前进程,分布式项目需要分布式锁,使用redis实现,这种方式没有实现锁在业务执行时自动续期
redis命令是SET key value [EX seconds] [PX milliseconds] [NX|XX]
public Map<String, List<Catelog2Vo>> getCatalogJsonFromDbWithRedisLock() {
// 占分布式锁,去redis占坑
// 1.设置过期时间避免宕机死锁,设置过期时间必须和加锁是同步的,也是为了防止正要设置过期时间时宕机死锁
// 2.uuid避免锁过期导致锁被替换,保证只能删进程自己的锁
String uuid = UUID.randomUUID().toString();
Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock", uuid,30,TimeUnit.SECONDS);
if (lock) {
System.out.println("获取分布式锁成功...");
Map<String, List<Catelog2Vo>> dataFromDb = null;
try {
// 加锁成功...执行业务
dataFromDb = getDataFromDb();
} finally {
// 执行完业务后需要删除锁
// 使用脚本是为了避免获取锁后,在返回传输途中锁到期被换成别人的锁,就会误删别人的锁
// 脚本的意思是获取redis里key为lock的值,如果等于传过去的uuid,就删掉,对比并删除必须是原子操作
// 如果uuid不一样,说明原来的锁到期自动删除了(异常/断电),别的进程抢到了新锁,执行删锁失败
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
// 删除锁,Long.class是脚本的返回值类型,lock是KEYS[1],uuid是ARGV[1]
stringRedisTemplate.execute(new DefaultRedisScript<Long>(script, Long.class), Arrays.asList("lock"), uuid);
}
return dataFromDb;
} else {
System.out.println("获取分布式锁失败...等待重试...");
// 加锁失败...重试机制
// 休眠一百毫秒
try { TimeUnit.MILLISECONDS.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); }
return getCatalogJsonFromDbWithRedisLock(); //自旋的方式
}
}
1.使用Redisson框架实现分布式锁
@Configuration
public class MyRedissonConfig {
/**
* 所有对Redisson的使用都是通过RedissonClient
*/
@Bean(destroyMethod="shutdown")
public RedissonClient redisson() throws IOException {
//1、创建配置
Config config = new Config();
config.useSingleServer().setAddress("redis://192.168.23.129:6379");
//2、根据Config创建出RedissonClient实例
//Redis url should start with redis:// or rediss://
RedissonClient redissonClient = Redisson.create(config);
return redissonClient;
}
}
@Autowired
private RedissonClient redissonClient;
public Map<String, List<Catelog2Vo>> getCatalogJsonFromDbWithRedissonLock() {
// 占分布式锁。去redis占坑
//(锁的粒度,越细越快:具体缓存的是某个数据,11号商品) product-11-lock
//创建读锁
RReadWriteLock readWriteLock = redissonClient.getReadWriteLock("catalogJson-lock");
RLock rLock = readWriteLock.readLock();
Map<String, List<Catelog2Vo>> dataFromDb = null;
try {
rLock.lock();
//加锁成功...执行业务
dataFromDb = getDataFromDb();
} finally {
rLock.unlock();
}
return dataFromDb;
}
2.Ressison的各种锁
可重入锁
一个线程不用释放,可以重复的获取一个锁n次,只是在释放的时候,也需要相应的释放n次
@ResponseBody
@GetMapping(value = "/hello")
public String hello() {
//1、获取可重入锁,只要锁的名字一样,就是同一把锁
RLock myLock = redisson.getLock("my-lock");
//2、加锁,阻塞式等待。默认加的锁都是30s
myLock.lock();
// 锁的自动续期,如果业务超长,运行期间自动锁上新的30s。不用担心业务时间长,锁自动过期被删掉
// 加锁的业务只要运行完成,就不会给当前锁续期,即使不手动解锁,锁默认会在30s内自动过期,不会产生死锁问题
// myLock.lock(10,TimeUnit.SECONDS); //10秒钟自动解锁,自动解锁时间一定要大于业务执行时间,推荐
// 尽管相对于lock(),lock(long leaseTime, TimeUnit unit)存在到期后自动删除的问题,但是我们对于它的使用还是比较多的,通常都会评估一下业务的最大执行用时,在这个时间内,如果仍然未能执行完成,则认为出现了问题,则释放锁执行其他逻辑
// 总结:
// 1、如果我们传递了锁的超时时间,就发送给redis执行脚本,进行占锁,默认超时就是我们指定的时间
// 2、如果我们未指定锁的超时时间,就使用 lockWatchdogTimeout = 30 * 1000 【看门狗默认时间】
// 只要占锁成功,就会启动一个定时任务【重新给锁设置过期时间,新的过期时间就是看门狗的默认时间】,每隔10秒都会自动的再次续期,续成30秒
try {
System.out.println("加锁成功,执行业务..." + Thread.currentThread().getId());
try { TimeUnit.SECONDS.sleep(20); } catch (InterruptedException e) { e.printStackTrace(); }
} catch (Exception ex) {
ex.printStackTrace();
} finally {
//3、解锁
myLock.unlock();
}
return "hello";
}
读写锁
保证一定能读到最新数据,修改期间,写锁是一个排它锁(互斥锁、独享锁),读锁是一个共享锁
读 + 读 :相当于无锁,并发读,只会在Redis中记录好,所有当前的读锁,他们都会同时加锁成功
写 + 读 :必须等待写锁释放
写 + 写 :阻塞方式
读 + 写 :有读锁,写也需要等待
只要有写的存在,不管是读锁/写锁必须等待
@GetMapping(value = "/write")
@ResponseBody
public String writeValue() {
String s = "";
RReadWriteLock readWriteLock = redisson.getReadWriteLock("rw-lock");
RLock rLock = readWriteLock.writeLock();
try {
//1、改数据加写锁,读数据加读锁
rLock.lock();
s = UUID.randomUUID().toString();
ValueOperations<String, String> ops = stringRedisTemplate.opsForValue();
ops.set("writeValue",s);
TimeUnit.SECONDS.sleep(10);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
rLock.unlock();
}
return s;
}
@GetMapping(value = "/read")
@ResponseBody
public String readValue() {
String s = "";
RReadWriteLock readWriteLock = redisson.getReadWriteLock("rw-lock");
//加读锁
RLock rLock = readWriteLock.readLock();
try {
rLock.lock();
ValueOperations<String, String> ops = stringRedisTemplate.opsForValue();
s = ops.get("writeValue");
try { TimeUnit.SECONDS.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); }
} catch (Exception e) {
e.printStackTrace();
} finally {
rLock.unlock();
}
return s;
}
闭锁
等待线程必须等其他工作线程完成后才可以继续
/**
* 放假、锁门
* 1班没人了
* 5个班,全部走完,我们才可以锁大门
*/
@GetMapping(value = "/lockDoor")
@ResponseBody
public String lockDoor() throws InterruptedException {
RCountDownLatch door = redisson.getCountDownLatch("door");
door.trySetCount(5); //等待5个工作线程,等待5个班的人走完
door.await(); //等待闭锁完成
return "放假了...";
}
@GetMapping(value = "/gogogo/{id}")
@ResponseBody
public String gogogo(@PathVariable("id") Long id) {
RCountDownLatch door = redisson.getCountDownLatch("door");
door.countDown(); //计数-1
return id + "班的人都走了...";
}
信号量
限制同时能够允许获取锁的线程数量
/**
* 车库停车
* 信号量也可以做分布式限流
*/
@GetMapping(value = "/park")
@ResponseBody
public String park() throws InterruptedException {
RSemaphore park = redisson.getSemaphore("park"); // 分布式信号量
park.trySetPermits(3); // 三个车位
//park.acquire(); //获取一个信号、获取一个值,占一个车位,阻塞式
boolean flag = park.tryAcquire(); //试着获取一个车位,立即返回结果
if (flag) {
//执行业务
} else {
return "error";
}
return "ok=>" + flag;
}
@GetMapping(value = "/go")
@ResponseBody
public String go() {
RSemaphore park = redisson.getSemaphore("park");
park.release(); //释放一个车位
return "ok";
}
2.2.缓存一致性
缓存里的数据和数据库的数据保持一致,加锁也可以保证,但是性能差
双写模式
数据库更新后更新缓存,如果更新缓存慢了,被别的线程更新了,就会导致缓存不一致
失效模式
数据库更新后删缓存,刚刚读数据库完,这个数据对应的缓存被其他线程更新并删掉了,放入缓存的就是旧数据
应该使用失效模式,如果数据库1小时内更新了1000次,那么缓存也要更新1000次,如果是删除的话,就算数据库更新了1000次,那么也只是做了1次缓存删除,只有当缓存真正被读取的时候才去数据库加载
2.3.数据库/缓存并发解决方案
数据库层面存在高并发读写的问题
缓存在高并发下读模式有缓存穿透,击穿,雪崩,写模式有缓存一致性等问题
数据库高并发读:性能问题
使用缓存
数据库高并发写:丢失更新与写入偏差
使用redis分布式锁
使用mysql的各种锁
本项目相关问题解决方案
数据库层面:
高并发读加入redis缓存,没有高并发写
缓存层面:
缓存空结果防止缓存穿透
设置随机过期时间防止缓存雪崩
使用分布式读写锁,保证只有一个线程查数据库,防止缓存击穿
分类数据一致性要求不高,缓存设置过期时间,数据过期下一次查询触发主动更新,保证最终一致性即可
2.4.SpringCache
整合SpringCache方便对缓存的操作
默认行为
- value = {“category”},缓存名字是category,在redis中体现为文件夹名,key是默认生成的,在redis中体现为缓存的名字::SimpleKey::[]
- 缓存的value值默认使用jdk序列化机制,将序列化的数据存到redis中
- 默认时间是 -1:
指定缓存的数据的存活时间
spring.cache.redis.time-to-live=3600000
缓存空值防止缓存穿透
spring.cache.redis.cache-null-values=true
加锁防止缓存击穿
#同步模式
@Cacheable(value = {"category"},key = "#root.method.name",sync = true)
指定缓存key前缀
#如果指定了前缀就用我们指定的前缀,如果没有就默认使用缓存的名字作为前缀
#spring.cache.redis.key-prefix=CACHE_
spring.cache.redis.use-key-prefix=true
将缓存的数据保存为json形式
/**
* 在spring开发过程中我们常使用到@ConfigurationProperties注解,通常是用来将properties和yml配置文件属性转化为bean对象使用,如@ConfigurationProperties(prefix="spring.cache")
* @EnableConfigurationProperties注解的作用是:让使用了 @ConfigurationProperties 注解的类生效,并且将该类注入到 IOC 容器中,交由 IOC 容器进行管理,这里就是CacheProperties.class,它上面有@ConfigurationProperties(prefix = "spring.cache"
)
*/
@EnableConfigurationProperties(CacheProperties.class)
@Configuration
@EnableCaching
public class MyCacheConfig {
@Bean
public RedisCacheConfiguration redisCacheConfiguration(CacheProperties cacheProperties) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig();
config = config.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()));
// 使用Json序列化
config = config.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));
// 将配置文件中所有的配置都生效
CacheProperties.Redis redisProperties = cacheProperties.getRedis();
if (redisProperties.getTimeToLive() != null) {
config = config.entryTtl(redisProperties.getTimeToLive());
}
if (redisProperties.getKeyPrefix() != null) {
config = config.prefixKeysWith(redisProperties.getKeyPrefix());
}
if (!redisProperties.isCacheNullValues()) {
config = config.disableCachingNullValues();
}
if (!redisProperties.isUseKeyPrefix()) {
config = config.disableKeyPrefix();
}
return config;
}
}
1.获取一级菜单分类、获取所有二级三级菜单分类添加缓存
见上面最终解决方案
2.级联修改菜单分类 + 失效模式
/**
* 级联更新所有关联的数据
* @CacheEvict:失效模式
* @CachePut:双写模式,需要有返回值
* 1、同时进行多种缓存操作:@Caching
* 2、指定删除某个分区下的所有数据 @CacheEvict(value = "category",allEntries = true)
* 3、存储同一类型的数据,都可以指定为同一分区
*/
// @Caching(evict = {
// @CacheEvict(value = "category",key = "'getLevel1Categorys'"),
// @CacheEvict(value = "category",key = "'getCatalogJson'")
// })
@CacheEvict(value = "category",allEntries = true)
@Transactional(rollbackFor = Exception.class)
@Override
public void updateCascade(CategoryEntity category) {
RReadWriteLock readWriteLock = redissonClient.getReadWriteLock("catalogJson-lock");
//创建写锁
RLock rLock = readWriteLock.writeLock();
try {
rLock.lock();
this.baseMapper.updateById(category);
// 修改菜单品牌关联表
categoryBrandRelationService.updateCategory(category.getCatId(), category.getName());
} catch (Exception e) {
e.printStackTrace();
} finally {
rLock.unlock();
}
//同时修改缓存中的数据
//删除缓存,等待下一次主动查询进行更新
}
3.CompletableFuture异步编排
1.开启异步任务
CompletableFuture 提供了四个静态方法来创建一个异步操作
public static CompletableFuture<Void> runAsync(Runnable runnable)
public static CompletableFuture<Void> runAsync(Runnable runnable, Executor executor)
public static <U> CompletableFuture<U> supplyAsync(Supplier<U> supplier)
public static <U> CompletableFuture<U> supplyAsync(Supplier<U> supplier, Executor executor)
没有指定Executor的方法会使用ForkJoinPool.commonPool() 作为它的线程池执行异步代码。如果指定线程池,则使用指定的线程池运行,runAsync方法不支持返回值,supplyAsync可以支持返回值
// 自定义线程池
public static ExecutorService executor = Executors.newFixedThreadPool(10);
// 当前线程:14
// 运行结果:5
CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
System.out.println("当前线程:" + Thread.currentThread().getId());
int i = 10 / 2;
System.out.println("运行结果:" + i);
}, executor);
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> {
System.out.println("当前线程:" + Thread.currentThread().getId());
int i = 10 / 2;
System.out.println("当前运行结果:" + i);
return i;
}, executor);
Integer integer = future.get();
System.out.println("main.............end......."+integer);
2.计算完成回调
whencomplete 和 whenCompleteAsync的区别:
whencomplete :执行当前任务的线程继续执行whencomplete的任务,实际用这个就ok
whenCompleteAsync: 执行把whenCompleteAsync这个任务继续提交给线程池来进行
//可以处理异常,无返回值
public CompletableFuture<T> whenComplete(BiConsumer<? super T,? super Throwable> action)
public CompletableFuture<T> whenCompleteAsync(BiConsumer<? super T,? super Throwable> action)
public CompletableFuture<T> whenCompleteAsync(BiConsumer<? super T,? super Throwable> action, Executor executor)
//可以处理异常,有返回值,相当于try/catch
public CompletableFuture<T> exceptionally(Function<Throwable,? extends T> fn)
CompletableFuture<Integer> future2 = CompletableFuture.supplyAsync(() -> {
System.out.println("当前线程:" + Thread.currentThread().getId());
int i = 10 / 0;
System.out.println("运行结果:" + i);
return i;
}, executor).whenComplete((res,exception) -> {
//虽然能得到异常信息,但是没法修改返回数据
System.out.println("异步任务成功完成了...结果是:" + res + "异常是:" + exception);
}).exceptionally(throwable -> {
//可以感知异常,同时返回默认值
return 10;
});
3.handle最终处理
handle和whenComplete方法类似,但是whenComplete能感知异常但是不能返回结果。只能通过exceptionally进行处理。
而handle即可以获取执行结果,也可以感知异常信息,并能处理执行结果并返回
CompletableFuture<Integer> future3 = CompletableFuture.supplyAsync(() -> {
System.out.println("当前线程:" + Thread.currentThread().getId());
int i = 10 / 2;
System.out.println("运行结果:" + i);
return i;
}, executor).handle((result,thr) -> {
if (result != null) {
return result * 2;
}
if (thr != null) {
System.out.println("异步任务成功完成了...结果是:" + result + "异常是:" + thr);
return 0;
}
return 0;
});
4.线程串行化
- thenApply 方法:获取上一个任务返回结果,有返回值。
- thenAccept方法:获取上一个任务返回结果,并消费处理,无返回值。
- thenRun方法:获取不到上个任务的返回结果,无返回值。
public <U> CompletableFuture<U> thenApply(Function<? super T,? extends U> fn)
public <U> CompletableFuture<U> thenApplyAsync(Function<? super T,? extends U> fn)
public <U> CompletableFuture<U> thenApplyAsync(Function<? super T,? extends U> fn, Executor executor)
public CompletableFuture<Void> thenAccept(Consumer<? super T> action)
public CompletableFuture<Void> thenAcceptAsync(Consumer<? super T> action)
public CompletableFuture<Void> thenAcceptAsync(Consumer<? super T> action,Executor executor)
public CompletableFuture<Void> thenRun(Runnable action)
public CompletableFuture<Void> thenRunAsync(Runnable action)
public CompletableFuture<Void> thenRunAsync(Runnable action,Executor executor)
CompletableFuture<String> future4 = CompletableFuture.supplyAsync(() -> {
System.out.println("当前线程:" + Thread.currentThread().getId());
int i = 10 / 2;
System.out.println("运行结果:" + i);
return i;
}, executor).thenApplyAsync(res -> {
System.out.println("任务2启动了..." + res);
return "Hello" + res;
}, executor);
System.out.println("main......end....." + future.get());
5.两任务组合
两个任务必须都完成,触发该任务
用法:第一个异步任务.thenCombine(第二个异步任务,第三个异步任务)
thenCombine 可以获取两个任务的返回值,并可以将任务三结果返回
thenAcceptBoth 可以获取两个任务的返回值,没有返回值
runAfterBoth 不能获取返回值,没有返回值
public <U,V> CompletionStage<V> thenCombine(CompletionStage<? extends U> other,
BiFunction<? super T,? super U,? extends V> fn);
public <U,V> CompletionStage<V> thenCombineAsync(CompletionStage<? extends U> other,
BiFunction<? super T,? super U,? extends V> fn);
public <U,V> CompletionStage<V> thenCombineAsync(CompletionStage<? extends U> other,
BiFunction<? super T,? super U,? extends V> fn,Executor executor);
public <U> CompletableFuture<Void> thenAcceptBoth(CompletionStage<? extends U> other,
BiConsumer<? super T, ? super U> action)
public <U> CompletableFuture<Void> thenAcceptBothAsync(CompletionStage<? extends U> other,
BiConsumer<? super T, ? super U> action)
public <U> CompletableFuture<Void> thenAcceptBothAsync(CompletionStage<? extends U> other,
BiConsumer<? super T, ? super U> action, Executor executor)
public CompletableFuture<Void> runAfterBoth(CompletionStage<?> other,Runnable action)}
public CompletableFuture<Void> runAfterBothAsync(CompletionStage<?> other,Runnable action) }
public CompletableFuture<Void> runAfterBothAsync(CompletionStage<?> other,Runnable action,
Executor executor)}
CompletableFuture<Integer> future1 = CompletableFuture.supplyAsync(() -> {
System.out.println("开启异步任务1...");
int i = 10 / 2;
return i;
}, service);
CompletableFuture<String> future2 = CompletableFuture.supplyAsync(() -> {
System.out.println("开启异步任务2...");
return "hello";
}, service);
CompletableFuture<String> stringCompletableFuture = future1.thenCombineAsync(future2, (res1, res2) -> {
System.out.println("任务3 启动了.... 任务1的返回值:" + res1 + " 任务2的返回值:" + res2);
return res1 + "-->" + res2;
}, service);
System.out.println("获取异步任务最终返回值:" + stringCompletableFuture.get());
两个任务只要有一个完成,执行该任务
public CompletableFuture<Void> runAfterEither(CompletionStage<?> other,Runnable action)
public CompletableFuture<Void> runAfterEitherAsync(CompletionStage<?> other,Runnable action)
public CompletableFuture<Void> runAfterEitherAsync(CompletionStage<?> other,Runnable action, Executor executor)
public CompletableFuture<Void> acceptEither(
CompletionStage<? extends T> other, Consumer<? super T> action)
public CompletableFuture<Void> acceptEitherAsync(
CompletionStage<? extends T> other, Consumer<? super T> action)}
public CompletableFuture<Void> acceptEitherAsync(
CompletionStage<? extends T> other, Consumer<? super T> action,Executor executor)}
public <U> CompletableFuture<U> applyToEither(
CompletionStage<? extends T> other, Function<? super T, U> fn)
public <U> CompletableFuture<U> applyToEitherAsync(
CompletionStage<? extends T> other, Function<? super T, U> fn)
public <U> CompletableFuture<U> applyToEitherAsync(
CompletionStage<? extends T> other, Function<? super T, U> fn,Executor executor)
5.多任务组合
- allOf:等待所有任务完成
- anyOf: 只要有一个任务完成
public static CompletableFuture<Void> allOf(CompletableFuture<?>... cfs)
public static CompletableFuture<Object> anyOf(CompletableFuture<?>... cfs)
CompletableFuture<String> future1 = CompletableFuture.supplyAsync(() -> {
System.out.println("查询商品图片...");
return "图片地址";
}, service);
CompletableFuture<String> future2 = CompletableFuture.supplyAsync(() -> {
System.out.println("查询商品属性...");
return "黑色 256G";
}, service);
CompletableFuture<String> future3 = CompletableFuture.supplyAsync(() -> {
System.out.println("查询商品品牌...");
return "苹果手机";
}, service);
CompletableFuture<Void> future = CompletableFuture.allOf(future1, future2, future3);
// 等待索引结果完成
future.get();
4.商品详情
1.自定义线程池
创建线程池属性配置类,并添加配置文件
#配置线程池
gulimall.thread.coreSize=20
gulimall.thread.maxSize=200
gulimall.thread.keepAliveTime=10
@ConfigurationProperties(prefix = "gulimall.thread")
@Data
public class ThreadPoolConfigProperties {
private Integer coreSize;
private Integer maxSize;
private Integer keepAliveTime;
}
让使用了 @ConfigurationProperties 注解的类生效,并且将该类注入到 IOC 容器中
@EnableConfigurationProperties(ThreadPoolConfigProperties.class)
@Configuration
public class MyThreadConfig {
@Bean
public ThreadPoolExecutor threadPoolExecutor(ThreadPoolConfigProperties pool) {
return new ThreadPoolExecutor(
pool.getCoreSize(),
pool.getMaxSize(),
pool.getKeepAliveTime(),
TimeUnit.SECONDS,
new LinkedBlockingDeque<>(100000),
Executors.defaultThreadFactory(),
new ThreadPoolExecutor.AbortPolicy()
);
}
}
2.展示商品详情(异步编排,线程池)
访问item.gulimall.com/{skuId}.html展示商品详情
一个SkuInfoEntity,需要查出这些信息:
@ToString
@Data
public class SkuItemVo {
//1、sku基本信息的获取 pms_sku_info
private SkuInfoEntity info;
private boolean hasStock = true;
//2、sku的图片信息 pms_sku_images
private List<SkuImagesEntity> images;
//3、获取sku的销售属性组合
private List<SkuItemSaleAttrVo> saleAttr;
//4、获取spu的介绍
private SpuInfoDescEntity desc;
//5、获取spu的规格参数信息
private List<SpuItemAttrGroupVo> groupAttrs;
//6、秒杀商品的优惠信息
private SeckillSkuVo seckillSkuVo;
}
@GetMapping("/{skuId}.html")
public String skuItem(@PathVariable("skuId") Long skuId, Model model) throws Exception {
System.out.println("准备查询" + skuId + "详情");
SkuItemVo vos = skuInfoService.item(skuId);
model.addAttribute("item",vos);
return "item";
}
@Override
public SkuItemVo item(Long skuId) throws ExecutionException, InterruptedException {
SkuItemVo skuItemVo = new SkuItemVo();
CompletableFuture<SkuInfoEntity> infoFuture = CompletableFuture.supplyAsync(() -> {
// sku基本信息的获取 pms_sku_info
SkuInfoEntity info = this.getById(skuId);
skuItemVo.setInfo(info);
return info;
}, executor);
CompletableFuture<Void> saleAttrFuture = infoFuture.thenAcceptAsync((res) -> {
// 获取sku的销售属性组合
List<SkuItemSaleAttrVo> saleAttrVos = skuSaleAttrValueService.getSaleAttrBySpuId(res.getSpuId());
skuItemVo.setSaleAttr(saleAttrVos);
}, executor);
CompletableFuture<Void> descFuture = infoFuture.thenAcceptAsync((res) -> {
// 获取spu的介绍 pms_spu_info_desc
SpuInfoDescEntity spuInfoDescEntity = spuInfoDescService.getById(res.getSpuId());
skuItemVo.setDesc(spuInfoDescEntity);
}, executor);
CompletableFuture<Void> baseAttrFuture = infoFuture.thenAcceptAsync((res) -> {
// 获取spu的规格参数信息
List<SpuItemAttrGroupVo> attrGroupVos = attrGroupService.getAttrGroupWithAttrsBySpuId(res.getSpuId(), res.getCatalogId());
skuItemVo.setGroupAttrs(attrGroupVos);
}, executor);
// sku的图片信息 pms_sku_images
CompletableFuture<Void> imageFuture = CompletableFuture.runAsync(() -> {
List<SkuImagesEntity> imagesEntities = skuImagesService.getImagesBySkuId(skuId);
skuItemVo.setImages(imagesEntities);
}, executor);
CompletableFuture<Void> seckillFuture = CompletableFuture.runAsync(() -> {
// 远程调用查询当前sku是否参与秒杀优惠活动
R skuSeckilInfo = seckillFeignService.getSkuSeckilInfo(skuId);
if (skuSeckilInfo.getCode() == 0) {
// 查询成功
SeckillSkuVo seckilInfoData = skuSeckilInfo.getData("data", new TypeReference<SeckillSkuVo>() {
});
skuItemVo.setSeckillSkuVo(seckilInfoData);
}
}, executor);
//等到所有任务都完成
CompletableFuture.allOf(saleAttrFuture,descFuture,baseAttrFuture,imageFuture,seckillFuture).get();
return skuItemVo;
}
7.认证服务开发
1.会员服务主要表结构
ums_member:会员信息表
会员等级,用户名,密码,昵称,手机号,启用状态,注册时间,社交用户唯一id,访问令牌,令牌有效时间
2.注册功能
1.注册发送验证码
接口防刷:防止用户刷新页面重复发送请求,同一个手机号验证码60s只能发一次
@ResponseBody
@GetMapping(value = "/sms/sendCode")
public R sendCode(@RequestParam("phone") String phone) {
// 验证码60s内只能发一次,验证码前缀sms:code:
String redisCode = stringRedisTemplate.opsForValue().get(AuthServerConstant.SMS_CODE_CACHE_PREFIX + phone);
if (!StringUtils.isEmpty(redisCode)) {
//用当前时间减去存入redis的时间,判断用户手机号是否在60s内发送验证码
long currentTime = Long.parseLong(redisCode.split("_")[1]);
if (System.currentTimeMillis() - currentTime < 60000) {
return R.error(BizCodeEnum.SMS_CODE_EXCEPTION.getCode(),BizCodeEnum.SMS_CODE_EXCEPTION.getMessage());
}
}
//redis的 key->phone value->code_当前时间
int code = (int) ((Math.random() * 9 + 1) * 100000);
String codeNum = String.valueOf(code);
String redisStorage = codeNum + "_" + System.currentTimeMillis();
//存入redis,验证码10分钟有效
stringRedisTemplate.opsForValue().set(AuthServerConstant.SMS_CODE_CACHE_PREFIX+phone,
redisStorage,10, TimeUnit.MINUTES);
//发送验证码
thirdPartFeignService.sendCode(phone, codeNum);
return R.ok();
}
2.注册用户
注册需要输入用户名,密码,手机号,验证码
- 若JSR303校验未通过,则通过
BindingResult
封装错误信息,并重定向至注册页面 - 若通过JSR303校验,则需要从
redis
中取值判断验证码是否正确,正确的话通过会员服务注册 - 使用重定向 ,防止数据重复提交,携带数据使用RedirectAttributes,其原理是放到session中
@PostMapping(value = "/register")
public String register(@Valid UserRegisterVo vos, BindingResult result,
RedirectAttributes attributes) {
// 如果有错误回到注册页面
if (result.hasErrors()) {
Map<String, String> errors = result.getFieldErrors().stream().collect(Collectors.toMap(FieldError::getField, FieldError::getDefaultMessage));
attributes.addFlashAttribute("errors",errors);
// 校验出错回到注册页面
return "redirect:http://auth.gulimall.com/reg.html";
}
// 获取用户输入的验证码,校验验证码
String code = vos.getCode();
// 获取存入Redis里的验证码
String redisCode = stringRedisTemplate.opsForValue().get(AuthServerConstant.SMS_CODE_CACHE_PREFIX + vos.getPhone());
if (!StringUtils.isEmpty(redisCode)) {
// 截取字符串
if (code.equals(redisCode.split("_")[0])) {
// 删除验证码
stringRedisTemplate.delete(AuthServerConstant.SMS_CODE_CACHE_PREFIX+vos.getPhone());
// 验证码通过,真正注册,调用远程服务进行注册
R register = memberFeignService.register(vos);
if (register.getCode() == 0) {
// 成功
return "redirect:http://auth.gulimall.com/login.html";
} else {
// 失败
Map<String, String> errors = new HashMap<>();
errors.put("msg", register.getData("msg",new TypeReference<String>(){}));
attributes.addFlashAttribute("errors",errors);
return "redirect:http://auth.gulimall.com/reg.html";
}
} else {
// 验证码错误,回到注册页面
Map<String, String> errors = new HashMap<>();
errors.put("code","验证码错误");
attributes.addFlashAttribute("errors",errors);
return "redirect:http://auth.gulimall.com/reg.html";
}
} else {
// 未发送验证码或验证码已经过期,回到注册页面
Map<String, String> errors = new HashMap<>();
errors.put("code","验证码错误");
attributes.addFlashAttribute("errors",errors);
return "redirect:http://auth.gulimall.com/reg.html";
}
}
会员服务注册,密码使用BCryptPasswordEncoder加密,加密时会对明文随机加盐后生成hash,因此每次加密的结果是不同的,但是可以用matches()方法验证
/member/member/register
MemberEntity memberEntity = new MemberEntity();
// 密码进行MD5加密
BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder();
String encode = bCryptPasswordEncoder.encode(vo.getPassword());
memberEntity.setPassword(encode);
// 保存数据
this.baseMapper.insert(memberEntity);
2.登录退出功能
1.用户名密码登录
登录展示页面
@GetMapping(value = "/login.html")
public String loginPage(HttpSession session) {
// 从session先取出来用户的信息,判断用户是否已经登录过了
Object attribute = session.getAttribute(LOGIN_USER);
// 如果用户没登录那就返回登录页面,如果登录了就跳转到商城首页
if (attribute == null) {
return "login";
} else {
return "redirect:http://gulimall.com";
}
}
发送登录请求,登录需要输入用户名,密码
@PostMapping(value = "/login")
public String login(UserLoginVo vo, RedirectAttributes attributes, HttpSession session) {
// 远程登录
R login = memberFeignService.login(vo);
if (login.getCode() == 0) {
// 登录成功,放置用户信息相关session
MemberResponseVo data = login.getData("data", new TypeReference<MemberResponseVo>() {});
session.setAttribute(LOGIN_USER,data);
return "redirect:http://gulimall.com";
} else {
// 登录失败,跳转到登录页
Map<String,String> errors = new HashMap<>();
errors.put("msg",login.getData("msg",new TypeReference<String>(){}));
attributes.addFlashAttribute("errors",errors);
return "redirect:http://auth.gulimall.com/login.html";
}
}
会员服务登录
@Override
public MemberEntity login(MemberUserLoginVo vo) {
String loginacct = vo.getLoginacct();
String password = vo.getPassword();
//1、去数据库查询 SELECT * FROM ums_member WHERE username = ? OR mobile = ?
MemberEntity memberEntity = this.baseMapper.selectOne(new QueryWrapper<MemberEntity>()
.eq("username", loginacct).or().eq("mobile", loginacct));
if (memberEntity == null) {
//登录失败
return null;
} else {
//获取到数据库里的password
String password1 = memberEntity.getPassword();
BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
//进行密码匹配
boolean matches = passwordEncoder.matches(password, password1);
if (matches) {
//登录成功
return memberEntity;
}
}
return null;
}
2.社交登录
Oauth2.0开放授权协议-授权码模式
1.点击社交登录按钮,跳转到认证服务器(微博)授权页,要求用户给予授权
https://api.weibo.com/oauth2/authorize?client_id=YOUR_CLIENT_ID&response_type=code&redirect_uri=YOUR_REGISTERED_REDIRECT_URI
2.用户输入账号密码同意授权后,跳转到回调地址,微博会在后面拼接上/?code=CODE,这个code用来申请token
YOUR_REGISTERED_REDIRECT_URI/?code=CODE
如果社交用户第一次登录,注册关联一个账号
3.接口里需要给认证服务器发送请求申请token,需要应用id,应用密钥,回调地址,认证码
https://api.weibo.com/oauth2/access_token?client_id=YOUR_CLIENT_ID&client_secret=YOUR_CLIENT_SECRET&grant_type=authorization_code&redirect_uri=YOUR_REGISTERED_REDIRECT_URI&code=CODE
返回数据包含这些,转化成对象
@Data
public class SocialUser {
private String access_token;
private String remind_in;
private long expires_in;
private String uid;
private String isRealName;
}
4.客户端使用令牌,向资源服务器(微博)申请获取资源
https://open.weibo.com/wiki/2/users/show
回调地址
/**
* 社交登录成功回调
*/
@GetMapping(value = "/oauth2.0/weibo/success")
public String weibo(@RequestParam("code") String code, HttpSession session) throws Exception {
Map<String, String> map = new HashMap<>();
map.put("client_id","588997645");
map.put("client_secret","5d7746d10c4d926ed38692c8d17b7e31");
map.put("grant_type","authorization_code");
map.put("redirect_uri","http://auth.gulimall.com/oauth2.0/weibo/success");
map.put("code",code);
// 根据用户授权返回的code换取access_token
HttpResponse response = HttpUtils.doPost("https://api.weibo.com", "/oauth2/access_token", "post", new HashMap<>(), map, new HashMap<>());
// 处理
if (response.getStatusLine().getStatusCode() == 200) {
// 获取到了access_token,转为通用社交登录对象
String json = JSON.toJSONString(response.getEntity());
SocialUser socialUser = JSON.parseObject(json, SocialUser.class);
// 当前用户如果是第一次进网站,自动注册进来(为当前社交用户生成一个会员信息,以后这个社交账号就对应指定的会员)
// 远程调用会员服务
R oauthLogin = memberFeignService.oauthLogin(socialUser);
if (oauthLogin.getCode() == 0) {
MemberResponseVo data = oauthLogin.getData("data", new TypeReference<MemberResponseVo>() {});
log.info("登录成功:用户信息:{}",data.toString());
//1、浏览器保存JSESSIONID这个cookie,以后浏览器访问哪个网站就会带上这个网站的cookie
//问题1、默认发的令牌,当前域(解决子域session共享问题),让所有服务都可以用
//问题2、使用JSON的序列化方式来序列化对象到Redis中,单点登录
session.setAttribute(LOGIN_USER,data);
//2、登录成功跳回首页
return "redirect:http://gulimall.com";
} else {
return "redirect:http://auth.gulimall.com/login.html";
}
} else {
return "redirect:http://auth.gulimall.com/login.html";
}
}
会员服务社交登录注册
@Override
public MemberEntity login(SocialUser socialUser) throws Exception {
//具有登录和注册逻辑
String uid = socialUser.getUid();
//1、判断当前社交用户是否已经登录过系统
MemberEntity memberEntity = this.baseMapper.selectOne(new QueryWrapper<MemberEntity>().eq("social_uid", uid));
if (memberEntity != null) {
// 这个用户已经注册过
// 更新用户的访问令牌的时间和access_token
MemberEntity update = new MemberEntity();
update.setId(memberEntity.getId());
update.setAccessToken(socialUser.getAccess_token());
update.setExpiresIn(socialUser.getExpires_in());
this.baseMapper.updateById(update);
memberEntity.setAccessToken(socialUser.getAccess_token());
memberEntity.setExpiresIn(socialUser.getExpires_in());
return memberEntity;
} else {
// 没有查到当前社交用户对应的记录我们就需要注册一个
MemberEntity register = new MemberEntity();
// 查询当前社交用户的社交账号信息(昵称、性别等)
Map<String,String> query = new HashMap<>();
query.put("access_token",socialUser.getAccess_token());
query.put("uid",socialUser.getUid());
HttpResponse response = HttpUtils.doGet("https://api.weibo.com", "/2/users/show.json", "get", new HashMap<String, String>(), query);
if (response.getStatusLine().getStatusCode() == 200) {
// 查询成功,获取用户信息
String json = EntityUtils.toString(response.getEntity());
JSONObject jsonObject = JSON.parseObject(json);
String name = jsonObject.getString("name");
String gender = jsonObject.getString("gender");
String profileImageUrl = jsonObject.getString("profile_image_url");
register.setNickname(name);
register.setGender("m".equals(gender)?1:0);
register.setHeader(profileImageUrl);
register.setCreateTime(new Date());
register.setSocialUid(socialUser.getUid());
register.setAccessToken(socialUser.getAccess_token());
register.setExpiresIn(socialUser.getExpires_in());
//把用户信息插入到数据库中
this.baseMapper.insert(register);
}
return register;
}
}
3.退出
@GetMapping(value = "/loguot.html")
public String logout(HttpServletRequest request) {
request.getSession().removeAttribute(LOGIN_USER);
request.getSession().invalidate();
return "redirect:http://gulimall.com";
}
3.SpringSession
正常情况下,session不可以跨域,所以不能被多个服务共享,服务重启session也会丢失
引入SpringSession框架,使用redis统一存储session解决
Session概念
Session 是服务器端的一个 Key-Value 的数据结构,经常和 Cookie 配合,保持用户的登陆会话。客户端在第一次访问服务端的时候,服务端会响应一个 SessionId 并且将它存入到本地 Cookie 中,在之后的访问中浏览器会将 Cookie 中的 sessionId 放入到请求头中去访问服务器
自定义配置
- 由于默认使用jdk进行序列化,通过导入
RedisSerializer
修改为json序列化 - 并且通过修改
CookieSerializer
扩大session
的作用域至**.gulimall.com
- 自定义session过期时间
@Configuration
public class GulimallSessionConfig {
@Bean
public CookieSerializer cookieSerializer() {
DefaultCookieSerializer cookieSerializer = new DefaultCookieSerializer();
// 放大作用域到gulimall.com
cookieSerializer.setDomainName("gulimall.com");
cookieSerializer.setCookieName("GULISESSION");
return cookieSerializer;
}
@Bean
public RedisSerializer<Object> springSessionDefaultRedisSerializer() {
return new GenericJackson2JsonRedisSerializer();
}
}
// 默认1800秒,即30分钟
@EnableRedisHttpSession(maxInactiveIntervalInSeconds = 60 * 60 * 3)
@EnableFeignClients
@EnableDiscoveryClient
@SpringBootApplication
public class GulimallAuthServerApplication {
public static void main(String[] args) {
SpringApplication.run(GulimallAuthServerApplication.class, args);
}
}
核心原理
@EnableRedisHttpSession会导入RedisHttpSessionConfiguration.class,RedisHttpSessionConfiguration继承了 SpringHttpSessionConfiguration,SpringHttpSessionConfiguration中注册了 SessionRepositoryFilter,这个过滤器会拦截所有的请求,将 request 和 response 对象转换成 SessionRepositoryRequestWrapper 和 SessionRepositoryResponseWrapper 。后续当第一次调用 request 的getSession方法时,会调用到 SessionRepositoryRequestWrapper的getSession方法。这个方法是被重写过的,以后创建获取session,都是在redis中创建获取
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
request.setAttribute(SESSION_REPOSITORY_ATTR, this.sessionRepository);
SessionRepositoryFilter<S>.SessionRepositoryRequestWrapper wrappedRequest = new SessionRepositoryFilter.SessionRepositoryRequestWrapper(request, response, this.servletContext);
SessionRepositoryFilter.SessionRepositoryResponseWrapper wrappedResponse = new SessionRepositoryFilter.SessionRepositoryResponseWrapper(wrappedRequest, response);
try {
filterChain.doFilter(wrappedRequest, wrappedResponse);
} finally {
wrappedRequest.commitSession();
}
}
4.token验证实现单点登录
使用SpringSession作用域放大最多到guliamll,不能实现不同域之间的单点登录
token概念:
浏览器第一次访问服务器时,服务器根据传过来的唯一标识userId,通过一些算法,加一个密钥,生成一个token,接着通过base64编码将token返回给客户端。客户端将token保存起来,下次请求时需要带着token,服务器收到请求后,用相同的算法和密钥去验证token。和session作用一样,都是告诉服务器你是谁,但是token不需要储存空间,浏览器不会自动带上
关键点:
1.系统1第一次登录时,服务器会根据用户id生成一个token,并给浏览器用cookie的方式保存token(保存登录服务器的域名下,给登录服务器留下痕迹),把token带到地址上返回(用这个token来做认证,查询信息)
2.系统2系统登录时会把登录服务器cookie中的token发送给服务器,服务器一看有token了,直接把token带到地址上返回
3.token验证通过,查询用户信息,保存到每个系统自己的session中
系统1
没登录,跳转到登录服务器,带上跳回地址
@GetMapping(value = "/employees")
public String employees(Model model, HttpSession session, @RequestParam(value = "token", required = false) String token) {
// 验证token成功就认为已经登录,将用户信息放到session中
if (!StringUtils.isEmpty(token)) {
RestTemplate restTemplate=new RestTemplate();
ResponseEntity<String> forEntity = restTemplate.getForEntity("http://sso.mroldx.cn:8080/userinfo?token=" + token, String.class);
String body = forEntity.getBody();
session.setAttribute("loginUser", body);
}
// 没登录命令浏览器重定向到登录服务器
Object loginUser = session.getAttribute("loginUser");
if (loginUser == null) {
// 重定向登录页+跳回地址
return "redirect:" + "http://sso.mroldx.cn:8080/login.html"+"?redirect_url=http://localhost:8081/employees";
} else {
// 有session,就从session中取出数据
List<String> emps = new ArrayList<>();
emps.add("张三");
emps.add("李四");
model.addAttribute("emps", emps);
return "employees";
}
}
登录服务器
先跳转到登录页,如果系统1已经登录,因为token已经存放在用户浏览器的cookie中了,系统2访问登录页就会直接带着token返回
// 登录页
@GetMapping("/login.html")
public String loginPage(@RequestParam("redirect_url") String url, Model model, @CookieValue(value = "sso_token", required = false) String sso_token) {
if (!StringUtils.isEmpty(sso_token)) {
return "redirect:" + url + "?token=" + sso_token;
}
model.addAttribute("url", url);
return "login";
}
输入用户名密码后发送登录请求(页面隐藏回调地址),给redis放置token,对应保存用户信息,将token保存在登录服务器的cookie返回原系统
@PostMapping(value = "/doLogin")
public String doLogin(@RequestParam("username") String username, @RequestParam("password") String password, @RequestParam("redirect_url") String url, HttpServletResponse response) {
// 登录成功跳转,跳回到登录页
if (!StringUtils.isEmpty(username) && !StringUtils.isEmpty(password)) {
String uuid = UUID.randomUUID().toString().replace("_", "");
redisTemplate.opsForValue().set(uuid, username);
Cookie sso_token = new Cookie("sso_token", uuid);
response.addCookie(sso_token);
return "redirect:" + url + "?token=" + uuid;
}
return "login";
}
8.购物车开发
1.存储及数据结构
购物车是一个读多写多的场景,因此放入数据库并不合适,但购物车又需要持久化,因此选用redis存储购物车数据
Map<购物车id,Map<SKUid,SKU详情>>
2.ThreadLocal和拦截器
参考京东,在点击购物车时,会为临时用户生成一个名字为user-key
的cookie临时标识,过期时间为一个月,如果手动清除user-key
,那么临时购物车的购物项也被清除,所以user-key
是用来标识和存储临时购物车数据的
/**
* 在执行目标方法之前,判断用户的登录状态.并封装传递给controller目标请求
**/
public class CartInterceptor implements HandlerInterceptor {
// 同一个线程共享数据
public static ThreadLocal<UserInfoTo> toThreadLocal = new ThreadLocal<>();
/***
* 目标方法执行之前
*/
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// userId,userKey,isTempUser
UserInfoTo userInfoTo = new UserInfoTo();
HttpSession session = request.getSession();
// 获得当前登录用户的信息
MemberResponseVo memberResponseVo = (MemberResponseVo) session.getAttribute(LOGIN_USER);
if (memberResponseVo != null) {
// 用户登录了
userInfoTo.setUserId(memberResponseVo.getId());
}
Cookie[] cookies = request.getCookies();
if (cookies != null && cookies.length > 0) {
for (Cookie cookie : cookies) {
// 找到user-key
String name = cookie.getName();
if (name.equals(TEMP_USER_COOKIE_NAME)) {
userInfoTo.setUserKey(cookie.getValue());
// 标记为已是临时用户
userInfoTo.setTempUser(true);
}
}
}
// 如果没有临时用户,即第一次登录,一定分配一个临时用户
if (StringUtils.isEmpty(userInfoTo.getUserKey())) {
String uuid = UUID.randomUUID().toString();
userInfoTo.setUserKey(uuid);
}
//目标方法执行之前,登陆了就有userid,是临时用户就有user-key,如果先是临时用户,再登陆,两个都有
toThreadLocal.set(userInfoTo);
return true;
}
/**
* 业务执行之后,分配临时用户来浏览器保存
*/
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
//获取当前用户的值
UserInfoTo userInfoTo = toThreadLocal.get();
//如果没有临时用户一定保存一个临时用户
if (!userInfoTo.getTempUser()) {
//创建一个cookie
Cookie cookie = new Cookie(TEMP_USER_COOKIE_NAME, userInfoTo.getUserKey());
//扩大作用域
cookie.setDomain("gulimall.com");
//设置过期时间
cookie.setMaxAge(TEMP_USER_COOKIE_TIMEOUT);
response.addCookie(cookie);
}
}
}
/**
* 拦截器配置
**/
@Configuration
public class GulimallWebConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new CartInterceptor())//注册拦截器
.addPathPatterns("/**");
}
}
3.展示购物车页面
- 若用户未登录,则直接使用user-key获取购物车数据
- 若用户登陆,则使用userId获取购物车数据,并将user-key对应临时购物车数据与用户购物车数据合并,并删除临时购物车
@GetMapping(value = "/cart.html")
public String cartListPage(Model model) throws ExecutionException, InterruptedException {
//快速得到用户信息:id,user-key
// UserInfoTo userInfoTo = CartInterceptor.toThreadLocal.get();
CartVo cartVo = cartService.getCart();
model.addAttribute("cart",cartVo);
return "cartList";
}
4.添加/修改/删除商品到购物车
- 若当前商品已经存在购物车,只需增添数量
- 否则需要查询商品购物项所需信息,并添加新商品至购物车
/**
* 添加商品到购物车,重定向防止重复提交
* attributes.addFlashAttribute():将数据放在session中,可以在页面中取出,但是只能取一次
* attributes.addAttribute():将数据放在url后面
* @return
*/
@GetMapping(value = "/addCartItem")
public String addCartItem(@RequestParam("skuId") Long skuId,
@RequestParam("num") Integer num,
RedirectAttributes attributes) throws ExecutionException, InterruptedException {
cartService.addToCart(skuId,num);
attributes.addAttribute("skuId",skuId);
return "redirect:http://cart.gulimall.com/addToCartSuccessPage.html";
}
//拿到要操作的购物车信息
BoundHashOperations<String, Object, Object> cartOps = getCartOps();
//判断Redis是否有该商品的信息
String productRedisValue = (String) cartOps.get(skuId.toString());
// 给redis里放购物车信息
cartOps.put(skuId.toString(),cartItemJson);
5.获取操作的购物车
/**
* 获取到我们要操作的购物车,区分操作登录用户的还是临时用户的
*/
private BoundHashOperations<String, Object, Object> getCartOps() {
// 先得到当前用户信息
UserInfoTo userInfoTo = CartInterceptor.toThreadLocal.get();
String cartKey = "";
if (userInfoTo.getUserId() != null) {
// gulimall:cart:1 登录
cartKey = CART_PREFIX + userInfoTo.getUserId();
} else {
// 临时用户
cartKey = CART_PREFIX + userInfoTo.getUserKey();
}
// 绑定指定的key操作Redis
BoundHashOperations<String, Object, Object> operations = redisTemplate.boundHashOps(cartKey);
return operations;
}
9.RabbitMQ消息队列
1.发送流程
生产者(message + route-key) -> broker(exchange -> queue) -> 被消费者监听
如果消息没有确认会变成unacked, 消费者断线又重新变成ready
public void sendMessageTest() {
OrderReturnReasonEntity reasonEntity = new OrderReturnReasonEntity();
reasonEntity.setId(1L);
rabbitTemplate.convertAndSend("hello-java-exchange","hello2.java",
reasonEntity,new CorrelationData(UUID.randomUUID().toString()));
}
// 如果队列没有会报错,注了 @RabbitListener(queues = {"hello-java-queue"})
// @RabbitHandler 可以用来重载不同的消息
public void revieveMessage(Message message,
OrderReturnReasonEntity content,
Channel channel) {
// 拿到主体内容
byte[] body = message.getBody();
// 拿到的消息头属性信息
MessageProperties messageProperties = message.getMessageProperties();
System.out.println("接受到的消息...内容" + message + "===内容:" + content);
// channel内按顺序自增的编号
long deliveryTag = message.getMessageProperties().getDeliveryTag();
System.out.println("deliveryTag===>" + deliveryTag);
// 签收货物,非批量模式(第二个false)
try {
if (deliveryTag % 2 == 0) {
// 收货
channel.basicAck(deliveryTag, false);
} else {
// 退货,第三个参数是否重新入队,如果不退货(宕机),没有回复的消息会变成unacked,
// 消费者断线又重新变成ready
channel.basicNack(deliveryTag, false, false);
}
} catch (IOException e) {
// 网络中断
}
}
2.自定义配置
- 注入rabbitTemplate
- 消息使用JSON格式存储
- 消息确认生产端2个,消费端1个确认机制
@Configuration
public class MyRabbitConfig {
private RabbitTemplate rabbitTemplate;
@Primary
@Bean
public RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory) {
RabbitTemplate rabbitTemplate = new RabbitTemplate(connectionFactory);
this.rabbitTemplate = rabbitTemplate;
rabbitTemplate.setMessageConverter(messageConverter());
initRabbitTemplate();
return rabbitTemplate;
}
@Bean
public MessageConverter messageConverter() {
return new Jackson2JsonMessageConverter();
}
/**
* 定制RabbitTemplate
* 1、服务收到消息就会回调
* 1、spring.rabbitmq.publisher-confirms: true
* 2、设置确认回调
* 2、消息正确抵达队列就会进行回调
* 1、spring.rabbitmq.publisher-returns: true
* spring.rabbitmq.template.mandatory: true
* 2、设置确认回调ReturnCallback
*
* 3、消费端确认(保证每个消息都被正确消费,此时才可以broker删除这个消息)
*
*/
//@PostConstruct //MyRabbitConfig对象创建完成以后,执行这个方法
public void initRabbitTemplate() {
/**
* 1、只要消息抵达Broker就ack=true
* correlationData:当前消息的唯一关联数据(这个是消息的唯一id)
* ack:消息是否成功收到
* cause:失败的原因
*/
//设置确认回调
rabbitTemplate.setConfirmCallback((correlationData,ack,cause) -> {
System.out.println("confirm...correlationData["+correlationData+"]==>ack:["+ack+"]==>cause:["+cause+"]");
});
/**
* 只要消息没有投递给指定的队列,就触发这个失败回调
* message:投递失败的消息详细信息
* replyCode:回复的状态码
* replyText:回复的文本内容
* exchange:当时这个消息发给哪个交换机
* routingKey:当时这个消息用哪个路邮键
*/
rabbitTemplate.setReturnCallback((message,replyCode,replyText,exchange,routingKey) -> {
System.out.println("Fail Message["+message+"]==>replyCode["+replyCode+"]" +
"==>replyText["+replyText+"]==>exchange["+exchange+"]==>routingKey["+routingKey+"]");
});
}
}
# 手动ack消息,不使用默认的消费端确认(默认收到就被移除)
# ack肯定确认,nack否定确认可以批量,reject否定确认不能批量
spring.rabbitmq.listener.simple.acknowledge-mode=manual
10.订单服务开发
1.订单服务主要表结构
oms_order:订单表
订单号,订单状态(0->待付款;1->待发货;2->已发货;3->已完成;4->已关闭;5->无效订单)
2.库存服务主要表结构
wms_ware_sku:商品库存表
skuid,仓库id,库存数,sku名称,锁定库存数
wms_ware_order_task:库存工作单表
订单号,收货人,付款方式,状态,物流单号,仓库id
wms_ware_order_task_detail:库存工作单详情表
skuid,sku名称,购买个数,工作单id,仓库id,锁定状态(1-已锁定 2-已解锁 3-扣减)
3.登录拦截器
@Component
public class LoginUserInterceptor implements HandlerInterceptor {
public static ThreadLocal<MemberResponseVo> loginUser = new ThreadLocal<>();
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// MQ调用直接放行
String uri = request.getRequestURI();
AntPathMatcher antPathMatcher = new AntPathMatcher();
boolean match = antPathMatcher.match("/order/order/status/**", uri);
boolean match1 = antPathMatcher.match("/payed/notify", uri);
if (match || match1) {
return true;
}
// 获取登录的用户信息
MemberResponseVo attribute = (MemberResponseVo) request.getSession().getAttribute(LOGIN_USER);
if (attribute != null) {
// 把登录后用户的信息放在ThreadLocal里面进行保存
loginUser.set(attribute);
return true;
} else {
// 未登录,返回登录页面
response.setContentType("text/html;charset=UTF-8");
PrintWriter out = response.getWriter();
out.println("<script>alert('请先进行登录,再进行后续操作!');location.href='http://auth.gulimall.com/login.html'</script>");
return false;
}
}
}
4.订单确认页
开启两个异步任务查询收货地址,购物车中所有购物项 -> 每个商品剩余库存,用户积分,计算总价
/**
* 去结算确认页
*/
@GetMapping(value = "/toTrade")
public String toTrade(Model model, HttpServletRequest request) throws ExecutionException, InterruptedException {
OrderConfirmVo confirmVo = orderService.confirmOrder();
model.addAttribute("confirmOrderData",confirmVo);
//展示订单确认的数据
return "confirm";
}
1.Feign远程调用丢失请求头
问题一:查询购物车信息时,由于请求头中SESSIONID丢失,会认为用户没有登录
在feign的调用过程中,会使用容器中的RequestInterceptor
对RequestTemplate
进行处理,因此我们可以通过向容器中导入定制的RequestInterceptor
为请求加上cookie
@Configuration
public class GuliFeignConfig {
@Bean("requestInterceptor")
public RequestInterceptor requestInterceptor() {
RequestInterceptor requestInterceptor = new RequestInterceptor() {
@Override
public void apply(RequestTemplate template) {
// 使用RequestContextHolder拿到刚进来的请求数据,RequestContextHolder为SpingMVC中共享request数据的上下文,底层由ThreadLocal实现
ServletRequestAttributes requestAttributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (requestAttributes != null) {
// 老请求,即订单服务的请求
HttpServletRequest request = requestAttributes.getRequest();
if (request != null) {
// 同步请求头的数据(主要是cookie)
// 把老请求的cookie值放到新请求上来,进行一个同步
String cookie = request.getHeader("Cookie");
template.header("Cookie", cookie);
}
}
}
};
return requestInterceptor;
}
}
问题二:异步模式查询购物车时,新开了一个线程,由于RequestContextHolder
使用ThreadLocal
共享数据,所以在开启异步时获取不到老请求的信息,自然也就无法共享cookie了
在这种情况下,我们需要在开启异步的时候将老请求的RequestContextHolder
的数据设置进去,让上面拦截器requestAttributes.getRequest()的时候有值
// 获取当前线程请求头信息
RequestAttributes requestAttributes = RequestContextHolder.getRequestAttributes();
//开启异步任务
CompletableFuture<Void> cartInfoFuture = CompletableFuture.runAsync(() -> {
// 每一个线程都来共享之前的请求数据
RequestContextHolder.setRequestAttributes(requestAttributes);
// 远程查询购物车所有选中的购物项
}, threadPoolExecutor).thenRunAsync(() -> {
// 远程查询商品库存信息
},threadPoolExecutor);
5.提交订单
1.防止用户重复提交订单(接口幂等性)
展示订单确认页的时候,创建一个防重令牌
// 为用户设置一个token,三十分钟过期时间(存在redis)
String token = UUID.randomUUID().toString().replace("-", "");
redisTemplate.opsForValue().set(USER_ORDER_TOKEN_PREFIX+memberResponseVo.getId(),token,30, TimeUnit.MINUTES);
confirmVo.setOrderToken(token);
2.下单
@PostMapping(value = "/submitOrder")
public String submitOrder(OrderSubmitVo vo, Model model, RedirectAttributes attributes) {
try {
SubmitOrderResponseVo responseVo = orderService.submitOrder(vo);
//下单成功来到支付选择页
//下单失败回到订单确认页重新确定订单信息
if (responseVo.getCode() == 0) {
//成功
model.addAttribute("submitOrderResp",responseVo);
return "pay";
} else {
String msg = "下单失败";
switch (responseVo.getCode()) {
case 1: msg += "令牌订单信息过期,请刷新再次提交"; break;
case 2: msg += "订单商品价格发生变化,请确认后再次提交"; break;
}
attributes.addFlashAttribute("msg",msg);
return "redirect:http://order.gulimall.com/toTrade";
}
} catch (Exception e) {
if (e instanceof NoStockException) {
String message = ((NoStockException)e).getMessage();
attributes.addFlashAttribute("msg",message);
}
return "redirect:http://order.gulimall.com/toTrade";
}
}
验证令牌,创建订单,验证价格,保存订单,锁定库存
@Transactional(rollbackFor = Exception.class)
@Override
public SubmitOrderResponseVo submitOrder(OrderSubmitVo vo) {
confirmVoThreadLocal.set(vo);
SubmitOrderResponseVo responseVo = new SubmitOrderResponseVo();
//获取当前用户登录的信息
MemberResponseVo memberResponseVo = LoginUserInterceptor.loginUser.get();
responseVo.setCode(0);
//1、验证令牌是否合法(令牌的对比和删除必须保证原子性)
String script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
String orderToken = vo.getOrderToken();
//通过lura脚本原子验证令牌和删除令牌
Long result = redisTemplate.execute(new DefaultRedisScript<Long>(script, Long.class),
Arrays.asList(USER_ORDER_TOKEN_PREFIX + memberResponseVo.getId()),
orderToken);
if (result == 0L) {
//令牌验证失败
responseVo.setCode(1);
return responseVo;
} else {
//令牌验证成功
//1、创建订单、订单项等信息
OrderCreateTo order = createOrder();
//2、验证价格
BigDecimal payAmount = order.getOrder().getPayAmount();
BigDecimal payPrice = vo.getPayPrice();
if (Math.abs(payAmount.subtract(payPrice).doubleValue()) < 0.01) {
//3、保存订单
saveOrder(order);
//4、库存锁定,只要有异常,回滚订单数据
//订单号、所有订单项信息(skuId,skuNum,skuName)
WareSkuLockVo lockVo = new WareSkuLockVo();
lockVo.setOrderSn(order.getOrder().getOrderSn());
//获取出要锁定的商品数据信息
List<OrderItemVo> orderItemVos = order.getOrderItems().stream().map((item) -> {
OrderItemVo orderItemVo = new OrderItemVo();
orderItemVo.setSkuId(item.getSkuId());
orderItemVo.setCount(item.getSkuQuantity());
orderItemVo.setTitle(item.getSkuName());
return orderItemVo;
}).collect(Collectors.toList());
lockVo.setLocks(orderItemVos);
// 调用远程锁定库存的方法
// 出现的问题:扣减库存成功了,但是由于网络原因超时,出现异常,导致订单事务回滚,库存事务不回滚(解决方案:seata)
// 为了保证高并发,不推荐使用seata,因为是加锁,并行化,提升不了效率,可以发消息给库存服务
R r = wmsFeignService.orderLockStock(lockVo);
if (r.getCode() == 0) {
// 锁定成功
responseVo.setOrder(order.getOrder());
// int i = 10/0; 这里出异常库存不会回滚
// 订单创建成功,发送消息给MQ
rabbitTemplate.convertAndSend("order-event-exchange","order.create.order",order.getOrder());
//删除购物车里的数据
redisTemplate.delete(CART_PREFIX+memberResponseVo.getId());
return responseVo;
} else {
//锁定失败
String msg = (String) r.get("msg");
throw new NoStockException(msg);
}
} else {
responseVo.setCode(2);
return responseVo;
}
}
}
3.锁定库存
构造对象:哪件商品,需要锁几件,在哪个仓库有库存
每个仓库尝试锁定,库存不够就尝试下一个仓库
**所有商品锁定成功:**因为订单服务有可能回滚,因此如果所有商品锁定成功,需要将当前商品锁定了几件的工作单记录发给MQ,消息进入延迟队列2分钟过期
**锁定失败:**抛出异常回滚
<!-- 锁定库存sql -->
<update id="lockSkuStock">
UPDATE wms_ware_sku
SET stock_locked = stock_locked + #{num}
WHERE
sku_id = #{skuId}
AND ware_id = #{wareId}
AND stock - stock_locked >= #{num}
</update>
@Transactional(rollbackFor = Exception.class)
@Override
public boolean orderLockStock(WareSkuLockVo vo) {
// 保存库存工作单详情信息,为了追溯
WareOrderTaskEntity wareOrderTaskEntity = new WareOrderTaskEntity();
wareOrderTaskEntity.setOrderSn(vo.getOrderSn());
wareOrderTaskEntity.setCreateTime(new Date());
wareOrderTaskService.save(wareOrderTaskEntity);
//1、找到每个商品在哪个仓库都有库存
// 构造库存锁定类
List<OrderItemVo> locks = vo.getLocks();
List<SkuWareHasStock> collect = locks.stream().map((item) -> {
SkuWareHasStock stock = new SkuWareHasStock();
Long skuId = item.getSkuId();
// 商品Id
stock.setSkuId(skuId);
// 锁几件
stock.setNum(item.getCount());
// 查询这个商品在哪个仓库有库存
List<Long> wareIdList = wareSkuDao.listWareIdHasSkuStock(skuId);
stock.setWareId(wareIdList);
return stock;
}).collect(Collectors.toList());
//2、锁定库存
for (SkuWareHasStock hasStock : collect) {
// false代表没锁住
boolean skuStocked = false;
Long skuId = hasStock.getSkuId();
List<Long> wareIds = hasStock.getWareId();
if (StringUtils.isEmpty(wareIds)) {
//没有任何仓库有这个商品的库存
throw new NoStockException(skuId);
}
// 遍历有库存的仓库
//1、如果每一个商品都锁定成功,将当前商品锁定了几件的工作单记录发给MQ
//2、锁定失败。前面保存的工作单信息都回滚了。发送出去的消息,即使要解锁库存,由于在数据库查不到指定的id,所有就不用解锁
for (Long wareId : wareIds) {
//锁定成功就返回1,失败就返回0
Long count = wareSkuDao.lockSkuStock(skuId,wareId,hasStock.getNum());
if (count == 1) {
skuStocked = true;
// 告诉MQ库存锁定成功
WareOrderTaskDetailEntity taskDetailEntity = WareOrderTaskDetailEntity.builder()
.skuId(skuId)
.skuName("")
.skuNum(hasStock.getNum())
.taskId(wareOrderTaskEntity.getId())
.wareId(wareId)
.lockStatus(1)
.build();
wareOrderTaskDetailService.save(taskDetailEntity);
StockLockedTo lockedTo = new StockLockedTo();
lockedTo.setId(wareOrderTaskEntity.getId());
StockDetailTo detailTo = new StockDetailTo();
BeanUtils.copyProperties(taskDetailEntity,detailTo);
lockedTo.setDetailTo(detailTo);
rabbitTemplate.convertAndSend("stock-event-exchange","stock.locked",lockedTo);
break;
} else {
//当前仓库锁失败,重试下一个仓库
}
}
if (skuStocked == false) {
//当前商品所有仓库都没有锁住
throw new NoStockException(skuId);
}
}
//3、能走到这肯定全部都是锁定成功的
return true;
}
@Data
class SkuWareHasStock {
private Long skuId;
private Integer num;
private List<Long> wareId;
}
6.分布式事务
如果库存锁定成功,返回订单服务,接下来的代码出错,库存服务无法回滚
利用消息队列实现最终一致性:
库存锁定成功给MQ发消息(库存工作单id + 库存工作单详情),过段时间判断订单状态来实现解锁 /不解锁
柔性事务满足BASE理论(基本可用,软状态,最终一致性)
刚性事务满足ACID理论(一致性,可用性,分区容错性只能实现两个)
1.刚性事务-2PC模式
数据库支持的 2PC【2 phase commit 二阶提交】,又叫做 XA Transactions。
- 第一阶段:事务协调器要求每个涉及到事务的数据库预提交(precommit)此操作,并反映是否可以提交
- 第二阶段:事务协调器要求每个数据库提交数据
如果有任何一个数据库否决此次提交,所有数据库回滚
缺点是锁定资源时间长,性能不理想
2.柔性事务-TCC事务补偿型方案
- 一阶段 prepare 行为:调用 自定义 的 prepare 逻辑。
- 二阶段 commit 行为:调用 自定义 的 commit 逻辑。
- 二阶段 rollback 行为:调用 自定义 的 rollback 逻辑。
TCC的核心思想是:针对每个操作,都要注册一个与其对应的确认和补偿(撤销)操作
从正常的流程上讲,TCC仍然是一个两阶段提交协议。但是,在执行出现问题的时候,有一定的自我修复能力,如果任何一个事务参与者出现了问题,协调者可以通过执行逆操作来取消之前的操作,达到最终的一致状态
3.柔性事务-最大努力通知型方案
- 按规律进行通知,不保证数据一定能通知成功,但会提供可查询操作接口进行核对。
- 这种方案主要用在与第三方系统通讯时,比如:调用微信或支付宝支付后的支付结果通知。
- 这种方案也是结合 MQ 进行实现,例如:通过MQ发送 http 请求,设置最大通知次数。达到通知次数后即不再通知。
4.柔性事务-基于消息中间件(本项目使用)
- 一定要确保消息不丢失
5.Seata分布式事务框架
详细可以看文档
使用步骤
1.每一个微服务先必须创建undo_logo
2.安装事务协调器seata-server
3.导入依赖spring-cloud-starter-alibaba-seata seata-all-1.0.0.jar
4.解压并启动seata-server,将这两个文件导入每个微服务
registry.conf注册中心相关的配置,修改registry type=nacos
file.conf修改vgroup_mapping.gulimall-order-fescar-service-group = “default”
5.所有想要用到分布式事务的微服务使用seata DatasourceProxy代理自己的数据源
@Configuration
public class MySeataConfig {
@Autowired
DataSourceProperties dataSourceProperties;
/**
* 需要将 DataSourceProxy 设置为主数据源,否则事务无法回滚
*/
@Bean
public DataSource dataSource(DataSourceProperties dataSourceProperties) {
HikariDataSource dataSource = dataSourceProperties.initializeDataSourceBuilder().type(HikariDataSource.class).build();
if (StringUtils.hasText(dataSourceProperties.getName())) {
dataSource.setPoolName(dataSourceProperties.getName());
}
return new DataSourceProxy(dataSource);
}
}
6、给分布式大事务的路口标注@GlobalTransactional,每一个远程的小事务用 @Transactional
7.RabbitMQ延时队列
场景:
比如未付款订单,超过一定时间后,系统自动取消订单并解锁库存。
常用解决方案:spring的 schedule 定时任务轮询数据库,缺点是消耗系统内存、增加了数据库的压力、存在较大的时间误差
解决:rabbitmq的消息TTL和死信交换机结合
消息TTL就是设置消息的存活时间
一个消息被Consumer拒收了,并且reject方法的参数里requeue是false也就是说不
会被再次放在队列里,消息的TTL到了,队列的长度限制满了这三种情况会让一个消息投递到死信交换机
1.订单服务交换机,绑定关系,队列
订单服务的消息都需要经过这个交换机
@Bean
public Exchange orderEventExchange() {
return new TopicExchange("order-event-exchange", true, false);
}
这个交换机对应三个绑定关系,第一个使用order.create.order路由键绑定order.delay.queue,即创建完订单的时候进入延迟队列,设置消息1分钟过期,过期之后发回给交换机order-event-exchange,路由键order.release.order进入普通队列
@Bean
public Binding orderCreateBinding() {
/*
* String destination, 目的地(队列名或者交换机名字)
* DestinationType destinationType, 目的地类型(Queue、Exhcange)
* String exchange,交换机
* String routingKey,路由键
* Map<String, Object> arguments,属性
* */
return new Binding("order.delay.queue",
Binding.DestinationType.QUEUE,
"order-event-exchange",
"order.create.order",
null);
}
@Bean
public Queue orderDelayQueue() {
/*
Queue(String name, 队列名字
boolean durable, 是否持久化
boolean exclusive, 是否排他
boolean autoDelete, 是否自动删除
Map<String, Object> arguments) 属性
*/
HashMap<String, Object> arguments = new HashMap<>();
arguments.put("x-dead-letter-exchange", "order-event-exchange");
arguments.put("x-dead-letter-routing-key", "order.release.order");
arguments.put("x-message-ttl", 60000); // 消息过期时间 1分钟
Queue queue = new Queue("order.delay.queue", true, false, false, arguments);
return queue;
}
第二个使用order.release.order路由键绑定order.release.order.queue,就是上面延时队列中设置的,延迟队列中消息过期了就会进入这里
@Bean
public Binding orderReleaseBinding() {
return new Binding("order.release.order.queue",
Binding.DestinationType.QUEUE,
"order-event-exchange",
"order.release.order",
null);
}
/**
* 普通队列
*/
@Bean
public Queue orderReleaseQueue() {
return new Queue("order.release.order.queue", true, false, false);
}
第三个使用order.release.other.#路由键绑定stock.release.stock.queue
/**
* 订单释放直接和库存释放进行绑定
*/
@Bean
public Binding orderReleaseOtherBinding() {
return new Binding("stock.release.stock.queue",
Binding.DestinationType.QUEUE,
"order-event-exchange",
"order.release.other.#",
null);
}
2.库存服务交换机,绑定关系,队列
库存服务的交换机
@Bean
public Exchange stockEventExchange() {
TopicExchange topicExchange = new TopicExchange("stock-event-exchange", true, false);
return topicExchange;
}
这个交换机对应两个绑定关系,第一个通过stock.locked路由键绑定延迟队列stock.delay.queue,这个队列中的消息2分钟过期,过期之后发回给库存交换机,路由键stock.release进入普通队列
@Bean
public Binding stockLockedBinding() {
return new Binding("stock.delay.queue",
Binding.DestinationType.QUEUE,
"stock-event-exchange",
"stock.locked",
null);
}
@Bean
public Queue stockDelay() {
HashMap<String, Object> arguments = new HashMap<>();
arguments.put("x-dead-letter-exchange", "stock-event-exchange");
arguments.put("x-dead-letter-routing-key", "stock.release");
// 消息过期时间 2分钟
arguments.put("x-message-ttl", 120000);
Queue queue = new Queue("stock.delay.queue", true, false, false,arguments);
return queue;
}
第二个通过stock.release.#路由键绑定stock.release.stock.queue,就是上面延时队列中设置的,延时队列消息过期后就会进入这里
@Bean
public Binding stockLocked() {
//String destination, DestinationType destinationType, String exchange, String routingKey,
// Map<String, Object> arguments
Binding binding = new Binding("stock.release.stock.queue",
Binding.DestinationType.QUEUE,
"stock-event-exchange",
"stock.release.#",
null);
return binding;
}
@Bean
public Queue stockReleaseStockQueue() {
Queue queue = new Queue("stock.release.stock.queue", true, false, false);
return queue;
}
8.定时关单
订单创建完成发送消息,1分钟过期进入order.release.order.queue,监听这个队列
如果用户1分钟未付款,自动取消订单,需要和库存解锁结合
@RabbitListener(queues = "order.release.order.queue")
@Service
public class OrderCloseListener {
@Autowired
private OrderService orderService;
@RabbitHandler
public void listener(OrderEntity orderEntity, Channel channel, Message message) throws IOException {
System.out.println("收到过期的订单信息,准备关闭订单" + orderEntity.getOrderSn());
try {
orderService.closeOrder(orderEntity);
channel.basicAck(message.getMessageProperties().getDeliveryTag(),false);
} catch (Exception e) {
channel.basicReject(message.getMessageProperties().getDeliveryTag(),true);
}
}
}
/**
* 关闭订单
* 下单会给订单取消和库存解锁发MQ延时消息,分别是1分钟和2分钟,如果下单的时候订单消息延迟,
* 库存先解锁,此时查订单状态还是新建,就不解锁了,此时订单消息才发到,取消订单后库存没法释放
* 所以等订单消息发到,取消订单的时候再通知一次解锁库存,直接发库存队列(不延时)
*/
@Override
public void closeOrder(OrderEntity orderEntity) {
//关闭订单之前先查询一下数据库,判断此订单状态是否已支付
OrderEntity orderInfo = this.getOne(new QueryWrapper<OrderEntity>().
eq("order_sn",orderEntity.getOrderSn()));
if ((orderInfo != null) && orderInfo.getStatus().equals(OrderStatusEnum.CREATE_NEW.getCode())) {
//待付款状态进行关单
OrderEntity orderUpdate = new OrderEntity();
orderUpdate.setId(orderInfo.getId());
orderUpdate.setStatus(OrderStatusEnum.CANCLED.getCode());
this.updateById(orderUpdate);
// 发送消息给MQ,发给订单交换机,绑定的是库存解锁队列,订单关闭解锁
OrderTo orderTo = new OrderTo();
BeanUtils.copyProperties(orderInfo, orderTo);
try {
//TODO 确保每个消息发送成功,给每个消息做好日志记录,(给数据库保存每一个详细信息)保存每个消息的详细信息
rabbitTemplate.convertAndSend("order-event-exchange", "order.release.other", orderTo);
} catch (Exception e) {
//TODO 定期扫描数据库,重新发送失败的消息
}
}
}
9.解锁库存
库存解锁分为库存服务解锁+订单服务解锁
库存服务的消息在锁定所有商品成功后发送,延时2分钟进入队列,为了实现分布式事务+订单取消解锁库存,但是出现延迟问题时库存永远不会解锁
订单服务的消息在关闭订单后进入队列,是为了延迟发生时保证库存一定能解锁成功(如果只有这个,不能实现分布式事务)
收到的消息StockLockedTo包含:工作单id(为了查出订单信息)和对应工作单详情的全部数据(哪几件商品在哪几个仓库锁定了几件)
/**
* 库存被动解锁+订单主动解锁
*/
@Slf4j
@RabbitListener(queues = "stock.release.stock.queue")
@Service
public class StockReleaseListener {
@Autowired
private WareSkuService wareSkuService;
/**
* 库存自动解锁
* 只要解锁库存的消息失败,一定要告诉服务解锁失败
*/
@RabbitHandler
public void handleStockLockedRelease(StockLockedTo to, Message message, Channel channel) throws IOException {
try {
// 解锁库存
wareSkuService.unlockStock(to);
// 手动删除消息
channel.basicAck(message.getMessageProperties().getDeliveryTag(),false);
} catch (Exception e) {
// 解锁失败 将消息重新放回队列,让别人消费
channel.basicReject(message.getMessageProperties().getDeliveryTag(),true);
}
}
/**
* 下单会给订单取消和库存解锁发MQ延时消息,分别是1分钟和2分钟,如果订单消息延迟,
* 库存先解锁,此时查订单状态还是新建,就不解锁了,此时订单消息才发到,取消订单后库存没法释放
* 所以订单消息发到,取消订单的时候再通知一次解锁库存,直接发解锁库存队列(不延时)
*/
@RabbitHandler
public void handleOrderCloseRelease(OrderTo orderTo, Message message, Channel channel) throws IOException {
try {
wareSkuService.unlockStock(orderTo);
// 手动删除消息
channel.basicAck(message.getMessageProperties().getDeliveryTag(),false);
} catch (Exception e) {
// 解锁失败 将消息重新放回队列,让别人消费
channel.basicReject(message.getMessageProperties().getDeliveryTag(),true);
}
}
}
<!-- 解锁库存sql -->
<update id="unLockStock">
UPDATE wms_ware_sku
SET stock_locked = stock_locked - #{num}
WHERE
sku_id = ${skuId}
AND ware_id = #{wareId}
</update>
库存服务解锁:
所有商品锁定成功,发送消息,两分钟过期发到这个队列里
1.下订单成功,库存锁定成功,接下来的业务调用失败,导致订单回滚。之前锁定的库存就要解锁
2.订单主动取消/未支付导致取消,也需要解锁
3.如果查不到对应的工作单,说明锁定失败被回滚了,自然无需解锁
4.如果订单状态为已支付,也不用解锁
@Override
public void unlockStock(StockLockedTo to) {
//库存工作单的id
StockDetailTo detail = to.getDetailTo();
Long detailId = detail.getId();
/**
* 解锁
* 1、查询数据库关于这个订单锁定库存信息
* 有:证明库存锁定成功了
* 解锁:订单状况
* 1、没有这个订单,说明被回滚了,必须解锁库存
* 2、有这个订单,不一定解锁库存
* 订单状态:已取消:解锁库存
* 已支付:不能解锁库存
*/
WareOrderTaskDetailEntity taskDetailInfo = wareOrderTaskDetailService.getById(detailId);
if (taskDetailInfo != null) {
//查出wms_ware_order_task工作单的信息
Long id = to.getId();
WareOrderTaskEntity orderTaskInfo = wareOrderTaskService.getById(id);
//获取订单号查询订单状态
String orderSn = orderTaskInfo.getOrderSn();
//远程查询订单信息
R orderData = orderFeignService.getOrderStatus(orderSn);
if (orderData.getCode() == 0) {
//订单数据返回成功
OrderVo orderInfo = orderData.getData("data", new TypeReference<OrderVo>() {});
//判断订单状态是否已取消或者订单不存在,解锁库存,消息可以确认
if (orderInfo == null || orderInfo.getStatus() == 4) {
if (taskDetailInfo.getLockStatus() == 1) {
//当前库存工作单详情状态1,已锁定才可以解锁,保证幂等,防止库存解锁后ack时发生宕机
//解锁:加回数量并将工作单详情状态变为已经解锁
unLockStock(detail.getSkuId(),detail.getWareId(),detail.getSkuNum(),detailId);
}
}
} else {
// 远程调用失败
// 消息拒绝以后重新放在队列里面,让别人继续消费解锁,抛出异常代表解锁失败
throw new RuntimeException("远程调用服务失败");
}
} else {
//没有工作单,无需解锁,消息可以确认
}
}
订单服务解锁:
定时关单完成,消息发送到这个队列
/**
* 下单会给订单取消和库存解锁发MQ延时消息,分别是1分钟和2分钟,如果订单消息延迟,
* 库存先解锁,此时查订单状态还是新建,就不解锁了,此时订单消息才发到,取消订单后库存没法释放
* 所以订单消息发到,取消订单的时候再通知一次解锁库存,直接发库存队列(不延时)
*/
@Transactional(rollbackFor = Exception.class)
@Override
public void unlockStock(OrderTo orderTo) {
String orderSn = orderTo.getOrderSn();
//查一下最新的库存解锁状态,防止重复解锁库存
WareOrderTaskEntity orderTaskEntity = wareOrderTaskService.getOrderTaskByOrderSn(orderSn);
//按照工作单的id找到所有没有解锁的库存,进行解锁
Long id = orderTaskEntity.getId();
List<WareOrderTaskDetailEntity> list = wareOrderTaskDetailService.list(new QueryWrapper<WareOrderTaskDetailEntity>().eq("task_id", id).eq("lock_status", 1));
for (WareOrderTaskDetailEntity taskDetailEntity : list) {
unLockStock(taskDetailEntity.getSkuId(),
taskDetailEntity.getWareId(),
taskDetailEntity.getSkuNum(),
taskDetailEntity.getId());
}
}
10.消息可靠性解决方案
消息丢失
1、做好消息确认机制(pulisher开启confirms和return确认机制,consumer手动ack)
2、开启RabbitMQ的持久化,创建queue的时候将其设置为持久化,发送消息的时候将消息的deliveryMode设置为 2
3、每一个发送的消息都在数据库做好记录。定期将失败的消息再次发送一 遍
消息重复
1.接口应该设计成幂等性的,比如扣减库存要判断工作单的状态标志
2.rabbitmq的每个消息都有redelivered字段,可以判断当前消息是否被第二次及以后派发过来了
Boolean redelivered = message.getMessageProperties().getRedelivered();
消息积压
1.上线更多的消费者
2.上线专门的队列消费服务,将消息批量取出记录数据库,后续慢慢处理
11.订单支付
- 支付宝加密采用RSA非对称加密,分别在商户端和支付宝端有两对公钥和私钥
- 在发送订单数据时,直接使用明文,但会使用商户私钥加一个对应的签名,支付宝端会使用商户公钥对签名进行验签
- 支付成功后,支付宝发送支付成功数据之外,还会使用支付宝私钥加一个对应的签名,商户端收到支付成功数据之后也会使用支付宝公钥验签
/**
* 用户点击去支付宝支付
* 1、返回支付页让浏览器展示
* 2、支付成功以后,跳转到用户的订单列表页
*/
@ResponseBody
@GetMapping(value = "/aliPayOrder",produces = "text/html")
public String aliPayOrder(@RequestParam("orderSn") String orderSn) throws AlipayApiException {
PayVo payVo = orderService.getOrderPay(orderSn);
// 返回的是支付页,直接交给浏览器
String pay = alipayTemplate.pay(payVo);
System.out.println(pay);
return pay;
}
支付成功后,如果使用同步通知模式(支付宝回调商户接口的时候修改订单状态),有可能因为网路原因修改失败
因此使用支付宝异步通知模式,隔一段时间会通知商户支付成功,返回success支付宝便不再通知
/**
* 支付宝异步通知接口
*/
@PostMapping(value = "/payed/notify")
public String handleAlipayed(PayAsyncVo asyncVo, HttpServletRequest request) throws AlipayApiException, UnsupportedEncodingException {
// 拼接参数,验签
Map<String, String> params = new HashMap<>();
Map<String, String[]> requestParams = request.getParameterMap();
for (String name : requestParams.keySet()) {
String[] values = requestParams.get(name);
String valueStr = "";
for (int i = 0; i < values.length; i++) {
valueStr = (i == values.length - 1) ? valueStr + values[i]
: valueStr + values[i] + ",";
}
params.put(name, valueStr);
}
boolean signVerified = AlipaySignature.rsaCheckV1(params, alipayTemplate.getAlipay_public_key(),
alipayTemplate.getCharset(), alipayTemplate.getSign_type()); //调用SDK验证签名
if (signVerified) {
System.out.println("签名验证成功...");
//去修改订单状态
String result = orderService.handlePayResult(asyncVo);
return result;
} else {
System.out.println("签名验证失败...");
return "error";
}
}
12.秒杀
1.秒杀服务主要表结构
sms_seckill_promotion:秒杀活动表
活动标题,活动名称,开始时间,结束时间,启用状态
这张表没有用到,可以不看
sms_seckill_session:每日秒杀活动表
活动标题,场次名称,每日开始时间,每日结束时间,启用状态
在这个项目中每日秒杀活动表被当成秒杀活动表用了
sms_seckill_sku_relation:秒杀活动商品关联表
活动场次id,每日活动场次id,skuid,秒杀价格,秒杀总量,每人限购数量,
本项目中,只关联了每日活动场次id
2.秒杀架构思路
- 项目独立部署,独立秒杀模块gulimall-seckill
- 库存预热,使用定时任务每天三点上架最新秒杀商品,削减高峰期压力
- 秒杀链接加密,为秒杀商品添加唯一商品随机码,在开始秒杀时才暴露接口,防止恶意攻击
- 快速扣减+控制流量,先从数据库中扣除一部分库存以redisson信号量的形式存储在redis中
- 队列削峰,秒杀成功后立即返回,然后以发送消息的形式创建订单
3.定时提前缓存秒杀活动
配置类开启异步定时任务
@EnableAsync
@EnableScheduling
@Configuration
public class ScheduledConfig {
}
配置异步任务线程池
spring:
task:
execution:
pool:
core-size: 5
max-size: 50
每天凌晨三点自动上架最近三天的秒杀活动和商品,由于在分布式情况下该方法可能同时被调用多次,因此加入分布式锁,同时只有一个服务可以调用该方法
@Scheduled(cron = "0 0 3 * * ? ")
public void uploadSeckillSkuLatest3Days() {
RLock lock = redissonClient.getLock(upload_lock);
try {
// 加锁,10秒释放
lock.lock(10, TimeUnit.SECONDS);
seckillService.uploadSeckillSkuLatest3Days();
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
redis中秒杀活动场次缓存结构:
key: seckill:sessions:开始时间_结束时间
value: 场地id_商品skuId(list结构)
redis中秒杀商品缓存结构:
key: seckill:skus
value: key:场地id_商品skuId value:sku所有详细信息+商品秒杀表信息+秒杀开始结束时间+随机码JSON(hash结构)
随机码在秒杀活动开始后才可以返回给接口
redisson信号量限流:
// 每个商品将可以秒杀的数量以redisson信号量的形式存储在redis中
RSemaphore semaphore = redissonClient.getSemaphore(SKU_STOCK_SEMAPHORE + token);
semaphore.trySetPermits(seckillSkuVo.getSeckillCount());
redis中缓存结构
key: seckill:stock:随机码
value: 库存数(string)
4.首页获取当前可秒杀商品
/getCurrentSeckillSkus
从Redis中查询到所有key以seckill:sessions开头的所有数据
Set<String> keys = redisTemplate.keys(SESSION_CACHE_PREFIX + "*");
遍历这些秒杀场次,如果在当前时间内,获取这个秒杀场次关联的所有商品信息(场地id-skuid)
if (currentTime >= startTime && currentTime <= endTime) {
List<String> range = redisTemplate.opsForList().range(key, -100, 100);
}
在seckill:skus开头的缓存中,依次根据场次id-skuid这个key获取value
BoundHashOperations<String, String, String> hasOps = redisTemplate.boundHashOps(SECKILL_CHARE_PREFIX);
List<String> listValue = hasOps.multiGet(range);
5.商品详情页展示秒杀信息
/sku/seckill/{skuId}
/**
* 根据skuId查询商品是否参加秒杀活动
*/
@Override
public SeckillSkuRedisTo getSkuSeckilInfo(Long skuId) {
// 找到所有需要秒杀的商品的key信息,前缀seckill:skus
BoundHashOperations<String, String, String> hashOps = redisTemplate.boundHashOps(SECKILL_CHARE_PREFIX);
// 拿到所有的key,场次id-skuid
Set<String> keys = hashOps.keys();
if (keys != null && keys.size() > 0) {
// 正则表达式进行匹配
String reg = "\\d-" + skuId;
for (String key : keys) {
// 如果匹配上了
if (Pattern.matches(reg,key)) {
// 从Redis中取出数据来
String redisValue = hashOps.get(key);
// 进行序列化
SeckillSkuRedisTo redisTo = JSON.parseObject(redisValue, SeckillSkuRedisTo.class);
Long currentTime = System.currentTimeMillis();
Long startTime = redisTo.getStartTime();
Long endTime = redisTo.getEndTime();
// 如果当前时间大于等于秒杀活动开始时间并且要小于活动结束时间
if (currentTime >= startTime && currentTime <= endTime) {
return redisTo;
}
// 随机码不能提前暴露
redisTo.setRandomCode(null);
return redisTo;
}
}
}
return null;
}
全部代码见展示商品详情部分
CompletableFuture<Void> seckillFuture = CompletableFuture.runAsync(() -> {
// 远程调用查询当前sku是否参与秒杀优惠活动
R skuSeckilInfo = seckillFeignService.getSkuSeckilInfo(skuId);
if (skuSeckilInfo.getCode() == 0) {
// 查询成功
SeckillSkuVo seckilInfoData = skuSeckilInfo.getData("data", new TypeReference<SeckillSkuVo>() {
});
skuItemVo.setSeckillSkuVo(seckilInfoData);
}}, executor);
6.秒杀下单
1./kill请求配置登录拦截器
拦截器配置都类似,完整参考购物车拦截器配置
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
String uri = request.getRequestURI();
AntPathMatcher antPathMatcher = new AntPathMatcher();
boolean match = antPathMatcher.match("/kill", uri);
if (match) {
HttpSession session = request.getSession();
// 获取登录的用户信息
MemberResponseVo attribute = (MemberResponseVo) session.getAttribute(LOGIN_USER);
if (attribute != null) {
// 把登录后用户的信息放在ThreadLocal里面进行保存
loginUser.set(attribute);
return true;
} else {
// 未登录,返回登录页面
response.setContentType("text/html;charset=UTF-8");
PrintWriter out = response.getWriter();
out.println("<script>alert('请先进行登录,再进行后续操作!');location.href='http://auth.gulimall.com/login.html'</script>");
return false;
}
}
return true;
}
2.点击按钮开始秒杀
- 点击立即抢购时,会发送请求
- 秒杀请求会对请求校验时效、商品随机码、当前用户是否已经抢购过当前商品、库存和购买量,通过校验的则秒杀成功,发送消息创建订单
返回页面,根据有无订单号判断是否成功
<div th:if="${orderSn != null}" class="mc success-cont">
<h1>恭喜秒杀成功 订单号 [[${orderSn}]]</h1>
<h2>正在准备订单数据 10秒钟后自动跳转支付
<a style="color: red" th:href="${'http://order.gulimall.com/aliPayOrder?orderSn=' + orderSn}">点击支付</a>
</h2>
</div>
<div th:if="${orderSn == null}">
<h1>手气不好 秒杀失败 下次再来</h1>
</div>
秒杀请求
@GetMapping(value = "/kill")
public String seckill(@RequestParam("killId") String killId,
@RequestParam("key") String key,
@RequestParam("num") Integer num,
Model model) {
String orderSn = null;
try {
orderSn = seckillService.kill(killId,key,num);
model.addAttribute("orderSn",orderSn);
} catch (Exception e) {
e.printStackTrace();
}
return "success";
}
验证活动时效,随机码,商品id,数量后,请求合法,根据userid-skuid在redis中占位,占位成功才可以获取信号量,占位失败说明之前已经秒杀过了
String redisKey = user.getId() + "-" + skuId;
// 设置自动过期(活动结束时间-当前时间)
Long ttl = endTime - currentTime;
Boolean aBoolean = redisTemplate.opsForValue().setIfAbsent(redisKey, num.toString(), ttl, TimeUnit.MILLISECONDS);
if (aBoolean) {
// 占位成功说明从来没有买过,分布式锁(获取信号量-1)
RSemaphore semaphore = redissonClient.getSemaphore(SKU_STOCK_SEMAPHORE + randomCode);
boolean semaphoreCount = semaphore.tryAcquire(num);
// 获取信号量成功即为秒杀成功
if (semaphoreCount) {
// 创建订单号和订单信息发送给MQ,整个操作时间在10ms左右
String timeId = IdWorker.getTimeId();
SeckillOrderTo orderTo = new SeckillOrderTo();
orderTo.setOrderSn(timeId);
orderTo.setMemberId(user.getId());
orderTo.setNum(num);
orderTo.setPromotionSessionId(redisTo.getPromotionSessionId());
orderTo.setSkuId(redisTo.getSkuId());
orderTo.setSeckillPrice(redisTo.getSeckillPrice());
// 主启动类不用@EnableRabbit, 因为我们只用来发送消息,不接收消息
rabbitTemplate.convertAndSend("order-event-exchange","order.seckill.order",orderTo);
// 返回订单号
return timeId;
}
}
3.订单服务监听消息创建秒杀单
秒杀队列和绑定关系
@Bean
public Queue orderSecKillOrrderQueue() {
Queue queue = new Queue("order.seckill.order.queue", true, false, false);
return queue;
}
@Bean
public Binding orderSecKillOrrderQueueBinding() {
Binding binding = new Binding(
"order.seckill.order.queue",
Binding.DestinationType.QUEUE,
"order-event-exchange",
"order.seckill.order",
null);
return binding;
}
订单服务监听秒杀队列的消息,收到消息只需要根据订单号和订单信息创建订单
@Slf4j
@Component
@RabbitListener(queues = "order.seckill.order.queue")
public class OrderSeckillListener {
@Autowired
private OrderService orderService;
@RabbitHandler
public void listener(SeckillOrderTo orderTo, Channel channel, Message message) throws IOException {
try {
orderService.createSeckillOrder(orderTo);
channel.basicAck(message.getMessageProperties().getDeliveryTag(),false);
} catch (Exception e) {
channel.basicReject(message.getMessageProperties().getDeliveryTag(),true);
}
}
}
13.Sentinel
用分布式锁信号量只能控制单个商品的流量,如果很多商品同时秒杀也会产生大流量
整合
- 导入依赖spring-cloud-starter-alibaba-sentinel和spring-boot-starter-actuator
- 下载sentinel控制台
- 配置sentienl
spring:
sentinel:
transport:
#配置sentinel dashboard地址
dashboard: localhost:8080
#默认8719端口,假如被占用会自动从8719开始依次+1扫描,直至找到未被占用的端口
port: 8719
#暴露所有端点,让控制台上有图形化的展示
management:
endpoints:
web:
exposure:
include: '*'
1.限流
- 在控制台配置
监控应用流量的QPS或并发线程数,当达到指定的阈值时对流量进行控制
- 自定义URL被限流后的返回方法,每个自定义资源返回方法的定义方式见第3节
@Configuration
public class GulimallSeckillSentinelConfig {
public GulimallSeckillSentinelConfig() {
WebCallbackManager.setUrlBlockHandler(new UrlBlockHandler() {
@Override
public void blocked(HttpServletRequest request, HttpServletResponse response, BlockException ex) throws IOException {
R error = R.error(BizCodeEnum.TO_MANY_REQUEST.getCode(), BizCodeEnum.TO_MANY_REQUEST.getMessage());
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json");
response.getWriter().write(JSON.toJSONString(error));
}
});
}
}
2.降级(熔断由feign提供)
开启feign对sentinel的支持后,在控制台上就可以看到所有feign的远程调用接口
feign:
sentinel:
enabled: true
调用远程服务熔断保护:
- 如果远程服务宕机,触发熔断回调方法,如商品服务远程调用秒杀服务,这个回调是写在调用方(商品服务),这个功能由feign提供
@FeignClient(value = "gulimall-seckill",fallback = SeckillFeignServiceFallBack.class)
public interface SeckillFeignService {
/**
* 根据skuId查询商品是否参加秒杀活动
*/
@GetMapping(value = "/sku/seckill/{skuId}")
R getSkuSeckilInfo(@PathVariable("skuId") Long skuId);
@Component
public class SeckillFeignServiceFallBack implements SeckillFeignService {
@Override
public R getSkuSeckilInfo(Long skuId) {
return R.error(BizCodeEnum.TO_MANY_REQUEST.getCode(),BizCodeEnum.TO_MANY_REQUEST.getMessage());
}
}
调用远程服务降级策略:
- 在控制台根据平均响应时间,异常比例,异常数配置,如果触发了降级,在一定时间内(可配置)就会调用熔断方法
不调用远程服务配置降级策略:
- 如果不是在商品服务配置,而是在秒杀服务配置降级策略,秒杀服务被降级后返回的是限流后的数据(WebCallbackManager.setUrlBlockHandler)
3.自定义受保护的资源
- 基于代码
try (Entry entry = SphU.entry("seckillSkus")) {
//业务逻辑
} catch(BlockException e) {
log.error("资源被限流{}",e.getMessage());
}
- 基于注解,被限流/降级后调用blockHandler方法
@SentinelResource(value = "getCurrentSeckillSkusResource",blockHandler = "blockHandler")
@Override
public List<SeckillSkuRedisTo> getCurrentSeckillSkus() {}
public List<SeckillSkuRedisTo> blockHandler(BlockException e) {
log.error("getCurrentSeckillSkusResource被限流了,{}",e.getMessage());
return null;
}
4.整合SpringCloud-Gateway
导入依赖
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-sentinel-gateway</artifactId>
<version>2.1.0.RELEASE</version>
</dependency>
自定义网关流控返回
@Configuration
public class SentinelGatewayConfig {
public SentinelGatewayConfig() {
GatewayCallbackManager.setBlockHandler(new BlockRequestHandler() {
// 网关限流了请求,就会调用此回调
@Override
public Mono<ServerResponse> handleRequest(ServerWebExchange exchange, Throwable t) {
R error = R.error(BizCodeEnum.TO_MANY_REQUEST.getCode(), BizCodeEnum.TO_MANY_REQUEST.getMessage());
String errorJson = JSON.toJSONString(error);
Mono<ServerResponse> body = ServerResponse.ok().body(Mono.just(errorJson), String.class);
return body;
}
});
}
}
14.Sleuth+Zipkin链路追踪
引入依赖
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-zipkin</artifactId>
</dependency>
配置
#服务追踪地址
spring.zipkin.base-url=http://192.168.77.130:9411/
#不要让nacos也把zipkin注册到注册中心
spring.zipkin.discovery-client-enabled=false
#使用http方式传输数据
spring.zipkin.sender.type=web
#配置采样器,数据收集的比例
spring.sleuth.sampler.probability=1