谷粒商城分布式基础篇
谷粒商城分布式高级篇(上)
谷粒商城分布式高级篇(中)
谷粒商城分布式高级篇(下)
ElasticSearch | MySQL |
---|---|
Index (索引) | 数据库(DataBase) |
Type (类型) | 数据表 |
Document (文档) | 数据 |
属性 | 列名 |
mkdir -p /mydata/elasticsearch/config
mkdir -p /mydata/elasticsearch/data
echo "http.host: 0.0.0.0" >> /mydata/elasticsearch/config/elasticsearch.yml
docker run --name elasticsearch -p 9200:9200 -p 9300:9300 \
-e "discovery.type=single-node" \
-e ES_JAVA_OPTS="-Xms64m -Xmx128m" \
-v /mydata/elasticsearch/config/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml \
-v /mydata/elasticsearch/data:/usr/share/elasticsearch/data \
-v /mydata/elasticsearch/plugins:/usr/share/elasticsearch/plugins \
-d elasticsearch:7.4.2
在虚拟机创建了 elasticsearch 的两个 docker 外部 挂载用文件夹
mkdir -p /mydata/elasticsearch/config
mkdir -p /mydata/elasticsearch/data
写入了一个配置并创建了yml
配置文件, 代表可以被远程的任意 机器访问
echo "http.host:0.0.0.0" >> /mydata/elasticsearch/config/elasticsearch.yml
为容器起了一个名字elasticsearch 暴露两个端口 9200端口 向elasticsearch的restApi发送http请求的端口 9300是es在分布式集群状态下 节点之间的通讯端口
docker run --name elasticsearch -p 9200:9200 -p 9300:9300 \
以单节点模式运行
-e "discovery.type=single-node" \
指定内存占用
-e ES_JAVA_OPTS="-Xms64m -Xmx128m" \
目录的挂载
-v /mydata/elasticsearch/config/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml \
-v /mydata/elasticsearch/data:usr/share/elasticsearch/data \
-v /mydata/elasticsearch/plugins:usr/share/elasticsearch/plugins \
指定刚下载的镜像
-d elasticsearch:7.4.2
查看日志,发现权限不够
docker logs elasticsearch
赋予权限
重新启动容器
docker run --name kibana -e ELASTICSEARCH_HOSTS=http://192.168.56.10:9200 -p 5601:5601 \
-d kibana:7.4.2
http://192.168.56.10:9200
为自己的虚拟机地址
乐观锁做并发修改
每次修改 _seq_no 都会改变 修改时带上这个值才能成功
post 带_update 更新会对比源数据,如果没做改变,那么什么都不变
post 不带 _update 不会检查元数据
put 同上
链接:https://pan.baidu.com/s/1BJ6_6EAhjmTNdSgXjB4TcQ
提取码:hnfd
docker 容器自启动
文档
将查询条件写为json的方式成为 Query DSL(查询领域对象语言)
请求体中的各个参数就像sql中的查询条件
match_all = select *
相当于 查询 字段 account_number 为 20 的值
GET bank/_search
{
"query": {
"match": {
"account_number": "20"
}
}
}
又可以模糊查询
GET bank/_search
{
"query": {
"match": {
"address": "Kings"
}
}
}
不分词查询,包含完整的短语
可以加入多种条件查询
GET bank/_search
{
"query": {
"bool": {
"must": [
{
"match": {
"gender": "M"
}
},
{
"match": {
"address": "mill"
}
}
],
"must_not": [
{
"match": {
"age": "30"
}
}
],
"should": [
{
"match": {
"lastname": "Wallace"
}
}
]
}
}
}
条件关键词
GET bank/_search
{
"query": {
"range": {
"age": {
"gte": 10,
"lte": 30
}
}
}
}
term和match一样是查询
但是文本字段避免使用term查询,文本字段的全文检索推荐 match。精确数组字段使用 term
规定全文检索字段用match,其他非text字段匹配用term
每一个文本字段都可以 .keyword
代表匹配文本字段的整个精确值(不分词匹配)
和 match_phrase 短语匹配的区别
和 match_phrase
短语匹配的区别
.keyword
匹配的值中 只能全等于 这个值
match_phrase
匹配的值中 包含 这个值 (此短语)
聚合语法
为query的查询结果做聚合,有多少种不同的 age字段的值 size 前10个可能
terms聚合,用来查看值有多少种可能
GET bank/_search
{
"query": {
"match": {
"address": "mill"
}
},
"aggs": {
"ageAgg": {
"terms": {
"field": "age",
"size": 10
}
}
}
}
指定显示hits的条数size
聚合中再子聚合 先聚合出年龄段 再聚合年龄段的平均薪资
GET bank/_search
{
"query": {
"match_all": {
}},
"aggs": {
"ageAgg": {
"terms": {
"field": "age",
"size": 100
},
"aggs": {
"genderAvg": {
"terms": {
"field": "gender.keyword",
"size": 10
},"aggs": {
"genderBanlance": {
"avg": {
"field": "balance"
}
}
}
},
"allBalanceAvg":{
"avg": {
"field": "balance"
}
}
}
}
},
"size": 0
}
相当于 MySQL创建表时 定义每一列的类型 (如 String int date )
_mapping可以查看当前所有的所有属性的类型
第一次存数据的时候,ES就会猜测 属性的类型
可以在第一次保存数据前可以给索引指定映射,创建索引时指定映射
在6.0版本移除了映射类型
因为ES底层是Lucene开发的
新增一个属性的映射
index:false
为此映射不需要被索引
不写的话 都是默认 为 index:true
是用来控制这个属性是不是来参与检索的
相当于做了一个冗余字段
要修改映射类型,只能创建新索引指定好映射类型后,再数据迁移
创建新索引并指定好映射
PUT /newbank
{
"mappings": {
"properties" : {
"account_number" : {
"type" : "long"
},
"address" : {
"type" : "text"
},
"age" : {
"type" : "integer"
},
"balance" : {
"type" : "long"
},
"city" : {
"type" : "keyword"
},
"email" : {
"type" : "keyword"
},
"employer" : {
"type" : "keyword"
},
"firstname" : {
"type" : "text"
},
"gender" : {
"type" : "keyword"
},
"lastname" : {
"type" : "text",
"fields" : {
"keyword" : {
"type" : "keyword",
"ignore_above" : 256
}
}
},
"state" : {
"type" : "keyword"
}
}
}
}
数据迁移
老数据有Type的,需要指定type 没有的就不用指定
测试标准分词器的分词
POST _analyze
{
"analyzer": "standard",
"text": "which you can then accept by hitting Enter/Tab."
}
标准分词器会将中文分词一个一个字,不好用
测试ik分词器的效果
这两个文件就是网卡文件
修改eth1 文件
重启网卡 service network restart
下载新 的 yum
设置国内的 yum 源
安装 wget 和 unzip
之前创建ES 内存分配的有点小,现在移除掉 ES容器 重新创建ES容器
docker run --name elasticsearch -p 9200:9200 -p 9300:9300 \
-e "discovery.type=single-node" \
-e ES_JAVA_OPTS="-Xms64m -Xmx512m" \
-v /mydata/elasticsearch/config/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml \
-v /mydata/elasticsearch/data:/usr/share/elasticsearch/data \
-v /mydata/elasticsearch/plugins:/usr/share/elasticsearch/plugins \
-d elasticsearch:7.4.2
安装 nginx
先创建nginx的挂载目录
docker run -p 80:80 --name nginx \
-v /mydata/nginx/html:/usr/share/nginx/html \
-v /mydata/nginx/logs:/var/log/nginx \
-v /mydata/nginx/conf:/etc/nginx \
-d nginx:1.10
在nginx 的html 目录中创建 词库
词库中输入单词,回车分隔
进入 ES的plugins 中修改 ik分词的配置
将刚创建的词库的地址填入
创建ES模块
在pom中导入 ES依赖
<dependency>
<groupId>org.elasticsearch.clientgroupId>
<artifactId>elasticsearch-rest-high-level-clientartifactId>
<version>7.4.2version>
dependency>
发现包管理中的 ES版本不对,因为spring-boot 会对ES的版本做管理
所以在pom中单独指定版本
做好nacos配置
参考官网的ES配置
官网文档
编写配置类
测试
在对ES做所有操作前要做 RequestOptions(请求的设置项)
带上安全头等设置信息
测试保存 详细在 IndexAPI文档
详细在文档 Search APIs
sku在es中的存储模型设计有两种
第一种、冗余设计,每个sku基本信息带上检索属性attrs的冗余
第二种、避免冗余,分开索引,将attrs 新创建索引
但是在聚合规格做分析时,会动态计算所有sku的 attrs 就会造成沉重的分布查询
这种会造成超大的查询压力
所以采用 第一种设计的冗余存储查询,用空间换时间的思想
商品的数据模型
PUT product
{
"mappings": {
"properties": {
"skuId": {
"type": "long"
},
"spuId": {
"type": "long"
},
"skuTitle": {
"type": "text",
"analyzer": "ik_smart"
},
"skuPrice": {
"type": "keyword"
},
"skuImg": {
"type": "keyword",
"index": false,
"doc_values": false
},
"saleCount": {
"type": "long"
},
"hasStock": {
"type": "boolean"
},
"brandId": {
"type": "long"
},
"catalogId": {
"type": "long"
},
"brandName": {
"type": "keyword",
"index": false,
"doc_values": false
},
"brandImg": {
"type": "keyword",
"index": false,
"doc_values": false
},
"catalogName": {
"type": "keyword",
"index": false,
"doc_values": false
},
"attrs": {
"type": "nested",
"properties": {
"attrId": {
"type": "long"
},
"attrName": {
"type": "keyword",
"index": false,
"doc_values": false
},
"attrValue": {
"type": "keyword"
}
}
}
}
}
}
type:keyword
不可拆分的精确检索
index:false
不可用来被检索
doc_value:false
默认true默认可以用来聚合
nested 嵌入式的数据类型 官方解释
因为数组默认被扁平化处理了会出现错误的查询结果
重新设计 R 工具类,加上泛型,可以解决远程调用后还要强转一次返回值
192.168.56.10 guliamll.com
在总配置文件中的http块中配置 upstream (上游服务器) 命名为 gulimall
设置为路由到88端口,路由到网关模块,再由网关分配
站点配置文件中 配置上
重启nginx
配置网关断言规则,网关接受nginx负载均衡来的请求 -Host
断言 判断请求头中 的Host 的域名并 路由到指定 服务
配置完成后发现无法访问
因为nginx代理给网关的时候,会丢失请求的host信息,导致网关无法判断,其实nginx会丢掉很多信息
必须在站点负载均衡时手动设置上请求头需要携带的信息
压测内容 | 压测线程数 | 吞吐量/s | 90%响应时间 | 99%响应时间 | 压测地址 |
---|---|---|---|---|---|
Nginx | 50 | 8459 | 7 | 67 | 192.168.60.10:80/ |
GateWay | 50 | 30430 | 3 | 7 | localhost:88/ |
简单服务 | 50 | 34552 | 2 | 6 | localhost:10000/hello |
首页一级菜单渲染 | 50 | 903(db,thymeleaf) | 64 | 207 | localhost:10000/ |
首页渲染(开缓存) | 50 | 1107 | 54 | 89 | localhost:10000/ (开缓存) |
首页渲染(开缓存、优化Db、关日志) | 50 | 1977 | 34 | 54 | localhost:10000/ (开缓存、优化Db、关日志) |
三级分类数据获取 | 50 | 8 (db) | 6908 | 7109 | loaclhost:10000/index/catalog.json |
三级分类数据获取(业务优化) | 50 | 207 | 263 | 689 | loaclhost:10000/index/catalog.json |
首页全量数据获取 | 50 | 47 (静态资源) | 1170 | 1370 | localhost:10000/ (高级设置) |
首页全量数据获取(动静分离后,内存分配后) | 50 | 268 | 966 | 6192 | localhost:10000/ (高级设置) |
Nginx+Gateway | 50 | ||||
Gateway+简单服务 | 50 | 8,445 | 9 | 16 | localhost:88/hello |
全链路 | 50 | 2539 | 30 | 46 | gulimall.com:80/hello |
首页渲染(开缓存、优化Db)
index/img/xxx.xxx
----> staitc/index/img/xxx.xxx
配置意为 路径为/static/
的地址 资源在 root /usr/share/nginx/html;
下寻找
发现设置动静分离后提升不大,而老年代几乎爆满,慢在老年代的GC
设置服务的最大内存占用重新分配内存
配置意思分别为 最大内存 最小内存 新生代内存(伊甸园区+幸存者区)
优化业务,只做一次查询
@RunWith(SpringRunner.class)
@SpringBootTest
public class GulimallProductApplicationTests {
@Autowired
StringRedisTemplate stringRedisTemplate;
@Test
public void contextLoads() {
ValueOperations<String, String> ops = stringRedisTemplate.opsForValue();
ops.set("hello","world_"+ UUID.randomUUID().toString());
String hello = ops.get("hello");
System.out.println(hello);
}
}
@Override
public Map<String, List<Catelog2Vo>> getCatelogJson() {
//1、加入缓存逻辑,缓存中存的数据是json字符串
//JSON 跨语言跨平台
String catalogJson = stringRedisTemplate.opsForValue().get("catalogJson");
if (StringUtils.isEmpty(catalogJson)) {
//缓存中没查到,查询数据库
Map<String, List<Catelog2Vo>> catelogJsonFromDb = getCatelogJsonFromDb();
//查询到的数据先转为JSON再放入缓存
String s = JSON.toJSONString(catelogJsonFromDb);
stringRedisTemplate.opsForValue().set("catalogJson", s);
return catelogJsonFromDb;
}
//缓存中查到,将JSON转为对象再返回
Map<String, List<Catelog2Vo>> stringListMap =
JSON.parseObject(catalogJson,
new TypeReference<Map<String, List<Catelog2Vo>>>() {
});
return stringListMap;
}
压测 http://localhost:10000/index/catalog.json
出现 异常
堆外内存溢出
压测内容 | 压测线程数 | 吞吐量/s | 90%响应时间 | 99%响应时间 | 压测地址 |
---|---|---|---|---|---|
三级分类数据获取 | 50 | 8 (db) | 6908 | 7109 | loaclhost:10000/index/catalog.json |
三级分类数据获取(业务优化) | 50 | 207 | 263 | 689 | loaclhost:10000/index/catalog.json |
三级分类数据获取(业务优化、redis缓存) | 50 | 915 | 70 | 99 | loaclhost:10000/index/catalog.json |
@Override
public Map<String, List<Catelog2Vo>> getCatelogJson() {
//1、加入缓存逻辑,缓存中存的数据是json字符串
//JSON 跨语言跨平台
String catalogJson = stringRedisTemplate.opsForValue().get("catalogJson");
if (StringUtils.isEmpty(catalogJson)) {
//缓存中没查到,查询数据库
System.out.println("缓存不命中,查询数据库");
Map<String, List<Catelog2Vo>> catelogJsonFromDb = getCatelogJsonFromDb();
//查询到的数据先转为JSON再放入缓存
String s = JSON.toJSONString(catelogJsonFromDb);
stringRedisTemplate.opsForValue().set("catalogJson", s);
return catelogJsonFromDb;
}
System.out.println("缓存命中,直接返回");
//缓存中查到,将JSON转为对象再返回
Map<String, List<Catelog2Vo>> stringListMap =
JSON.parseObject(catalogJson,
new TypeReference<Map<String, List<Catelog2Vo>>>() {
});
return stringListMap;
}
public Map<String, List<Catelog2Vo>> getCatelogJsonFromDb() {
synchronized (this){
//得到锁后在缓存确认依次,如果没有再查询
String catalogJson = stringRedisTemplate.opsForValue().get("catalogJson");
if (!StringUtils.isEmpty(catalogJson)){
//缓存不为空,直接返回
return stringListMap;
}
System.out.println("查询了数据库");
//1、查出一级分类
List<CategoryEntity> level1Categorys = getParentCid(selectList, 0L);
//2、封装分类
业务逻辑
return map;
}
}
要保证查询和放入是一个原子操作,否则会出现在释放锁后放入缓存的间隙其他线程拿到锁再查询
idea启动多个商品服务,测试分布式下的本地锁功能
更改名字和端口就能启动多个服务
压测地址为 http gulimall.com 80
由网关分配给各个服务
测试结果如预期每一个服务都单独查询了数据 库
用 xshell 测试 redis 占坑
撰写栏中发送给所有会话
改造
这种方法也会出现死锁问题,因为在 执行业务时可能会发生异常导致没有及时删除锁,这就造成死锁
解决办法,给锁设置自动过期时间
这种也会出现问题,可能在拿到锁后发生意外,程序没有执行到设置过期时间而造成了死锁
解决办法,拿锁和设置时间一条命令完成
redis 命令为
代码方法为
这种也会产生一个问题,删锁问题
可重入锁:嵌套调用可以重复使用的锁,所有的锁都应该设置为可重入锁,避免死锁问题
简单解释: 方法A中调用方法B ,方法A有lock1
方法B也能使用 lock1
B执行完后 A释放锁程序结束,如果不可重入锁,B无法拿到方法持有的lock1
锁,这就形成了死锁,所以所有的锁都应该设计为可重入锁
测试
redisson
的锁是一个阻塞式等待 lock.lock();
方法 如果拿不到锁就会一直停留在这儿等待锁的释放,默认加锁时间是30s@ResponseBody
@GetMapping("/hello")
public String hello(){
//1、获取一把锁,只要锁的名字一样,就是同一把锁
RLock lock = redisson.getLock("my-lock");
//2、加锁
lock.lock();//阻塞式等待
try {
System.out.println("加锁成功:"+Thread.currentThread().getId());
Thread.sleep(10000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
System.out.println("释放锁");
lock.unlock();
}
return "hello";
}
可以给锁指定过期时间
lock.lock(10, TimeUnit.SECONDS);
但是这个方法无法给锁自动续期,到时锁自动删除
推荐手动设置超时时间
读写锁文档
分布式可重入读写锁允许同时有多个读锁和一个写锁处于加锁状态。
RReadWriteLock rwlock = redisson.getReadWriteLock("anyRWLock");
// 最常见的使用方法
rwlock.readLock().lock();
// 或
rwlock.writeLock().lock();
//写锁
@ResponseBody
@GetMapping("/write")
public String writeLock() {
RReadWriteLock lock = redisson.getReadWriteLock("rw-lock");
String s = "";
RLock rLock = lock.writeLock();
rLock.lock();
try {
System.out.println("写锁加锁成功。。。" + Thread.currentThread().getId());
s = UUID.randomUUID().toString();
redisTemplate.opsForValue().set("writeValue", s);
Thread.sleep(10000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
rLock.unlock();
System.out.println("写锁释放。。。" + Thread.currentThread().getId());
}
return s;
}
@ResponseBody
@GetMapping("/read")
public String readLock() {
RReadWriteLock lock = redisson.getReadWriteLock("rw-lock");
String s = "";
RLock rLock = lock.readLock();
rLock.lock();
try {
System.out.println("读锁加锁成功。。。" + Thread.currentThread().getId());
s = redisTemplate.opsForValue().get("writeValue");
Thread.sleep(10000);
} catch (InterruptedException e) {
e.printStackTrace();
} finally {
rLock.unlock();
System.out.println("读锁释放。。。" + Thread.currentThread().getId());
}
return s;
}
设置等待次数,达到次数才能释放锁
@ResponseBody
@GetMapping("/lockDoor")
public String lockDoor() throws InterruptedException {
RCountDownLatch door = redisson.getCountDownLatch("door");
door.trySetCount(5);
door.await();
return "全部释放完成";
}
@ResponseBody
@GetMapping("/gogo")
public String gogo() throws InterruptedException {
RCountDownLatch door = redisson.getCountDownLatch("door");
//计数-1,锁的数-1 (i--)
door.countDown();
long count = door.getCount();
return "走了....剩余"+count;
}
信号量可以用来限量
acquire
和 tryAcquire
的区别
acquire
为阻塞式等待,会一直等到释放锁在执行操作
tryAcquire
非阻塞式等待,没有锁就直接返回false
//信号量
@ResponseBody
@GetMapping("/park")
public String park() throws InterruptedException {
RSemaphore park = redisson.getSemaphore("park");
//park.acquire();//是阻塞式获取无返回值、一定要获取一个占位才能继续执行
boolean b = park.tryAcquire();//非阻塞、有量就返回true,无量返回false,继续向下执行
if (b){
return "有位置了";
}else {
return "没位置";
}
}
@ResponseBody
@GetMapping("/gocar")
public String goCar(){
RSemaphore park = redisson.getSemaphore("park");
park.release();//释放一个信号 释放一个值 释放一个车位
return "ok";
}
redisson 改造 之前手写的 redis 锁
如何保持缓存一致性
1. 引入依赖
spring-boot-starter-cache、spring-Boot-starter-data-redis
2. 写配置
application.properties文件中 写入缓存类型 spring.cache.type=redis
3. 配置类开启缓存
@EnableConfigurationProperties(CacheProperties.class)
//开启缓存功能
@EnableCaching
@Configuration
public class MyCacheConfig {
@Bean
RedisCacheConfiguration redisCacheConfiguration(CacheProperties cacheProperties) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig();
// config = config.entryTtl();
config = config.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()));
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. 可以给缓存的key设置名字
2. 可以用ttl表达式获取参数的名字作为key值
@EnableConfigurationProperties(CacheProperties.class)
//开启缓存功能
@EnableCaching
@Configuration
public class MyCacheConfig {
@Bean
RedisCacheConfiguration redisCacheConfiguration(CacheProperties cacheProperties) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig();
// config = config.entryTtl();
config = config.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()));
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;
}
}
#以下是整合Spring Cache 的相关配置
#配置缓存的类型 (最简化的配置)
spring.cache.type=redis
#指定缓存的名字
#spring.cache.cache-names=qq,
#指定缓存的存活时间 单位:ms
spring.cache.redis.time-to-live=3600000
#为了区分redis其他的东西
#如果指定了前缀,就用我们指定的前缀,如果没有就默认使用缓存的名字(分区名-value)作为前缀
#优先级高
#spring.cache.redis.key-prefix=CACHE_
#默认是使用前缀的
spring.cache.redis.use-key-prefix=true
#是否缓存空值 防止缓存穿透
spring.cache.redis.cache-null-values=true
@CacheEvict 删除 触发将数据从缓存删除的操作
调用方法将删除redis缓存
@Cacheable(value = "category",key = "#root.methodName")
@Override
public Map<String, List<Catelog2Vo>> getCatelogJson(){
System.out.println("查数据库");
List<CategoryEntity> selectList = baseMapper.selectList(null);
//1、查出一级分类
//2、封装分类
//业务逻辑
return map;
}
@Caching
注解 多个操作,同时删除多个缓存
指定删除分区下的数据
@CacheEvict(value = "category", allEntries = true)
search 服务加入thymeleaf
template目录放入html页面
静态资源导入nginx 静态资源目录下
网关模块转发至 search 服务
- id: gulimall_search_route
uri: lb://gulimall-search
predicates:
- Host=search.gulimall.com
devtools
和关闭thymeleaf
缓存所有的检索条件抽取为一个对象传输
抽象一个处理检索的service
分析要返回页面的数据,和能进行检索的属性设计返回模型
@Data
public class SearchParam {
private String keyword;//页面传递过来的检索参数 相当于全文匹配关键字
private Long catalog3Id;//三级分类id
/**
* 排序条件
* sort=saleCount_asc/desc 倒序
* sort=skuPrice_asc/desc 根据价格
* sort=hotScore_asc/desc
*/
private String sort;
/**
* hasStock(是否有货) skuPrice区间 brandId catalog3Id attrs
* hasStock 0/1
* skuPrice=1_500 500_ _500
* brandId = 1
* attrs1_5寸_6寸
* // 0 无库存 1有库存
*/
private Integer hasStock;
/**
* 价格区查询
*/
private String skuPrice;
/**
* 多个品牌id
*/
private List<Long> brandId;
/**
* 按照属性进行筛选
*/
private List<String> attrs;
/**
* 页码
*/
private Integer pageNum = 1;
}
DSL依次包含有
GET newproduct/_search
{
"query": {
"bool": {
"must": [
{
"match": {
"skuTitle": "华为"
}
}
],
"filter": [
{
"term": {
"catalogId": "225"
}
},
{
"terms": {
"brandId": [
"1",
"2",
"3"
]
}
},
{
"nested": {
"path": "attrs",
"query": {
"bool": {
"must": [
{
"term": {
"attrs.attrId": {
"value": "11"
}
}
}
]
}
}
}
},
{
"term": {
"hasStock": false
}
},
{
"range": {
"skuPrice": {
"gte": 0,
"lte": 5000
}
}
}
]
}
},
"sort": [
{
"skuPrice": {
"order": "desc"
}
}
],
"from": 0,
"size": 1,
"highlight": {
"fields": {
"skuTitle": {
}},
"pre_tags": "",
"post_tags": ""
}
}
按品牌id聚合就会列出所含有的brandId
嵌套聚合,用上一层聚合出来的brandId 聚合出 brandName
发现报错,错误为,type为Keyword的属性不能用来聚合
所以重新put一个索引,更改属性
PUT gulimall_product
{
"mappings": {
"properties": {
"skuId": {
"type": "long"
},
"spuId": {
"type": "long"
},
"skuTitle": {
"type": "text",
"analyzer": "ik_smart"
},
"skuPrice": {
"type": "keyword"
},
"skuImg": {
"type": "keyword"
},
"saleCount": {
"type": "long"
},
"hasStock": {
"type": "boolean"
},
"brandId": {
"type": "long"
},
"catalogId": {
"type": "long"
},
"brandName": {
"type": "keyword"
},
"brandImg": {
"type": "keyword"
},
"catalogName": {
"type": "keyword"
},
"attrs": {
"type": "nested",
"properties": {
"attrId": {
"type": "long"
},
"attrName": {
"type": "keyword"
},
"attrValue": {
"type": "keyword"
}
}
}
}
}
}
迁移数据
POST _reindex
{
"source": {
"index": "product"
}
,"dest": {
"index": "gulimall_product"
}
}
更改常量中的索引
单独的聚合语句,注意属性为嵌入式的聚合有些许不同
GET gulimall_product/_search
{
"query": {
"match_all": {
}
},
"aggs": {
"brand_agg": {
"terms": {
"field": "brandId",
"size": 10
},
"aggs": {
"brand_name_agg": {
"terms": {
"field": "brandName",
"size": 1
}
},
"brand_img_agg":{
"terms": {
"field": "brandImg",
"size": 10
}
}
}
},
"catalog_agg": {
"terms": {
"field": "catalogId",
"size": 10
},
"aggs": {
"catalog_name_agg": {
"terms": {
"field": "catalogName",
"size": 10
}
}
}
},
"attr_agg":{
"nested": {
"path": "attrs"
},
"aggs": {
"attr_id_agg": {
"terms": {
"field": "attrs.attrId",
"size": 10
},
"aggs": {
"attr_name_agg": {
"terms": {
"field": "attrs.attrName",
"size": 10
}
},
"attr_value_agg": {
"terms": {
"field": "attrs.attrValue",
"size": 10
}
}
}
}
}
}
}
}
分为三大部分
(将上一节分析列出的DSL用 api 构造出)
(传入用api构造的DSL )
(操作上一步api返回的数据,封装成需要的格式)
/**
* 准备检索请求
* 模糊匹配、过滤(按照属性、分类、品牌、价格区间、库存)、排序、分页、高亮、聚合分析
*
* @return SearchResult
*/
private SearchRequest buildSearchRequest(SearchParam param) {
SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();//构建DSL语句
/**
* 模糊匹配、过滤(按照属性、分类、品牌、价格区间、库存)
*/
//1、构建bool - query
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
//1.1、must - 模糊匹配
if (!StringUtils.isEmpty(param.getKeyword())) {
boolQuery.must(QueryBuilders.matchQuery("skuTitle", param.getKeyword()));
}
//1.2、filter - catalogId
if (!StringUtils.isEmpty(param.getCatalog3Id())) {
boolQuery.filter(QueryBuilders.termQuery("catalogId", param.getCatalog3Id()));
}
//1.3、filter - brandId
if (!StringUtils.isEmpty(param.getBrandId())) {
boolQuery.filter(QueryBuilders.termsQuery("brandId", param.getBrandId()));
}
//1.4、filter - nested
if (param.getAttrs() != null && param.getAttrs().size() > 0) {
//attrs=1_5寸:8寸&attrs=2_16g:8g
for (String attrStr : param.getAttrs()) {
BoolQueryBuilder nestedBoolQuery = QueryBuilders.boolQuery();
//attr = 1_5寸:8寸
String[] s = attrStr.split("_");
String attrId = s[0];//检索属性的id
String[] attrValues = s[1].split(":");//检索属性的值
nestedBoolQuery.must(QueryBuilders.termQuery("attrs.attrId", attrId));
nestedBoolQuery.must(QueryBuilders.termsQuery("attrs.attrValue", attrValues));
//每一个都单独生成一个nested查询
NestedQueryBuilder nestedQuery = QueryBuilders.nestedQuery("attrs", nestedBoolQuery, ScoreMode.None);
boolQuery.filter(nestedQuery);
}
}
//1.5、filter - hasStock
boolQuery.filter(QueryBuilders.termQuery("hasStock", param.getHasStock() == 1));
//1.6、filter - range 区间
if (!StringUtils.isEmpty(param.getSkuPrice())) {
// 1_500 or _500 or 500_
RangeQueryBuilder rangeQuery = QueryBuilders.rangeQuery("skuPrice");
String[] s = param.getSkuPrice().split("_");
if (s.length == 2) {
//区间
rangeQuery.gte(s[0]).lte(s[1]);
} else if (s.length == 1) {
if (param.getSkuPrice().startsWith("_")) {
rangeQuery.lte(s[0]);
}
if (param.getSkuPrice().endsWith("_")) {
rangeQuery.gte(s[0]);
}
}
boolQuery.filter(rangeQuery);
}
//把所有条件都拿来封装,query部分结束
sourceBuilder.query(boolQuery);
/**
* 排序、分页、高亮
*/
//2.1 sort
if (!StringUtils.isEmpty(param.getSort())) {
String sort = param.getSort();
//sort=hotScore_asc
String[] s = sort.split("_");
SortOrder order = s[1].equalsIgnoreCase("asc") ? SortOrder.ASC : SortOrder.DESC;
sourceBuilder.sort(s[0], order);
}
//2.2 分页
sourceBuilder.from((param.getPageNum() - 1) * EsConstant.PRODUCT_PAGESIZE);
sourceBuilder.size(EsConstant.PRODUCT_PAGESIZE);
//2.3 高亮
if (!StringUtils.isEmpty(param.getKeyword())) {
HighlightBuilder highlightBuilder = new HighlightBuilder();
highlightBuilder.field("skuTitle");
highlightBuilder.preTags("");
highlightBuilder.postTags("");
sourceBuilder.highlighter(highlightBuilder);
}
/**
* 聚合分析
*/
System.out.println("构建的DSL语句" + sourceBuilder.toString());
SearchRequest searchRequest = new SearchRequest(new String[]{
EsConstant.PRODUCT_INDEX}, sourceBuilder);
return searchRequest;
}
根据DSL语句依次聚合,难点在 nested 的聚合
//TODO 聚合分析
//1、品牌聚合 brand_agg
TermsAggregationBuilder brand_agg = AggregationBuilders.terms("brand_agg");
brand_agg.field("brandId").size(50);
//1.1 品牌聚合的子聚合
brand_agg.subAggregation(AggregationBuilders.terms("brand_name_agg").field("brandName").size(1));
brand_agg.subAggregation(AggregationBuilders.terms("brand_img_agg").field("brandImg").size(1));
sourceBuilder.aggregation(brand_agg);
//2、分类聚合 catalog_agg
TermsAggregationBuilder catalog_agg = AggregationBuilders.terms("catalog_agg");
catalog_agg.field("catalogId").size(50);
//2.1 分类聚合的子聚合
catalog_agg.subAggregation(AggregationBuilders.terms("catalog_name_agg").field("catalogName").size(1));
sourceBuilder.aggregation(catalog_agg);
//3、属性聚合 (nested) attr_agg
NestedAggregationBuilder attr_agg = AggregationBuilders.nested("attr_agg", "attrs");
//3.1 属性聚合的子聚合
TermsAggregationBuilder attr_id_agg = AggregationBuilders.terms("attr_id_agg").field("attrs.attrId");
//3.1 属性聚合的子聚合的子聚合
//聚合分析出当前attr_id对应的名字
attr_id_agg.subAggregation(AggregationBuilders.terms("attr_name_agg").field("attrs.attrName").size(1));
//聚合分析出当前attr_id对应的所有可能的属性值atrValue
attr_id_agg.subAggregation(AggregationBuilders.terms("attr_value_agg").field("attrs.attrValue").size(50));
//依次放入
attr_agg.subAggregation(attr_id_agg);
sourceBuilder.aggregation(attr_agg);
构造页面需要的类的结果大致步骤
/**
* 构造结果数据
*
* @return SearchResult
*/
private SearchResult buildSearchResult(SearchResponse response,SearchParam param) {
SearchResult result = new SearchResult();
//1、返回所有查询到的商品
result.setProducts();
//2、当前所有商品涉及到的所有属性信息
result.setAttrs();
//3、当前有所商品涉及到的所有分类信息
result.setBrands();
//4、当前所有商品涉及到的所有分类信息
result.setCatalogs();
//5、分页信息-页码
result.setPageNum();
//5.1、分页信息总记录数
result.setTotal();
//5.2、分页信息-总页码
result.setTotalPages();
return result;
}
debug 断点在此方法,分析传入的 SearchResponse
debug
将返回的数据通过thymeleaf渲染到页面
编写一个统一函数,点击属性值统一调用跳转
报错无法处理特殊字符
改为双引号的转义字符 "
function searchProducts(name, value) {
//原来页面的值
var href = location.href + ""
if (href.indexOf("?") != -1){
location.href = location.href + "&" + name + "=" + value;
}else {
location.href = location.href + "?" + name + "=" + value;
}
}
为搜索按钮加入筛选
function searchByKeyword(){
searchProducts("keyword",$("#keyword_input").val());
}
还是如上一步方法,在链接加入参数
为返回类型加入记录页数以便 thymeleaf 循环
页面部分,上一页就是当前页码减一,并自定义一个属性值pn。循环出封装的页数,
页码class的点击部分,点击后将当前页码拼接或替换到链接
//分页被点击后
$(".page_a").click(function () {
//拿到当前属性中自定义属性pn 用户记录当前记录数 默认是1
var pn = $(this).attr("pn");
//拿到当前连接
var href = location.href;
console.log(href)
//连接存在pagenum字段
console.log(href.indexOf("pageNum"))
//没找到
if (href.indexOf("pageNum") != -1) {
//替换pageNum的值
location.href = replaceAndAddParamVal(href, "pageNum", pn);
} else {
let c = replaceAndAddParamVal(location.href, "pageNum", pn, true);
location.href = c;
//否则
// location.href = location.href + "&pageNum=" + pn;
}
return false
})
SearchResult
中加入面包屑的集合属性
2. buildSearchResult
方法内继续封装面包屑,重新包装远程接口返回的AttrRespVo
为AttrResponseVo
//6、构建面包屑导航功能
List<SearchResult.NavVo> navVos = param.getAttrs().stream().map(attr -> {
SearchResult.NavVo navVo = new SearchResult.NavVo();
//attrs=2_5寸:6寸 封装地址参数中存在的 属性值
String[] s = attr.split("_");
navVo.setNavValue(s[0]);
R r = productFeignService.attrInfo(Long.parseLong(s[0]));
if (r.getCode() == 0){
AttrResponseVo data = r.getData("attr", new TypeReference<AttrResponseVo>() {
});
navVo.setNavName(data.getAttrName());
}else {
navVo.setNavName(s[0]);
}
//取消了这个面包屑后要跳转的地方
return navVo;
}).collect(Collectors.toList());
result.setNavs(navVos);
点击取消了这个面包屑后要跳转的地方,将请求地址的url里面的当前置空
拿到所有的查询条件,去掉当前
在controller中 调用原生的 severlet 可获取到参数部分的字符串,在返回类型中添加参数部分的属性
直接去掉当前attrs参数,注意链接的编码转化问题
end