(回抄注:原文 3 处图片托管于阿里云 OSS,2026-09-19 实测已 404,图已永久丢失;各处原地址留在原位。)

全文检索

ElasticSearch

简介

1.一个分布式的开源搜索和分析引擎,适用于所有类型的数据,包括文本,数字,地理空间,结构化和非结构化数据。

2.全文搜索属于最常见的需求,开源的 Elasticsearch是目前全文搜索引擎的首选。它可以快速地储存、搜索和分析海量数据。维基百科、Stack Overflow、Github 都采用它。

3.Elastic 的底层是开源库 Lucene。但是,你没法直接用Lucene,必须自己写代码去调用它的接口。Elastic是 Lucene 的封装,提供了 REST API的操作接口,开箱即用。

REST API:天然的跨平台。
官方文档:https://www.elastic.co/guide/en/elasticsearch/reference/current/index.html

官方中文:https://www.elastic.co/guide/cn/elasticsearch/guide/current/foreword_id.html

社区中文:
http://doc.codingdict.com/elasticsearch/0/

基本概念
1.index (索引)

动词,相当于MySQL中的insert

名词,相当于MySQL中的Database

2.Type (类型)

在Index(索引)中,可以定义一个或多个类型。类似于MySQL中的Table;每一种类型的数据放在一起;

3.Document (文档)

保存在某个索引(Index)下,某种类型(Type)的一个数据(Ducument),文档是JSON格式的,Document就像是MySQL中的某个Table里面的内容;

4.倒排索引机制

分词:将整句拆分为单词

Docker安装ES

1.下载镜像文件
1
2
3
4
# 拉取ES镜像
docker pull elasticsearch:7.4.2
# 拉取可视化镜像
docker pull kibana:7.4.2
2.创建实例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
 # 创建配置文件夹
mkdir -p /mydata/elasticsearch/config
# 创建数据文件夹
mkdir -p /mydata/elasticsearch/data
# 创建插件文件夹
mkdir -p /mydata/elasticsearch/plugins
# 创建配置文件
echo "http.host:0.0.0.0" >> /mydata/elasticsearch/config/elasticsearch.yml
# 让任何用户都能读写
chmod -R 777 /mydata/elasticsearch/
# 启动单机ES
# 9200 http通信端口 9300集群模式下通信端口
# -e "discovery.type=single-node" 单节点运行
# 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 :指定插件路径
docker run --name elasticsearch -p 9200:9200 -p 9300:9300 \
-e "discovery.type=single-node" -e ES_JAVA_OPTS="-Xms64m -Xmx520m" \
-v /mydata/elasticsearch/data:/usr/share/elasticsearch/data \
-v /mydata/elasticsearch/plugins:/usr/share/elasticsearch/plugins \
-v /mydata/elasticsearch/config/elasticsearch.yml:/usr/share/elasticsearch/config/elasticsearch.yml \
-d elasticsearch:7.4.2

Docker安装Kibana

1
2
3
4
5
6
7
8
docker run --name kibana \
-e ELASTICSEARCH_URL=http://192.168.72.130:9200 \
-p 5601:5601 \
-d kibana:7.4.2
# 如果-e ELASTICSEARCH_URL=http://192.168.72.130:9200 没有效果,我们必须进入kibana容器中,修改kibana.yml 文件的内容
cd ./config
vi kibana.yml
# elasticsearch.hosts: [ “http://{你的安装ES的ip地址}:9200” ]

入门

_cat (查看ES信息)

1.使用Postman 对ES服务发送查询请求

GET /_cat/nodes: 查看所有节点

GET /_cat/health: 查看ES健康状态

GET /_cat/master: 查看主节点

GET /_cat/indices: 查看ES所有索引 就像MySQL的show databases

put&post新增数据

1.使用postman 向ES服务期发送PUT请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
url:http://192.168.72.130:9200/customer/external/1
method:put
body:
{
"name":"Tom"
}
response:
{
"_index": "customer", // 索引
"_type": "external", // 类型
"_id": "1", // id
"_version": 4, // 版本号
"result": "updated", // 结果 created:新建 ; updated:更新
"_shards": { // 分片,集群参数
"total": 2,
"successful": 1,
"failed": 0
},
"_seq_no": 3, // 并发控制字段,每次更新就会+1,用来做乐观锁
"_primary_term": 1 //主分片重新分配,如重启,就会变化
}

2.使用postman 向ES服务期发送POST请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
url:http://192.168.72.130:9200/customer/external/1
method:post
body:
{
"name":"Tom"
}
response:
{
"_index": "customer",
"_type": "external",
"_id": "1",
"_version": 5,
"result": "updated",
"_shards": {
"total": 2,
"successful": 1,
"failed": 0
},
"_seq_no": 4,
"_primary_term": 1
}

注:PUT和POST区别

1.POST新增:如果不指定id,会自动生成id。指定id就会修改这个数据,并新增版本号

2.PUT可以新增也可以修改。PUT必须指定id;由于PUT需要指定id,我们一般都用来做修改操作,不指定id会报错。

get查询数据&乐观锁
get查询数据

查询直接使用get请求 /{索引}/{类型}/{id}

乐观锁

使用_seq_no 来确认版本号

1.乐观锁更新数据

url:http://192.168.72.130:9200/customer/external/1?if_seq_no=3&if_primary_term=1

method:PUT或者POST

如果版本号不定义给定的条件,则出现以下结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
{
"error": {
"root_cause": [
{
"type": "version_conflict_engine_exception",
"reason": "[1]: version conflict, required seqNo [3], primary term [1]. current document has seqNo [4] and primary term [1]",
"index_uuid": "zb_uJgoTTIS96jNeCk45xQ",
"shard": "0",
"index": "customer"
}
],
"type": "version_conflict_engine_exception",
"reason": "[1]: version conflict, required seqNo [3], primary term [1]. current document has seqNo [4] and primary term [1]",
"index_uuid": "zb_uJgoTTIS96jNeCk45xQ",
"shard": "0",
"index": "customer"
},
"status": 409
}
put&post修改数据

方法1:

1
2
3
4
5
6
POST customer/external/1/_update
{
"doc":{
"name":"Tms1"
}
}

方法2:

1
2
3
4
5
6
POST customer/external/1
{
"name":"Tms1"
}
或者
PUT customer/external/1

1.异同

不同:POST操作会对比源文档数据,如果相同不会有什么操作,文档version不增加,PUT操作总会将数据重新保存并添加version版本;带_update对比元数据如果一样就不进行任何操作

看场景:

对于大并发更新,使用不带update;

对于大并发查询偶尔更新,带update;对比更新,重新计算分配规则。

删除数据&bulk批量操作
删除文档

可以直接删除索引和文档,但不能删除类型

1
DELETE customer/external/1
bulk批量操作

示例1:

1.登入kibana 控制台,点击Dev Tools,在console 输入框里输入以下内容:

1
2
3
4
5
POST /customer/external/_bulk
{"index":{"_id":"1"}}
{"name":"jojo"}
{"index":{"_id":"2"}}
{"name":"jiji"}

2.结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
{
"took" : 8, // 花费多少毫秒
"errors" : false, // 是否有错误
"items" : [ // 结果子项
{
"index" : { // index 表示保存操作
"_index" : "customer",
"_type" : "external",
"_id" : "1",
"_version" : 1,
"result" : "created",
"_shards" : {
"total" : 2,
"successful" : 1,
"failed" : 0
},
"_seq_no" : 6,
"_primary_term" : 1,
"status" : 201
}
},
{
"index" : {
"_index" : "customer",
"_type" : "external",
"_id" : "2",
"_version" : 1,
"result" : "created",
"_shards" : {
"total" : 2,
"successful" : 1,
"failed" : 0
},
"_seq_no" : 7,
"_primary_term" : 1,
"status" : 201
}
}
]
}

示例2:

1.在控制台输入以下内容:

1
2
3
4
5
6
7
8
POST /_bulk
{"delete":{"_index":"website","_type":"blog","_id":"123"}}
{"create":{"_index":"website","_type":"blog","_id":"123"}}
{"title":"My first blog post"}
{"index":{"_index":"website","_type":"blog"}}
{"title":"My sencond blog post"}
{"update":{"_index":"website","_type":"blog","_id":"123"}}
{"doc":{"title":"My updated blog post"}}

2.结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
{
"took" : 148,
"errors" : false,
"items" : [
{
"delete" : {
"_index" : "website",
"_type" : "blog",
"_id" : "123",
"_version" : 1,
"result" : "not_found",
"_shards" : {
"total" : 2,
"successful" : 1,
"failed" : 0
},
"_seq_no" : 0,
"_primary_term" : 1,
"status" : 404
}
},
{
"create" : {
"_index" : "website",
"_type" : "blog",
"_id" : "123",
"_version" : 2,
"result" : "created",
"_shards" : {
"total" : 2,
"successful" : 1,
"failed" : 0
},
"_seq_no" : 1,
"_primary_term" : 1,
"status" : 201
}
},
{
"index" : {
"_index" : "website",
"_type" : "blog",
"_id" : "_SBEJnYB0s8ja78yQIS4",
"_version" : 1,
"result" : "created",
"_shards" : {
"total" : 2,
"successful" : 1,
"failed" : 0
},
"_seq_no" : 2,
"_primary_term" : 1,
"status" : 201
}
},
{
"update" : {
"_index" : "website",
"_type" : "blog",
"_id" : "123",
"_version" : 3,
"result" : "updated",
"_shards" : {
"total" : 2,
"successful" : 1,
"failed" : 0
},
"_seq_no" : 3,
"_primary_term" : 1,
"status" : 200
}
}
]
}
导入客户银行客户样本测试数据

elasticsearch/accounts.json at master · elastic/elasticsearch (github.com)

1
2
POST /bank/account/_bulk
粘贴样本数据

进阶

两种查询方式
SearchAPI

ES支持两种基本方式检索:

  • 通过使用REST request URL 发送搜索参数(uri+检索参数)

  • 通过使用REST request body 来发送它们(uri+请求体)

uri+检索参数
  1. GET bank/_search

检索bank下所有信息,包括type的和docs

  1. GET bank/_search?q=*&sort=account_number:asc

请求参数方式检索

默认返回10条记录

  1. 结果解析

took:花费时间

time_out:是否超时

_shards:集群情况下的信息

hits:命中的记录

total :总记录数

uri+检索参数(QueryDSL方式)

1.在Kibana控制台输入以下内容:

1
2
3
4
5
6
7
8
9
GET bank/_search
{
"query": {
"match_all": {}
},
"sort":[
{"account_number":"asc"}
]
}

query:查询条件

match_all:匹配所有

sort:排序条件

QueryDSL基本使用&match_all

1.在kibana 根据语法提示直接操作

https://www.elastic.co/guide/en/elasticsearch/reference/current/getting-started-search.html 官方操作实例

2.使用_source 后接 数组字符串,按需返回字段值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
GET bank/_search
{
"query":{
"match_all": {}
},
"from":0,
"size":5,
"sort":[
{
"account_number":{
"order":"desc"
}
}
],
"_source": ["banlance","firstname"]
}

3.demo

3.1 demo1

1
2
3
4
5
6
GET bank/_search
{
"query":{
"match_all": {}
}
}

3.2 demo2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
GET bank/_search
{
"query":{
"match_all": {}
},
"from":0,
"size":5,
"sort":[
{
"account_number":{
"order":"desc"
}
}
]
}
match全文检索

1.基本类型(非字符串),精确匹配

1.1 精确匹配demo

1
2
3
4
5
6
7
8
GET bank/_search
{
"query": {
"match": {
"account_number": "20"
}
}
}

1.2 模糊查询(全文检索)按照评分进行排序,会对检索条件进行分词匹配

1
2
3
4
5
6
7
8
GET bank/_search
{
"query": {
"match": {
"address": "Kings"
}
}
}
match_parase短语匹配

将需要匹配的值当成一个整体单词(不分词)进行检索

1
2
3
4
5
6
7
8
GET bank/_search
{
"query": {
"match_phrase": {
"address": "mill road"
}
}
}
multi_match多字段匹配

address 包含 mill 或者 Movico,city 包含 mill 或者 Movico

1
2
3
4
5
6
7
8
9
GET bank/_search
{
"query": {
"multi_match": {
"query": "mill Movico",
"fields": ["address","city"]
}
}
}
bool复合查询

bool 用来做复合查询:

复合语句可以合并任何其他查询语句,包括复合语句,了解这一点是很重要的。这就意味着,复合语句之间可以互相嵌套,可以表达非常复杂的逻辑。

must:必须达到must列举的所有条件

满足should指定的条件,则_scource得分高

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
GET bank/_search
{
"query": {
"bool": {
"must": [
{"match": {
"address": "mill"
}},
{"match": {
"gender": "M"
}}
],
"must_not": [
{"match": {
"age": "18"
}}
],
"should": [
{"match": {
"lastname": "Wallace"
}}
]
}
}
}
filter (结果过滤)

并不是所有的查询都需要产生分数,特别是那些仅用于”filtering”(过滤)的文档。为了不计算分数Elasticsearch会自动检查场景并且优化查询的执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
GET bank/_search
{
"query": {
"bool": {
"must": [
{"match": {
"address": "mill"
}},
{"match": {
"gender": "M"
}}
],
"must_not": [
{"match": {
"age": "18"
}}
],
"should": [
{"match": {
"lastname": "Wallace"
}}
],
"filter": {"range": {
"age": {
"gte": 18,
"lte": 30
}
}}
}
}
}

GET bank/_search
{
"query": {
"bool": {
"filter": {
"range": {
"age": {
"gte": 10,
"lte": 20
}
}
}
}
}
}
term 查询

和match一样。匹配某个属性值。全文检索字段用match,其他非text字段匹配用term。

  1. 精确匹配数值
1
2
3
4
5
6
7
8
9
10
11
GET bank/_search
{
"query": {
"term": {
"age": {
"value": 28
}
}
}
}

  1. keyword 精确匹配
1
2
3
4
5
6
7
8
GET bank/_search
{
"query": {
"match": {
"address.keyword": "789 Madison Street"
}
}
}

注:数值类型使用term,文本字段使用match

aggregations 聚合分析

聚合提供了从数据中分组和提取数据的能力。最简单的聚合方法大致等于SQL的GROUP BY和SQL的聚合函数。在Elasticsearch中,您有执行搜索返回hits(命中结果),并且同时返回聚合结果,把一个响应的所有hits(命中结果)分隔开的能力。这是非常强大且有效的,您可以执行查询和多个聚合,并且在一次使用中得到各自的(任何一个的)返回结果,使用一次简洁和简化的API来避免网络往返。

搜索address中包含mill的所有人的年龄分布以及平均年龄,但不显示这些人的详情。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
GET bank/_search
{
"query": {
"match": {
"address": "mill"
}
}
,
"aggs": {
"ageAgg": {
"terms": {
"field": "age",
"size": 10
}
},
"ageAvg": {
"avg": {
"field": "age"
}
},
"balanceAvg": {
"avg": {
"field": "balance"
}
}
}
}

按照年龄聚合,并且请求这些年龄端的这些人的平均薪资

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
GET bank/_search
{
"query": {
"match_all": {}
},
"aggs": {
"ageAgg": {
"terms": {
"field": "age",
"size": 100
},
"aggs": {
"ageAvg": {
"avg": {
"field": "balance"
}
}
}
}
}
}

查出所有年龄分布,并且这些年龄段中M(女士)的平均薪资和F(男士)的平均薪资以及这个年龄段的总体平均薪资

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
GET bank/_search
{
"query": {
"match_all": {}
},
"aggs": {
"ageAgg": {
"terms": {
"field": "age",
"size": 100
},
"aggs": {
"genderAgg": {
"terms": {
"field": "gender.keyword",
"size": 10
},
"aggs": {
"banlanceAvg": {
"avg": {
"field": "balance"
}
}
}
},
"ageBalanceAvg": {
"avg": {
"field": "balance"
}
}
}
}
}
}

映射

mapping创建
什么是Mapping(映射)?

Mapping是用来定义一个文档,以及它所包含的属性是如何存储和索引的。比如,使用mapping来定义:

  • 哪些字符串属性应该被看做是全文本属性。

  • 哪些属性包含数字,日期或者地理位置。

  • 文档中的所有属性是否都能被索引(_all配置)。

  • 日期的格式。

  • 自定义映射规则来执行动态添加属性。

  1. 查询映射
1
GET /bank/_mapping
  1. 创建映射
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
PUT /my-index
{
"mappings": {
"properties": {
"age": {
"type": "integer"
},
"email": {
"type": "keyword"
},
"name": {
"type": "text"
}
}
}
}
添加新的字段映射

使用 PUT /my_index/_mapping

1
2
3
4
5
6
7
8
9
PUT /my-index/_mapping
{
"properties": {
"employee-id": {
"type": "keyword",
"index": false
}
}
}
修改映射&数据迁移
更新映射

对于已经存在的映射字段,我们不能更新。更新必须创建新的索引进行数据迁移。

数据迁移
  1. 查询之前的mapping数据,复制properties 中的数据
1
GET /bank/_mapping
  1. 创建新的索引映射
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
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"
}
}
}
}
  1. 迁移数据
1
2
3
4
5
6
7
8
9
10
POST _reindex
{
"source": {
"index": "bank",
"type": "account"
},
"dest": {
"index": "newbank"
}
}

分词

分词&安装ik分词
何为分词器?

一个tokenizer(分词器)接收一个字符流,将之分割为独立的tokens(词元,通常是独立的单词),然后输出tokens流。

例如,whitespace tokenizer遇到空白字符时分割文本。它会将文本”Quick brown fox!”分割为 [Quick,brown,fox!]。

该tokenizer(分词器)还负责记录各个term(词条)的顺序或position位置(用于phrase短语和word proximity 词近邻拆查询),以及term(词条)所代表的原始word(单词)的start(起始)和end(结束)的character offsets (字符偏移量)(用于高亮显示搜索的内容)。

Elasticsearch 提供了很多内置的分词器,可以用来构建custom analyzer (自定义分词器)

  1. 使用分词器

1.1 空格分词器

1
2
3
4
5
POST _analyze
{
"analyzer": "whitespace",
"text": "The quick brown fox."
}

1.2 标准分词器

1
2
3
4
5
POST _analyze
{
"analyzer": "standard",
"text": "The quick brown fox."
}

2.安装ik分词器

2.1 使用wget 下载 ik分词器

1
wget https://github.com/medcl/elasticsearch-analysis-ik/releases/download/v7.4.2/elasticsearch-analysis-ik-7.4.2.zip

2.2 解压压缩包,放到挂载的plugins/ik文件夹下

2.3 测试分词器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 使用标准分词器
GET my-index/_analyze
{
"text": ["我是中国人"]
}
# 使用ik_smart分词器
GET my-index/_analyze
{
"analyzer": "ik_smart",
"text": ["我是中国人"]
}
# 使用ik_max_word分词器
GET my-index/_analyze
{
"analyzer": "ik_max_word",
"text": ["我是中国人"]
}
自定义扩展词库
为何需要自定义扩展词库?

有一些词汇,分词器中没有对应的,需要我们自己去添加到词库,然后分词器才能够识别。

自定义词库
  1. 使用nginx做静态文件重定向,需要安装nginx和启动,无需做nginx配置

1.1 在html文件夹下,创建es文件夹,再然后创建fenci.txt文本,内容是你需要进行分词的词句。

1.2 访问http://p/es/fenci.txt,网页能打开看到内容,说明能访问。

1.3 配置ik分词器配置文件,在elasticsearch的挂载目录的plugins下,解压之后的ik文件夹下进入config 文件夹下的IKAnalyzer.cfg.xml配置文件,把http://p/es/fenci.txt配置进去

1
2
3
4
5
6
7
8
9
10
11
12
13
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE properties SYSTEM "http://java.sun.com/dtd/properties.dtd">
<properties>
<comment>IK Analyzer 扩展配置</comment>
<!--用户可以在这里配置自己的扩展字典 -->
<entry key="ext_dict"></entry>
<!--用户可以在这里配置自己的扩展停止词字典-->
<entry key="ext_stopwords"></entry>
<!--用户可以在这里配置远程扩展字典 -->
<!-- <entry key="remote_ext_dict">http://192.168.72.130/es/fenci.txt</entry> -->
<!--用户可以在这里配置远程扩展停止词字典-->
<!-- <entry key="remote_ext_stopwords">words_location</entry> -->
</properties>

整合

SpringBoot整合high-level-client
java客户端
  1. spring-data-elasticsearch-transport-api.jar (不兼容7.x,8以后废弃)

通过9300(TCP)端口操作

以下通过9200 Http请求交互

  1. JestClient

非官方,更新慢

  1. RestTemplate

模拟发http请求,ES很多操作需要自己封装,麻烦

  1. httpClient

模拟发http请求,ES很多操作需要自己封装,麻烦

  1. Elasticsearch-Rest-Client

官方RestClient,封装了ES操作,API层次分明,上手简单。

引入Elasticsearch-Rest-Client
  1. 引入maven依赖
1
2
3
4
5
<dependency>
<groupId>org.elasticsearch.client</groupId>
<artifactId>elasticsearch-rest-high-level-client</artifactId>
<version>7.4.2</version>
</dependency>
  1. 修改SpringBoot 默认版本,由于spring-boot-dependencies 中elasticsearch默认版本为<elasticsearch.version>6.4.3</elasticsearch.version>
1
<elasticsearch.version>7.4.2</elasticsearch.version>
  1. 加入common 模块,配置服务发现,再配置ElasticSearch配置类,加入到Bean容器中。
1
2
3
4
5
6
7
8
9
10
11
@Configuration
public class ElasticSearchConfig {

@Bean
public RestHighLevelClient esRestClient() {
RestClientBuilder builder = RestClient.builder(new HttpHost("192.168.72.130", 9200,
"http"));
RestHighLevelClient client = new RestHighLevelClient(builder);
return client;
}
}
  1. 使用测试类,是否已经导入到容器中
1
2
3
4
5
6
7
8
9
10
11
12
13
@RunWith(SpringRunner.class)
@SpringBootTest
public class SuperMallElasticsearchApplicationTests {

@Autowired
RestHighLevelClient esClient;

@Test
public void contextLoads() {
System.out.println(esClient);
}

}
测试保存
  1. 在配置类加上ES公共配置Options
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Configuration
public class ElasticSearchConfig {

public static final RequestOptions COMMON_OPTIONS;

private static final String TOKEN = "CENTER_SEPT";

static {
RequestOptions.Builder builder = RequestOptions.DEFAULT.toBuilder();
// builder.addHeader("Authorization", "Bearer" + TOKEN);
// builder.setHttpAsyncResponseConsumerFactory(new HttpAsyncResponseConsumerFactory.HeapBufferedResponseConsumerFactory(30 * 1024 * 1024 * 1024));
COMMON_OPTIONS = builder.build();
}

@Bean
public RestHighLevelClient esRestClient() {
RestClientBuilder builder = RestClient.builder(new HttpHost("192.168.72.130", 9200,
"http"));
RestHighLevelClient client = new RestHighLevelClient(builder);
return client;
}

}
  1. 在测试类,加入以下代码,测试保存。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
@RunWith(SpringRunner.class)
@SpringBootTest
public class SuperMallElasticsearchApplicationTests {

@Autowired
RestHighLevelClient esClient;

/**
* 保存index到es中
*/
@Test
public void addIndex() throws IOException {
IndexRequest indexRequest = new IndexRequest("user");
indexRequest.id("1");
// 这是一种方式
// indexRequest.source("userName","chaoren","age","18","gender","男");
// 第二种方式
User user = new User();
user.setUserName("chaoren");
user.setAge(18);
user.setGender("男");
String string = JSON.toJSONString(user);
indexRequest.source(string, XContentType.JSON);
// 执行保存操作
IndexResponse index = esClient.index(indexRequest, ElasticSearchConfig.COMMON_OPTIONS);
System.out.println(index);
}


@Data
class User {
private String userName;
private String gender;
private Integer age;
}

@Test
public void contextLoads() {
System.out.println(esClient);
}

}
测试复杂检索

测试类加入以下内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
   @Test
public void searchData() throws IOException {
// 创建索引请求
SearchRequest searchRequest = new SearchRequest();
// 指定索引
searchRequest.indices("bank");
// 指定DSL,索引条件
SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder();
// 构造检索条件
//searchSourceBuilder.query(QueryBuilders.matchAllQuery());
searchSourceBuilder.query(QueryBuilders.matchQuery("address", "mill"));
// 聚合
// 按照年龄的值分布进行聚合
searchSourceBuilder.aggregation(AggregationBuilders.terms("ageAgg").field("age").size(10));
// 计算平均薪资
searchSourceBuilder.aggregation(AggregationBuilders.avg("balanceAvg").field("balance"));
searchRequest.source(searchSourceBuilder);


// 执行检索
SearchResponse search = esClient.search(searchRequest, ElasticSearchConfig.COMMON_OPTIONS);
System.out.println(search);
// 分析结果
// 获取所有命中的记录
SearchHits hits = search.getHits();
SearchHit[] searchHit = hits.getHits();
for (SearchHit documentFields : searchHit) {
String sourceAsString = documentFields.getSourceAsString();
Account account = JSON.parseObject(sourceAsString, Account.class);
System.out.println(account);
}
// 分析聚合信息
Aggregations aggregations = search.getAggregations();
// for (Aggregation aggregation : aggregations) {
// System.out.println("当前聚合:"+aggregation.getName());
// }
Terms ageAgg = aggregations.get("ageAgg");
List<? extends Terms.Bucket> buckets = ageAgg.getBuckets();
for (Terms.Bucket bucket : buckets) {
String keyAsString = bucket.getKeyAsString();
System.out.println("年龄:" + keyAsString);
}
Avg balanceAvg = aggregations.get("balanceAvg");
double value = balanceAvg.getValue();
System.out.println("平均薪资:" + value);
}

@ToString
@Data
static class Account {
private int account_number;
private int balance;
private String firstname;
private String lastname;
private int age;
private String gender;
private String address;
private String employer;
private String email;
private String city;
private String state;
}

商城业务

商品上架

上架的商品才可以在网站上展示

上架的商品需要可以被检索

sku在es中存储模型分析

  1. 商城mapping
分析:商品上架在es中是存sku还是spu ?
  1. 检索的时候输入名字,是需要按照sku的title进行全文检索的。
  2. 检索使用商品规格,规格是spu的公共属性,每个spu是一样的
  3. 按照分类id进去的都是直接列出spu的,还可以切换
  4. 我们如果将sku的全量信息保存到es中(包括spu属性)就太多量字段
  5. 我们如果将spu以及他包含的sku信息保存到es中,也可以方便检索。但是sku属于spu的级联对象,在es中需要nested模型,这种性能差点。
  6. 但是存储与检索我们必须性能折中
  7. 如果我们拆分存储,spu和attr一个索引,sku单独一个索引可能涉及的问题

检索商品的名字,如“手机”,对应的spu有很多,我们要分析出这些spu的所有关联属性,在做一次查询,就必须将所有spu_id都发出去。假设有1万个数据,数据传输就一次就10000*4=4MB;并发情况下假设1000检索请求,那就是4GB的数据,传输阻塞时间会很长,业务无法继续。

所以,我们如下设计,这样才是文档区别于关系型数据库的地方,宽表设计,不能考虑数据库范式。

添加product索引

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
PUT product
{
"mappings": {
"properties": {
"skuId": {
"type": "long"
},
"spuId": {
"type": "text"
},
"skuTitle": {
"type": "text",
"analyzer": "ik_smart"
},
"skuPrice": {
"type": "keyword"
},
"skuImg": {
"type": "keyword",
"index": false,
"doc_values": false
},
"saleCount": {
"type": "long"
},
"hasStock": {
"type": "boolean"
},
"hotScore": {
"type": "long"
},
"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"
}
}
}
}
}
}

nested数据类型场景

Es-数组的扁平化处理

(图:原 OSS 链接已失效,原地址 https://center-sept-image.oss-cn-shenzhen.aliyuncs.com/img/image-20201218143304391.png)

为了不让数组扁平化的发生,我们可以使用nested。

  1. 测试数据
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
PUT my_index/_doc/1
{
"group": "fans",
"user": [
{
"first": "John",
"last": "Smith"
},
{
"first": "Alice",
"last": "White"
}
]
}
GET my_index/_search
{
"query": {
"bool": {
"must": [
{
"match": {
"user.first": "Alice"
}
},
{
"match": {
"user.last": "Smith"
}
}
]
}
}
}
GET my_index/_mapping
  1. 修改为嵌入式
1
2
3
4
5
6
7
8
9
10
11
12
13
DELETE my_index

PUT my_index
{
"mappings": {
"properties": {
"user": {
"type": "nested"
}
}
}
}
# 再执行上面的插入操作,使用数据进行对比

构造基本数据

  1. 添加上架业务

SpuInfoController.java

1
2
3
4
5
@PostMapping("/{spuId}/up")
public R spuUp(@PathVariable("spuId") Long spuId){
spuInfoService.up(spuId);
return R.ok();
}

SpuInfoServiceImpl.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/**
* 商品上架
*
* @param spuId
*/
@Override
public void up(Long spuId) {
// 组装需要数据
// 查出当前spuId对应的所有sku信息,品牌的名称
List<SkuInfoEntity> skus = skuInfoService.getSkusBySpuId(spuId);
// todo attrs 查询当前sku的所有可以被用来检索的规格属性
// 封装每个sku的信息
skus.stream().map(sku -> {
SkuEsModel skuEsModel = new SkuEsModel();
BeanUtils.copyProperties(sku, skuEsModel);
// skuPrice,skuImg
skuEsModel.setSkuPrice(sku.getPrice());
skuEsModel.setSkuImg(sku.getSkuDefaultImg());
// hasStock,hotScore
// todo 1.发送远程调用,库存系统查询是否有库存
// todo 2.热度评分
// 3.查询品牌信息
// brandName,brandImg
BrandEntity brandEntity = brandService.getById(skuEsModel.getBrandId());
skuEsModel.setBrandName(brandEntity.getName());
skuEsModel.setBrandImg(brandEntity.getLogo());
// catalogName
// 4.查询分类信息
CategoryEntity categoryEntity = categoryService.getById(skuEsModel.getCatalogId());
skuEsModel.setCatalogName(categoryEntity.getName());
return skuEsModel;
}).collect(Collectors.toList());
// todo 将数据发送给es进行保存;
}

Common模块添加 SkuEsModel.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
@Data
public class SkuEsModel {
private Long skuId;
private Long spuId;
private String skuTitle;
private BigDecimal skuPrice;
private String skuImg;
private Long saleCount;
private Boolean hasStock;
private Long hotScore;
private Long brandId;
private Long catalogId;
private String brandName;
private String brandImg;
private String catalogName;
private List<Attrs> attrs;

@Data
public static class Attrs {
private Long attrId;
private String attrName;
private String attrValue;
}
}

SkuInfoServiceImpl.java

1
2
3
4
5
@Override
public List<SkuInfoEntity> getSkusBySpuId(Long spuId) {
List<SkuInfoEntity> list = this.list(new QueryWrapper<SkuInfoEntity>().eq("spu_id",spuId));
return list;
}

构造sku检索数据

  1. 查询当前sku的所有可以被用来检索的规格属性

1.1 SpuInfoServiceImpl 修改 up 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
        public void up(Long spuId) {
// 组装需要数据
// 查出当前spuId对应的所有sku信息,品牌的名称
List<SkuInfoEntity> skus = skuInfoService.getSkusBySpuId(spuId);
// attrs 查询当前sku的所有可以被用来检索的规格属性
List<ProductAttrValueEntity> baseAttrs = attrValueService.baseAttrlistforspu(spuId);
List<Long> attrIds = baseAttrs.stream().map(attr -> {
return attr.getAttrId();
}).collect(Collectors.toList());
List<Long> searchAttrIds = attrService.selectSearchAttrs(attrIds);
Set<Long> idSet = new HashSet<>(searchAttrIds);
// 过滤出不存在的规格属性,构建属性集合
List<SkuEsModel.Attrs> attrs = new ArrayList<>();
List<SkuEsModel.Attrs> attrsList = baseAttrs.stream().filter(item -> {
return idSet.contains(item.getAttrId());
}).map(item -> {
SkuEsModel.Attrs attrs1 = new SkuEsModel.Attrs();
BeanUtils.copyProperties(item, attrs1);
return attrs1;
}).collect(Collectors.toList());
// 封装每个sku的信息
skus.stream().map(sku -> {
SkuEsModel skuEsModel = new SkuEsModel();
BeanUtils.copyProperties(sku, skuEsModel);
// skuPrice,skuImg
skuEsModel.setSkuPrice(sku.getPrice());
skuEsModel.setSkuImg(sku.getSkuDefaultImg());
// hasStock,hotScore
// todo 1.发送远程调用,库存系统查询是否有库存
// todo 2.热度评分,暂时定为零
skuEsModel.setHotScore(0L);
// 3.查询品牌信息
// brandName,brandImg
BrandEntity brandEntity = brandService.getById(skuEsModel.getBrandId());
skuEsModel.setBrandName(brandEntity.getName());
skuEsModel.setBrandImg(brandEntity.getLogo());
// catalogName
// 4.查询分类信息
CategoryEntity categoryEntity = categoryService.getById(skuEsModel.getCatalogId());
skuEsModel.setCatalogName(categoryEntity.getName());
// 设置检索属性
skuEsModel.setAttrs(attrsList);
return skuEsModel;
}).collect(Collectors.toList());
// todo 将数据发送给es进行保存;
}

1.2 添加根据attrId集合查询检索属性集合

AttrDao.java

1
List<Long> selectSearchAttrs(@Param("attrIds") List<Long> attrIds);

AttrDao.xml

1
2
3
4
5
6
7
<select id="selectSearchAttrs"  resultType="java.lang.Long">
select attr_id from pms_attr where attr_id in
<foreach collection="attrIds" item="id" separator="," open="(" close=")">
#{id}
</foreach>
and search_type = 1
</select>

AttrService.java

1
2
3
4
5
6
/**
* 在指定的所有属性集合里面,挑出检索属性
* @param attrIds
* @return
*/
List<Long> selectSearchAttrs(List<Long> attrIds);

AttrServiceImpl.java

1
2
3
4
public List<Long> selectSearchAttrs(List<Long> attrIds) {
List<Long> longs = this.baseMapper.selectSearchAttrs(attrIds);
return longs;
}

远程查询库存&泛型结果封装

  1. 远程查询库存

1.1 修改上架方法

SpuInfoServiceImpl.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
@Autowired
WareFeignService wareFeignService;

public void up(Long spuId) {
// 组装需要数据
// 查出当前spuId对应的所有sku信息,品牌的名称
List<SkuInfoEntity> skus = skuInfoService.getSkusBySpuId(spuId);
List<Long> skuIds = skus.stream().map(item -> {
return item.getSkuId();
}).collect(Collectors.toList());
// attrs 查询当前sku的所有可以被用来检索的规格属性
List<ProductAttrValueEntity> baseAttrs = attrValueService.baseAttrlistforspu(spuId);
List<Long> attrIds = baseAttrs.stream().map(attr -> {
return attr.getAttrId();
}).collect(Collectors.toList());
List<Long> searchAttrIds = attrService.selectSearchAttrs(attrIds);
Set<Long> idSet = new HashSet<>(searchAttrIds);
// 过滤出不存在的规格属性,构建属性集合
List<SkuEsModel.Attrs> attrs = new ArrayList<>();
List<SkuEsModel.Attrs> attrsList = baseAttrs.stream().filter(item -> {
return idSet.contains(item.getAttrId());
}).map(item -> {
SkuEsModel.Attrs attrs1 = new SkuEsModel.Attrs();
BeanUtils.copyProperties(item, attrs1);
return attrs1;
}).collect(Collectors.toList());

// todo 1.发送远程调用,库存系统查询是否有库存
Map<Long, Boolean> stockMap = null;
try {
R<List<SkuHasStockVo>> skusHasStock = wareFeignService.getSkusHasStock(skuIds);
stockMap = skusHasStock.getData().stream().collect(Collectors.toMap(SkuHasStockVo::getSkuId
, item -> item.getHasStock()));
} catch (Exception e) {
log.error("库存服务查询异常:原因{}",e);
}

// 封装每个sku的信息
Map<Long, Boolean> finalStockMap = stockMap;
skus.stream().map(sku -> {
SkuEsModel skuEsModel = new SkuEsModel();
BeanUtils.copyProperties(sku, skuEsModel);
// skuPrice,skuImg
skuEsModel.setSkuPrice(sku.getPrice());
skuEsModel.setSkuImg(sku.getSkuDefaultImg());
// hasStock,hotScore
// 判断是否有库存,然后设置库存信息
if (finalStockMap ==null) {
// 如果库存服务异常,则默认为有库存 todo
skuEsModel.setHasStock(true);
}else{
skuEsModel.setHasStock(finalStockMap.get(sku.getSkuId()));
}
// todo 2.热度评分,暂时定为零
skuEsModel.setHotScore(0L);
// 3.查询品牌信息
// brandName,brandImg
BrandEntity brandEntity = brandService.getById(skuEsModel.getBrandId());
skuEsModel.setBrandName(brandEntity.getName());
skuEsModel.setBrandImg(brandEntity.getLogo());
// catalogName
// 4.查询分类信息
CategoryEntity categoryEntity = categoryService.getById(skuEsModel.getCatalogId());
skuEsModel.setCatalogName(categoryEntity.getName());
// 设置检索属性
skuEsModel.setAttrs(attrsList);
return skuEsModel;
}).collect(Collectors.toList());
// todo 将数据发送给es进行保存;
}

1.2 Ware 模块检索业务

SkuHasStockVo.java Product和Ware模块同时建立这个VO

1
2
3
4
5
@Data
public class SkuHasStockVo {
private Long skuId;
private Boolean hasStock;
}

WareSkuController.java

1
2
3
4
5
6
7
@PostMapping("/hasstock")
public R<List<SkuHasStockVo>> getSkusHasStock(@RequestBody List<Long> skuIds) {
List<SkuHasStockVo> skuHasStockVos = wareSkuService.getSkusHasStock(skuIds);
R<List<SkuHasStockVo>> ok = R.ok();
ok.setData(skuHasStockVos);
return ok;
}

WareSkuServiceImpl.java

1
2
3
4
5
6
7
8
9
10
11
12
13
@Override
public List<SkuHasStockVo> getSkusHasStock(List<Long> skuIds) {
List<SkuHasStockVo> result = skuIds.stream().map(item -> {
SkuHasStockVo vo = new SkuHasStockVo();
// 查询当前sku的总库存量
// select sum(stock-stock_locked) from `wms_ware_sku` where sku_id = 1
long count = baseMapper.getSkuStock(item);
vo.setSkuId(item);
vo.setHasStock(count > 0);
return vo;
}).collect(Collectors.toList());
return result;
}

WareSkuDao.java

1
long getSkuStock(Long item);

WareSkuDao.xml

1
2
3
<select id="getSkuStock" resultType="java.lang.Long" >
select sum(stock-stock_locked) from wms_ware_sku where sku_id = #{skuId}
</select>

1.3 Product 模块构建Ware服务的feign

WareFeignService.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@FeignClient("supermall-ware")
public interface WareFeignService {

/**
* 1.R设计的时候可以加上泛型
* 2.直接返回对应的类型
* 3.自己封装解析结果
*
* @param skuIds
* @return
*/
@PostMapping("/ware/waresku/hasstock")
R<List<SkuHasStockVo>> getSkusHasStock(@RequestBody List<Long> skuIds);
}
  1. 泛型结果封装

增加R.java 中的泛型成员变量data;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
public class R<T> extends HashMap<String, Object> {

private static final long serialVersionUID = 1L;

private T data;

public T getData() {
return data;
}

public void setData(T data) {
this.data = data;
}

public R() {
put("code", 0);
put("msg", "success");
}

public static R error() {
return error(HttpStatus.SC_INTERNAL_SERVER_ERROR, "未知异常,请联系管理员");
}

public static R error(String msg) {
return error(HttpStatus.SC_INTERNAL_SERVER_ERROR, msg);
}

public static R error(int code, String msg) {
R r = new R();
r.put("code", code);
r.put("msg", msg);
return r;
}

public static R ok(String msg) {
R r = new R();
r.put("msg", msg);
return r;
}

public static R ok(Map<String, Object> map) {
R r = new R();
r.putAll(map);
return r;
}

public static R ok() {
return new R();
}

public R put(String key, Object value) {
super.put(key, value);
return this;
}

public Integer getCode() {
Integer code = (Integer) this.get("code");
return code;
}
}

远程上架接口

  1. 在elasticsearch模块新建商品上架信息保存到ES的业务逻辑

ElasticSearchSaveController

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Slf4j
@RequestMapping("/search/save")
@RestController
public class ElasticSearchSaveController {

@Autowired
ProductSaveService productSaveService;

@PostMapping("/product")
public R productStatusUp(@RequestBody List<SkuEsModel> skuEsModels) {
boolean b = false;
try {
b = productSaveService.productStatusUp(skuEsModels);
} catch (IOException e) {
log.error("ElasticSearchSaveController商品上架错误:{}", e);
return R.error(BizCodeEnume.PRODUCT_UP_EXCEPTION.getCode(), BizCodeEnume.PRODUCT_UP_EXCEPTION.getMsg());
}
// 成功
if (!b) {
return R.ok();
} else {
return R.error(BizCodeEnume.PRODUCT_UP_EXCEPTION.getCode(), BizCodeEnume.PRODUCT_UP_EXCEPTION.getMsg());
}
}
}

ProductSaveService

1
2
3
public interface ProductSaveService {
boolean productStatusUp(List<SkuEsModel> skuEsModels) throws IOException;
}

ProductSaveServiceImpl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@Slf4j
@Service
public class ProductSaveServiceImpl implements ProductSaveService {

@Autowired
RestHighLevelClient restHighLevelClient;


@Override
public boolean productStatusUp(List<SkuEsModel> skuEsModels) throws IOException {
// 1.给es中建立一个索引。product.
// 建立好映射关系product-mapping.txt
// 2.保存到es中
BulkRequest bulkRequest = new BulkRequest();
for (SkuEsModel skuEsModel : skuEsModels) {
// 赋值索引
IndexRequest indexRequest = new IndexRequest(EsConstant.PRODUCT_INDEX);
// 赋值id
indexRequest.id(skuEsModel.getSkuId().toString());
// 设置数据
String data = JSON.toJSONString(skuEsModel);
indexRequest.source(data, XContentType.JSON);
// 添加到批量操作中
bulkRequest.add(indexRequest);
}
BulkResponse bulk = restHighLevelClient.bulk(bulkRequest, ElasticSearchConfig.COMMON_OPTIONS);
// 处理响应结果
// todo 如果批量错误
boolean b = bulk.hasFailures();
List<String> collect = Arrays.stream(bulk.getItems()).map(item -> {
return item.getId();
}).collect(Collectors.toList());
log.error("商品上架完成:{}", collect);
return b;
}
}

EsConstant 常量

1
2
3
4
public class EsConstant {
// sku数据在ES中的索引
public static final String PRODUCT_INDEX = "product";
}

增加Product 上架异常错误编码

BizCodeEnume

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public enum BizCodeEnume {
UNKNOW_EXCEPTION(10000, "系统未知异常"),
VAILD_EXCEPTION(10001, "参数格式校验失败"),
PRODUCT_UP_EXCEPTION(11000, "商品上架异常");

private int code;
private String msg;

BizCodeEnume(int code, String msg) {
this.code = code;
this.msg = msg;
}

public int getCode() {
return code;
}

public String getMsg() {
return msg;
}
}

在resource 下新建 product-mapping.txt,保存新建索引内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
PUT product
{
"mappings": {
"properties": {
"skuId": {
"type": "long"
},
"spuId": {
"type": "text"
},
"skuTitle": {
"type": "text",
"analyzer": "ik_smart"
},
"skuPrice": {
"type": "keyword"
},
"skuImg": {
"type": "keyword",
"index": false,
"doc_values": false
},
"saleCount": {
"type": "long"
},
"hasStock": {
"type": "boolean"
},
"hotScore": {
"type": "long"
},
"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"
}
}
}
}
}
}
  1. 回到product模块,新建远程调用类

SearchFeignService

1
2
3
4
5
6
@FeignClient("supermall-elasticsearch")
public interface SearchFeignService {

@PostMapping("/search/save/product")
public R productStatusUp(@RequestBody List<SkuEsModel> skuEsModels);
}
  1. 修改上架逻辑

SpuInfoServiceImpl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
@Autowired
SearchFeignService searchFeignService;

public void up(Long spuId) {
// 组装需要数据
// 查出当前spuId对应的所有sku信息,品牌的名称
List<SkuInfoEntity> skus = skuInfoService.getSkusBySpuId(spuId);
List<Long> skuIds = skus.stream().map(item -> {
return item.getSkuId();
}).collect(Collectors.toList());
// attrs 查询当前sku的所有可以被用来检索的规格属性
List<ProductAttrValueEntity> baseAttrs = attrValueService.baseAttrlistforspu(spuId);
List<Long> attrIds = baseAttrs.stream().map(attr -> {
return attr.getAttrId();
}).collect(Collectors.toList());
List<Long> searchAttrIds = attrService.selectSearchAttrs(attrIds);
Set<Long> idSet = new HashSet<>(searchAttrIds);
// 过滤出不存在的规格属性,构建属性集合
List<SkuEsModel.Attrs> attrs = new ArrayList<>();
List<SkuEsModel.Attrs> attrsList = baseAttrs.stream().filter(item -> {
return idSet.contains(item.getAttrId());
}).map(item -> {
SkuEsModel.Attrs attrs1 = new SkuEsModel.Attrs();
BeanUtils.copyProperties(item, attrs1);
return attrs1;
}).collect(Collectors.toList());

// 1.发送远程调用,库存系统查询是否有库存
Map<Long, Boolean> stockMap = null;
try {
R<List<SkuHasStockVo>> skusHasStock = wareFeignService.getSkusHasStock(skuIds);
stockMap = skusHasStock.getData().stream().collect(Collectors.toMap(SkuHasStockVo::getSkuId
, item -> item.getHasStock()));
} catch (Exception e) {
log.error("库存服务查询异常:原因{}",e);
}

// 封装每个sku的信息
Map<Long, Boolean> finalStockMap = stockMap;
List<SkuEsModel> skuEsModels = skus.stream().map(sku -> {
SkuEsModel skuEsModel = new SkuEsModel();
BeanUtils.copyProperties(sku, skuEsModel);
// skuPrice,skuImg
skuEsModel.setSkuPrice(sku.getPrice());
skuEsModel.setSkuImg(sku.getSkuDefaultImg());
// hasStock,hotScore
// 判断是否有库存,然后设置库存信息
if (finalStockMap == null) {
// 如果库存服务异常,则默认为有库存 todo
skuEsModel.setHasStock(true);
} else {
skuEsModel.setHasStock(finalStockMap.get(sku.getSkuId()));
}
// todo 2.热度评分,暂时定为零
skuEsModel.setHotScore(0L);
// 3.查询品牌信息
// brandName,brandImg
BrandEntity brandEntity = brandService.getById(skuEsModel.getBrandId());
skuEsModel.setBrandName(brandEntity.getName());
skuEsModel.setBrandImg(brandEntity.getLogo());
// catalogName
// 4.查询分类信息
CategoryEntity categoryEntity = categoryService.getById(skuEsModel.getCatalogId());
skuEsModel.setCatalogName(categoryEntity.getName());
// 设置检索属性
skuEsModel.setAttrs(attrsList);
return skuEsModel;
}).collect(Collectors.toList());
// todo 将数据发送给es进行保存;
R r = searchFeignService.productStatusUp(skuEsModels);
if (r.getCode()==0) {
// 远程调用成功
// 修改当前Spu的上架状态
baseMapper.updateSpuStatus(spuId, ProductConstant.StatusEnum.SPU_UP.getCode());
}else{
// 调用失败
// todo 重复调用,接口的幂等
}
}

SpuInfoDao

1
void updateSpuStatus(@Param("spuId") Long spuId, @Param("code") int code);

SpuInfoDao.xml

1
2
3
<update id="updateSpuStatus">
UPDATE pms_spu_info set public_status=#{code},update_time = NOW() where id = #{spuId}
</update>

上架接口调试&feign源码

  1. WareSkuServiceImpl 修改 getSkusHasStock 方法
1
2
3
4
5
6
7
8
9
10
11
12
public List<SkuHasStockVo> getSkusHasStock(List<Long> skuIds) {
List<SkuHasStockVo> result = skuIds.stream().map(item -> {
SkuHasStockVo vo = new SkuHasStockVo();
// 查询当前sku的总库存量
// select sum(stock-stock_locked) from `wms_ware_sku` where sku_id = 1
Long count = baseMapper.getSkuStock(item);
vo.setSkuId(item);
vo.setHasStock(count == null ? false : count > 0);
return vo;
}).collect(Collectors.toList());
return result;
}

WareSkuDao

1
Long getSkuStock(Long item);

WareFeignService

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@FeignClient("supermall-ware")
public interface WareFeignService {

/**
* 1.R设计的时候可以加上泛型
* 2.直接返回对应的类型
* 3.自己封装解析结果
*
* @param skuIds
* @return
*/
@PostMapping("/ware/waresku/hasstock")
List<SkuHasStockVo> getSkusHasStock(@RequestBody List<Long> skuIds);
}

WareSkuController

1
2
3
4
5
@PostMapping("/hasstock")
public List<SkuHasStockVo> getSkusHasStock(@RequestBody List<Long> skuIds) {
List<SkuHasStockVo> skuHasStockVos = wareSkuService.getSkusHasStock(skuIds);
return skuHasStockVos;
}

抽取响应结果&上架测试完成

  1. R继承了hashMap 在里面定义泛型成员变量,没有效果?

R.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
public class R extends HashMap<String, Object> {

private static final long serialVersionUID = 1L;

public R setData(Object data) {
put("data", data);
return this;
}

public <T> T getData(TypeReference<T> tTypeReference) {
return this.getData("data", tTypeReference);
}

public <T> T getData(String key, TypeReference<T> tTypeReference) {
Object data = this.get(key);
String toJSONString = JSON.toJSONString(data);
T t = JSON.parseObject(toJSONString, tTypeReference);
return t;
}

public R() {
put("code", 0);
put("msg", "success");
}

public static R error() {
return error(HttpStatus.SC_INTERNAL_SERVER_ERROR, "未知异常,请联系管理员");
}

public static R error(String msg) {
return error(HttpStatus.SC_INTERNAL_SERVER_ERROR, msg);
}

public static R error(int code, String msg) {
R r = new R();
r.put("code", code);
r.put("msg", msg);
return r;
}

public static R ok(String msg) {
R r = new R();
r.put("msg", msg);
return r;
}

public static R ok(Map<String, Object> map) {
R r = new R();
r.putAll(map);
return r;
}

public static R ok() {
return new R();
}

public R put(String key, Object value) {
super.put(key, value);
return this;
}

public Integer getCode() {
Integer code = (Integer) this.get("code");
return code;
}
}

使用alibaba.json 快速转换,SpuInfoServiceImpl中的up

1
TypeReference<List<SkuHasStockVo>> typeReference = new TypeReference<List<SkuHasStockVo>>(){};       skuHasStocks.getData(typeReference).stream().collect(Collectors.toMap(SkuHasStockVo::getSkuId, SkuHasStockVo::getHasStock));

首页

整合thymeleaf渲染首页

动静分离架构,网关更好的做鉴权

动静分离:

静:图片,js,css等静态资源(以实际文件存在的方式)

动:服务器需要处理的请求

(图:原 OSS 链接已失效,原地址 https://center-sept-image.oss-cn-shenzhen.aliyuncs.com/img/image-20201222150442692.png)

  1. 在Product导入thymeleaf 模板引擎
1
2
3
4
5
<!--   thymeleaf 依赖-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-thymeleaf</artifactId>
</dependency>
  1. 导入商城静态资源到resouce/static下,页面拷贝到template文件夹下;要看默认配置,查看WebMvcAutoConfiguration.java类。
  2. 在配置文件加入以下配置,暂时不是用缓存,让页面实时更新。
1
2
3
spring:
thymeleaf:
cache: false
  1. 创建一个web包,要来处理页面请求

整合dev-tools渲染一级分类数据

  1. 创建首页请求相应类IndexController
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Controller
public class IndexController {

@Autowired
CategoryService categoryService;

@GetMapping({"/", "index.html"})
public String indexPage(Model model) {
//获取一级分类
List<CategoryEntity> list = categoryService.getLevelFirstCategorys();
model.addAttribute("categorys", list);
// 视图解析器自动拼接前后缀
// classpath:templates/ + index + .html
return "index";
}
}
  1. 新建查询一级分类方法
1
2
3
4
public List<CategoryEntity> getLevelFirstCategorys() {
List<CategoryEntity> list = baseMapper.selectList(new QueryWrapper<CategoryEntity>().eq("parent_cid", 0));
return list;
}
  1. 声明 thymeleaf 语法到html中
1
<html lang="en" xmlns:th="http://www.thymeleaf.org">
  1. 使用thymeleaf语法渲染一级分类
1
2
3
4
5
6
7
<div class="header_main_left">
<ul>
<li th:each=" category : ${categorys}">
<a href="#" class="header_main_left_a" th:attr="ctg-data=${category.catId}"><b th:text="${category.name}"></b></a>
</li>
</ul>
</div>
  1. 修改页面,无需重启服务器实时更新页面

5.1 需要引入dev-tool 依赖,product pom 中加入以下依赖

1
2
3
4
5
6
7
<!--   dev-tool-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-devtools</artifactId>
<!-- true 为开启 -->
<optional>true</optional>
</dependency>

5.2 页面修改完,build项目,就相当于发布完成。

渲染二级和三级分类数据

  1. 由于菜单渲染是根据catalogLoader.js这个文件请求 index/json/catalog.json来获取resouce下的静态文件catalog.json中的数据的。所以我们需要读取数据库中配置的动态数据作为渲染数据。
  2. 在后端做二三级渲染

Catelog2Vo

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@NoArgsConstructor
@AllArgsConstructor
@Data
public class Catelog2Vo {
private String catalog1Id;
private List<Object> catalog3List;
private String id;
private String name;


@NoArgsConstructor
@AllArgsConstructor
@Data
public static class Catelog3Vo{
private String catalog2Id;
private String id;
private String name;
}
}

CategoryServiceImpl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@Override
public Map<String, List<Catelog2Vo>> getCatalogJson() {
// 查出所有一级分类
List<CategoryEntity> levelFirstCategorys = getLevelFirstCategorys();
Map<String, List<Catelog2Vo>> collect = levelFirstCategorys.stream().collect(Collectors.toMap(k -> {
return k.getCatId().toString();
}, lv1 -> {
// 每一个的一级分类,查到这个一级分类的二级分类
List<CategoryEntity> list2 = baseMapper.selectList(new QueryWrapper<CategoryEntity>().eq("parent_cid",
lv1.getCatId()));
List<Catelog2Vo> collect2 = null;
if (list2 != null) {
collect2 = list2.stream().map(lv2 -> {
Catelog2Vo catelog2Vo = new Catelog2Vo(lv1.getCatId().toString(), null, lv2.getCatId().toString()
, lv2.getName());
List<CategoryEntity> list3 = baseMapper.selectList(new QueryWrapper<CategoryEntity>().eq(
"parent_cid",
lv2.getCatId()));
if (list3 != null) {
List<Catelog2Vo.Catelog3Vo> collect3 = list3.stream().map(lv3 -> {
Catelog2Vo.Catelog3Vo catelog3Vo = new Catelog2Vo.Catelog3Vo(lv2.getCatId().toString(),
lv3.getCatId().toString(), lv3.getName());
return catelog3Vo;
}).collect(Collectors.toList());
catelog2Vo.setCatalog3List(collect3);
}
return catelog2Vo;
}).collect(Collectors.toList());
}
return collect2;
}));
return collect;
}

IndexController

1
2
3
4
5
6
@ResponseBody
@GetMapping("/index/catalog.json")
public Map<String, List<Catelog2Vo>> getCatalogJson() {
Map<String, List<Catelog2Vo>> result = categoryService.getCatalogJson();
return result;
}

修改catalogLoader.js 请求路径

1
$.getJSON("index/catalog.json",function (data) 

nginx

搭建域名访问环境一(反向代理配置)

正向代理与反向代理

(图:原 OSS 链接已失效,原地址 https://center-sept-image.oss-cn-shenzhen.aliyuncs.com/img/image-20201223095121108.png)

  1. 使用SwitchHosts直接可以修改hosts配置文件,并提供添加修改方法策略。
1
192.168.72.130 supermall.com
  1. 启动虚拟机nginx

2.1 在挂载的conf.d文件夹下创建一个配置conf,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#http服务,一个server可以配置多个location
server {
listen 80; #服务监听端口
server_name supermall.com; #主机名、域名

#charset koi8-r;
#access_log /var/log/nginx/host.access.log main;

location / {
proxy_pass http://{这里为你的主机的网关地址加Product模块端口号};
}

#error_page 404 /404.html;

# 将500 502 503 504的错误页面重定向到 /50x.html
error_page 500 502 503 504 /50x.html;
location = /50x.html { #匹配error_page指定的页面路径
root /usr/share/nginx/html; #页面存放的目录
}

# proxy the PHP scripts to Apache listening on 127.0.0.1:80
#
#location ~ \.php$ {
# proxy_pass http://127.0.0.1;
#}

# pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000
#
#location ~ \.php$ {
# root html;
# fastcgi_pass 127.0.0.1:9000;
# fastcgi_index index.php;
# fastcgi_param SCRIPT_FILENAME /scripts$fastcgi_script_name;
# include fastcgi_params;
#}

# deny access to .htaccess files, if Apache's document root
# concurs with nginx's one
#
#location ~ /\.ht {
# deny all;
#}
}

2.2 在你的主机启动项目,访问在hosts中配置好的映射域名进行访问,我的是supermall.com

搭建域名访问环境二(负载均衡到网关)

  1. 新增负载均衡 upstream

1.1 在nginx.conf 中加入以下负载均衡配置:

1
2
3
upstream supermall {
server 192.168.72.1:88;
}

1.2 在supermall.conf 中替换内容:

注:因为nginx会对请求做丢弃处理,所以为了让请求头中host属性不会被丢弃而造成请求达到网关服务时候匹配不到服务,所以我们需要加上proxy_set header Host $host

1
2
3
4
location / {
proxy_set header Host $host;
proxy_pass http://supermall;
}
  1. 配置网关服务,加入页面请求断言
1
2
3
4
- id: super_mall_host_route
uri: lb://supermall-product
predicates:
- Host=**.supermall.com,supermall.com

性能压测

压力测试

基本介绍

压力测试考察当前软硬件环境下系统所能承受的最大负荷并帮助找出系统瓶颈所在。压测都是为了系统在线上的处理能力和稳定性维持在一个标准范围内,做到心中有数。

使用压力测试,我们有希望找到很多种用其他测试方法更难发现的错误。有两种错误类型是:

内存泄漏,并发与同步

有效的压力测试系统将应用以下这些关键条件:重复,并发,量级,随机变化。

  1. 性能指标

响应时间(Response Time:RT)

​响应时间指用户从客户端发起一个请求开始,到客户端接收到从服务器端返回的响应结束,整个过程所耗费的时间。

HPS(Hits Per Second)

​每秒点击次数,单位是次/秒

TPS(Transaction per Second)

​系统每秒处理交易数,单位是笔/秒

QPS(Query per Second)

​系统每秒处理查询次数,单位是次/秒

​对于互联网业务中,如果某些业务有且仅有一个请求连接,那么TPS=QPS=HPS,一般情况下用TPS来衡量整个业务流程,用QPS来衡量接口查询次数,用HPS来表示对服务器单词请求次数。

无论是TPS,QPS,HPS,此指标是衡量系统处理能力非常重要的指标,越大越好,根据经验,一般情况:

​金融行业:1000TPS~50000TPS,不包括互联网化的活动

​保险行业:100TPS~100000TPS,不包括互联网化的活动

​制造行业:10TPS~5000TPS

​互联网电子商务:10000TPS~1000000TPS

​互联网中型网站:1000TPS~50000TPS

​互联网小型网站:500TPS~10000TPS

最大响应时间(Max Response Time):指用户发出请求或者指令到系统做出反应(响应)的最大时间。

最少响应时间(Mininum Response Time):指用户发出请求或者指令到系统做出反应(响应)的最少时间。

90%响应时间(90% Response Time):是指所有用户的响应时间进行排序,第90%的响应时间。

从外部看,性能测试主要关注如下三个指标:

​吞吐量:每秒钟系统能够处理的请求数,任务数。

​响应时间:服务处理一个请求或一个任务的耗时。

​错误率:一批请求中结果出错的请求所占比例。

Apache JMeter安装使用

  1. JMeter 安装

https://jmeter.apache.org/download_jmeter.cgi

下载对应的压缩包,解压运行jmeter.bat 即可

  1. JMeter的使用(多用用)

  2. 压测调优

3.1 加大JVM内存

3.2 加大tomcat的最大线程数

1
server.tomcat.max-threads=200

JMeter在windows下地址占用bug解决

JMeter Address Already in user 错误解决

windows本身提供的端口访问机制的问题。

Windows 提供给TCP/IP链接的端口为1024-5000,并且要四分钟来循环回收他们。就导致我们在短时间内跑大量的请求时将端口占满了。

解决方案:

  1. cmd中,用regedit命令打开注册表
  2. 在HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Service\Tcpip\Parameters 下,
    1. 右击parameters,添加一个新的DWORD,名字为MaxUserPort
    2. 然后点击MaxUserPort,输入数值数据为65534,基数选择十进制(如果是分布式运行的话,控制机器和负载机器都需要这样操作)
  3. 修改配置完毕后重启机器
  4. 按照以上步骤再入TCPTimedWaitDelay:30

性能监控

堆内存与垃圾回收

影响性能考虑点包括:

​数据库,应用程序,中间件(tomcat,nginx),网络和操作系统等方面

首先考虑自己的应用属于 CPU密集型 还是 IO密集型

jconsole与jvisualvm

​Jdk的两个小工具jconsole,jvisualvm(升级版jconsole);通过命令行启动,可监控本地和远程应用。远程应用需要配置。

在windows下 cmd 下,执行命令 jconsole 或者 jvisualvm 就可以打开。

jvisualvm能干什么?

监控内存泄露,跟踪垃圾回收,执行时内存,cpu分析,线程分析

运行:正在运行

休眠:sleep

等待:wait

驻留:线程池里面的空闲线程

监视:阻塞的线程,正在等待锁

安装插件方便查看GC
  1. 启动jvisualvm,工具->插件
  2. 如果503错误解决:

2.1 打开网址https://visualvm.github.io/pluginscenters.html

2.2 cmd 查看自己的的jdk版本,找到对应的版本,然后复制网页上的Catalog URL

2.3 然后粘贴到更新插件中心的URL中,最后可以使用插件中心了,下载Vusual GC 插件可以查看动态的GC。

优化

中间件对性能的影响

  1. 使用JMeter,50线程不断请求nginx,然后使用docker stats 命令查看nginx容器的状态。

  2. 压测网关,使用JvisualVM查看cpu和GC状态

  3. 使用一个普通的业务模块压测,使用JvisualVM查看cpu和GC状态。

  4. 网关加简单业务服务,使用JvisualVM查看cpu和GC状态。

压测内容 压测线程数 吞吐量/s 90%响应时间 99%响应时间
Nginx 50 5152 11 16
Gateway 50 4986 1 4
简单服务 50 5247 1 3
首页一级菜单渲染(thymeleaf缓存) 50 290
首页一级菜单渲染 50 515 51 79
首页一级菜单渲染(thymeleaf缓存+db索引+取消日志) 50 270 267 365
三级分类数据获取 50 15 3690 4007
首页全量数据获取 50 7 (静态资源)
Nginx+Gateway 50
Gateway+简单服务 50 2675 5 12
全链路 50 1225 34 45

结论:中间件越多,性能损耗越大,大多都损失在网络交互。

优化:

SQL耗时越小越好,一般情况下微秒级别。

命中率越高越好,一般情况下不能低于95%

锁等待次数越低越好,等待时间越短越好。

简单优化吞吐量测试

  1. 压测首页一级菜单

  2. 直接压测三级菜单

  3. 压测首页全量数据(图片,js等静态数据)

JMeter http取样器中选择高级,勾选”从HTML文件中获取所有的资源”

  1. 首页一级菜单渲染(thymeleaf缓存+db索引+取消日志)
  2. 首页一级菜单渲染(thymeleaf缓存)
  3. 优化之后的全量数据

nginx动静分离

  1. 将所有项目的静态资源都nginx里面
  2. 指定一个规则:/static/***下的所有请求都有nginx直接返回
  3. 在nginx的html下创建static文件夹专门存放静态资源
  4. 然后修改index.html中的所有静态资源路径,在路径前加上static或/static

href=” 替换href=”/static/

<script src=” 替换 <script src=”/static/

<img src=” 替换 <img src=”/static/

  1. nginx的商城配置文件加入以下配置:
1
2
3
 location /static/ {
root /usr/share/nginx/html;
}
  1. JS 文件的路径也要重新配置

模拟线上应用内存崩溃宕机情况

GC时间的占用是硬指标

JVM调优,合理分配内容

优化三级分类数据获取

  1. 将数据库的多次查询变为一次

缓存

缓存使用

本地缓存与分布式缓存

​ 为了系统性能的提升,我们一般都会将部分数据放入缓存中,加速访问。而db承担数据落盘工作。

哪些数据适合放入缓存?

  1. 即时性、数据一致性要求不高的。
  2. 访问量大且更新频率不高的数据(读多,写少)

整合redis测试

  1. 引入redis的启动器
1
2
3
4
5
<!--   整合 redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
  1. 加入redis的配置信息
1
2
3
4
spring:
redis:
host: 192.168.72.130
port: 6379
  1. 引入配置bean
1
2
@Autowired
StringRedisTemplate stringRedisTemplate;
  1. 测试bean是否引入
1
2
3
4
5
6
7
8
9
10
11
 @Autowired
StringRedisTemplate stringRedisTemplate;

@Test
public void testStringRedisTemplate() {
ValueOperations<String, String> stringStringValueOperations =
stringRedisTemplate.opsForValue();
stringStringValueOperations.set("hello", "world" + UUID.randomUUID().toString());
String hello = stringStringValueOperations.get("hello");
System.out.println("保存的数据是:" + hello);
}

改造三级分类业务

  1. 修改CategoryServiceImpl类中getCatalogJson方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
@Autowired
private StringRedisTemplate stringRedisTemplate;

@Override
public Map<String, List<Catelog2Vo>> getCatalogJson() {
// 加入缓存
String catalogJSON = stringRedisTemplate.opsForValue().get("catalogJSON");
Map<String, List<Catelog2Vo>> catalogJson;
if (StringUtils.isEmpty(catalogJSON)) {
// 缓存中没有
// 从数据库中取数据
catalogJson = getCatalogJsonFromDb();
// 查到的数据转为json对象放到缓存中
stringRedisTemplate.opsForValue().set("catalogJSON", JSON.toJSONString(catalogJson));
return catalogJson;
}
catalogJson = JSON.parseObject(catalogJSON, new TypeReference<Map<String, List<Catelog2Vo>>>() {
});
return catalogJson;
}

public Map<String, List<Catelog2Vo>> getCatalogJsonFromDb() {
// 一次将所有数据查出来
List<CategoryEntity> all = baseMapper.selectList(null);
// 查出所有一级分类
List<CategoryEntity> levelFirstCategorys = getItem(all, 0L);
Map<String, List<Catelog2Vo>> collect = levelFirstCategorys.stream().collect(Collectors.toMap(k -> {
return k.getCatId().toString();
}, lv1 -> {
// 每一个的一级分类,查到这个一级分类的二级分类
List<CategoryEntity> list2 = getItem(all, lv1.getParentCid());
List<Catelog2Vo> collect2 = null;
if (list2 != null) {
collect2 = list2.stream().map(lv2 -> {
Catelog2Vo catelog2Vo = new Catelog2Vo(lv1.getCatId().toString(), null, lv2.getCatId().toString()
, lv2.getName());
List<CategoryEntity> list3 = getItem(all, lv2.getParentCid());
if (list3 != null) {
List<Catelog2Vo.Catelog3Vo> collect3 = list3.stream().map(lv3 -> {
Catelog2Vo.Catelog3Vo catelog3Vo = new Catelog2Vo.Catelog3Vo(lv2.getCatId().toString(),
lv3.getCatId().toString(), lv3.getName());
return catelog3Vo;
}).collect(Collectors.toList());
catelog2Vo.setCatalog3List(collect3);
}
return catelog2Vo;
}).collect(Collectors.toList());
}
return collect2;
}));
return collect;
}

压力测试出的内存泄露及解决

使用原本redis 启动器自带的lettuce redis 客户端连接器会造成什么问题?

会产生对外内存溢出:OutOfDirectMemoryError

原因:lettuce的bug导致netty堆外内存溢出 -Xmx300m;netty如果没有指定堆外内存,默认使用-Xmx300m

解决方案:

  1. 可以使用-Dio.netty.maxDirectMemory调大这个最大内存(但是解决不了根本性问题)
  2. 升级lettuce客户端版本
  3. 切换使用jedis

3.1 去除使用lettuce依赖,加上jedis依赖

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!--   整合 redis -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<exclusions>
<exclusion>
<groupId>io.lettuce</groupId>
<artifactId>lettuce-core</artifactId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
</dependency>

redisTemplate 与 lettuce 、jedis 的关系?

lettuce、jedis都是操作redis的底层客户端。Spring 在 RedisAutoConfiguration 中对这两个客户端进行再次封装,生成一个工厂连接类。

缓存击穿、穿透、雪崩

穿透:直接返回null值

雪崩:随机设置过期时间

击穿:只有一个线程进行查库操作,获取锁在进行操作

加锁解决缓存击穿问题

改造获取三级分类方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
public Map<String, List<Catelog2Vo>> getCatalogJson() {
// 加入缓存
String catalogJSON = stringRedisTemplate.opsForValue().get("catalogJSON");
Map<String, List<Catelog2Vo>> catalogJson;
if (StringUtils.isEmpty(catalogJSON)) {
// 缓存中没有
// 从数据库中取数据
catalogJson = getCatalogJsonFromDb();
return catalogJson;
}
catalogJson = JSON.parseObject(catalogJSON, new TypeReference<Map<String, List<Catelog2Vo>>>() {
});
return catalogJson;
}

public Map<String, List<Catelog2Vo>> getCatalogJsonFromDb() {
Map<String, List<Catelog2Vo>> catalogJson;
// 本地进程保证只有一个线程进入查询数据库
// TODO 分布式接口需要替换成分布式锁
synchronized (this) {
// 进入到查询原子操作,再次执行缓存查询
String catalogJSON = stringRedisTemplate.opsForValue().get("catalogJSON");
// 不为空直接返回
if (!StringUtils.isEmpty(catalogJSON)) {
catalogJson = JSON.parseObject(catalogJSON, new TypeReference<Map<String, List<Catelog2Vo>>>() {
});
return catalogJson;
}
System.out.println("缓存中不存在三级菜单数据,需要调用db查询");
// 一次将所有数据查出来
List<CategoryEntity> all = baseMapper.selectList(null);
// 查出所有一级分类
List<CategoryEntity> levelFirstCategorys = getItem(all, 0L);
catalogJson = levelFirstCategorys.stream().collect(Collectors.toMap(k -> {
return k.getCatId().toString();
}, lv1 -> {
// 每一个的一级分类,查到这个一级分类的二级分类
List<CategoryEntity> list2 = getItem(all, lv1.getParentCid());
List<Catelog2Vo> collect2 = null;
if (list2 != null) {
collect2 = list2.stream().map(lv2 -> {
Catelog2Vo catelog2Vo = new Catelog2Vo(lv1.getCatId().toString(), null,
lv2.getCatId().toString()
, lv2.getName());
List<CategoryEntity> list3 = getItem(all, lv2.getParentCid());
if (list3 != null) {
List<Catelog2Vo.Catelog3Vo> collect3 = list3.stream().map(lv3 -> {
Catelog2Vo.Catelog3Vo catelog3Vo = new Catelog2Vo.Catelog3Vo(lv2.getCatId().toString(),
lv3.getCatId().toString(), lv3.getName());
return catelog3Vo;
}).collect(Collectors.toList());
catelog2Vo.setCatalog3List(collect3);
}
return catelog2Vo;
}).collect(Collectors.toList());
}
return collect2;
}));
// 查库,将结果同步至缓存,必须是一个原子操作
System.out.println("将db返回的结果同步到缓存中...");
// 查到的数据转为json对象放到缓存中
stringRedisTemplate.opsForValue().set("catalogJSON", JSON.toJSONString(catalogJson), 1, TimeUnit.DAYS);
}
return catalogJson;
}
锁的时序问题

保证查询db操作和更新缓存操作的原子性

  1. 再次确认缓存中不存在数据
  2. 查询数库
  3. 将结果放入缓存

本地锁在分布式下的问题

将Product 服务复制多份,分别使用不同的端口启动,然后使用网关地址进行压测

–server.port={指定不冲突的端口号}

分布式锁

分布式锁原理与使用

  1. 使用redis 提供的nx参数,判断该key是否已经存在内存中,如果存在则返回false,反之为true

  2. 如果锁没有设置过期时间,加锁的服务刚好还没释放锁的时候宕机,导致其他服务无法获取到锁,一直轮询调用会造成其他服务崩溃。所以我们必须设置一个过期时间,保证这个锁必须自然释放。

    解决方案:设置过期时间

  3. 释放锁的时候存在问题,因为释放锁的时候,都是同一个key,每个线程公用这个key。由于业务超时或者网络延迟,第一个线程超时了,第二个线程获取到锁,已经在处理业务;这个时候,第一个线程执行完业务,执行删除锁操作,就会删除调第二个线程使用的锁。所以我们必须在value值中设置一个唯一uuid号(如果出现碰撞可以使用雪花算法),用于区分每个线程调用的锁是不是属于你的。

    解决方案:每个线程执行的vlaue指定一个唯一的值

  4. 由于释放锁的时候,查询value和比对value不是原子操作(即不是在redis上同时操作),由于网络存在传输延迟,导致获取value之后,还要在代码中执行删除操作,这个操作就有可能存在删除其他线程锁的问题。

    解决方案:使用lua

修改方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
    @Override
public Map<String, List<Catelog2Vo>> getCatalogJson() {
// 加入缓存
String catalogJSON = stringRedisTemplate.opsForValue().get("catalogJSON");
Map<String, List<Catelog2Vo>> catalogJson;
if (StringUtils.isEmpty(catalogJSON)) {
// 缓存中没有,从数据库中取数据
catalogJson = getCatalogJsonFromDbWithDistributedLock();
return catalogJson;
}
catalogJson = JSON.parseObject(catalogJSON, new TypeReference<Map<String, List<Catelog2Vo>>>() {
});
return catalogJson;
}


/**
* 本地锁,适用于单个服务
*
* @return
*/
public Map<String, List<Catelog2Vo>> getCatalogJsonFromDbWithLocalLock() {
Map<String, List<Catelog2Vo>> catalogJson;
// 本地进程保证只有一个线程进入查询数据库
// TODO 分布式接口需要替换成分布式锁
synchronized (this) {
// 一次将所有数据查出来
catalogJson = getCatelogDataFromDb();
}
return catalogJson;
}

/**
* 分布式锁,适用于多服务
*
* @return
*/
public Map<String, List<Catelog2Vo>> getCatalogJsonFromDbWithDistributedLock() {
Map<String, List<Catelog2Vo>> catalogJson = null;
// 本地进程保证只有一个线程进入查询数据库
// 分布式接口需要替换成分布式锁
// 占用分布式锁,去redis占坑
String uuid = UUID.randomUUID().toString();
Boolean lock = stringRedisTemplate.opsForValue().setIfAbsent("lock", uuid, 300, TimeUnit.SECONDS);
// 判断是否已经获取到锁
if (lock) {
// 加锁成功,执行业务操作
// 设置过期时间,必须和加锁是同步的,原子的
// stringRedisTemplate.expire("lock",300,TimeUnit.SECONDS);
try {
catalogJson = getCatelogDataFromDb();
return catalogJson;
} finally {
// 使用lua脚本删除,原子操作
String luaDelete = "if redis.call(\"get\",KEYS[1]) == ARGV[1]\n" +
"then\n" +
" return redis.call(\"del\",KEYS[1])\n" +
"else\n" +
" return 0\n" +
"end";
Long lock1 = stringRedisTemplate.execute(new DefaultRedisScript<Integer>(luaDelete, Long.class),
Arrays.asList("lock"), uuid);
System.out.println("删除分布式锁操作结果:" + lock1);
}
// 获取值比对+比对成功删除=原子操作 lua脚本
// String lockValue = stringRedisTemplate.opsForValue().get("lock");
// if (uuid.equals(lockValue)) {
// // 比对成功删除自己的锁
// stringRedisTemplate.delete("lock");
// }
} else {
// 获取不到锁,自旋调用
try {
wait(100);
return getCatalogJsonFromDbWithDistributedLock();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
return catalogJson;
}

private Map<String, List<Catelog2Vo>> getCatelogDataFromDb() {
System.out.println("缓存中不存在三级菜单数据,需要调用db查询");
Map<String, List<Catelog2Vo>> catalogJson;// 一次将所有数据查出来
// 进入到查询原子操作,再次执行缓存查询
String catalogJSON = stringRedisTemplate.opsForValue().get("catalogJSON");
// 不为空直接返回
if (!StringUtils.isEmpty(catalogJSON)) {
catalogJson = JSON.parseObject(catalogJSON, new TypeReference<Map<String, List<Catelog2Vo>>>() {
});
return catalogJson;
}
List<CategoryEntity> all = baseMapper.selectList(null);
// 查出所有一级分类
List<CategoryEntity> levelFirstCategorys = getItem(all, 0L);
catalogJson = levelFirstCategorys.stream().collect(Collectors.toMap(k -> {
return k.getCatId().toString();
}, lv1 -> {
// 每一个的一级分类,查到这个一级分类的二级分类
List<CategoryEntity> list2 = getItem(all, lv1.getParentCid());
List<Catelog2Vo> collect2 = null;
if (list2 != null) {
collect2 = list2.stream().map(lv2 -> {
Catelog2Vo catelog2Vo = new Catelog2Vo(lv1.getCatId().toString(), null,
lv2.getCatId().toString()
, lv2.getName());
List<CategoryEntity> list3 = getItem(all, lv2.getParentCid());
if (list3 != null) {
List<Catelog2Vo.Catelog3Vo> collect3 = list3.stream().map(lv3 -> {
Catelog2Vo.Catelog3Vo catelog3Vo = new Catelog2Vo.Catelog3Vo(lv2.getCatId().toString(),
lv3.getCatId().toString(), lv3.getName());
return catelog3Vo;
}).collect(Collectors.toList());
catelog2Vo.setCatalog3List(collect3);
}
return catelog2Vo;
}).collect(Collectors.toList());
}
return collect2;
}));
// 查库,将结果同步至缓存,必须是一个原子操作
System.out.println("将db返回的结果同步到缓存中...");
// 查到的数据转为json对象放到缓存中
stringRedisTemplate.opsForValue().set("catalogJSON", JSON.toJSONString(catalogJson), 1, TimeUnit.DAYS);
return catalogJson;
}

Redisson简介&整合

  1. 引入依赖
1
2
3
4
5
6
<!--        使用redisson作为所有分布式锁,分布式对象等功能框架-->
<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.12.0</version>
</dependency>
  1. 配置redisson
1
2
3
4
5
6
7
8
9
10
11
12
13
@Configuration
public class MyRedissonConfig {

@Bean(destroyMethod = "shutdown")
public RedissonClient redisson() {
// 创建配置
Config config = new Config();
// 使用单体服务
config.useSingleServer().setAddress("redis://192.168.72.130:6379");
return Redisson.create(config);
}

}
  1. 测试是否已经引入bean
1
2
3
4
5
6
7
@Autowired
RedissonClient redissonClient;

@Test
public void testRedisson() {
System.out.println("redisson客户端:"+redissonClient);
}

Redisson

Redisson-lock锁测试

在测试TestController下添加内容

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Autowired
RedissonClient redissonClient;

@GetMapping("/testRedisson")
public String testRedisson(){
// 获取第一把锁,只要key值一样,就是同一把锁
RLock lock = redissonClient.getLock("my-lock");
// 阻塞加锁
lock.lock();
// 默认加锁时间30s
// 1.锁会自动续期,如果业务超长,运行期间自动给锁续上新的30s.不是担心业务时间过长,锁自动过期被删掉
// 2.加锁的业务只要运行完成,就不会给当前锁续期,即使不手动续期,锁默认在30s以后自动删除
try {
System.out.println("加锁成功,执行业务..."+Thread.currentThread().getId());
Thread.sleep(30000);
} catch (InterruptedException e) {

} finally {
System.out.println("释放锁..."+Thread.currentThread().getId());
lock.unlock();
}
return "testRedisson";
}

lock看门狗原理-reidisson 如何解决死锁

看门狗机制:自动为键值续期

  1. lock.lock(10,TimeUnit.SECONS); 在锁时间到了以后,不会自动续期,如果其他线程解锁会抛出异常;

    1.1 如果我们传递了锁的超时时间,就发送给redis执行脚本,进行占锁,默认超时就是我们只当的时间

    1.2 如果我们未指定锁的超时时间,就使用30*1000 【LockWatchdogTimeout看门狗的默认时间】

    ​ 如果占锁成功,就会启动一个定时任务【重新给锁设置过期时间,新的过期时间就是看门狗的默认时间】,每个10s都会自动再次续期,续成30s

    ​ internallockLeaseTime【看门狗的默认时间】/ 3

注:最好的还是自定义过期时间

读写锁测试

测试类TestController 加入以下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@Autowired
StringRedisTemplate redisTemplate;

@GetMapping("/testReadWriteLockForWrite")
public String testReadWriteLockForWrite() {
RReadWriteLock testRWLock = redissonClient.getReadWriteLock("testRWLock");
RLock rLock = testRWLock.writeLock();
String uuid = null;
try {
rLock.lock();
uuid = UUID.randomUUID().toString();
redisTemplate.opsForValue().set("testRW", uuid);
Thread.sleep(10000);
} catch (Exception e) {
e.printStackTrace();
} finally {
rLock.unlock();
}
return uuid;
}

@GetMapping("/testReadWriteLockForRead")
public String testReadWriteLockForRead() {
RReadWriteLock testRWLock = redissonClient.getReadWriteLock("testRWLock");
RLock rLock = testRWLock.readLock();
String uuid = null;
try {
rLock.lock();
uuid = redisTemplate.opsForValue().get("testRW");
} catch (Exception e) {
e.printStackTrace();
} finally {
rLock.unlock();
}
return uuid;
}

读写锁补充

保证一定能读到最新数据,修改期间,写锁是一个排他锁(互斥锁)。读锁是一个共享锁

写锁没释放读就必须等待

写 + 读:必须等待写锁释放

写 + 写:阻塞方式

读 + 读:相当于无锁,并发读,只会在redis中记录好,所有当前的读锁。他们都会同时加锁成功

读 + 写:有读锁。写也需要等待

只要有写的存在,都必须等待前面的锁释放

闭锁测试

测试类TestController 加入以下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@GetMapping("/testCountDownLatch/lockDoor")
public String testCountDownLatchForLockDoor() {
RCountDownLatch door = redissonClient.getCountDownLatch("door");
try {
door.trySetCount(3);
door.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
return "班级全部人都走了...关门";
}

@GetMapping("/testCountDownLatch/goaway")
public String testCountDownLatchForGoAway() {
RCountDownLatch door = redissonClient.getCountDownLatch("door");
door.countDown();
return "有一个班级走了,还剩" + door.getCount() + "个班级没走";
}

信号量测试

测试类TestController 加入以下内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@GetMapping("/testSemaphoreForPark")
public String testSemaphoreForPark() {
RSemaphore park = redissonClient.getSemaphore("park");
try {
park.acquire();
} catch (InterruptedException e) {
e.printStackTrace();
}
return "ok";
}

@GetMapping("/testSemaphoreForGo")
public String testSemaphoreForGo() {
RSemaphore park = redissonClient.getSemaphore("park");
park.release();
return "ok";
}

场景:分布式限流

缓存一致性解决

  1. 双写模式

由于卡顿等原因,导致写缓存2在最前面,写缓存1在最后面就出现了不一致。

脏数据问题:这是暂时性的脏数据问题,但是在数据稳定,缓存过期以后,又能得到最新的正确数据。

读到的最新数据有延迟:最终一致性

  1. 失效模式

解决方案:

无论是双写模式还是失效模式,都会导致缓存的不一致问题。即多个实例同时更新会出事。怎么办?

  1. 如果是用户纬度数据(订单数据,用户数据),这种并发几率非常小,不用考虑这个问题,缓存数据加上过期事件,每隔一段时间触发读的主动更新即可。
  2. 如果是菜单,商品介绍等基础数据,也可以去使用canal订阅binlog的方式。
  3. 缓存数据+过期时间也足够解决大部分业务对于缓存的要求。
  4. 通过加锁保证并发读写,写写的时候按顺序排好队。读读无所谓。所以适合使用读写锁。(业务不关心脏数据,允许临时的脏数据可忽略)

总结:

  1. 我们能放入缓存的数据本就不应该是实时性、一致性要求超高的。所以缓存数据的时候加上过期时间,保证每天拿到当前最新数据即可。
  2. 我们不应该过度设计,增加系统的复杂性。
  3. 遇到实时性、一致性要求高的数据,就应该查数据库,即使慢点。
Canal
  1. 根据订阅binlog,更新redis
  2. 解决数据异构,分析用户兴趣爱好,生成另外一张表,例如:用户推荐表

SpringCache

简介

  1. Spring从3.1开始定义了org.springframework.cache.Cache 和 org.springframework.cache.CacheManeger 接口来统一不同的缓存技术;并支持使用JCache(JSR-107)注解简化我们开发;

  2. Cache接口为缓存的组件规范定义,包含缓存的各种操作集合;Cache接口下Spring提供各种xxxCache的实现;如:RedisCache,EhCacheCache,ConcurrentMapCache等;

  3. 每次调用需要缓存功能的方法时,Spring会检查指定参数的指定目标方法是否已经被调用过;如果有就直接从缓存中获取方法调用后的结果,如果没有就调用方法缓存结果后返回给用户。下次调用直接从缓存中获取。

  4. 使用Spring缓存抽象时我们需要关注以下两点:

    1. 确定方法需要被缓存以及他们的缓存策略;
    2. 从缓存中读取之前缓存存储的数据;

整体&体验@Cacheable

  1. 引入依赖
1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
  1. 加入配置,CacheAutoConfiguration会导入RedisCacheConfiguration,自动配置好了缓存管理器RedisCacheManager
1
2
#使用redis缓存
spring.cache.type=redis
  1. 使用
1
2
3
4
5
6
7
// 每一个需要缓存的数据我们都来指定要放到那个名字的缓存
@Cacheable({"category"}) // 代表当前方法的结果需要缓存,如果缓存中有,方法不用调用。如果缓存中没有,会调用方法,最后将方法的返回值缓存。
@Override
public List<CategoryEntity> getLevelFirstCategorys() {
List<CategoryEntity> list = baseMapper.selectList(new QueryWrapper<CategoryEntity>().eq("parent_cid", 0));
return list;
}

@Cacheable细节设置

默认行为
  1. 如果缓存中有,方法不用调用
  2. key默认自动生成;缓存的名字;SimpleKey [] (自动生成的key值)
  3. 缓存的value的值。默认使用jdk序列化机制,将序列化的数据存到redis
  4. 默认事件 -1;
自定义
  1. 指定生成的缓存使用的key

    key属性指定,接受一个SpEL

1
@Cacheable(value = {"category"},key = "'levelFirstCategorys'")

使用方法名为key

1
@Cacheable(value = {"category"},key = "#root.method.name")
  1. 指定缓存的数据的存活时间

    统一设置存活时间,在配置文件中配置,是以毫秒为单位

1
spring.cache.redis.time-to-live=6000
  1. 将数据保存为json格式

自定义缓存设置

  1. CacheAutoConfiguration 导入了 RedisCacheConfiguration ,自动配置了RedisCacheManager,初始化所有的缓存,每个缓存决定使用什么配置,如果redisCacheConfiguration有就用已有的,没有就用默认配置,想改缓存的配置,只需要给容器中放一个RedisCacheConfiguration即可,就会应用到当前RedisCacheManager管理的所有缓存分区中。
  2. 建立一个MyCacheConfig,修改默认序列化配置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
//@EnableConfigurationProperties(CacheProperties.class)
@Configuration
@EnableCaching
public class MyCacheConfig {

// @Autowired
// CacheProperties cacheProperties;

@Bean
RedisCacheConfiguration redisCacheConfiguration(CacheProperties cacheProperties) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig();
// config = config.entryTtl();
//key的序列化
config =
config.serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()));
//value的序列化
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. 加入Cache的一些自定义配置。
1
2
3
4
5
6
7
8
#设置缓存时间
spring.cache.redis.time-to-live=600000
#key的前缀,如果指定了前缀就用我们指定的前缀,如果没有就默认使用缓存的名字作为前缀
#spring.cache.redis.key-prefix=CACHE_
#缓存的前缀是否使用
spring.cache.redis.use-key-prefix=true
#是否缓存空值 解决缓存穿透
spring.cache.redis.cache-null-values=true

@CacheEvict

从缓存中删除数据
  1. 使用@Caching 组合操作进行操作
1
@Caching(evict = {@CacheEvict(value = "category", key = "'getLevelFirstCategory'"),@CacheEvict(value = "category", key = "'getCatalogJson'")})
  1. 指定删除某个分区下的所有数据
1
@CacheEvict(value = "category" , allEntries = true)
  1. 存储同一类型的数据,都可以指定成同一个分区。分区名默认就是缓存的前缀

  2. @CachePut 使用双写模式

  3. 修改之前的逻辑

CategoryServiceImpl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
    /**
* 级联更新所有关联的数据
*
* @param category
*/
@Caching(evict = {@CacheEvict(value = "category", key = "'getLevelFirstCategorys'"), @CacheEvict(value =
"category", key = "'getCatalogJson'")})
// @CacheEvict(value = "category" , allEntries = true)
@Transactional
@Override
public void updateCascade(CategoryEntity category) {
this.updateById(category);
categoryBrandRelationService.updateCategory(category.getCatId(), category.getName());
}

// 每一个需要缓存的数据我们都来指定要放到那个名字的缓存
@Cacheable(value = {"category"}, key = "#root.methodName") // 代表当前方法的结果需要缓存,如果缓存中有,方法不用调用。如果缓存中没有,会调用方法,最后将方法的返回值缓存。
@Override
public List<CategoryEntity> getLevelFirstCategorys() {
List<CategoryEntity> list = baseMapper.selectList(new QueryWrapper<CategoryEntity>().eq("parent_cid", 0));
return list;
}

@Cacheable(value = {"category"}, key = "#root.methodName")
@Override
public Map<String, List<Catelog2Vo>> getCatalogJson() {
// 加入缓存
String catalogJSON = stringRedisTemplate.opsForValue().get("getCatalogJson");
Map<String, List<Catelog2Vo>> catalogJson;
if (StringUtils.isEmpty(catalogJSON)) {
// 缓存中没有,从数据库中取数据
catalogJson = getCatalogJsonFromDbWithDistributedLock();
return catalogJson;
}
catalogJson = JSON.parseObject(catalogJSON, new TypeReference<Map<String, List<Catelog2Vo>>>() {
});
return catalogJson;
}

原理与不足

Spring-Cache不足
  1. 读模式

    缓存穿透:查询一个null数据。解决:缓存空数据;cache-null-value=true

    缓存击穿:大量并发进来同时查询一个正好过期的数据。解决:加锁;?默认是没有加锁的;在注解中加入sync=true属性(加锁,解决击穿)

    缓存雪崩:大量的key同时过期。解决:加随机时间。加上过期时间。spring.cache.redis.time-to-live=600000

  2. 写模式(缓存与数据库一致)

    2.1 读写加锁

    2.2 引入Canal,感知到MySql的更新操作去更新缓存

    2.3 读多写多,直接去数据库查询。

总结:

常规数据(读多写少,即时性,一致性要求不高的数据);完全可以使用Spring-Cache

特殊数据:特殊设计

原理

​ CacheManager(RedisCacheManager) ——》Cache ——》Cache负责缓存的读写

​ 查看RedisCache源码

商城业务

检索服务

搭建页面环境

  1. 在ES模块引入模板引擎
  2. 将index.html加入资源文件template文件夹下,文件头引入名称空间,<html lang=”en” xmlns:th=”">http://www.thymeleaf.org">
  3. href=” 替换 href=”/static/search/ ,src=” 替换 src=”/static/search/
  4. hosts 加入 域名映射配置
1
192.168.72.130 search.supermall.com
  1. 修改nginx配置
1
server_name  supermall.com *.supermall.com; #主机名、域名
  1. 配置网关配置
1
2
3
4
- id: super_mall_search_host_route
uri: lb://supermall-elasticsearch
predicates:
- Host=search.supermall.com

调试页面跳转

  1. 调试搜索页面跳转首页
1
<a href="http://supermall.com"><img src="/static/search/image/logo1.jpg" alt=""></a>
  1. 新建listPage 跳转方法,把index.html改成list.html
1
2
3
4
5
6
7
8
@Controller
public class SearchController {

@GetMapping("/list.html")
public String listPage(){
return "list";
}
}
  1. 修改catalogLoder.js 文件,然后上传到nginx对应的文件夹下
1
var cata3link = $("<a href=\"http://{自己定义的域名}/list.html?catalog3Id="+ctg3.id+"\" style=\"color: #999;\">" + ctg3.name + "</a>");
  1. 修改搜索方法的跳转元素
1
<a href="javascript:search();" ><img src="/static/index/img/img_09.png" /></a>
  1. 修改跳转方法
1
2
3
4
function search() {
var keyword=$("#searchText").val()
window.location.href="http://search.supermall.com/list.html?keyword="+keyword;
}

检索查询参数模型分析抽取

  1. 创建商城检索服务和接收检索的实体类

    MallSearchService.java

1
2
3
4
5
6
/**
*
* @param searchParam 检索的所有参数
* @return
*/
Object search(SearchParam searchParam);

SearchController.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Controller
public class SearchController {

@Autowired
MallSearchService mallSearchService;

/**
* 自动将页面提交过来的所有请求参数封装成指定的对象
* @param searchParam
* @return
*/
@GetMapping("/list.html")
public String listPage(SearchParam searchParam){
Object search = mallSearchService.search(searchParam);
return "list";
}
}

SearchParam.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
@Data
public class SearchParam {

/**
* 页面传递过来的全文匹配关键字
*/
private String keyword;

/**
* 三级分类id
*/
private Long catalog3Id;

/**
* 排序条件
* 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
* attrs=2_5寸:6寸
*/

/**
* 是否只显示有货
*/
private Integer hasStock;


/**
* 价格区间查询
*/
private String skuPrice;


/**
* 品牌id 支持多选
* 如何多拼
* brandId=1&brandId=2
*/
private List<Long> brandId;

/**
* 属性
*/
private List<String> attrs;

/**
* 页码
*/
private Integer pageNum;
}

1.1 商品检索分为三个入口:

  1. 选择分类进入商品检索

  2. 输入检索关键字展示检索页

  3. 选择筛选条件进入(最复杂)

    3.1 商城检索-检索条件分析

    1. 全文检索:skuTitle
    2. 排序:saleCount(销量),hotScore人(热度),skuPrice(价格)
    3. 过滤:hasStock,skuPrice区间,brandId,catalog3Id,attrs
    4. 聚合:attrs

    3.2 一个完整的查询

1
keyword=小米&sort=saleCount_desc/asc&hasStock=0/1&skuPrice=400_1900&brandId=1&catalog3Id=1&attrs=1_3G:4G:5G&attrs=2_骁龙845&attrs=4_高清屏

检索返回结果模型分析抽取

  1. 新建返回对象 SearchResults.java
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
@Data
public class SearchResults {

/**
* 查询到的所有商品信息
*/
private List<SkuEsModel> products;

/**
* 当前查询到的结果,涉及所有品牌信息
*/
private List<BrandVo> brands;

/**
* 涉及到的所有属性
*/
private List<AttrVo> attrs;

/**
* 涉及到的所有分类
*/
private List<CatalogVo> catalogs;

// ===============一下是分页信息=====================

/**
* 当前页面
*/
private Integer pageNum;

/**
* 总记录数
*/
private Long total;

/**
* 总页码
*/
private Integer totalPages;

// ===============以上是返回到页面的所有信息=================

@Data
public static class BrandVo {

private Long brandId;

private String brandName;

private String brandImg;
}

@Data
public static class AttrVo {

private Long attrId;

private String attrName;

private List<String> attrValue;
}

@Data
public static class CatalogVo{

private Long catalogId;

private String catalogName;
}

}
  1. 修改SearchController
1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* 自动将页面提交过来的所有请求参数封装成指定的对象
*
* @param searchParam
* @return
*/
@GetMapping("/list.html")
public String listPage(SearchParam searchParam, Model model) {
// 根据传递来的页面的查询参数,去ES中检索商品
SearchResults results = mallSearchService.search(searchParam);
model.addAttribute("result",results);
return "list";
}

检索DSL测试

查询部分

使用kibana管理界面测试检索内容,测试命令如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
GET product/_search
{
"query": {
"bool": {
"must": [
{
"match": {
"skuTitle": "华为"
}
}
],
"filter": [
{
"term": {
"catalogId": "225"
}
},
{
"terms": {
"brandId": [
"1",
"2",
"9"
]
}
},
{
"nested": {
"path": "attrs",
"query": {
"bool": {
"must": [
{
"term": {
"attrs.attrId": {
"value": "15"
}
}
},
{
"terms": {
"attrs.attrValue": [
"海斯",
"以官网消息为准"
]
}
}
]
}
}
}
},
{
"term": {
"hasStock": {
"value": "true"
}
}
},
{
"range": {
"skuPrice": {
"gte": 0,
"lte": 60000
}
}
}
]
}
},
"sort": [
{
"skuPrice": {
"order": "desc"
}
}
],
"from": 0,
"size": 20,
"highlight": {
"fields": {"skuTitle": {}},
"pre_tags": "<b style='color:red'>",
"post_tags": "</b>"
}
}
  1. must 中的条件参与评分,filter中的条件不参与;
  2. must 中一般是keyword字段中带出的字符串;
  3. 范围检索range
  4. 分页用from和size
  5. 关键字高亮highlight,配置需要高亮的字段,例如:skuTittle ,使用pre_tags配置前缀拼接,post_tags配置后置拼接。
聚合部分

使用aggs进行聚合分析

在结果中的aggregations字段下存在聚合的结果,聚合中的结果key则为聚合的结果值,doc_count则为命中的条数。

使用子聚合查询聚合结果中的详细内容

由于之前做brandName字段不做检索 index=false和 doc_values = false;

因为需要修改映射值,所以必须重新创建一个索引,然后重新创建映射,然后再把数据导入到新的映射中去。

修改es服务中的EsConstant文件PRODUCT_INDEX常量为刚刚新建的映射索引。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
#聚合数据
GET supermall_product/_search
{
"query": {
"match_all": {}
},
"aggs": {
"brand_agg": {
"terms": {
"field": "brandId",
"size": 10
},
"aggs": {
"brand_name_agg": {
"terms": {
"field": "brandName",
"size": 10
}
},
"brand_img_agg":{
"terms": {
"field": "brandImg",
"size": 10
}
}
}
},
"catalog_agg": {
"terms": {
"field": "catalogId",
"size": 10
}
}
}
}

#迁移数据
#从product映射迁移到supermall_product映射
POST _reindex
{
"source": {
"index": "product"
},
"dest": {
"index": "supermall_product"
}
}
#查询
GET supermall_product/_search
{
"query": {
"bool": {
"must": [
{
"match": {
"skuTitle": "华为"
}
}
],
"filter": [
{
"term": {
"catalogId": 225
}
},
{
"terms": {
"brandId": [
"4"
]
}
},
{
"nested": {
"path": "attrs",
"query": {
"bool": {
"must": [
{
"term": {
"attrs.attrId": {
"value": "1"
}
}
},
{
"terms": {
"attrs.attrValue": [
"OLED",
"护眼模式"
]
}
}
]
}
}
}
}
]
}
},
"sort": [
{
"skuPrice": {
"order": "desc"
}
}
],
"from": 0,
"size": 20,
"highlight": {
"fields": {
"skuTitle": {}
},
"pre_tags": "<b style='color:red'>",
"post_tags": "</b>"
}
}