文章目录
1. 概述
随着 Redis 4.0 的发布,Redis支持了可扩展模块(RedisMod)。而在2021年12月,基于Redis 6.x,RedisLabs 给出了两个重量级的第三方模块,RedisJSON 和 RediSearch。
RedisJSON实现了 JSON 数据交换标准 ECMA-404,作为原生数据类型。它允许从 Redis 中存储、更新和获取 JSON 值。
完全支持JSON标准 使用类似JSONPath的语法,用于在文档中选择元素 文档以二进制数据的形式存储在树结构中,允许快速访问子元素 所有JSON值类型都是原子操作
RediSearch一个高性能的全文搜索引擎。
简单,快速索引和搜索 数据存储在内存中,使用内存-有效的自定义数据结构 支持多种使用UTF-8编码的语言 文档和字段评分 结果的数值过滤 通过词干扩展查询 精确的短语搜索 按特定属性过滤结果(例如仅在标题中搜索“foo”) 强大的自动提示引擎 增量索引(不需要对索引进行优化和压缩) 支持用作存储在另一数据库中的文档的搜索索引 支持已经在Redis中存在的HASH对象作为文件的索引 扩展到多个Redis实例
抱着踩坑的想法,做了这次简单的试验。
2. 性能
官网给出了RedisJSON(RedisSearch)的性能测试报告,可谓碾压其他NoSQL:
对于隔离写入(isolated writes),RedisJSON 比 MongoDB 快 5.4 倍,比 ElasticSearch 快 200 倍以上。
对于隔离读取(isolated reads),RedisJSON 比 MongoDB 快 12.7 倍,比 ElasticSearch 快 500 倍以上。
在混合工作负载场景中,实时更新不会影响 RedisJSON 的搜索和读取性能,而 ElasticSearch 会受到影响。
RedisJSON* 支持的操作数/秒比 MongoDB 高约 50 倍,比 ElasticSearch 高 7 倍/秒。
RedisJSON* 的延迟比 MongoDB 低约 90 倍,比 ElasticSearch 低 23.7 倍。
此外,RedisJSON 的读取、写入和负载搜索延迟在更高的百分位数中远比 ElasticSearch 和 MongoDB 稳定。当增加写入比率时,RedisJSON 还能处理越来越高的整体吞吐量,而当写入比率增加时,ElasticSearch 会降低它可以处理的整体吞吐量。
详细的性能评测,可以看【这里】
后面有时间也想对 RedisJSON + RediSearch 做大数据量下的压测,看看实际效果,为是否能替代 ElasticSearch 提供实际依据。
3. 安装
3.1. 物理机/虚拟机安装
虽说 Redis 4.0 已经开始支持 RedisMod,但从功能和性能考虑,推荐使用 Redis 6.0 以上版本。
关于集群的安装,这里不再赘述,有兴趣的同学可以参考【这里】
最省事的方式就是下载编译后的 so 文件:
下载地址:https://redis.com/redis-enterprise-software/download-center/modules/
1)下载后解压缩文件,获取 rejson.so 和 module-enterprise.so (可以重命名为 redisearch.so)文件。
2)进入 Redis 的安装目录, 在该目录下创建 module 目录,上传 rejson.so 和 module-enterprise.so 文件,并修改可执行权限。
3)停止 Redis 节点服务。
4)修改 redis.conf 文件:
……
loadmodule $REDIS_HOME/module/rejson.so
loadmodule $REDIS_HOME/module/module-enterprise.so
5)启动 Redis 节点服务。
6)进入命令行工具,查看插件列表:
127.0.0.1:6379> module list
1) 1) "name"
2) "ReJSON"
3) "ver"
4) "20007"
2) 1) "name"
2) "search"
3) "ver"
4) "20403"
如果为集群模式,在各个节点均需安装 RedisJSON 和 RediSearch 的 Mod。
3.2. 容器化
镜像下载地址:https://hub.docker.com/r/redislabs/redismod
1)修改镜像配置文件:
/home/user/redis.conf
requirepass mypass
dir /data
loadmodule /usr/lib/redis/module/rejson.so
loadmodule /usr/lib/redis/module/redisearch.so
2)镜像启动参数:
$ docker run \
-p 6379:6379 \
-v /home/user/data:/data \
-v /home/user/redis.conf:/usr/local/etc/redis/redis.conf \
redislabs/redismod \
/usr/local/etc/redis/redis.conf
3)进入命令行工具,查看插件列表:
127.0.0.1:6379> module list
1) 1) "name"
2) "ReJSON"
3) "ver"
4) "20007"
2) 1) "name"
2) "search"
3) "ver"
4) "20403"
4. 脚手架/命令行操作 RedisJSON 及 RediSearch
首先,为什么不直接介绍在代码中如何使用这两个 Mod,而是要讲命令行的执行方式呢?其实,熟悉 Redis 的伙伴肯定都知道,代码中执行的命令其实就是命令行工具中的命令。所以先学习命令行的内容,有助于更好地理解代码的执行。
4.1. 工具
工欲善其事,必先利其器。就像使用 MySQL 时,有一个可视化的 IDE 会更方便操作,对于数据展示、库操作都很便利。有人说使用 redis-cli 脚手架不是可以执行 Redis 命令吗?是可以执行,但是查看数据以及操作便利性都不如 IDE 更舒服和效率。
然而并不是所有 IDE 都支持 RedisJSON 与 RediSearch,下面推荐几款适用的 IDE 工具:
- RedisInsight
Redis Labs 提供的监控分析级别的 IDE 工具,可以查看节点 CPU 状态、命令执行并发量、存储量以及连接数等服务器端信息,并可以可视化操作数据、执行命令。命令输入时会有输入提示,并可以查看历史执行的命令并重新执行。同时,RedisInsight 还支持 rdb 的分析功能,之前分析 rdb 的存储分布,有点经验的都会用 rdb-tools 去分析,而这款工具直接集成了这个功能。在分析功能中的 Profiler 能监听一段时间内所有执行的 Redis 命令 ,Slowlog 能显示出执行比较慢的 Redis 命令。除此之外,这个软件还能执行批量操作。最重要的是,它是免费的,强烈推荐。
下载地址:https://redis.com/redis-enterprise/redis-insight/#insight-form
PS: 需要翻墙。。
- RESP.app
老牌IDE工具,原名叫做 Redis Desktop Manager(RDM) 除了界面丑点还收费(试用期半个月),其他没有什么可挑剔的。
下载地址:https://resp.app/
- Another Redis Desktop Manager
很中规中矩的一款开源免费的redis可视化工具,基本的功能都有。有监控统计,支持暗黑主题,还支持集群的添加。缺点是没什么亮点,UI很简单,不支持stream数据类型。命令行模式也比较单一。value展示支持的类型也只有3种。
下载地址:https://github.com/qishibo/AnotherRedisDesktopManager/
- FastoRedis
收费软件,虽然跨平台,但是试用只有一天的时间。功能比较强大,支持了集群模式和哨兵模式,尤其在存储数据的展示上竟然能够支持 17 种格式的渲染。
下载地址:https://fastoredis.com/anonim_users_downloads
4.2. 约束
- 命令和子命令的名称是大写的,例如 JSON.SET 和 INDENT
- 强制参数用尖括号括起来,例如 <path>
- 可选参数用方括号括起来,例如 [index]
- 其他可选参数由三个句点字符表示,即 …
- 管道字符 | 表示异或
4.3. RedisJSON 命令
4.3.1. 路径语法
RedisJSON 目前支持两种查询语法:JSONPath 语法和 RedisJSON 第一个版本的路径语法。
RedisJSON 根据路径查询的第一个字符决定使用哪种语法。如果查询以字符$开头,则使用JSONPath语法。否则,它默认为路径语法。
JSONPath
RedisJSON 2.0 引入了 JSONPath 支持。
JSONPath 查询可以解析 JSON 文档中的多个位置。在这种情况下,JSON 命令将操作每个可能的位置。这是对遗留查询的重大改进,早期查询只在第一条路径上运行。
注意:在使用 JSONPath 时,命令响应的结构通常不同。
新语法支持括号表示法,允许在键名中使用特殊字符,如冒号 “:” 或空格。
Legacy Path syntax (RedisJSON v1)
RedisJSON 的第一个版本有以下实现。RedisJSON v2 仍然支持它。
路径总是从 JSON 值的根开始。根由字符(.)表示。对于引用根的子级的路径,可以选择在路径前面加上根前缀。
要访问数组元素,请将其索引括在一对方括号内。索引是基于 0 的, 0 是数组的第一个元素。可以使用负偏移来访问从数组末端开始的元素。-1 是数组中的最后一个元素。
json key的规则:
- 必须以字符、$、_ 开头
- 可以包含字符、数字、$、_
- 大小写敏感
4.3.2. 命令
-
标量命令
-
设置 json 值
JSON.SET <key> <path> <json> [NX | XX]
说明:
NX:如果不存在就添加
XX:如果存在就更新 -
查询 key 的值
JSON.GET <key> [INDENT indentation-string] [NEWLINE line-break-string] [SPACE space-string] [path ...]
说明:
可以接受多个 path ,默认是root
INDENT:设置嵌套级别
NEWLINE:每行末尾打印的字符串
SPACE:设置 key 和 value 之间的字符串
例如:JSON.GET myjsonkey INDENT "\t" NEWLINE "\n" SPACE " " . JSON.SET doc $ '{"a":2, "b": 3, "nested": {"a": 4, "b": null}}' JSON.GET doc $..b JSON.GET doc ..a $..b
-
查询指定路径下的多个 key ,不存在的 key 或 path 返回 null
JSON.MGET <key> [key ...] <path>
例如:
JSON.SET doc1 $ '{"a":1, "b": 2, "nested": {"a": 3}, "c": null}' JSON.SET doc2 $ '{"a":4, "b": 5, "nested": {"a": 6}, "c": null}' JSON.MGET doc1 doc2 $..a
-
删除值
JSON.DEL <key> [path]
说明:
不存在的 key 或 path 会被忽略
返回 integer -
增加数字的值
JSON.NUMINCRBY <key> <path> <number>
-
数字乘法
JSON.NUMMULTBY <key> <path> <number>
-
追加字符串
JSON.STRAPPEND <key> [path] <json-string>
-
字符串的长度
JSON.STRLEN <key> [path]
-
-
数组命令
-
追加数组元素
JSON.ARRAPPEND <key> <path> <json> [json ...]
-
搜索指定元素在数组中第一次出现的位置。如果存在返回索引,不存在返回 -1。
JSON.ARRINDEX <key> <path> <json-scalar> [start [stop]]
说明:
[start [stop]] 从 start开始(包含)到 stop(不包含)的范围
-
在数组指定位置插入元素
JSON.ARRINSERT <key> <path> <index> <json> [json ...]
说明:
index: 0 是数组第一个元素,负数表示从末端开始计算 -
数组的长度
JSON.ARRLEN <key> [path]
说明:
如果 key 或 path 不存在,返回 null -
删除返回数组中指定位置的元素
JSON.ARRPOP <key> [path [index]]
说明:
index默认是 -1,最后一个元素 -
去掉元素,使其仅包含指定的包含范围的元素
JSON.ARRTRIM <key> <path> <start> <stop>
-
-
对象命令
-
返回对象中的 key
JSON.OBJKEYS <key> [path]
-
返回对象 key 的数量
JSON.OBJLEN <key> [path]
-
-
模块命令
-
返回 json value 的数据类型
JSON.TYPE <key> [path]
-
返回 key 的字节数
JSON.DEBUG MEMORY <key> [path]
-
4.4. RediSearch 命令
除了存储 JSON 文档,还可以使用 RediSearch 模块进行索引,使用全文检索功能。
4.4.1. 命令
-
创建索引
FT.CREATE {index} [ON {data_type}] [PREFIX {count} {prefix} [{prefix} ..] [LANGUAGE {default_lang}] SCHEMA {identifier} [AS {attribute}] [TEXT | NUMERIC | GEO | TAG ] [CASESENSITIVE] [SORTABLE] [NOINDEX]] ...
说明:
- index:索引名称;
- data_type:建立索引的数据类型,目前支持JSON或者HASH两种;
- PREFIX:通过它可以选择需要建立索引的数据前缀,比如 PREFIX 1 “product:” 表示为键中以 product: 为前缀的数据建立索引;
- LANGUAGE:指定TEXT类型属性的默认语言,使用chinese可以设置为中文;
- identifier:指定属性名称;
- attribute:指定属性别名;
- TEXT | NUMERIC | GEO | TAG:这些都是属性可选的类型;
- SORTABLE:指定属性可以进行排序。
例如:
FT.CREATE productIdx ON JSON PREFIX 1 "product:" LANGUAGE chinese SCHEMA $.id AS id NUMERIC $.name AS name TEXT $.subTitle AS subTitle TEXT $.price AS price NUMERIC SORTABLE $.brandName AS brandName TAG
-
搜索
FT.SEARCH index query [FILTER numeric_field min max] [GEOFILTER geo_field lon lat radius m|km|mi|ft] [RETURN count field [field ...]] [SORTBY sortby [ASC|DESC]] [LIMIT offset num]
貌似还是很简单,但实际复杂的是在 query 的使用上。下面举几个例子可以更加一目了然。
比如我们使用上面创建索引的例子创建好索引:
-
使用 * 可以查询全部
FT.SEARCH productIdx *
-
排序
由于我们设置了 price 字段为 SORTABLE ,我们可以以 price 降序返回商品信息:
FT.SEARCH productIdx * SORTBY price DESC
-
返回字段
还可以指定返回的字段
FT.SEARCH productIdx * RETURN 3 name subTitle price
-
TAG类型作为查询条件
brandName为 TAG 类型,可以使用如下语句查询品牌为小米或苹果的商品
FT.SEARCH productIdx '@brandName:{小米 | 苹果}'
-
数字范围查询条件
price 是 NUMERIC 类型,我们可以使用如下语句查询价格在 500~1000 的商品
FT.SEARCH productIdx '@price:[500 1000]'
-
模糊查询
类似于 SQL 中的 LIKE
FT.SEARCH productIdx '@name:小米*'
-
全局检索
直接指定搜索关键词,可以对所有TEXT类型的属性进行全局搜索,支持中文搜索,比如我们搜索下包含黑色字段的商品
FT.SEARCH productIdx '黑色'
-
指定字段检索
也可以指定搜索的字段,比如搜索副标题中带有红色字段的商品
FT.SEARCH productIdx '@subTitle:红色'
-
-
删除索引
FT.DROPINDEX index [DD]
说明:
- index:索引名称;
- DD:连带数据一起删除
例如:
FT.DROPINDEX productIdx DD
-
查看索引状态
FT.INFO index
说明:
- index:索引名称;
- DD:连带数据一起删除
例如:
FT.INFO productIdx
4.4.2. SQL 对照表
SQL 条件 | RediSearch 等价条件 | 备注 |
---|---|---|
WHERE x = ‘foo’ AND y = ‘bar’ | @x:foo @y:bar | 推荐对条件添加括号来减少歧异,例如(@x:foo) (@y:bar) |
WHERE x = ‘foo’ AND y != ‘bar’ | @x:foo -@y:bar | |
WHERE x = ‘foo’ OR y != ‘bar’ | (@x:foo) | (@y:bar) | |
WHERE x IN (‘foo’, ‘bar’, ‘oth wd’) | @x:(foo|bar|“oth wd”) | 使用双引号来明确短语 |
WHERE y = ‘foo’ AND x NOT IN (‘foo’, ‘bar’) | @y:foo -@x:(foo|bar) | |
WHERE num BETWEEN 10 AND 20 | @num:[10 20] | |
WHERE num >= 10 | @num:[10 +inf] | |
WHERE num > 10 | @num:[(10 +inf] | |
WHERE num < 10 | @num:[-inf (10] | |
WHERE num <= 10 | @num:[-inf 10] | |
WHERE num < 10 OR > 20 | @num:[-inf (10] | @num:[(20 +inf] |
4.4.3. 分词器
RedisSearch 在搜索的时候,会先将要搜索的内容进行分词处理,创建索引的时候也会分词(通过创建索引时的索引字段属性设定)。对于英文来说,分词比较简单,基本上空格和标点符号就可以,但是中文分词相对复杂一些,因为中文不能通过空格进行简单的分词。
熟悉 ElasticSearch 或 lucence / solr 等全文检索引擎的朋友都知道,中文分词器——比如 jieba,IK 的重要性,那么 RedisSearch 使用的分词器就是:friso。
friso 在 gitee 上可以找到:https://gitee.com/lionsoul/friso
关于 friso 的使用,这里不过多赘述,有兴趣的朋友可以去 gitee 的介绍中了解。
friso 的分词效果不如 jieba ,作者对这款分词器的维护也是零零星星,那为什么会介绍这款分词器呢?原因很简单,这个分词器是 RediSearch 内置的,所以使用方便。
其实对于中文分词,friso 的默认字典并不适合,因此需要自定义一个词库以供使用。
自定义的词库加载,可以通过 Redis 启动时的参数进行设定:
redis-server FRISOINI /home/friso.ini
friso.ini 文件可以从 gitee 上获取,只需要更改其中的字典路径即可:
friso.lex_dir = /home/vendors/dict/UTF-8/
friso_dict 文件夹内容结构为:
friso_dict
-vendors
-Makefile.am
-dict
-Makefile.am
-GBK
-UTF-8
-friso.lex.ini
-lex-xx.lex
其中,最后一行的 lex-xx.lex 即为自定义词库,文件名可以自己定义,比如人名、地名、专业术语等等,要在上述文件内容倒数第二行的 friso.lex.ini 中将自定义词库加入:
__LEX_CJK_WORDS__ :[
lex-main.lex;
lex-admin.lex;
lex-chars.lex;
lex-cn-mz.lex;
lex-cn-place.lex;
lex-company.lex;
lex-festival.lex;
lex-flname.lex;
lex-food.lex;
lex-lang.lex;
lex-nation.lex;
lex-net.lex;
lex-org.lex;
lex-touris.lex;
# add more here
lex-xx.lex;
]
除此以外,还可以通过 Redis 的配置文件对词库做相应的配置,有兴趣的同学可以去官网了解。
5. 程序实现
终于完成了冗长乏味的基础介绍,现在要介绍在 Java 中使用 RedisJson 和 RediSearch 了。下面都是干货。
5.1. 场景假设
要进行使用,就要假设一个使用场景,以方便理解使用技巧。
假设有需求需要实现以下功能:
A. 工程启动时,将全部营业员数据从 MySQL 加载入 Redis 中。
MySQL 中的表结构如下:
create table t_worker
(
id bigint auto_increment comment '主键id' primary key,
province_code varchar(16) null comment '省份编码',
city_code varchar(32) null comment '地市编码',
oa_no varchar(64) not null comment 'OA工号',
user_name varchar(128) null comment '姓名',
user_phone varchar(32) null comment '手机号',
remark varchar(128) null comment '备注',
consult_code varchar(4) default '0' not null comment '1:启用,0:禁用',
work_time date null comment '工作时间',
is_del int(1) default 0 null comment '是否删除 0.否 1.是',
create_user_id varchar(32) null comment '创建人',
create_time datetime default CURRENT_TIMESTAMP null comment '系统创建时间',
update_user_id varchar(32) null comment '修改人',
update_time datetime default CURRENT_TIMESTAMP null on update CURRENT_TIMESTAMP comment '系统更新时间'
)
comment '工号信息表';
B. 通过 RestAPI 访问服务,获取符合条件的营业员列表。
可供选择的输入条件如下:
- OA工号
- 省份编码
- 地市编码
- 渠道编码
- 姓名
- 手机号
5.2. 环境准备
由于是试验,搭建的是 Standalone 模式的单例 Redis,分配资源 4C / 8G。
数据库使用 MySQL 数据库。
5.3. 程序设计
5.3.1. 功能实现
将需求要点进行分解,找到对应的实现途径,以及需要使用的中间件。
- RestAPI 接口实现
首选使用 SpringBoot。
- 启动时加载任务
可以使用 SpringBoot 的 ApplicationRunner 接口实现启动时加载。
- 加载 MySQL 数据
使用 Druid + SpringData JPA (做例子,图方便,MyBatis 也可以)。
- 数据加载至 Redis
使用 Jedis 做数据加载。←这时可能有人会说,为什么不使用 SpringData Redis ?理由很简单,SpringData Redis 并不支持 RedisJSON 和 RedisSearch,而 Jedis 4.0+ 支持。
5.3.2. 程序增强
- 使用 lombok 提供 @Slf4j 与 @Data 支撑
- 使用 common-lang3 提供字符串处理工具类
- 使用 fastjson 提供 JSON 处理能力支撑
5.3.3. 最终 pom.xml
我们最终的技术栈:Springboot + Spring Data JPA + jedis + Druid + fastjson
pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.6.7</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>RediSearch</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>RediSearch</name>
<description>RediSearch</description>
<properties>
<java.version>11</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
</dependency>
<!-- Spring Data Jpa -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- Jedis -->
<dependency>
<groupId>redis.clients</groupId>
<artifactId>jedis</artifactId>
<version>4.0.1</version>
</dependency>
<!-- common-lang3 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<!-- MySQL Connector -->
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Druid -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>druid-spring-boot-starter</artifactId>
<version>1.1.14</version>
</dependency>
<!-- fastjson -->
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.76</version>
</dependency>
<!-- lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.apache.tomcat.embed</groupId>
<artifactId>tomcat-embed-core</artifactId>
<version>9.0.62</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>
5.3.4 开始搭建骨架与工具类
使用 Spring Initializr 初始化项目,添加文件,最终目录树结构:
# | 目录 | 说明 |
---|---|---|
1 | src | |
2 | └─ main | |
3 | ├─ java | |
4 | │ └─ com | |
5 | │ └─ example | |
6 | │ └─ redisearch | |
7 | │ ├─ RediSearchApplication.java | SpringBoot 启动程序 |
8 | │ ├─ SynRunner.java | 启动时运行程序 |
9 | │ ├─ bo | |
10 | │ │ └─ WorkerSearchReq.java | RestAPI 接口参数定义 |
11 | │ ├─ config | |
12 | │ │ ├─ JedisPoolConfig.java | Jedis 连接池配置加载 |
13 | │ │ └─ UnifiedJedisPoolAutoConfig.java | Jedis 连接自动配置加载 |
14 | │ ├─ constant | |
15 | │ │ └─ Constants.java | 常量定义 |
16 | │ ├─ controller | |
17 | │ │ └─ SearchController.java | RestAPI 接口 |
18 | │ ├─ entity | |
19 | │ │ └─ TWorkerEntity.java | 数据库查询实体定义 |
20 | │ ├─ redis | |
21 | │ │ ├─ JediSearch.java | RediSearch 访问封装工具类 |
22 | │ │ └─ JedisJson.java | RedisJson 访问封装工具类 |
23 | │ ├─ repository | |
24 | │ │ └─ WorkerRepository.java | 数据库查询持久层 |
25 | │ ├─ service | |
26 | │ │ ├─ LoadWorkerCacheService.java | 启动时加载数据库数据至 Redis 的服务接口定义 |
27 | │ │ ├─ SearchWorkerService.java | RestAPI 访问 RediSearch 的服务接口定义 |
28 | │ │ └─ impl | |
29 | │ │ ├─ LoadWorkerCacheServiceImpl.java | 启动时加载数据库数据至 Redis 的服务接口实现 |
30 | │ │ └─ SearchWorkerServiceImpl.java | RestAPI 访问 RediSearch 的服务接口实现 |
31 | │ └─ util | |
32 | │ └─ R.java | RestAPI 通用响应体工具类 |
33 | └─ resources | |
34 | └─ application.yml | SpringBoot 配置文件 |
其中,配置文件的定义如下:
application.yml
spring:
## 配置数据源
datasource:
## 数据库驱动
driver-class-name: com.mysql.cj.jdbc.Driver
## 数据库连接字符串
url: jdbc:mysql://127.0.0.1:3307/database?useUnicode=true&characterEncoding=utf8&serverTimezone=UTC&useSSL=false&allowPublicKeyRetrieval=true
## 用户名
username: test
## 密码
password: 123456
## Druid 连接池配置
druid:
## 初始化连接数
initial-size: 20
## 最小连接数
min-idle: 10
## 最大活动连接数
max-active: 20
## 连接最大等待时间(ms)
max-wait: 60000
## 数据库连接心跳验证
validation-query: select 1
## 获取连接时验证空闲连接是否可用
test-while-idle: true
## 获取连接时是否检测连接的可用性
test-on-borrow: false
## 返还连接时是否检测连接的可用性
test-on-return: false
## 连接池是否缓存游标
pool-prepared-statements: true
## 开启 JPA
jpa:
hibernate:
## 自动建表
ddl-auto: update
## 打印 SQL 语句
show-sql: true
## 配置 Redis
redis:
## 数据库
database: 1
## 连接地址
host: 127.0.0.1
## 连接端口号
port: 6379
## 密码
password:
## jedis 设置
jedis:
## 连接池设置
pool:
## 最大空闲连接
max-idle: 20
## 最小空闲连接
min-idle: 10
## 最大连接数
max-active: 20
## 最大等待时间
max-wait: 1
## 连接超时时间(ms)
timeout: 5000
下面捡主要的代码说明一下:
- Jedis 配置文件加载:
JedisPoolConfig.java
package com.example.redisearch.config;
import lombok.Data;
import org.apache.commons.pool2.impl.GenericObjectPoolConfig;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@ConfigurationProperties(prefix = "spring.redis.jedis.pool")
@Data
@Component
public class JedisPoolConfig extends GenericObjectPoolConfig {
private int maxIdle;
private int minIdle;
private int maxActive;
private int maxWait;
}
借用 Spring 框架的 GenericObjectPoolConfig ,重载后设置连接池配置,然后注入 UnifiedJedisPoolAutoConfig 中。
UnifiedJedisPoolAutoConfig.java
package com.example.redisearch.config;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import redis.clients.jedis.JedisPooled;
import redis.clients.jedis.UnifiedJedis;
@Data
@Configuration
@ConfigurationProperties(prefix = "spring.redis")
@NoArgsConstructor
public class UnifiedJedisPoolAutoConfig {
private String host;
private Integer port = 6379;
private Integer timeout;
private String password;
private Integer database = 0;
@Autowired
private JedisPoolConfig jedisPoolConfig;
@Bean
public UnifiedJedis unifiedJedis() {
return new JedisPooled(jedisPoolConfig, host, port, timeout, password, database);
}
}
使用 JedisPooled 获取 Jedis 连接。
JedisJson.java
package com.example.redisearch.redis;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import redis.clients.jedis.UnifiedJedis;
import redis.clients.jedis.json.Path;
@Slf4j
@Component
public class JedisJson {
@Autowired
private UnifiedJedis client;
/**
* 设置 RedisJSON 值
*
* @param key 键
* @param obj 值
*/
public <T> void set(String key, T obj) {
client.jsonSet(key, Path.ROOT_PATH, obj);
}
/**
* 获取 RedisJSON 值
*
* @param key 键
* @param clazz 反序列化类
* @return 值
*/
public <T> T get(String key, Class<T> clazz) {
return client.jsonGet(key, clazz, Path.ROOT_PATH);
}
}
提供对 RedisJSON 类型数据的设置与获取方法。与 JSON.SET、JSON.GET命令相对应。
对于 client#jsonSet 方法需要注意,它有多个重构方法,有部分方法并不会将传入的 JavaBean 做 GSON 序列化,因此会导致设置时发生 RuntimeException。
另外,如果对 value 设置为 null 值,将会报 IllegalArgumentException。
JediSearch.java
package com.example.redisearch.redis;
import com.alibaba.fastjson.JSON;
import com.example.redisearch.constant.Constants;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import redis.clients.jedis.UnifiedJedis;
import redis.clients.jedis.search.*;
import java.util.ArrayList;
import java.util.List;
@Slf4j
@Component
public class JediSearch {
@Autowired
private UnifiedJedis client;
/**
* 删除索引
*
* @param idxName 索引名称
*/
public void dropIndex(String idxName) {
try {
client.ftDropIndexDD(idxName);
} catch (Exception e) {
log.error(e.getMessage(), e);
}
}
/**
* 创建索引
*
* @param idxName 索引名称
* @param prefix 要索引的数据前缀
* @param schema 索引字段配置
*/
public void createIndex(String idxName, String prefix, Schema schema) {
IndexDefinition rule = new IndexDefinition(IndexDefinition.Type.JSON)
.setPrefixes(prefix)
.setLanguage(Constants.JEDI_SEARCH_LANG);
client.ftCreate(idxName,
IndexOptions.defaultOptions().setDefinition(rule),
schema);
}
/**
* 查询
* @param idxName 索引名称
* @param search 查询key
* @param sort 排序
* @param offset 从第offset条数据
* @param limit 取limit条数据
* @param clazz 反序列化类
* @return 查询结果列表
*/
public <T> List<T> query(String idxName, String search,
String sort, int offset, int limit, Class<T> clazz) {
Query q = new Query(search);
if (StringUtils.isNotBlank(sort)) {
q.setSortBy(sort, false);
}
q.setLanguage(Constants.JEDI_SEARCH_LANG);
q.limit(offset, limit);
SearchResult sr = client.ftSearch(idxName, q);
List<T> ret = new ArrayList<>();
sr.getDocuments().stream().forEach(doc -> {
doc.getProperties().forEach(x -> {
T obj = JSON.parseObject(x.getValue().toString(), clazz);
ret.add(obj);
});
});
return ret;
}
}
1)dropIndex 方法
client#ftDropIndexDD 方法相对应的 Redis 命令为 FT.DROPINDEX indexName DD,表示在删除索引时同时删除数据。
client#ftDropIndex 方法对应的 Redis 命令为 FT.DROPINDEX indexName,表示只删除索引,并不删除数据。
当没有索引可删除时,会发生 JedisConnectionException,所以需要捕捉。
2)createIndex 方法
推荐使用 RedisJSON 时,数据通过前缀(prefix)做分组,一个前缀相当于一个表的数据。
client#ftCreate 方法相对应的 Redis 命令为 FT.CREATE。
client#ftSearch 方法相对应的 Redis 命令为 FT.SEARCH。
构建好 Query 对象后即可调用 ftSearch 方法进行查询。
返回结果为 SearchResult 类型,该类型实例中除了有检索结果的数组外,还可以根据查询 Query 的设置,返回数据条数、高亮(HighLight)等属性字段,这里仅用到了检索结果。
对于检索结果数组,每条 JSON 数据都在 Document 对象中,因此需要遍历 Document 中的 Properties(Map 类型),并将 value 取出后再反序列化到实体中。
以上的内容都是与业务无关的运行环境、配置和工具类的代码。
接下来就是 5.1. 中的假设场景的需求实现代码了。
- 场景 A 的业务实现
SynRunner.java
package com.example.redisearch;
import com.example.redisearch.service.LoadWorkerCacheService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;
@Component
public class SynRunner implements ApplicationRunner {
@Autowired
private LoadWorkerCacheService service;
@Override
public void run(ApplicationArguments args) {
service.flushAll();
service.load();
}
}
实现 ApplicationRunner 接口,服务启动时自动运行,实施清空 Redis 中的索引和数据并重新同步。
LoadWorkerCacheImpl.java
package com.example.redisearch.service.impl;
import com.example.redisearch.entity.TWorkerEntity;
import com.example.redisearch.redis.JediSearch;
import com.example.redisearch.redis.JedisJson;
import com.example.redisearch.repository.WorkerRepository;
import com.example.redisearch.service.LoadWorkerCacheService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.stereotype.Service;
import redis.clients.jedis.search.FieldName;
import redis.clients.jedis.search.Schema;
import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import static com.example.redisearch.constant.Constants.*;
@Slf4j
@Service
public class LoadWorkerCacheServiceImpl implements LoadWorkerCacheService {
@Autowired
private WorkerRepository workerRepository;
@Autowired
private JedisJson jedisJson;
@Autowired
private JediSearch jediSearch;
private ExecutorService es = Executors.newFixedThreadPool(20);
private final CountDownLatch latch = new CountDownLatch(20);
@Override
public void flushAll() {
jediSearch.dropIndex(IDX_WORKER);
}
@Override
public void load() {
long startTime = System.currentTimeMillis();
log.info("Start sync...");
jediSearch.createIndex(IDX_WORKER, IDX_PREFIX_WORKER, createSchema());
long totalCount;
try {
Page<TWorkerEntity> workerList = workerRepository.findAll(PageRequest.of(0, 1000));
totalCount = workerList.getTotalElements();
cachePutting(workerList.getContent());
while (workerList.hasNext()) {
workerList = workerRepository.findAll(workerList.nextOrLastPageable());
cachePutting(workerList.getContent());
}
} finally {
es.shutdown();
}
try {
latch.await();
log.info("Sync complete! total count:{}, total cost: {}ms", totalCount, System.currentTimeMillis() - startTime);
} catch (InterruptedException e) {
}
}
private void cachePutting(List<TWorkerEntity> list) {
es.execute(() -> {
long startTime = System.currentTimeMillis();
try {
if (list.size() == 0) {
return;
}
list.stream().forEach(tWorkerEntity -> {
jedisJson.set(IDX_PREFIX_WORKER + tWorkerEntity.getId(), tWorkerEntity);
});
} finally {
latch.countDown();
log.info("Sync processed {}, cost: {}ms", list.size(), System.currentTimeMillis() - startTime);
}
});
}
private Schema createSchema() {
return new Schema()
.addField(new Schema.Field(FieldName.of("$.id").as("id"), Schema.FieldType.NUMERIC, true, false))
.addField(new Schema.TextField(FieldName.of("$.oaNo").as("oaNo"), 1d, true, true, false, null))
.addField(new Schema.TextField(FieldName.of("$.provinceCode").as("provinceCode"), 1d, false, true, false, null))
.addField(new Schema.TextField(FieldName.of("$.cityCode").as("cityCode"), 1d, false, true, false, null))
.addField(new Schema.TextField(FieldName.of("$.userName").as("userName"), 1d, false, true, false, null))
.addField(new Schema.TextField(FieldName.of("$.userPhone").as("userPhone"), 1d, false, true, false, null));
}
}
#flushAll 方法中的内容不过多赘述。
#load 方法的流程:
为了充分发挥节点性能,在启动时使用多线程将获取到的数据并行插入 Redis 中,这样可以大大缩短缓存从数据库加载的时间。
对于索引的创建,需要关注如下代码:
private Schema createSchema() {
return new Schema()
.addField(new Schema.Field(FieldName.of("$.id").as("id"), Schema.FieldType.NUMERIC, true, false))
.addField(new Schema.TextField(FieldName.of("$.oaNo").as("oaNo"), 1d, true, true, false, null))
.addField(new Schema.TextField(FieldName.of("$.provinceCode").as("provinceCode"), 1d, false, true, false, null))
.addField(new Schema.TextField(FieldName.of("$.cityCode").as("cityCode"), 1d, false, true, false, null))
.addField(new Schema.TextField(FieldName.of("$.userName").as("userName"), 1d, false, true, false, null))
.addField(new Schema.TextField(FieldName.of("$.userPhone").as("userPhone"), 1d, false, true, false, null));
}
虽然 Schema 对象具有 addTextField、addNumericField 等更方便的方法,但这些方法并不能提供设置别名(alias)的功能,同时,对于索引字段的属性也无法做细节设置,因此这里选择使用重新实例化 Field 的方法进行创建。
以上代码等价于 RediSearch 的以下命令:
FT.CREATE IDX_WORKER
ON JSON
PREFIX 1 'IDX_CW:'
Schema
'$.id' AS id NUMERIC SORTABLE
'$.oaNo' AS oaNo TEXT SORTABLE NOSTEM
'$.provinceCode' AS provinceCode TEXT NOSTEM
'$.cityCode' AS cityCode TEXT NOSTEM
'$.userName' AS userName TEXT NOSTEM
'$.userPhone' AS userPhone TEXT NOSTEM
TEXT 类型的字段默认搜索权重(weight)都是 1.0(double 类型),本次需求的查询为全字匹配,因此不需要做分词处理,所以指定 NOSTEM 属性,因此使用 Redis 命令建立该类型字段时也不用特意指定权重。
创建索引时指定了 PREFIX 为 “IDX_CW:”,因此在数据插入时,也需在 KEY 上设置 PREFIX,以保证索引创建时查找 PREFIX 不会失败。
注意:为什么要起别名?
原因就是在执行查询时,命令中不能使用 Path 路径,只能使用创建索引时的字段。
如果创建的索引不是基于 JSON 而是 HASH,比如对 HSET 的内容做索引,这时字段就可以直接使用 HSET 中的字段名了。
使用 JSON 就需要在创建索引时,对 JSON 字段(包含 Path)建立别名,这样才能保证插入的数据被正常记录到索引中。
- 场景 B 的业务实现
首先创建 RestAPI 接口 Controller:
SearchController.java
package com.example.redisearch.controller;
import com.example.redisearch.bo.WorkerSearchReq;
import com.example.redisearch.entity.TWorkerEntity;
import com.example.redisearch.service.SearchWorkerService;
import com.example.redisearch.util.R;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@Slf4j
@RestController
@RequestMapping("/search")
public class SearchController {
@Autowired
private SearchWorkerService service;
@PostMapping("/worker")
public R search(@RequestBody WorkerSearchReq req) {
long start = System.currentTimeMillis();
List<TWorkerEntity> ret = service.search(req);
log.info("/search/worker Executed in {}ms", System.currentTimeMillis() - start);
return R.success().add("data", ret);
}
}
定义 API 访问 URI 为:/search/worker
查询参数使用 WorkerSearchReq 接收。
然后就是 RediSearch 查询实现:
SearchWorkerServiceImpl.java
package com.example.redisearch.service.impl;
import com.example.redisearch.bo.WorkerSearchReq;
import com.example.redisearch.entity.TWorkerEntity;
import com.example.redisearch.redis.JediSearch;
import com.example.redisearch.service.SearchWorkerService;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.List;
import static com.example.redisearch.constant.Constants.IDX_WORKER;
@Slf4j
@Service
public class SearchWorkerServiceImpl implements SearchWorkerService {
@Autowired
private JediSearch jediSearch;
@Override
public List<TWorkerEntity> search(WorkerSearchReq req) {
StringBuilder builder = new StringBuilder();
if (StringUtils.isNotEmpty(req.getOaNo())) {
builder.append("@oaNo:").append(req.getOaNo()).append(" ");
}
if (StringUtils.isNotEmpty(req.getProvinceCode())) {
builder.append("@provinceCode:").append(req.getProvinceCode()).append(" ");
}
if (StringUtils.isNotEmpty(req.getCityCode())) {
builder.append("@cityCode:").append(req.getCityCode()).append(" ");
}
if (StringUtils.isNotEmpty(req.getUserName())) {
builder.append("@userName:").append(req.getUserName()).append(" ");
}
if (StringUtils.isNotEmpty(req.getUserPhone())) {
builder.append("@userPhone:").append(req.getUserPhone()).append(" ");
}
return jediSearch.query(
IDX_WORKER,
builder.toString(),
null,
0,
10,
TWorkerEntity.class
);
}
}
根据 WorkerSearchReq 中的定义,拼装查询字符串,然后执行查询。
下面是工具类的代码:
R.java
package com.example.redisearch.util;
import org.apache.commons.lang3.builder.ToStringBuilder;
import java.util.HashMap;
import java.util.Map;
public class R {
private int code;
private String msg;
private Map<String, Object> map = new HashMap<>();
public static R success() {
R r = new R();
r.code = 200;
r.msg = "请求成功";
return r;
}
public static R result(boolean result) {
return result ? success() : error();
}
public static R result(boolean result, String errorMessage) {
return result ? success() : error(errorMessage);
}
public static R success(String msg) {
R r = new R();
r.code = 200;
r.msg = msg;
return r;
}
public static R error() {
R r = new R();
r.code = 500;
r.msg = "请求失败";
return r;
}
public static R error(String msg) {
R r = new R();
r.code = 500;
r.msg = msg;
return r;
}
public R add(String key, Object value) {
map.put(key, value);
return this;
}
public int getCode() {
return code;
}
public void setCode(int code) {
this.code = code;
}
public String getMsg() {
return msg;
}
public void setMsg(String msg) {
this.msg = msg;
}
public Map<String, Object> getMap() {
return map;
}
public void setMap(Map<String, Object> map) {
this.map = map;
}
@Override
public String toString() {
return new ToStringBuilder(this)
.append("code", code)
.append("msg", msg)
.append("map", map)
.toString();
}
}