赞
踩
随着 Redis 4.0 的发布,Redis支持了可扩展模块(RedisMod)。而在2021年12月,基于Redis 6.x,RedisLabs 给出了两个重量级的第三方模块,RedisJSON 和 RediSearch。
RedisJSON实现了 JSON 数据交换标准 ECMA-404,作为原生数据类型。它允许从 Redis 中存储、更新和获取 JSON 值。
完全支持JSON标准 使用类似JSONPath的语法,用于在文档中选择元素 文档以二进制数据的形式存储在树结构中,允许快速访问子元素 所有JSON值类型都是原子操作
- 1
- 2
- 3
- 4
RediSearch一个高性能的全文搜索引擎。
简单,快速索引和搜索 数据存储在内存中,使用内存-有效的自定义数据结构 支持多种使用UTF-8编码的语言 文档和字段评分 结果的数值过滤 通过词干扩展查询 精确的短语搜索 按特定属性过滤结果(例如仅在标题中搜索“foo”) 强大的自动提示引擎 增量索引(不需要对索引进行优化和压缩) 支持用作存储在另一数据库中的文档的搜索索引 支持已经在Redis中存在的HASH对象作为文件的索引 扩展到多个Redis实例
- 1
- 2
- 3
- 4
- 5
- 6
- 7
- 8
- 9
- 10
- 11
- 12
- 13
抱着踩坑的想法,做了这次简单的试验。
官网给出了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 提供实际依据。
虽说 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。
镜像下载地址: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"
首先,为什么不直接介绍在代码中如何使用这两个 Mod,而是要讲命令行的执行方式呢?其实,熟悉 Redis 的伙伴肯定都知道,代码中执行的命令其实就是命令行工具中的命令。所以先学习命令行的内容,有助于更好地理解代码的执行。
工欲善其事,必先利其器。就像使用 MySQL 时,有一个可视化的 IDE 会更方便操作,对于数据展示、库操作都很便利。有人说使用 redis-cli 脚手架不是可以执行 Redis 命令吗?是可以执行,但是查看数据以及操作便利性都不如 IDE 更舒服和效率。
然而并不是所有 IDE 都支持 RedisJSON 与 RediSearch,下面推荐几款适用的 IDE 工具:
Redis Labs 提供的监控分析级别的 IDE 工具,可以查看节点 CPU 状态、命令执行并发量、存储量以及连接数等服务器端信息,并可以可视化操作数据、执行命令。命令输入时会有输入提示,并可以查看历史执行的命令并重新执行。同时,RedisInsight 还支持 rdb 的分析功能,之前分析 rdb 的存储分布,有点经验的都会用 rdb-tools 去分析,而这款工具直接集成了这个功能。在分析功能中的 Profiler 能监听一段时间内所有执行的 Redis 命令 ,Slowlog 能显示出执行比较慢的 Redis 命令。除此之外,这个软件还能执行批量操作。最重要的是,它是免费的,强烈推荐。
下载地址:https://redis.com/redis-enterprise/redis-insight/#insight-form
PS: 需要翻墙。。
老牌IDE工具,原名叫做 Redis Desktop Manager(RDM) 除了界面丑点还收费(试用期半个月),其他没有什么可挑剔的。
下载地址:https://resp.app/
很中规中矩的一款开源免费的redis可视化工具,基本的功能都有。有监控统计,支持暗黑主题,还支持集群的添加。缺点是没什么亮点,UI很简单,不支持stream数据类型。命令行模式也比较单一。value展示支持的类型也只有3种。
下载地址:https://github.com/qishibo/AnotherRedisDesktopManager/
收费软件,虽然跨平台,但是试用只有一天的时间。功能比较强大,支持了集群模式和哨兵模式,尤其在存储数据的展示上竟然能够支持 17 种格式的渲染。
下载地址:https://fastoredis.com/anonim_users_downloads
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的规则:
标量命令
设置 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]
除了存储 JSON 文档,还可以使用 RediSearch 模块进行索引,使用全文检索功能。
创建索引
FT.CREATE {index}
[ON {data_type}]
[PREFIX {count} {prefix} [{prefix} ..]
[LANGUAGE {default_lang}]
SCHEMA {identifier} [AS {attribute}]
[TEXT | NUMERIC | GEO | TAG ] [CASESENSITIVE]
[SORTABLE] [NOINDEX]] ...
说明:
例如:
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]
说明:
例如:
FT.DROPINDEX productIdx DD
查看索引状态
FT.INFO index
说明:
例如:
FT.INFO productIdx
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] |
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 的配置文件对词库做相应的配置,有兴趣的同学可以去官网了解。
终于完成了冗长乏味的基础介绍,现在要介绍在 Java 中使用 RedisJson 和 RediSearch 了。下面都是干货。
要进行使用,就要假设一个使用场景,以方便理解使用技巧。
假设有需求需要实现以下功能:
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 访问服务,获取符合条件的营业员列表。
可供选择的输入条件如下:
由于是试验,搭建的是 Standalone 模式的单例 Redis,分配资源 4C / 8G。
数据库使用 MySQL 数据库。
将需求要点进行分解,找到对应的实现途径,以及需要使用的中间件。
首选使用 SpringBoot。
可以使用 SpringBoot 的 ApplicationRunner 接口实现启动时加载。
使用 Druid + SpringData JPA (做例子,图方便,MyBatis 也可以)。
使用 Jedis 做数据加载。←这时可能有人会说,为什么不使用 SpringData Redis ?理由很简单,SpringData Redis 并不支持 RedisJSON 和 RedisSearch,而 Jedis 4.0+ 支持。
我们最终的技术栈: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>
使用 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
下面捡主要的代码说明一下:
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. 中的假设场景的需求实现代码了。
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)建立别名,这样才能保证插入的数据被正常记录到索引中。
首先创建 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(); } }
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。