RedisJSON 与 RediSearch

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.SETINDENT
  • 强制参数用尖括号括起来,例如 <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 初始化项目,添加文件,最终目录树结构:

#目录说明
1src
2└─ main
3├─ java
4│ └─ com
5│ └─ example
6│ └─ redisearch
7│ ├─ RediSearchApplication.javaSpringBoot 启动程序
8│ ├─ SynRunner.java启动时运行程序
9│ ├─ bo
10│ │ └─ WorkerSearchReq.javaRestAPI 接口参数定义
11│ ├─ config
12│ │ ├─ JedisPoolConfig.javaJedis 连接池配置加载
13│ │ └─ UnifiedJedisPoolAutoConfig.javaJedis 连接自动配置加载
14│ ├─ constant
15│ │ └─ Constants.java常量定义
16│ ├─ controller
17│ │ └─ SearchController.javaRestAPI 接口
18│ ├─ entity
19│ │ └─ TWorkerEntity.java数据库查询实体定义
20│ ├─ redis
21│ │ ├─ JediSearch.javaRediSearch 访问封装工具类
22│ │ └─ JedisJson.javaRedisJson 访问封装工具类
23│ ├─ repository
24│ │ └─ WorkerRepository.java数据库查询持久层
25│ ├─ service
26│ │ ├─ LoadWorkerCacheService.java启动时加载数据库数据至 Redis 的服务接口定义
27│ │ ├─ SearchWorkerService.javaRestAPI 访问 RediSearch 的服务接口定义
28│ │ └─ impl
29│ │ ├─ LoadWorkerCacheServiceImpl.java启动时加载数据库数据至 Redis 的服务接口实现
30│ │ └─ SearchWorkerServiceImpl.javaRestAPI 访问 RediSearch 的服务接口实现
31│ └─ util
32│ └─ R.javaRestAPI 通用响应体工具类
33└─ resources
34└─ application.ymlSpringBoot 配置文件

其中,配置文件的定义如下:

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.SETJSON.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 方法的流程:

Created with Raphaël 2.3.0 开始 开启线程池 创建索引 获取1000条数据 开启线程,遍历数据投入缓存 是否有后续数据? 获取后续1000条数据 开启线程,遍历数据投入缓存 关闭线程池 结束 yes no

为了充分发挥节点性能,在启动时使用多线程将获取到的数据并行插入 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();
    }
}
  • 1
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值