
在技术的海洋里遨游
暂时未有相关通用技术能力~
阿里云技能认证
详细说明在上一篇中,我们已经把Nacos的集群搭建好了,那么既然已经搭建好了,就要在咱们的项目中去使用。Nacos既可以做配置中心,也可以做注册中心。我们先来看看在项目中如何使用Nacos做配置中心。 Nacos配置中心 在项目中使用Nacos做配置中心还是比较简单的,我们先创建SpringBoot项目,然后引入nacos-config的jar包,具体如下: <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId> </dependency> 如果你不想使用SpringBoot默认的nacos-config版本,也可以指定版本号。 首先,我们进入到nacos的管理后台,第一步要创建命名空间,如图: 我们创建了user的服务配置,以后user相关的微服务都在这个命名空间中拉取配置。我们点击保存,命名空间的id会自动生成,这个id是十分重要的,我们要在项目中配置这个id。命名空间创建好以后,我们再创建配置文件, 在配置列表中,我们先选中刚才新建的命名空间:user服务配置。然后再点击新建,我的截图中已经把user-provider的配置文件创建好了。我们可以看一下如何新建,如图: 其中Data ID我们叫做user-provider,group我们用来区分本地环境、测试环境、生产环境。配置格式我们选择yaml,内容我们先配置一个username看看能不能生效。 然后在resources目录下创建bootstrap.yml,这个bootstrap.yml和application.yml是不一样的,它优先加载于application.yml,大家一定要注意他们的区别。我们要在bootstrap.yml文件中,要配置nacos的地址、命名空间、文件名等信息,具体如下: spring: cloud: nacos: server-addr: nacos-host:80 config: file-extension: yml name: user-provider group: ${spring.profiles.active} namespace: e5aebd28-1c15-4991-a36e-0865bb5af930 application: name: user-provider spring.application.name,这个不用说了,就是你应用的名称,我们叫做user-provider,用户服务的提供者。 再看上面的部分server-addr,这个是nacos的地址,我们配置为nacos-host:80。其中nacos-host需要配置host,指向nacos的ip,而端口80也是需要指定的,如果不指定端口,会默认8848端口。 再看config的部分,file-extension,文件的扩展名,这里我们使用yml,相应的,在nacos配置中心中,配置格式选择yaml。 config.name对应着nacos管理后台的Data ID。 group,在这里是分组,我们用作区分不同环境的标识,通过项目启动时传入的参数${spring.profiles.active}获得。 namespace,命名空间,这里要填写命名空间的id,这个id在nacos后台中获取。这里我们填写的是user配置服务的命名空间id。 到这里,在项目中使用nacos做配置中心就搭建好了。我们在项目当中写个属性类,测试一下,看看能不能取到值。 @RefreshScope @Setter@Getter @Configuration public class DatabaseConfig { @Value("${username}") private String username; @Value("${server.port}") private String port; } 我们写了个DatabaseConfig类,先注意一下类上面的注解,@RefreshScope这个注解可以使我们在nacos管理后台修改配置以后,项目不用重启,就可以更改变量的值。 @Setter@Getter这个是Lombok的注解,可以省去setget方法。 @Configuration标识这个类是一个配置类,项目启动时会实例化。 在类里边,我们定义了两个变量,username和port,两个变量上面的注解@Value,可以取到对应的,属性的值。${username}这个我们在nacos管理后台已经设置了,${server.port}这个我们可以通过项目启动参数获取到,一会带着大家试一下。 我们在写个controller,把变量的值打印出来,如下: @RestController @RequestMapping("user") public class UserController { @Autowired private DatabaseConfig databaseConfig; @RequestMapping("config") public String config() { return databaseConfig.getUsername()+":"+databaseConfig.getPort(); } } 我们将username和port两个变量打印出来。好了,程序相关的部分就都写好了,然后,我们添加项目启动参数,如图: spring.profiles.active=local,这个参数很重要,项目要用这个local值去nacos管理后台找对应的分组group是local的配置。 server.port=8080,这个是项目的启动端口,同时,我们也将这个值打印出来了。 好了,我们现在启动项目,并且在浏览器中访问我们刚才写的controller,浏览器返回的结果如下: user:8080 user,是我们在nacos中配置的值,8080是我们添加的启动参数。 返回结果没有问题。然后我们再去nacos管理后台将user改成tom,项目不重启,再看看返回的结果,如图: 确认发布以后,我们刷新一下浏览器, tom:8080 我们并没有重启项目,但是返回的结果变成了tom。怎么样?使用nacos做配置中心还是比较好用的吧~ Nacos注册中心 通常情况下,我们一般会选择Zookeeper、Eureka做注册中心,其实Nacos也是可以做注册中心的。既然我们项目使用了Nacos做配置中心,那么使用Nacos做注册中心也是非常好的选择。下面让我们看看在项目中如何使用Nacos做注册中心。 首先,还是在项目中引入Nacos注册中心的jar包,如下: <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId> </dependency> 我们引入了nacos-discovery的jar包,如果您不想使用默认的版本,可以指定需要引入的版本。 然后,我们就要配置Nacos注册中心的地址了,通常情况下,我们是在application.yml文件中进行配置。但是,这次我们使用了Nacos做配置中心,就可以在Nacos的管理后台进行配置了,如下: username: tom spring: cloud: nacos: discovery: server-addr: nacos-host:80 namespace: e5aebd28-1c15-4991-a36e-0865bb5af930 group: ${spring.profiles.active} 我们需要在nacos.discovery节点下进行配置,server-addr,这个属性和前面的配置是一样的,nacos-host是配置了HOST,指向Nacos的ip,80端口也是需要指定的,默认端口是8848。 namespace,命名空间,我们复用前面的就可以了。 group,同样,我们用来区分不同的环境,它的值也是从启动参数中获取。 最后,我们在项目的启动类中添加@EnableDiscoveryClient的注解,如下: @SpringBootApplication @EnableDiscoveryClient public class UserProviderApplication { public static void main(String[] args) { SpringApplication.run(UserProviderApplication.class, args); } } 好了,到这里,服务提供者的配置以及代码上的改动都调整完毕了,我们启动一下项目,然后去Nacos管理后台看看服务是否已经注册到Nacos当中。 我们在Nacos管理后台选择服务列表菜单,可以看到我们启动的项目已经注册到nacos中了。如果我们再启动一个服务提供者会是什么样子呢?我们刚启动的项目指定的端口是8080,我们再启动一个项目,将端口指定为8081,看看服务列表是什么样子。 我们看到实例数由原来的1变为了2。说明我们的user-provider服务有了两个,我们再点右边的详情看一下, 服务的详情以及具体的实例都给我们列了出来,我们还可以编辑和下线具体的实例,这个我们后面再介绍。 好了,到这里,服务提供者的就搭建好了,我们分别访问两个服务提供者的具体连接得到的结果如下: # http://localhost:8080/user/config tom:8080 # http://localhost:8081/user/config tom:8081 接下来,我们再看看服务的消费者如何搭建。我们新建一个SpringBoot项目user-consumer,这个项目我们同样使用Nacos作为配置中心,而且要从Nacos这个注册中心获取服务列表,所以引入jar包如下: <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId> </dependency> <dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId> </dependency> 然后在bootstrap.yml中,填写nacos配置中心的相关配置,这个和前面的配置的差不多的,只需要改一下相应的文件名称就可以了。 spring: cloud: nacos: server-addr: nacos-host:80 config: file-extension: yml name: user-consumer group: ${spring.profiles.active} namespace: e5aebd28-1c15-4991-a36e-0865bb5af930 application: name: user-consumer 注意config.name,我们改为了user-consumer。并且应用的名称改为了user-consumer。 然后,我们再去Nacos管理后台添加user-consumer的配置,如图: DataID就是我们配置的user-consumer,group我们同样配置为local,标识着本地。 具体的配置内容是nacos服务的地址,如图。这样我们的服务消费者项目user-consumer就可以从nacos配置中心获取到注册中心的地址和命名空间,并且可以从命名空间获取服务的地址。 配置的部分就到这里了,然后再去启动类中,添加@EnableDiscoveryClient注解,如下: @SpringBootApplication @EnableDiscoveryClient public class UserConsumerApplication { public static void main(String[] args) { SpringApplication.run(UserConsumerApplication.class, args); } } 最后,我们写个Controller,从Nacos获取服务提供者的地址,并调用服务提供者,如下: @RestController @RequestMapping("user") public class UserController { @Autowired private LoadBalancerClient loadBalancerClient; @RequestMapping("consumer") public String consumer() { ServiceInstance provider = loadBalancerClient.choose("user-provider"); String url = "http://"+provider.getHost()+":"+provider.getPort()+"/user/config"; RestTemplate restTemp = new RestTemplate(); String result = restTemp.getForObject(url, String.class); return result; } } 这个是SpringCloud Alibaba官网给出的调用示例,使用的是LoadBalancerClient,我们先将其注入。 在方法里边,我们调用choose方法,选择user-provider服务,这个是我们服务提供者的名称,在nacos管理后台的服务列表中可以查看到的,这个方法会返回具体的服务实例,我们的服务实例有2个,分别是8080端口和8081端口的两个服务,在这里,默认是轮询的负载均衡策略。 选择了具体的服务实例,我们就来拼装请求地址,从服务实例中获取地址和端口。 最后使用RestTemplate完成调用。 最后,我们配置项目启动,设置spring.profiles.active=local,并且指定端口为9090,如图: 最后,我们启动项目,访问http://localhost:9090/user/consumer,访问结果如下: tom:8080 很明显,我们调用到了8080端口的服务提供者,我们再刷新一下,看看返回结果, tom:8081 这次又调用到了8081端口的服务提供者,我们多次刷新,发现它会在8080和8081之间切换,这说明我们的负载均衡策略应该是轮询。 使用Feign完成服务的调用 上面的例子中,我们使用的是LoadBalancerClient完成服务的调用,接下来,我们分别看看Feign和Ribbon怎么调用服务。我们先来看看Feign,要使用Feign完成服务的调用,先要引入Feign的jar包,如下: <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-openfeign</artifactId> <version>2.2.2.RELEASE</version> </dependency> 然后再启动类上添加@EnableFeignClients的注解,如下: @SpringBootApplication @EnableDiscoveryClient @EnableFeignClients public class UserConsumerApplication { public static void main(String[] args) { SpringApplication.run(UserConsumerApplication.class, args); } } 接下来,我们写一个interface来完成feign的服务调用和熔断,如下: @FeignClient(name = "user-provider",fallback = UserServiceFallback.class) public interface UserService { @RequestMapping("/user/config") String config(); } 我们写了一个UserService的接口,在接口上添加@FeignClient的注解,注解里有两个属性,name指定服务的名称,这里我们指定为user-provider,这是我们前面服务提供者的名称,fallback指定发生熔断时,调用的类。当我们的服务提供者不能正常提供服务时,就会触发熔断机制,会调用熔断服务类的逻辑,返回结果。 在接口中,我们写了一个config()方法,方法上添加@RequestMapping的注解,并配置具体的路径。这样,我们在调用服务的时候,通过Feign调用到具体的服务提供者了。 我们再来看看熔断实现类UserServiceFallback的具体内容,如下: @Service public class UserServiceFallback implements UserService { @Override public String config() { return "user-fallback"; } } 首先,它是UserService接口,也就是Feign接口的实现类,然后实现接口中的方法,我们直接返回user-fallback字符串。 Feign的接口和熔断的实现类都写好了,但是这还不算完,要使熔断生效,还要添加额外的配置,我们直接去nacos管理后台去配置,进入到user-consumer的配置中,添加如下配置: feign: hystrix: enabled: true 这个就是feign的熔断开关,默认是关闭的,现在打开。 最后,我们在controller中,调用UserService接口,如下: @Autowired private UserService userService; @RequestMapping("consumer-feign") public String userService() { String result = userService.config(); return result; } 将UserService,注入进来,然后直接调用方法即可。 我们访问一下http://localhost:9090/user/consumer-feign,看看返回的结果。如下: tom:8080 tom:8081 返回的结果和前面是一样的,我们不断的刷新,它也会在8080和8081之间轮询。 使用Ribbon完成服务的调用 同样,我们也可以使用Ribbon完成服务的调用,Ribbon和RestTemplate在内部是紧密结合的。我们只需要将RestTemplate实例化,并添加@LoadBalanced注解就可以了,如下: @Bean @LoadBalanced public RestTemplate restTemplate(){ return new RestTemplate(); } 然后在,controller中,我们使用这个实例化好的RestTemplate,就可以了,具体实现如下: @Autowired private RestTemplate restTemplate; @RequestMapping("consumer-ribbon") public String consumerribbon() { String url = "http://user-provider/user/config"; String result = restTemplate.getForObject(url, String.class); return result; } 我们将restTemplate注入进来。 在具体方法中,url的地址,我们直接写服务名称user-provider加路径的方式,大家可以参照第一种调用方式,看看区别。 我们重启项目,访问http://localhost:9090/user/consumer-ribbon,结果如下: tom:8080 tom:8081 返回的结果和前面是一样的,我们不断的刷新,它也会在8080和8081之间轮询。 使用Nacos权重负载均衡 三种服务的调用方法都给大家介绍完了,但是,他们的负载均衡策略都是轮询,这有点不符合我们的要求,我们进入到Nacos的管理后台,调节一下服务的权重,如图: 我们将8080接口的服务权重由1改为10,点击确认,再多次刷新一下我们的访问地址,发现服务的调用还是在8080和8081之间轮询。这是什么情况?这里就不和大家卖关子了,这是因为LoadBalancerClient、Feign和Ribbon3种方式,它们的底层都是使用Ribbon做负载均衡的,而Ribbon负载均衡默认使用的策略是ZoneAvoidanceRule,我们要修改Ribbon的默认策略,让它使用nacos的权重,那么该如何配置呢? 我们进入到nacos管理后台,修改user-consumer的配置,添加如下配置: user-provider: ribbon: NFLoadBalancerRuleClassName: com.alibaba.cloud.nacos.ribbon.NacosRule user-provider是我们服务的名称,你配置哪个服务的负载均衡策略,就写哪个服务的名字。 后面ribbon.NFLoadBalancerRuleClassName需要配置负载均衡策略的具体实现,这个实现类要实现IRule接口,在这里,我们指定实现类为com.alibaba.cloud.nacos.ribbon.NacosRule。这是nacos的负载均衡规则,它是实现了IRule接口的。 我们重启项目,调用我们之前的3个链接,调用哪个效果都是一样的,我们发现返回tom:8080的次数明显增多,说明Nacos服务的权重配置生效了。小伙伴们还可以将权重改成其他的值试一下。这里就不给大家演示了。 总结 Nacos的配置中心和服务注册中心就给大家介绍完了,还是很好用的,这为我们搭建微服务提供了另外一种选择。当然消费端的调用还是首推Feign+hystrix熔断的,功能很强大,小伙伴们在项目中多实践吧~
一提到注册中心,大家往往想到Zookeeper、或者Eureka。今天我们看看阿里的一款配置中心+注册中心的中间件——Nacos。有了它以后,我们的项目中的配置就可以统一从Nacos中获取了,而且Spring Cloud的提供者和消费者还可以使用它做注册发现中心。 在搭建Nacos的时候,为了保证高可用,我们要采用的集群的方式搭建。 首先,我们要在数据库中创建一些Nacos的表,Sql文件可以点击下面的链接下载, Sql文件 然后,我们再下载Nacos的压缩包,连接如下: tar.gz包 将下载好的压缩包分别上传到3个服务器上,在我们这里3台机器分别是192.168.73.141,192.168.73.142,192.168.73.143,然后进行解压, tar -zxvf nacos-server-1.3.2.tar.gz 然后,我们进入到conf目录,修改配置,如下: vim application.properties #*************** Config Module Related Configurations ***************# ### 数据源指定MySQL spring.datasource.platform=mysql ### 数据库的数量: db.num=1 ### 数据库连接 IP 端口 数据库名称需要改成自己的 db.url.0=jdbc:mysql://192.168.73.150:3306/nacos_config?characterEncoding=utf8&connectTimeout=1000&socketTimeout=3000&autoReconnect=true&useUnicode=true&useSSL=false&serverTimezone=UTC ### 用户名 db.user=user ### 密码 db.password=youdontknow 这里我们主要修改数据库的配置,然后再看看集群的配置,如下: ### 将示例文件改为集群配置文件 cp cluster.conf.example cluster.conf vim cluster.conf ### 将3个机器的IP和端口写到集群配置文件中 192.168.73.141:8848 192.168.73.142:8848 192.168.73.143:8848 好了,到这里,Nacos的集群就配置好了,简单吧,然后我们分别启动3台机器上的Nacos,进入到Nacos的主目录,执行如下命令, ./bin/start.sh ### 查看每台机器上的启动日志 tail -500f logs/start.log 我们可以看到Nacos启动成功的日志。好了,到这里Nacos集群就搭建完成了。 剩下的事情就是在这3台机器之间做负载均衡了,方案也有很多,可以使用Nginx、HAProxy、Keepalived+LVS等。这里就不给大家做过多的介绍了,比较简单的,我们可以使用Nginx,然后配置HOST进行访问。
今天闲来无事,打算搭建一个MySQL的高可用架构,采用的是MySQL的主主结构,再外加Keepalived,对外统一提供虚IP。先来说说背景吧,现在的项目为了高可用性,都是避免单节点的存在的,比如,我们的应用程序,都是部署多个节点,通过Nginx做负载均衡,某个节点出现问题,并不会影响整体应用。那么数据库层如何搭建高可用的架构呢?今天我们就来看看。 整体架构 MySQL采用主主结构,我们使用两台机器就够了,然后再这两台机器上再安装Keepalived,使用vrrp技术,虚拟出一个IP。两台机器如下: 192.168.73.141:MySQL(主1)、Keepalived(MASTER) 192.168.73.142:MySQL(主2)、Keepalived(BACKUP) 192.168.73.150:虚IP 整体架构图如下: MySQL主主搭建 我们分别在两台机器上安装MySQL,使用yum方式安装,首先从MySQL官网下载rpm包,选择对应的系统,在这里,我们选择CentOS7的prm包,mysql80-community-release-el7-3.noarch.rpm。然后将rpm文件分别上传到两台机器上,接下来我们就是用yum来安装MySQL。 在192.168.73.141(主1)执行如下命令, # 使用yum安装rpm包 yum install mysql80-community-release-el7-3.noarch.rpm # 安装MySQL社区版 时间较长 耐心等待 yum install mysql-community-server #启动MySQL服务 service mysqld start 到这里,MySQL就安装完成,并且正常启动了。然后,我们用root账号登录MySQL,并创建一个可用的账号。 # 从MySQL的日志中 找到root账号的临时密码 grep 'temporary password' /var/log/mysqld.log # 使用root账号登录 输入临时密码 登录成功 mysql -uroot -p # 修改root账号的密码 使用MYSQL_NATIVE_PASSWORD的加密方式 这种方式大多数客户端都可以连接 ALTER USER 'root'@'localhost' IDENTIFIED WITH MYSQL_NATIVE_PASSWORD BY 'MyNewPass4!'; # 创建MySQL账号 CREATE USER 'USER'@'%' IDENTIFIED WITH MYSQL_NATIVE_PASSWORD BY 'USER_PWD'; # 对USER账号授权 GRANT ALL ON *.* TO 'USER'@'%'; # 刷新权限 FLUSH PRIVILEGES; 好了,到这里,在192.168.73.141上安装MySQL成功,并且创建了USER账户,我们可以使用NAVICAT等客户端连接。 在192.168.73.142(主2)上也执行上面的命令,这样我们在两台机器上都安装了MySQL。接下来,我们就要配置MySQL的主主结构了。 首先,我们修改192.168.73.141(主1)上的my.cnf文件。 vim /etc/my.cnf datadir=/var/lib/mysql socket=/var/lib/mysql/mysql.sock log-error=/var/log/mysqld.log pid-file=/var/run/mysqld/mysqld.pid # 配置server-id 每个MySQL实例的server-id都不能相同 server-id=1 # MySQL的日志文件的名字 log-bin=mysql_master # 作为从库时 更新操作是否写入日志 on:写入 其他数据库以此数据库做主库时才能进行同步 log-slave-updates=on # MySQL系统库的数据不需要同步 我们这里写了3个 更加保险 # 同步数据时忽略一下数据库 但是必须在使用use db的情况下才会忽略;如果没有使用use db 比如create user 数据还是会同步的 replicate-ignore-db=information_schema replicate-ignore-db=mysql replicate-ignore-db=performance_schema replicate-ignore-db=sys # 使用通配符忽略MySQL系统库的表 这样在create user时也不会进行同步了 replicate_wild_ignore_table=information_schema.% replicate_wild_ignore_table=mysql.% replicate_wild_ignore_table=performance_schema.% replicate_wild_ignore_table=sys.% # MySQL系统库的日志不计入binlog 这样更加保险了 binlog-ignore-db=information_schema binlog-ignore-db=mysql binlog-ignore-db=performance_schema binlog-ignore-db=sys 在192.168.73.142(主2)上也修改my.cnf文件,我们直接复制过去,只需要修改其中的两个地方,如下: # 配置server-id=2 server-id=2 # MySQL的日志文件的名字 不改名字也可以 这里主要为了区分 log-bin=mysql_slave 配置文件都已经修改好了,我们分别在192.168.73.141(主1)和192.168.73.142(主2)上重启MySQL服务, service mysqld restart 下面我们就要配置主从了,其实主主模式就是配置两个主从,先配置192.168.73.141(主1)->192.168.73.142(主2)的主从,然后再反过来配置192.168.73.142(主2)->192.168.73.141(主1)的主从,这样主主的模式就配置好了。 我们先来配置192.168.73.141(主1)->192.168.73.142(主2)的主从 先登录192.168.73.141(主1)的数据库,并执行如下命令: # 创建备份的账号 使用MYSQL_NATIVE_PASSWORD的方式加密 mysql> CREATE USER 'repl_master'@'%' IDENTIFIED WITH MYSQL_NATIVE_PASSWORD BY 'password'; # 对repl_master授予备份的权限 mysql> GRANT REPLICATION SLAVE ON *.* TO 'repl_master'@'%'; # 刷新权限 mysql> FLUSH PRIVILEGES; # 查看MySQL主节点的状态 mysql> SHOW MASTER STATUS; +-------------------+---------+--------------+---------------------------------------------+------------------+ | File | Position | Binlog_Do_DB | Binlog_Ignore_DB | Executed_Gtid_Set | +-------------------+---------+--------------+---------------------------------------------+------------------+ | mysql_master.000001 | 516 | | information_schema,mysql,performance_schema,sys | | +-------------------+---------+--------------+---------------------------------------------+------------------+ 1 row in set 我们要记住binlog文件的名字,也就是mysql_master.000001,和位置,也就是516。 然后,我们再登录到192.168.73.142(主2)的数据库,执行如下命令: mysql> CHANGE MASTER TO # MySQL主的IP -> MASTER_HOST='192.168.73.141', # MySQL主的端口 -> MASTER_PORT=3306 # MySQL主的备份账号 -> MASTER_USER='repl_master', # MySQL主的备份账号密码 -> MASTER_PASSWORD='password', # 日志文件 通过show master status得到的 -> MASTER_LOG_FILE='mysql_master.000001', # 日志文件位置 通过show master status得到的 -> MASTER_LOG_POS=516; # 开启从库 mysql> START SLAVE; # 查看从库的状态 mysql> SHOW SLAVE STATUS; 这样,192.168.73.141(主1)->192.168.73.142(主2)的主从就搭建好了。然后,我们再反过来,搭建192.168.73.142(主2)->192.168.73.141(主1)的主从。 先登录192.168.73.142(主2)的数据库,执行如下命令: # 创建备份的账号 使用MYSQL_NATIVE_PASSWORD的方式加密 mysql> CREATE USER 'repl_slave'@'%' IDENTIFIED WITH MYSQL_NATIVE_PASSWORD BY 'password'; # 对repl_slave授予备份的权限 mysql> GRANT REPLICATION SLAVE ON *.* TO 'repl_slave'@'%'; # 刷新权限 mysql> FLUSH PRIVILEGES; # 查看MySQL主节点的状态 mysql> SHOW MASTER STATUS; +-------------------+---------+--------------+---------------------------------------------+------------------+ | File | Position | Binlog_Do_DB | Binlog_Ignore_DB | Executed_Gtid_Set | +-------------------+---------+--------------+---------------------------------------------+------------------+ | mysql_slave.000001 | 379 | | information_schema,mysql,performance_schema,sys | | +-------------------+---------+--------------+---------------------------------------------+------------------+ 1 row in set 再登录到192.168.73.141(主1)的数据库,执行如下命令: mysql> CHANGE MASTER TO # MySQL主的IP -> MASTER_HOST='192.168.73.142', # MySQL主的端口 -> MASTER_PORT=3306 # MySQL主的备份账号 -> MASTER_USER='repl_slave', # MySQL主的备份账号密码 -> MASTER_PASSWORD='password', # 日志文件 通过show master status得到的 -> MASTER_LOG_FILE='mysql_slave.000001', # 日志文件位置 通过show master status得到的 -> MASTER_LOG_POS=379; # 开启从库 mysql> START SLAVE; # 查看从库的状态 mysql> SHOW SLAVE STATUS; 这样,192.168.73.142(主2)->192.168.73.141(主1)的主从也搭建好了。我们可以使用navicat分别连接192.168.73.141(主1)和192.168.73.142(主2),并执行建表、插入语句,验证一下主主同步是否成功,这里就不给大家演示了。 Keepalived高可用 MySQL主主结构已经搭建好了,无论从哪个MySQL插入数据,都会同步到另外一个MySQL。虽然有了MySQL主主结构,但是不能保证高可用,比如,我们的应用程序连接的是192.168.73.141(主1),倘若192.168.73.141(主1)的MySQL挂掉了,我们的应用程序并不能自动的切换到192.168.73.142(主2),我们的应用程序也是不可用的状态。要做到这一点,就要借助于Keepalived。 Keepalived有两个主要的功能: 提供虚IP,实现双机热备 通过LVS,实现负载均衡 我们这里使用Keepalived,只需要使用其中的一个功能,提供虚IP,实现双机热备。我们需要在192.168.73.141(主1)和192.168.73.142(主2)上都安装Keepalived,执行命令如下: yum install keepalived 我们直接使用yum进行安装。安装完之后,编辑keepalived的配置文件,首先编辑192.168.73.141(主1)上的配置文件,如下: vim /etc/keepalived/keepalived.conf # 全局配置 不用动 只需注释掉vrrp_strict global_defs { notification_email { acassen@firewall.loc failover@firewall.loc sysadmin@firewall.loc } notification_email_from Alexandre.Cassen@firewall.loc smtp_server 192.168.200.1 smtp_connect_timeout 30 router_id LVS_DEVEL vrrp_skip_check_adv_addr #必须注释掉 否则报错 #vrrp_strict vrrp_garp_interval 0 vrrp_gna_interval 0 } # 检查mysql服务是否存活的脚本 vrrp_script chk_mysql { script "/usr/bin/killall -0 mysqld" } # vrrp配置虚IP vrrp_instance VI_1 { # 状态:MASTER 另外一台机器为BACKUP state MASTER # 绑定的网卡 interface ens33 # 虚拟路由id 两台机器需保持一致 virtual_router_id 51 # 优先级 MASTER的值要大于BACKUP priority 100 advert_int 1 authentication { auth_type PASS auth_pass 1111 } # 虚拟IP地址 两台keepalived需要一致 virtual_ipaddress { 192.168.73.150 } # 检查脚本 vrrp_script的名字 track_script { chk_mysql } } ###后边的virtual_server全部注释掉 它是和LVS做负载均衡用的 这里用不到 ### 再编辑192.168.73.142(主2)上的配置文件,只需要将state MASTER改为state BACKUP,如下: state BACKUP 通过keepalived的配置,我们对外提供192.168.73.150的IP,这个IP实际指向是192.168.73.141(主1),因为它的state是MASTER。当keepalived检测到192.168.73.141(主1)上的MySQL不可用时,会自动切换到192.168.73.142(主2)。对于外部用户是无感知的,因为外部统一使用的是192.168.73.150。 我们再来看看检测的脚本/usr/bin/killall -0 mysqld,killall命令不是系统自带的,需要安装,我们还是使用yum来安装,如下: # 先查询一下killall yum search killall #找到了psmisc.x86_64 Loading mirror speeds from cached hostfile ===============Matched: killall ================================ psmisc.x86_64 : Utilities for managing processes on your system # 安装psmisc yum install psmisc 这样我们就可以使用killall命令了。killall -0 并不是杀掉进程,而是检查进程是否存在,如果存在则返回0,如果不存在则返回1。当返回1时,keepalived就会切换主备状态。 好了,killall也介绍完了,我们在两台机器上启动keepalived,如下: # 启动keepalived service keepalived start 然后,我们在192.168.73.141(主1)上查看一下IP是否有192.168.73.150,如下: ip addr 1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 inet 127.0.0.1/8 scope host lo valid_lft forever preferred_lft forever inet6 ::1/128 scope host valid_lft forever preferred_lft forever 2: ens33: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP group default qlen 1000 link/ether 00:0c:29:57:8c:cd brd ff:ff:ff:ff:ff:ff inet 192.168.73.141/24 brd 192.168.73.255 scope global noprefixroute ens33 valid_lft forever preferred_lft forever inet 192.168.73.150/32 scope global ens33 # 我们看到了192.168.73.150 valid_lft forever preferred_lft forever inet6 fe80::720b:92b0:7f78:57ed/64 scope link noprefixroute valid_lft forever preferred_lft forever 到这里,keepalived的配置就完成了,我们通过navicat连接192.168.73.150,可以正常的连接数据库,实际上它连接的是192.168.73.141的数据库,我们操作数据库也是正常的。 然后,我们停掉192.168.73.141(主1)上的MySQL服务, service mysqld stop # 再用 ip addr查看一下 ip addr 1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 inet 127.0.0.1/8 scope host lo valid_lft forever preferred_lft forever inet6 ::1/128 scope host valid_lft forever preferred_lft forever 2: ens33: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc pfifo_fast state UP group default qlen 1000 link/ether 00:0c:29:57:8c:cd brd ff:ff:ff:ff:ff:ff inet 192.168.73.141/24 brd 192.168.73.255 scope global noprefixroute ens33 valid_lft forever preferred_lft forever inet6 fe80::720b:92b0:7f78:57ed/64 scope link noprefixroute valid_lft forever preferred_lft forever 192.168.73.150的IP找不到了,我们再去192.168.73.142(主2)上去查看,可以发现192.168.73.150的IP。我们在navicat上操作数据库,是可以正常使用的。但这时实际连接的是192.168.73.142(主2)的数据库。我们是没有感知的。如果我们把192.168.73.141(主1)上的mysql服务再启动起来,192.168.73.150还会切换到192.168.73.141(主1)。 总结 我们通过MySQL主主结构+keepalived双机热备实现了MySQL的高可用,我们应用程序可以连接虚IP,具体连接的实际MySQL,不需要我们关心。如果我们再做读写分离的话,可以将MySQL(主2)作为主,配置数据库的主从关系。这时,虚IP连接的是MySQL(主1),MySQL(主1)将数据同步到MySQL(主2),然后MySQL(主2)再将数据同步到其他从库。如果MySQL(主1)挂掉,虚IP指向MySQL(主2),MySQL(主2)再将数据同步到其他从库。
WebSocket,干什么用的?我们有了HTTP,为什么还要用WebSocket?很多同学都会有这样的疑问。我们先来看一个场景,大家的手机里都有微信,在微信中,只要有新的消息,这个联系人的前面就会有一个红点,这个需求要怎么实现呢?大家思考3秒钟。哈哈,最简单,最笨的方法就行客户端轮询,在微信的客户端每隔一段时间(比如:1s或者2s),向服务端发送一个请求,查询是否有新的消息,如果有消息就显示红点。这种方法是不是太笨了呢?每次都要客户端去发起请求,难道就不能从服务端发起请求吗?这样客户端不就省事了吗。再看看股票软件,每个股票的当前价格都是实时的,这我们怎么做,每个一秒请求后台查询当前股票的价格吗?这样效率也太低了吧,而且时效性也很低。这就需要我们今天的主角WebSocket去实现了。 什么是WebSocket WebSocket协议,RFC 6455这个大家有兴趣可以看看,太深,太底层。它是通过一个TCP连接,在客服端与服务端之间建立的一个全双工、双向的通信渠道。它是一个不同于HTTP的TCP协议,但是它通过HTTP工作。它的默认端口也是80和443,和HTTP是一样的。 一个WebSocket的交互开始于一个HTTP请求,这是一个握手请求,这个请求中包含一个Upgrade请求头,具体如下: GET /spring-websocket-portfolio/portfolio HTTP/1.1 Host: localhost:8080 Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: Uc9l9TMkWGbHFD2qnFHltg== Sec-WebSocket-Protocol: v10.stomp, v11.stomp Sec-WebSocket-Version: 13 Origin: http://localhost:8080 我们看到的第3行和第4行就是这个特殊的请求头,既然包含了这个特殊的请求头,那么请求就要升级,升级成WebSocket请求。这个握手请求的响应也比较特殊,它的成功状态码是101,而不是HTTP的200,如下: HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: 1qVdfYHU9hPOl4JYYNXF623Gzn0= Sec-WebSocket-Protocol: v10.stomp 在这次成功的握手请求以后,在客户端和服务端之间的socket被打开,客户端和服务端可以进行消息的发送和接收。 程序实现 我们还是使用现在流程的SpringBoot去搭建我们的项目,在项目中,我们添加两个依赖,如下: <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency> spring-boot-starter-websocket,这是我们今天的主角,我们WebSocket的实现都依赖于这个jar包; spring-boot-starter-thymeleaf,这只是起个辅助作用,在项目中要写个页面; 好了,基础工作准备好了,下面进入最核心的代码,先写个WebSocketHandler,这个是用于在服务端接收和返回消息使用的。如下: public class MyHandler extends TextWebSocketHandler { @Override protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception { String payload = message.getPayload(); System.out.println("我接收到的消息:"+payload); String rtnMsg = "我回复了"; for (int i=0;i<10;i++) { Thread.sleep(2000); session.sendMessage(new TextMessage(rtnMsg+i)); } super.handleTextMessage(session, message); } } 我们创建一个MyHandler类,继承TextWebSocketHandler类,这个类主要是处理文本的,当然也可以继承其他的类,比如:处理二进制的BinaryWebSocketHandler; 然后,我们实现handleTextMessage方法,这个方法有两个参数,WebSocketSession和TextMessage,TextMessage是接收客户端发来的消息。WebSocketSession用于设置WebSocket会话和向客户端发送消息; 在具体的方法实现中,我们调用TextMessage的getPayload方法,可以取出客户端发送的消息; 最后我们通过WebSocketSession的sendMessage方法向客户端发送消息,这里进行10次循环,每次循环我们间隔2秒; 好了,到这里最核心的处理接收消息的方法,我们已经写好了,然后我们将这个handler指定一个URL,如下: @Configuration @EnableWebSocket public class WebSocketConfig implements WebSocketConfigurer { @Override public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) { registry.addHandler(myHandler(),"/websocket"); } @Bean public MyHandler myHandler() { return new MyHandler(); } } 首先,我们写一个WebSocket的配置类WebSocketConfig去实现WebSocketConfigurer接口; 由于这是一个配置类,所以在类上加上注解@Configuration,同时因为要做WebSocket的配置,还要加上@EnableWebSocket这个注解; 这个类要实现注册WebSocketHandler的方法registerWebSocketHandlers,在这里,我们将前面写的Handler映射到/websocket这个URL; 实例化前面写的MyHandler这个类; 到这里,WebSocket的服务端的内容就写好了,接下来,我们再写个简单的页面,在页面中,使用js进行socket的调用,具体页面内容如下: <body> <div id="msg"></div> <script src="https://cdn.staticfile.org/jquery/1.10.2/jquery.min.js"></script> <script> var socket = new WebSocket("ws://localhost:8080/websocket"); socket.onopen = function() { socket.send("发送数据"); console.log("数据发送中..."); }; socket.onmessage = function (evt){ var received_msg = evt.data; $("#msg").append(received_msg+'<br>'); console.log("数据已接收..."+received_msg); }; socket.onclose = function(){ // 关闭 websocket console.log("连接已关闭..."); }; </script> </body> 我们先写个div,在这个div中展示服务端返回内容; 引入jquery,主要进行div内容的操作; 在第二个script中,我们进行websocket的连接,注意,协议名称是ws,地址就是我们在WebSocketConfig中配置的地址; 接下来就是onopen,onmessage,onclose方法,分别对应着socket打开,接收服务端消息和socket关闭的方法。我们在onmessage方法中,接收到服务端的消息,将其添加到div当中。 最后,我们再给这个html页面写个controller映射,如下: @Controller public class MyController { @RequestMapping("index") public String index() { return "index"; } } 这个就不过多解释了,我们启动一下应用,在浏览器中访问一下这个html页面吧。 我们访问的连接是:http://localhost:8080/index,这对应我们写的html页面; 在这个页面中,我们通过js访问了服务端的websocket; socket连接成功后,每隔2s向服务端发送一条消息; 在html页面中,通过onmessage方法接收消息,并将消息添加到div当中; 如果使用以前轮询的方法,我们需要在html页面中,定时轮询请求后台。而现在,我们通过websocket,服务端可以向客户端发送消息,大大提高了效率。 好了,通过Spring整合WebSocket就先给大家介绍到这里了。
上一篇我们介绍Spring AOP的注解的配置,也叫做Java Config。今天我们看看比较传统的xml的方式如何配置AOP。整体的场景我们还是用原来的,“我穿上跑鞋”,“我要去跑步”。Service层的代码我们不变,还是用原来的,如下: @Service public class MyService { public void gotorun() { System.out.println("我要去跑步!"); } } 再看看上一篇中的MyAspect代码,里边都是使用注解配置的,我们AOP相关的配置全部删除掉,只留下“我床上跑鞋“这样一个方法,如下: public class MyAspect { public void putonshoes() { System.out.println("我穿上跑步鞋。"); } } 类中没有任何的注解,我们将全部通过xml的方式配置AOP。首先,我们要在xml中引入aop的schema,如下: <?xml version="1.0" encoding="UTF-8"?> <beans xmlns="http://www.springframework.org/schema/beans" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:aop="http://www.springframework.org/schema/aop" xsi:schemaLocation="http://www.springframework.org/schema/beans http://www.springframework.org/schema/beans/spring-beans.xsd http://www.springframework.org/schema/aop https://www.springframework.org/schema/aop/spring-aop.xsd"> </beans> 有了aop的schema,我们就可以使用Spring的aop的标签了,我们先将MyAspect实例化,因为我们的通知方法”我穿上跑鞋“在这个类中,如下: <bean id="myAspect" class="com.example.springaopdemo.aspect.MyAspect" /> 其中,id我们配置为myAspect。然后,我们就要配置<aop:config>了,这个标签说明这是一段aop配置,具体的aop内容都在这个标签内,如下: <aop:config proxy-target-class="true"> …… </aop:config> 其中,我们还可以配置proxy-target-class这个属性,还记得这个属性是什么意思吗?对了,它代表着是否使用CGLIB代理,由于我们项目引入的依赖是spring-boot-starter-aop,默认是使用CGLIB的,所以这里配置不配置都可以。 然后在里边我们配置切面<aop:aspect>,它标识着这是一个切面配置,在标签里还要指定我们的切面的bean,也就是myAspect,如下: <aop:aspect id="aopAspect" ref="myAspect"> …… </aop:aspect> 切面的id叫做aopAspect,ref指定我们切面的bean,就是前面实例化的myAspect。好了,切面就配置好了,然后就是切点和通知。切点和通知的配置一定要在<aop:aspect>内,说明这个切点和通知属于当前这个切面的。 先来看看切点<aop:pointcut>的配置吧,如下: <aop:pointcut id="pointcut" expression="execution(* com.example.springaopdemo.service.*.*(..))"> </aop:pointcut> 是不是很熟悉,我们看到了匹配方法的表达式。同样,我们要给切点定义一个id叫做pointcut,然后expression就是匹配的表达式,这个和上一篇是一样的,没有区别。在这里,我们还是匹配service包下的所有类的所有方法。好了,到这里切点就配置完成了。 最后,再来看看通知,通知是和<aop:pointcut>并列的,都在<aop:aspect>内,具体如下: <aop:before method="putonshoes" pointcut-ref="pointcut"></aop:before> 通知的5种类型,分别对应着5个不同的标签,在这里我们还是使用前置通知<aop:before>,在标签的内部,要指定它对应的切点,pointcut-ref="pointcut",切点我们指定前面配置的,id是pointcut。然后就要指定方法method了,这个方法是哪个类中的方法呢?还记得我们再配置<aop:aspect>时指定的bean吗?ref指定了myAspect,那么method指定的方法就是myAspect这个bean中的方法。这里我们配置putonshoes方法。 好了,到这里,aop的配置就全部配置完了,我们看一下全貌吧, <bean id="myAspect" class="com.example.springaopdemo.aspect.MyAspect" /> <aop:config proxy-target-class="true"> <aop:aspect id="aspect" ref="myAspect"> <aop:pointcut id="pointcut" expression="execution(* com.example.springaopdemo.service.*.*(..))"> </aop:pointcut> <aop:before method="putonshoes" pointcut-ref="pointcut"></aop:before> </aop:aspect> </aop:config> 最后,我们在SpringBoot的启动类中,使用@ImportResource("spring-aop.xml") 引入这个xml文件,如下: @SpringBootApplication @ImportResource("spring-aop.xml") public class SpringAopDemoApplication { public static void main(String[] args) { SpringApplication.run(SpringAopDemoApplication.class, args); } } 测试类的程序和上一篇是一致,没有变化,如下: @SpringBootTest class SpringAopDemoApplicationTests { @Autowired private MyService myService; @Test public void testAdvice() { myService.gotorun(); } } 运行一下看看结果, 我穿上跑步鞋。 我要去跑步! 没有问题,符合预期。 在上一篇中,我们可以使用简单的配置,也就是不配置切点,在通知中直接配置匹配表达式,如果忘记的同学可以翻一翻上一篇的内容。在xml的aop配置中,也是可以省略掉切点pointcut的配置的,我们在通知中,直接配置表达式,如下: <aop:config proxy-target-class="true"> <aop:aspect id="aspect" ref="myAspect"> <aop:before method="putonshoes" pointcut="execution(* com.example.springaopdemo.service.*.*(..))"> </aop:before> </aop:aspect> </aop:config> 是不是比前面的配置看起来清爽一些了。小伙伴们自己运行一下吧,结果是没有问题的。 好了,Spring AOP的Java Config和Schema-based 两种的方式的配置都介绍完了。我们拓展一下思维,Spring的事务管理也是AOP吧,在方法执行之前打开事务,在方法执行后提交事务。但是大家有没有留意,Spring的事务配置和咱们的AOP配置是不一样的,这是为什么呢?咱们下一篇再聊吧。
Sharding-Proxy是一个分布式数据库中间件,定位为透明化的数据库代理端。作为开发人员可以完全把它当成数据库,而它具体的分片规则在Sharding-Proxy中配置。它的整体架构图如下: 在架构图中,中间的蓝色方块就是我们的中间件Sharding-Proxy,下面连接的是数据库,我们可以配置每一个数据库的分片,还可以配置数据库的读写分离,影子库等等。上方则是我们的业务代码,他们统一连接Sharding-Proxy,就像直接连接数据库一样,而具体的数据插入哪一个数据库,则由Sharding-Proxy中的分片规则决定。再看看右侧,右侧是一些数据库的工具,比如:MySQL CLI,这是MySQL的命令行;Workbench是MySQL自己出的一个管理工具;还可以连接其他的工具,比如:Navicat,SQLYog等。最后再来看看左侧,是一个注册中心,目前支持最好的是Zookeeper,在注册中心中,我们可以统一配置分片规则,读写数据源等,而且是实时生效的,在管理多个Sharding-Proxy时,非常的方便。而官方也给我们提供了界面化的工具——ShardingSphere-UI,使用起来非常的方便。 Sharding-Proxy的安装 我们可以在Sharding-Proxy官网上找的下载目录,再找到Sharding-Proxy的下载链接,下载最新版本的二进制包。然后把二进制包(tar.gz)上传到服务器的目录中,这个目录可以自定义,/opt或者/usr/local都可以,然后解压,命令如下: tar -zxvf apache-shardingsphere-4.1.1-sharding-proxy-bin.tar.gz 解压后,进入到sharding-proxy的conf目录,这个目录sharding-proxy的配置目录,我们所有的数据源、分片规则、读写分离等都在此目录下配置。 [root@centOS-1 conf]# ll 总用量 28 -rw-r--r--. 1 root root 3019 6月 4 15:24 config-encrypt.yaml -rw-r--r--. 1 root root 3633 7月 7 13:51 config-master_slave.yaml -rw-r--r--. 1 root root 2938 6月 4 15:24 config-shadow.yaml -rw-r--r--. 1 root root 5463 7月 7 14:08 config-sharding.yaml -rw-r--r--. 1 root root 1322 6月 4 15:24 logback.xml -rw-r--r--. 1 root root 2171 7月 7 15:19 server.yaml logback.xml是日志的配置。 server.yaml是Sharding-Proxy的一些基础配置,比如:账号、密码、注册中心等。 剩下的所有以config开头的yaml文件,都是一个逻辑数据源,我们可以看到最常见的两个config-sharding.yaml(分片的配置),config-master_slave.yaml(读写分离的配置)。注意,如果我们要配置分片+读写分离,要不要在两个配置文件中配置呢?不需要的,我们只需要在config-sharding.yaml中配置就可以了,如果要配置单独的读写分离,则需要按照config-master_slave.yaml配置。单独的读写分离和分片+读写分离在配置上,还是有一些区别的。 这些配置我们在后面会展开讲。Sharding-Proxy默认支持的数据库是PostgreSQL,而我们大多数都是使用的MySQL,在这里我们的数据库使用的是MySQL,我们要将mysql-connector-java.jar这个jar包放入lib目录,这里推荐使用5.x版本的jar包,如果使用8.x可能会有一些位置的错误。 最后,我们执行bin目录下的start.sh就可以运行了。 ./bin/start.sh Sharding-Proxy默认的启动端口是3307,我们在连接的时候要格外注意一下。 server.yaml配置 下面我们看看server.yaml文件中,都具体配置哪些内容,我们用vim打开文件, vim server.yaml 文件的内容如下: ######################################################################################### # # If you want to configure orchestration, authorization and proxy properties, please refer to this file. # ######################################################################################### # #orchestration: # orchestration_ds: # orchestrationType: registry_center,config_center # instanceType: zookeeper # serverLists: 192.168.73.131:2181 # namespace: sharding-proxy # props: # overwrite: false # retryIntervalMilliseconds: 500 # timeToLiveSeconds: 60 # maxRetries: 3 # operationTimeoutMilliseconds: 500 authentication: users: root: password: root sharding: password: sharding authorizedSchemas: sharding_db 其中,orchestration是连接zookeeper注册中心,这里我们暂时用不到,将其注释掉。 authentication中,配置的是用户名和密码,以及授权的数据库,在这里,我们配置了两个用户,分别为:root/root和sharding/sharding,其中root默认授权所有的数据库,而sharding用户则授权sharding_db数据库。在这里的数据库(schema)是逻辑数据库,在config-*.yaml中配置的。 config-sharding.yaml的配置 这个文件是Sharding-Proxy的核心的配置,所有的分片规则都在这个文件中配置,让我们一起来看看吧, schemaName: sharding_db dataSources: ds_1: url: jdbc:mysql://192.168.73.132:3306/shard_order?serverTimezone=Asia/Shanghai&useSSL=false username: imooc password: Imooc@123456 connectionTimeoutMilliseconds: 30000 idleTimeoutMilliseconds: 60000 maxLifetimeMilliseconds: 1800000 maxPoolSize: 50 master_ds: url: jdbc:mysql://192.168.73.131:3306/sharding_order?serverTimezone=Asia/Shanghai&useSSL=false username: imooc password: Imooc@123456 connectionTimeoutMilliseconds: 30000 idleTimeoutMilliseconds: 60000 maxLifetimeMilliseconds: 1800000 maxPoolSize: 50 slave_ds_0: url: jdbc:mysql://192.168.73.130:3306/sharding_order?serverTimezone=Asia/Shanghai&useSSL=false username: imooc password: Imooc@123456 connectionTimeoutMilliseconds: 30000 idleTimeoutMilliseconds: 60000 maxLifetimeMilliseconds: 1800000 maxPoolSize: 50 在这个配置文件中,总共分为3个部分,我们先看看前面2个部分。 schemaName:是逻辑数据库的名称,这里我们叫做sharding_db。在server.yaml文件中,授权的schema就是这里的schemaName。 第二部分是数据源,在dataSources里边,我们配置了3个数据源。分别是ds_1、master_ds和slave_ds_0。我们先来说一下数据库的规划吧,我们的数据将通过user_id进行数据库的分片,总共有2个分片,user_id尾数为奇数的将分配到ds_1的数据库中,user_id尾数为偶数的,将分配到ds_0中,但是我们的数据源中没有ds_0呀,ds_0将由master_ds和slave_ds_0组成一个读写分离数据源。 接下来再看看具体分片的配置, shardingRule: masterSlaveRules: ds_0: masterDataSourceName: master_ds slaveDataSourceNames: - slave_ds_0 tables: t_order: actualDataNodes: ds_${0..1}.t_order_${1..2} tableStrategy: inline: shardingColumn: order_id algorithmExpression: t_order_${order_id % 2 + 1} keyGenerator: type: SNOWFLAKE column: order_id t_order_item: actualDataNodes: ds_${0..1}.t_order_item_${1..2} tableStrategy: inline: shardingColumn: order_id algorithmExpression: t_order_item_${order_id % 2 + 1} keyGenerator: type: SNOWFLAKE column: id defaultDatabaseStrategy: inline: shardingColumn: user_id algorithmExpression: ds_${user_id % 2} defaultTableStrategy: none: defaultDataSourceName: ds_0 分片的配置都在shardingRule下。 在这里我们要配置读写分离主从数据源,在这里我们配置的是分片+读写分离,和单纯的读写分离配置是不一样的。读写分离的配置在masterSlaveRules下,我们配置读写分离数据源ds_0,指定主库的数据源masterDataSourceName为master_ds,master_ds在上面的数据源中已经配置,而从数据源slaveDataSourceNames可以配置多个,也就是一主多从的配置,我们用数组的方式进行配置,- slave_ds_0指定从数据源为slave_ds_0,如果有多个从数据源,可以配置多个。 我们先跳过tables的配置,往下看,defaultDataSourceName,默认数据源,我们指定ds_0。这个配置非常有用,在我们的项目中,并不是所有的表都要进行水平切分,只有数据量比较大的表才会用到水平切分,比如:订单表(t_order)和订单明细表(t_order_item)。而其他的表数据量没有那么大,单库单表就可以完全支撑,这些表没有分片规则,而我们指定了默认的数据源,当我们操作这些没有分片规则的表时,都统一使用默认的数据源。 defaultTableStrategy,默认表的分片规则,这里我们配置的是none,没有。也就是说所有的分片表都要配置表的分片规则。 defaultDatabaseStrategy,默认数据库的分片规则,这里我们配置它的规则为行内表达式,分片字段为user_id,规则为ds_${user_id % 2},当user_id为偶数时,数据源为ds_0,也就是前面配置的读写分离数据源;而当user_id为奇数时,数据源为ds_1。如果我们的表的分片规则中,没有配置数据源的分片规则,将使用这个默认数据源的分片策略。 最后再来看看tables的配置,这里配置的是分片表的规则,我们配置两个表,t_order和t_order_item。每个分片表都由3部分组成。首先,actualDataNodes,实际的数据节点,这个节点是在MySQL中真实存在的,以t_order的配置为例,ds_${0..1}.t_order\_${1..2},说明t_order的数据节点有4个,分表为ds_0.t_order_1、ds_0.t_order_2、ds_1.t_order_1和ds_1.t_order_2。再来看表的分片规则,tableStrategy,它的规则也是用行内表达式配置的,分片字段为order_id,规则为t_order_${order_id % 2 + 1},当order_id为奇数时,数据会分配到表t_order_1中;当order_id为偶数时,会分配到表t_order_2中。 整个的分片策略就配置完了,决定每条数据的具体分片由两个字段决定,user_id决定数据分配到哪一个数据源中,order_id决定数据分配到哪一个表中。这就是分片+读写分离的配置,如果要进行更详细的配置,可以参考官方文档,这里不赘述了。 config-master_slave.yaml的配置 如果我们只配置数据源的读写分离,而不进行分片配置,就需要参照这个配置文件进行配置了,虽然分片+读写分离的配置已经有了读写分离的配置,但是他俩之间还是有一些细微的区别的,我们来看看这个文件中的内容吧, schemaName: master_slave_db dataSources: master_ds: url: jdbc:mysql://192.168.73.131:3306/sharding_order?serverTimezone=Asia/Shanghai&useSSL=false username: imooc password: Imooc@123456 connectionTimeoutMilliseconds: 30000 idleTimeoutMilliseconds: 60000 maxLifetimeMilliseconds: 1800000 maxPoolSize: 50 slave_ds: url: jdbc:mysql://192.168.73.130:3306/sharding_order?serverTimezone=Asia/Shanghai&useSSL=false username: imooc password: Imooc@123456 connectionTimeoutMilliseconds: 30000 idleTimeoutMilliseconds: 60000 maxLifetimeMilliseconds: 1800000 maxPoolSize: 50 slave_ds_1: url: jdbc:mysql://127.0.0.1:3306/demo_ds_slave_1?serverTimezone=UTC&useSSL=false username: root password: connectionTimeoutMilliseconds: 30000 idleTimeoutMilliseconds: 60000 maxLifetimeMilliseconds: 1800000 maxPoolSize: 50 masterSlaveRule: name: ds_0 masterDataSourceName: master_ds slaveDataSourceNames: - slave_ds - slave_ds_1 首先,我们还是定义逻辑数据库的名称,schemaName: master_slave_db,叫做master_slave_db。 然后在dataSources中定义数据源,这些配置的结构是通用,和前面没有区别,我们配置了3个数据源,一主两从,master_ds(主)、slave_ds(从)和slave_ds_1(从)。 最后就是主从的规则masterSlaveRule,在前面分片+读写分离的配置中,叫做masterSlaveRules,复数形式。说明在单独的读写分离配置中,只能配置一个主从数据源。主从数据源的名字叫做ds_0,主数据源masterDataSourceName是master_ds,从数据源slaveDataSourceNames配置了两个,slave_ds和slave_ds_1。 这里只是单纯的配置主从读写分离数据源,如果要配置分片+读写分离,请参照前面的配置。 config-shadow.yaml影子库配置 在现在微服务盛行的情况下,系统被切分的很细,这对于测试,尤其是压测是非常难的,如果在测试环境部署一套和生产一模一样的环境,是非常浪费资源的。而如果只部署一两个服务,又不能进行全链路的整体压测。而我们的解决方案是在生产环境直接进行压测,得出的结果也是真实有效的。那么这些压测的数据怎么办,如果不做特殊的处理,就和生产的真实数据混在一起了。 这里我们就需要配置影子数据库了,所有压测数据都会有一个特殊的标识,sharding-proxy根据这个特殊的标识,将压测的数据分配到影子库中,和生产的真实数据隔离开,我们看看具体怎么配置 schemaName: sharding_db dataSources: ds: url: jdbc:mysql://127.0.0.1:3306/demo_ds_0?serverTimezone=UTC&useSSL=false username: root password: connectionTimeoutMilliseconds: 30000 idleTimeoutMilliseconds: 60000 maxLifetimeMilliseconds: 1800000 maxPoolSize: 50 shadow_ds: url: jdbc:mysql://127.0.0.1:3306/demo_ds_1?serverTimezone=UTC&useSSL=false username: root password: connectionTimeoutMilliseconds: 30000 idleTimeoutMilliseconds: 60000 maxLifetimeMilliseconds: 1800000 maxPoolSize: 50 shadowRule: column: shadow shadowMappings: ds: shadow_ds 前面还是逻辑数据库的名称和数据源的配置。在数据源我们配置了两个,一个是真实的数据库ds,另一个是影子库shadow_ds,所有压测的数据都会分配的影子库中。 shadowRule中配置影子库的规则,column,影子库字段标识,所有压测数据,在程序中,将此字段设置为true。shadowMappings是主库和影子库的映射关系,ds数据库的影子库是shadow_ds。 影子库的配置在我们压测中还是十分有用的,将测试数据和生产数据隔离开,不会影响到生产数据。 config-encrypt.yaml数据加密配置 最后我们再看看数据加密的配置,一些用户的信息是不希望在数据库中以明文存在的,比如:用户的身份证号、银行卡号。但是,在使用的时候,我们还要把它解密回来。当然,我们可以在程序中,针对这些字段进行加解密,这里呢,我们看看Sharding-Proxy为我们提供的数据加密配置。我们看一下配置文件, schemaName: encrypt_db dataSource: url: jdbc:mysql://127.0.0.1:3306/demo_ds?serverTimezone=UTC&useSSL=false username: root password: connectionTimeoutMilliseconds: 30000 idleTimeoutMilliseconds: 60000 maxLifetimeMilliseconds: 1800000 maxPoolSize: 50 encryptRule: encryptors: encryptor_aes: type: aes props: aes.key.value: 123456abc tables: t_card_no: columns: card_no: cipherColumn: card_no_cipher encryptor: encryptor_aes 逻辑库与数据源的配置略过。 在加密规则encryptRule中,我们先定义加密算法,encryptor_aes,它的类型是aes,key是123456abc,这个key我们可以修改,但是一旦用这个key产生数据,就不要再改了,如果改了,旧数据就不能正确的解密了。 然后在tables中定义加密数据的表t_card_no,加密的列为card_no,这个列是逻辑列,在表中不是真实存在的,当你的sql中无论查询、插入,出现这个字段,都会进行加密处理。而cipherColumn是加密后存储数据的列,encryptor则是加密的规则。例如,我们执行insert into t_card_no (card_no) values ('123456'),card_no列在表t_card_no中并不存在,t_card_no中存在的是card_no_cipher列,我们执行成功后,card_no_cipher列存的是密文数据;当我们执行select card_no from t_card_no 时,虽然表t_card_no没有card_no 列,但是可以将card_no_cipher列解密,card_no 显示解密后的值。 数据加密在实际的应用中还是比较多的。 总结 这一篇我们主要介绍了Sharding-Proxy的一些基本功能,下一篇将给大家shardingsphere-ui和注册中心的应用。
在java的虚拟机异常中,有两个异常是大家比较关心的,一个是StackOverflowError,另一个是OutOfMemoryError。今天我们就来看看OutOfMemoryError是怎么产生的,以及如何去排查这个异常。 概念 要了解什么是OutOfMemoryError,我们可以直接看一下OutOfMemoryError的源码,在类上的英文注释很好的阐述了什么是OutOfMemoryError,翻译过来的意思是,由于内存不足,虚拟机没有可分配的内存了,垃圾回收器也不能释放更多的内存。在生产环境中,由于访问量过大,把内存吃满,会出现OutOfMemoryError的异常,小伙伴们如果没有经验的话,往往束手无策,到底是真的内存不够用了,还是自己的程序有问题,也不知道如何去排查这样的异常。 模拟OutOfMemoryError 在这里,我们写一段程序,来模拟一下OutOfMemoryError如何产生,我们创建一个List对象,然后向里边不停的添加1M的Byte,如下; public static void main(String[] args) { List<Byte[]> list = new ArrayList<>(); int i = 0; try { while (true) { list.add(new Byte[1024 * 1024]); i++; } } catch (Throwable e) { e.printStackTrace(); System.out.println("执行了"+i+"次"); } } 我们写了一个while(true)循环,每次都add一个1M的字节对象,1024*1024正好1M。 我们用i的值记录总共执行了几次。 如果这样不停的执行下去,不管你有多大的内存,都会被吃光的。 我们为了让程序运行时,快速的抛出OutOfMemoryError异常,可以在java的启动命令行增加启动参数,设置堆内存的初始值和最大值。这两个值在生产环境下,通常也是要配置的哦,要充分利用机器的内存嘛,如果不配置就会使用默认值。到时候由于内存不足向老板申请机器,可别挨骂哦~ 那这两个参数怎么去加呢? -Xms ,-Xms设置初始堆内存的大小 -Xmx, -Xmx设置最大堆内存的大小 通常情况下,这两个值设置成一样就可以了,总之,我们设置了堆内存的大小。我们在IDEA的启动配置中,统一设置堆内存为80M,如下; 好了~~我们运行一下,看看会不会抛出OutOfMemoryError异常吧 java.lang.OutOfMemoryError: Java heap space at com.diancan.JavaOOMDemo.main(JavaOOMDemo.java:14) 执行了14次 执行了14次,抛出了OutOfMemoryError异常。但是,如果抛出这样一个异常,我们怎么去排查呢?就这一行日志也看不出什么来啊。 排查 说到排查,如果我们能够拿到异常时的内存快照,然后通过一些工具就可以了进行内存的分析了。那么我们怎么去拿到内存溢出时的快照呢?其实,JDK也为我们提供了这样的命令参数,我们来看一下吧, -XX:+HeapDumpOnOutOfMemoryError,从字面就可以很容易的理解,在发生OutOfMemoryError异常时,进行堆的Dump,这样就可以获取异常时的内存快照了。 -XX:HeapDumpPath=D:heap-dump ,这个也很好理解,就是配置HeapDump的路径,方便我们管理,这里我们配置为D:heap-dump,当然你也可以根据自己的需要,定义为其他的目录。 注意,HeapDumpPath的目录一定要手动创建好,如果没有这个目录,Dump会失败的。 IDEA中的配置,如图: 我们再运行一下程序,看看是什么样子, java.lang.OutOfMemoryError: Java heap space Dumping heap to D:\heap-dump\java_pid24312.hprof ... Heap dump file created [123468648 bytes in 0.141 secs] java.lang.OutOfMemoryError: Java heap space at com.diancan.JavaOOMDemo.main(JavaOOMDemo.java:14) 执行了14次 我们发现日志上面多了点东西,创建了一个文件,在D:heap-dumpjava_pid24312.hprof。这个文件就是我们的内存快照。那么问题来了,我们如何查看这个文件呢?直接打开是不行的,用写字板等也是不行的,那怎么办?其实也没那么复杂,使用JDK自带的jvisualvm就可以查看。 这里边有个小坑,如果大家用JDK8,可以在JDK的bin目录下找到jvisualvm.exe,但是如果你使用的是JDK8以上的版本,就本示例中,使用的是JDK11,在bin目录下是找不到jvisualvm.exe的。大家可以去visualvm的主页下载。 我们启动visualvm,进入到如下的页面, 然后,点击左上角的加载快照按钮,然后选择刚才我们Dump的文件, 我们重点看一下右侧中间的部分, 类的实例大小排序,可以看到,我们的Byte占了96.5%。详细的信息,我们可以点进去看,包括变量里存的内容,这样我们就可以很快的定位到内存溢出的位置,并且可以判断是真的内存不够了,还是我们的代码出了问题。
终于到了今天了,终于要讲RocketMQ最牛X的功能了,那就是事务消息。为什么事务消息被吹的比较热呢?近几年微服务大行其道,整个系统被切成了多个服务,每个服务掌管着一个数据库。那么多个数据库之间的数据一致性就成了问题,虽然有像XA这种强一致性事务的支持,但是这种强一致性在互联网的应用中并不适合,人们还是更倾向于使用最终一致性的解决方案,在最终一致性的解决方案中,使用MQ保证各个系统之间的数据一致性又是首选。 RocketMQ为我们提供了事务消息的功能,它使得我们投放消息和其他的一些操作保持一个整体的原子性。比如:向数据库中插入数据,再向MQ中投放消息,把这两个动作作为一个原子性的操作。貌似其他的MQ是没有这种功能的。 但是,纵观全网,讲RocketMQ事务消息的博文中,几乎没有结合数据库的,都是直接投放消息,然后讲解事务消息的几个状态,虽然讲的也没毛病,但是和项目中事务最终一致性的落地方案还相距甚远。包括我自己在内,在项目中,服务化以后,用MQ保证事务的最终一致性,在网上一搜,根本没有落地的方案,都是侃侃而谈。于是,我写下这篇博文,结合数据库,来谈一谈RocketMQ的事务消息到底怎么用。 基础概念 要使用RocketMQ的事务消息,要实现一个TransactionListener的接口,这个接口中有两个方法,如下: /** * When send transactional prepare(half) message succeed, this method will be invoked to execute local transaction. * * @param msg Half(prepare) message * @param arg Custom business parameter * @return Transaction state */ LocalTransactionState executeLocalTransaction(final Message msg, final Object arg); /** * When no response to prepare(half) message. broker will send check message to check the transaction status, and this * method will be invoked to get local transaction status. * * @param msg Check message * @return Transaction state */ LocalTransactionState checkLocalTransaction(final MessageExt msg); RocketMQ的事务消息是基于两阶段提交实现的,也就是说消息有两个状态,prepared和commited。当消息执行完send方法后,进入的prepared状态,进入prepared状态以后,就要执行executeLocalTransaction方法,这个方法的返回值有3个,也决定着这个消息的命运, COMMIT_MESSAGE:提交消息,这个消息由prepared状态进入到commited状态,消费者可以消费这个消息; ROLLBACK_MESSAGE:回滚,这个消息将被删除,消费者不能消费这个消息; UNKNOW:未知,这个状态有点意思,如果返回这个状态,这个消息既不提交,也不回滚,还是保持prepared状态,而最终决定这个消息命运的,是checkLocalTransaction这个方法。 当executeLocalTransaction方法返回UNKNOW以后,RocketMQ会每隔一段时间调用一次checkLocalTransaction,这个方法的返回值决定着这个消息的最终归宿。那么checkLocalTransaction这个方法多长时间调用一次呢?我们在BrokerConfig类中可以找到, /** * Transaction message check interval. */ @ImportantField private long transactionCheckInterval = 60 * 1000; 这个值是在brokder.conf中配置的,默认值是60*1000,也就是1分钟。那么会检查多少次呢?如果每次都返回UNKNOW,也不能无休止的检查吧, /** * The maximum number of times the message was checked, if exceed this value, this message will be discarded. */ @ImportantField private int transactionCheckMax = 5; 这个是检查的最大次数,超过这个次数,如果还返回UNKNOW,这个消息将被删除。 事务消息中,TransactionListener这个最核心的概念介绍完后,我们看看代码如何写吧。 落地案例 我们在数据库中有一张表,具体如下: CREATE TABLE `s_term` ( `id` int(11) NOT NULL AUTO_INCREMENT, `term_year` year(4) NOT NULL , `type` int(1) NOT NULL DEFAULT '1' , PRIMARY KEY (`id`) ) 字段的具体含义大家不用管,一会我们将向这张表中插入一条数据,并且向MQ中投放消息,这两个动作是一个原子性的操作,要么全成功,要么全失败。 我们先来看看事务消息的客户端的配置,如下: @Bean(name = "transactionProducer",initMethod = "start",destroyMethod = "shutdown") public TransactionMQProducer transactionProducer() { TransactionMQProducer producer = new TransactionMQProducer("TransactionMQProducer"); producer.setNamesrvAddr("192.168.73.130:9876;192.168.73.131:9876;192.168.73.132:9876;"); producer.setTransactionListener(transactionListener()); return producer; } @Bean public TransactionListener transactionListener() { return new TransactionListenerImpl(); } 我们使用TransactionMQProducer生命生产者的客户端,并且生产者组的名字叫做TransactionMQProducer,后面NameServer的地址没有变化。最后就是设置了一个TransactionListener监听器,这个监听器的实现我们也定义了一个Bean,返回的是我们自定义的TransactionListenerImpl,我们看看里边怎么写的吧。 public class TransactionListenerImpl implements TransactionListener { @Autowired private TermMapper termMapper; @Override public LocalTransactionState executeLocalTransaction(Message msg, Object arg) { Integer termId = (Integer)arg; Term term = termMapper.selectById(termId); System.out.println("executeLocalTransaction termId="+termId+" term:"+term); if (term != null) return COMMIT_MESSAGE; return LocalTransactionState.UNKNOW; } @Override public LocalTransactionState checkLocalTransaction(MessageExt msg) { String termId = msg.getKeys(); Term term = termMapper.selectById(Integer.parseInt(termId)); System.out.println("checkLocalTransaction termId="+termId+" term:"+term); if (term != null) { System.out.println("checkLocalTransaction:COMMIT_MESSAGE"); return COMMIT_MESSAGE; } System.out.println("checkLocalTransaction:ROLLBACK_MESSAGE"); return ROLLBACK_MESSAGE; } } 在这个类中,我们要实现executeLocalTransaction和checkLocalTransaction两个方法,其中executeLocalTransaction是在执行完send方法后立刻执行的,里边我们根据term表的id去查询,如果能够查询出结果,就commit,消费端可以消费这个消息,如果查询不到,就返回一个UNKNOW,说明过一会会调用checkLocalTransaction再次检查。在checkLocalTransaction方法中,我们同样用termId去查询,这次如果再查询不到就直接回滚了。 好了,事务消息中最重要的两个方法都已经实现了,我们再来看看service怎么写吧, @Autowired private TermMapper termMapper; @Autowired @Qualifier("transactionProducer") private TransactionMQProducer producer; @Transactional(rollbackFor = Exception.class) public void sendTransactionMQ() throws Exception { Term term = new Term(); term.setTermYear(2020); term.setType(1); int insert = termMapper.insert(term); Message message = new Message(); message.setTopic("cluster-topic"); message.setKeys(term.getId()+""); message.setBody(new String("this is transaction mq "+new Date()).getBytes()); TransactionSendResult sendResult = producer .sendMessageInTransaction(message, term.getId()); System.out.println("sendResult:"+sendResult.getLocalTransactionState() +" 时间:"+new Date()); } 在sendTransactionMQ方法上,我们使用了@Transactional注解,那么在这个方法中,发生任何的异常,数据库事务都会回滚; 然后,我们创建Term对象,向数据库中插入Term; 构建Mesaage的信息,将termId作为message的key; 使用sendMessageInTransaction发送消息,传入message和termId,这两个参数和executeLocalTransaction方法的入参是对应的。 最后,我们在test方法中,调用sendTransactionMQ方法,如下: @Test public void sendTransactionMQ() throws InterruptedException { try { transactionService.sendTransactionMQ(); } catch (Exception e) { e.printStackTrace(); } Thread.sleep(600000); } 整个生产端的代码就是这些了,消费端的代码没有什么变化,就不给大家贴出来了。接下来,我们把消费端的应用启动起来,消费端的应用最好不要包含生产端的代码,因为TransactionListener实例化以后,就会进行监听,而我们在消费者端是不希望看到TransactionListener中的日志的。 我们运行一下生产端的代码,看看是什么情况,日志如下: executeLocalTransaction termId=15 term:com.example.rocketmqdemo.entity.Term@4a3509b0 sendResult:COMMIT_MESSAGE 时间:Wed Jun 17 08:56:49 CST 2020 我们看到,先执行的是executeLocalTransaction这个方法,termId打印出来了,发送的结果也出来了,是COMMIT_MESSAGE,那么消费端是可以消费这个消息的; 注意一下两个日志的顺序,先执行的executeLocalTransaction,说明在执行sendMessageInTransaction时,就会调用监听器中的executeLocalTransaction,它的返回值决定着这个消息是否真正的投放到队列中; 再看看消费端的日志, msgs.size():1 this is transaction mq Wed Jun 17 08:56:49 CST 2020 消息被正常消费,没有问题。那么数据库中有没有termId=15的数据呢?我们看看吧, 数据是有的,插入数据也是成功的。 这样使用就真的正确的吗?我们改一下代码看看,在service方法中抛个异常,让数据库的事务回滚,看看是什么效果。改动代码如下: @Transactional(rollbackFor = Exception.class) public void sendTransactionMQ() throws Exception { …… throw new Exception("数据库事务异常"); } 抛出异常后,数据库的事务会回滚,那么MQ呢?我们再发送一个消息看看, 生产端的日志如下: executeLocalTransaction termId=16 term:com.example.rocketmqdemo.entity.Term@5d6b5d3d sendResult:COMMIT_MESSAGE 时间:Wed Jun 17 09:07:15 CST 2020 java.lang.Exception: 数据库事务异常 从日志中,我们可以看到,消息是投放成功的,termId=16,事务的返回状态是COMMIT_MESSAGE; 最后抛出了我们定义的异常,那么数据库中应该是不存在这条消息的啊; 我们先看看数据库吧, 数据库中并没有termId=16的数据,那么数据库的事务是回滚了,而消息是投放成功的,并没有保持原子性啊。那么为什么在执行executeLocalTransaction方法时,能够查询到termId=16的数据呢?还记得MySQL的事务隔离级别吗?忘了的赶快复习一下吧。在事务提交前,我们是可以查询到termId=16的数据的,所以消息提交了,看看消费端的情况, msgs.size():1 this is transaction mq Wed Jun 17 09:07:15 CST 2020 消息也正常消费了,这明显不符合我们的要求,我们如果在微服务之间使用这种方式保证数据的最终一致性,肯定会有大麻烦的。那我们该怎么使用s呢?我们可以在executeLocalTransaction方法中,固定返回UNKNOW,数据插入数据库成功也好,失败也罢,我们都返回UNKNOW。那么这个消息是否投放到队列中,就由checkLocalTransaction决定了。checkLocalTransaction肯定在sendTransactionMQ后执行,而且和sendTransactionMQ不在同一事务中。我们改一下程序吧, @Override public LocalTransactionState executeLocalTransaction(Message msg, Object arg) { return LocalTransactionState.UNKNOW; } 其他的地方不用改,我们再发送一下消息, sendResult:UNKNOW 时间:Wed Jun 17 09:56:59 CST 2020 java.lang.Exception: 数据库事务异常 checkLocalTransaction termId=18 term:null checkLocalTransaction:ROLLBACK_MESSAGE 事务消息发送的结果是UNKNOW,然后抛出异常,事务回滚; checkLocalTransaction方法,查询termId=18的数据,为null,消息再回滚; 又看了一下消费端,没有日志。数据库中也没有termId=18的数据,这才符合我们的预期,数据库插入不成功,消息投放不成功。我们再把抛出异常的代码注释掉,看看能不能都成功。 @Transactional(rollbackFor = Exception.class) public void sendTransactionMQ() throws Exception { …… //throw new Exception("数据库事务异常"); } 再执行一下发送端程序,日志如下: sendResult:UNKNOW 时间:Wed Jun 17 10:02:57 CST 2020 checkLocalTransaction termId=19 term:com.example.rocketmqdemo.entity.Term@3b643475 checkLocalTransaction:COMMIT_MESSAGE 发送结果返回UNKNOW; checkLocalTransaction方法查询termId=19的数据,能够查到; 返回COMMIT_MESSAGE,消息提交到队列中; 先看看数据库中的数据吧, termId=19的数据入库成功了,再看看消费端的日志, msgs.size():1 this is transaction mq Wed Jun 17 10:02:56 CST 2020 消费成功,这才符合我们的预期。数据插入数据库成功,消息投放队列成功,消费消息成功。 总结 事务消息最重要的就是TransactionListener接口的实现,我们要理解executeLocalTransaction和checkLocalTransaction这两个方法是干什么用的,以及它们的执行时间。再一个就是和数据库事务的结合,数据库事务的隔离级别大家要知道。把上面这几点掌握了,就可以灵活的使用RocketMQ的事务消息了。
今天我们再来看看RocketMQ的另外两个小功能,消息的批量发送和过滤。这两个小功能提升了我们使用RocketMQ的效率。 批量发送 以前我们发送消息的时候,都是一个一个的发送,这样效率比较低下。能不能一次发送多个消息呢?当然是可以的,RocketMQ为我们提供了这样的功能。但是它也有一些使用的条件: 同一批发送的消息的Topic必须相同; 同一批消息的waitStoreMsgOK 必须相同; 批量发送的消息不支持延迟,就是上一节说的延迟消息; 同一批次的消息,大小不能超过1MiB; 好了,只要我们满足上面的这些限制,就可以使用批量发送了,我们来看看发送端的代码吧, @Test public void producerBatch() throws Exception { List<Message> messages = new ArrayList<>(); for (int i = 0;i<3;i++) { MessageExt message = new MessageExt(); message.setTopic("cluster-topic"); message.setKeys("key-"+i); message.setBody(("this is batchMQ,my NO is "+i+"---"+new Date()).getBytes()); messages.add(message); } SendResult sendResult = defaultMQProducer.send(messages); System.out.println("sendResult:" + sendResult.getSendStatus().toString()); } 其实批量发送很简单,我们只是把消息放到一个List当中,然后统一的调用send方法发送就可以了。 再来看看消费端的代码, @Bean(initMethod = "start",destroyMethod = "shutdown") public DefaultMQPushConsumer pushConsumer() { try { DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("DefaultMQPushConsumer"); consumer.setNamesrvAddr("192.168.73.130:9876;192.168.73.131:9876;192.168.73.132:9876;"); consumer.subscribe("cluster-topic", "*"); consumer.registerMessageListener(new MessageListenerConcurrently() { @Override public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) { System.out.println("msgs.size():"+msgs.size()); if (msgs != null && msgs.size() > 0) { for (MessageExt msg : msgs) { System.out.println(new String(msg.getBody())); } } return ConsumeConcurrentlyStatus.CONSUME_SUCCESS; } }); return consumer; }catch (Exception e) { e.printStackTrace(); } return null; } 消费端的代码没有任何的变化,正常的接收消息就可以了,我们只是打印出了msgs.size(),看看一次接收一个消息,还是一次可以批量的接收多个消息。 我们启动项目,批量发送一下,看看效果吧, 发送端的日志如下: sendResult:SEND_OK 发送成功,看来我们批量发送的3个消息都进入到了队列中,再看看消费端,是一次消费一个,还是一次消费3个,如下: msgs.size():1 this is batchMQ,my NO is 0---Mon Jun 15 09:31:04 CST 2020 msgs.size():1 this is batchMQ,my NO is 1---Mon Jun 15 09:31:04 CST 2020 msgs.size():1 this is batchMQ,my NO is 2---Mon Jun 15 09:31:04 CST 2020 看样子是一次只消费了一个消息,那么能不能一次消费3个消息呢?当然是可以的,不过要进行特殊的设置, consumer.setConsumeMessageBatchMaxSize(5); 在消费端,我们设置批量消费消息的数量是5,这个值默认是1。我们再看看消费端的日志, msgs.size():3 this is batchMQ,my NO is 0---Mon Jun 15 09:35:47 CST 2020 this is batchMQ,my NO is 1---Mon Jun 15 09:35:47 CST 2020 this is batchMQ,my NO is 2---Mon Jun 15 09:35:47 CST 2020 这次一次消费了3个消息,如果消息比较多的话,最大一次能消费5个。这就是RocketMQ的批量发送和批量消费。 消息过滤 其实我们在大多数情况下,使用tag标签就能够很好的实现消息过滤。虽然tag标签咱们并没有过多的介绍,其实也很好理解,就是一个子Topic的概念,咱们在构建消息message的时候,message.setTags("xxx")。然后在消费的时候,订阅Topic的时候,也可以指定订阅的tag, consumer.subscribe("cluster-topic", "*"); 看到那个"*"了吗?它就是订阅的tag,"*"代表全部的tag,如果您想订阅其中的一个或几个,可以使用这种方式"tagA || tagB || tagC",这是订阅了cluster-topic下的3个tag,其他的tag是不会被消费的。 这里我们所说的消息过滤比tag要高级很多,是可以支持sql的,怎么样?高级吧。比如:我们订阅"a > 5 and b = 'abc'"的消息,如下图: 但是,RocketMQ毕竟不是数据库,它只能支持一些基础的SQL语句,并不是所有的SQL都支持, 数字型的支持,>, >=, <, <=, BETWEEN, = 字符串支持,=, <>, IN IS NULL或者IS NOT NULL 逻辑判断,AND,OR,NOT; 字段的类型也只是简单的几种, 数字型,支持123,543.123,整型、浮点都可以; 字符串,必须使用单引号''括起来; 空值,NULL; 布尔型,TRUE或者FALSE; 并且对消费者的类型也有一定的限制,只能使用push consumer才可以进行消息过滤。好了,说了这么多了,我们看看怎么使用吧,消费端和生产端都要进行相应的改造,先看看生产端吧, @Test public void producerBatch() throws Exception { List<Message> messages = new ArrayList<>(); for (int i = 0;i<3;i++) { MessageExt message = new MessageExt(); message.setTopic("cluster-topic"); message.setKeys("key-"+i); message.setBody(("this is batchMQ,my NO is "+i+"---"+new Date()).getBytes()); int a = i+4; message.putUserProperty("a",String.valueOf(a)); messages.add(message); } SendResult sendResult = defaultMQProducer.send(messages); System.out.println("sendResult:" + sendResult.getSendStatus().toString()); } 我们在之前批量发送的基础上进行了修改,定义了a的值,等于i+4,这样循环3次,a的值就是4,5,6。然后调用message.putUserProperty("a",String.valueOf(a)),注意,在使用消息过滤的时候,这些附加的条件属性都是通过putUserProperty方法进行设置。这里,我们设置了a的值。再看看消费端, consumer.subscribe("cluster-topic", MessageSelector.bySql("a > 5")); 消费端,整体上没有变化,只是在订阅的方法中,使用MessageSelector.bySql("a > 5"),进行了条件的过滤。有的小伙伴可能会有疑问,我既想用sql过滤又想用tag过滤怎么办?当然也是可以,我们可以使用MessageSelector.bySql("a > 5").byTag("xx),byTag和bySql不分前后,怎么样,很强大吧。我们运行一下程序,看看效果吧。 我们启动一下服务,报错了,怎么回事?错误信息如下: The broker does not support consumer to filter message by SQL92 队列不支持过滤消息,我们查询了RocketMQ源码中的BrokerConfig类,这个类就是对broker的一些设置,其中发现了这两个属性, // whether do filter when retry. private boolean filterSupportRetry = false; private boolean enablePropertyFilter = false; filterSupportRetry是在重试的时候,是否支持filter; enablePropertyFilter,这个就是是否支持过滤消息的属性; 我们把这两个属性在broker的配置文件改为true吧,如下: filterSupportRetry=true enablePropertyFilter=true 然后,再重新部署一下我们两主两从的集群环境。环境部署完以后,我们再重启应用,没有报错。在生产端发送一下消息看看吧, sendResult:SEND_OK 生产端发送消息没有问题,说明3个消息都发送成功了。再看看消费端的日志, msgs.size():1 this is batchMQ,my NO is 2---Mon Jun 15 10:59:37 CST 2020 只消费了一个消息,并且这个消息中i的值是2,那么a的值就是2+4=6,它是>5的,满足SQL的条件,所以被消费掉了。这完全符合我们的预期。 总结 今天的两个小功能还是比较有意思的,但里边也有需要注意的地方, 消息的批量发送,只要我们满足它的条件,然后使用List发送就可以了;批量消费,默认的消费个数是1,我们可以调整它的值,这样就可以一次消费多个消息了; 过滤消息中,最大的坑就是队列的配置里,需要设置enablePropertyFilter=true,否则消费端在启动的时候报不支持SQL的错误; 我们在使用的时候,多加留意就可以了,有问题,评论区留言吧~
今天要给大家介绍RocketMQ中的两个功能,一个是“广播”,这个功能是比较基础的,几乎所有的mq产品都是支持这个功能的;另外一个是“延迟消费”,这个应该算是RocketMQ的特色功能之一了吧。接下来,我们就分别看一下这两个功能。 广播 广播是把消息发送给订阅了这个主题的所有消费者。这个定义很清楚,但是这里边的知识点你都掌握了吗?咱们接着说“广播”的机会,把消费者这端的内容好好和大家说说。 首先,消费者端的概念中,最大的应该是消费者组,一个消费者组中可以有多个消费者,这些消费者必须订阅同一个Topic。 那么什么算是一个消费者呢?我们在写消费端程序时,看到了setConsumeThreadMax这个方法,设置消费者的线程数,难道一个线程就是一个消费者?错!这里的一个消费者是一个进程,你可以理解为ip+端口。如果在同一个应用中,你实例化了两个消费者,这两个消费者配置了相同的消费者组名称,那么应用程序启动时会报错的,这里不给大家演示了,感兴趣的小伙伴私下里试一下吧。 同一个消息,可以被不同的消费者组同时消费。假设,我有两个消费者组cg-1和cg-2,这两个消费者组订阅了同一个Topic,那么这个Topic的消息会被cg-1和cg-2同时消费。那这是不是广播呢?错!当然不是广播,广播是同一个消费者组中的多个消费者都消费这个消息。如果配置的不是广播,像前几个章节中的那样,一个消息只能被一个消费者组消费一次。 好了,说了这么多,我们实验一下吧,先把消费者配置成广播,如下: @Bean(name = "broadcast", initMethod = "start",destroyMethod = "shutdown") public DefaultMQPushConsumer broadcast() throws MQClientException { DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("broadcast"); consumer.setNamesrvAddr("192.168.73.130:9876;192.168.73.131:9876;192.168.73.132:9876;"); consumer.subscribe("cluster-topic","*"); consumer.setMessageModel(MessageModel.BROADCASTING); consumer.registerMessageListener((MessageListenerConcurrently) (msgs, context) -> { for (MessageExt msg : msgs) { System.out.println(new String(msg.getBody())); } return ConsumeConcurrentlyStatus.CONSUME_SUCCESS; }); return consumer; } 其中,NameServer,订阅的Topic都没有变化。 注意其中consumer.setMessageModel(MessageModel.BROADCASTING);这段代码,设置消费者为广播。咱们可以看一下,MessageModel枚举中只有两个值,BROADCASTING和CLUSTERING,默认为CLUSTERING。 因为要测试广播,所以我们要启动多个消费者,还记得什么是消费者吗?对了,一个ip+端口算是一个消费者,在这里我们启动两个应用,端口分别是8080和8081。发送端的程序不变,如下: @Test public void producerTest() throws Exception { for (int i = 0;i<5;i++) { MessageExt message = new MessageExt(); message.setTopic("cluster-topic"); message.setKeys("key-"+i); message.setBody(("this is simpleMQ,my NO is "+i+"---"+new Date()).getBytes()); SendResult sendResult = defaultMQProducer.send(message); System.out.println("i=" + i); System.out.println("BrokerName:" + sendResult.getMessageQueue().getBrokerName()); } } 我们执行一下发送端的程序,日志如下: i=0 BrokerName:broker-a i=1 BrokerName:broker-a i=2 BrokerName:broker-b i=3 BrokerName:broker-b i=4 BrokerName:broker-b 再来看看8080端口的应用后台打印出来的日志: 消费了5个消息,再看看8081的后台打印的日志, 也消费了5个。两个消费者同时消费了消息,这就是广播。有的小伙伴可能会有疑问了,如果不设置广播,会怎么样呢?私下里实验一下吧,上面的程序中,只要把设置广播的那段代码注释掉就可以了。运行的结果当然是只有一个消费者可以消费消息。 延迟消息 延迟消息是指消费者过了一个指定的时间后,才去消费这个消息。大家想象一个电商中场景,一个订单超过30分钟未支付,将自动取消。这个功能怎么实现呢?一般情况下,都是写一个定时任务,一分钟扫描一下超过30分钟未支付的订单,如果有则被取消。这种方式由于每分钟查询一下订单,一是时间不精确,二是查库效率比较低。这个场景使用RocketMQ的延迟消息最合适不过了,我们看看怎么发送延迟消息吧,发送端代码如下: @Test public void producerTest() throws Exception { for (int i = 0;i<1;i++) { MessageExt message = new MessageExt(); message.setTopic("cluster-topic"); message.setKeys("key-"+i); message.setBody(("this is simpleMQ,my NO is "+i+"---"+new Date()).getBytes()); message.setDelayTimeLevel(2); SendResult sendResult = defaultMQProducer.send(message); System.out.println("i=" + i); System.out.println("BrokerName:" + sendResult.getMessageQueue().getBrokerName()); } } 我们只是增加了一句message.setDelayTimeLevel(2); 为了方便,这次我们只发送一个消息。 setDelayTimeLevel是什么意思,设置的是2,难道是2s后消费吗?怎么参数也没有时间单位呢?如果我要自定义延迟时间怎么办?我相信很多小伙伴都有这样的疑问,我也是带着这样的疑问查了很多资料,最后在RocketMQ的Github官网上看到了说明, 在RocketMQ的源码中,有一个MessageStoreConfig类,这个类中定义了延迟的时间,我们看一下, // org/apache/rocketmq/store/config/MessageStoreConfig.java private String messageDelayLevel = "1s 5s 10s 30s 1m 2m 3m 4m 5m 6m 7m 8m 9m 10m 20m 30m 1h 2h"; 我们在程序中设置的是2,那么这个消息将在5s以后被消费。 目前RocketMQ还不支持自定义延迟时间,延迟时间只能从上面的时间中选。如果你非要定义一个时间怎么办呢?RocketMQ是开源的,下载代码,把上面的时间改一下,再打包部署,就OK了。 再看看消费端的代码, @Bean(name = "broadcast", initMethod = "start",destroyMethod = "shutdown") public DefaultMQPushConsumer broadcast() throws MQClientException { DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("broadcast"); consumer.setNamesrvAddr("192.168.73.130:9876;192.168.73.131:9876;192.168.73.132:9876;"); consumer.subscribe("cluster-topic","*"); consumer.setMessageModel(MessageModel.BROADCASTING); consumer.registerMessageListener((MessageListenerConcurrently) (msgs, context) -> { for (MessageExt msg : msgs) { Date now = new Date(); System.out.println("消费时间:"+now); Date msgTime = new Date(); msgTime.setTime(msg.getBornTimestamp()); System.out.println("消息生成时间:"+msgTime); System.out.println(new String(msg.getBody())); } return ConsumeConcurrentlyStatus.CONSUME_SUCCESS; }); return consumer; } 我们还是使用广播的模式,没有变。 打印出了当前的时间,这个时间就是消费的时间。 通过msg.getBornTimestamp()方法,获得了消息的生成时间,也打印出来,看看是不是延迟5s。 启动两个消费者8080和8081,发送消息,再看看消费者的后台日志, 消费时间:Thu Jun 11 14:45:53 CST 2020 消息生成时间:Thu Jun 11 14:45:48 CST 2020 this is simpleMQ,my NO is 0---Thu Jun 11 14:45:47 CST 2020 我们看到消费时间比生成时间晚5s,符合我们的预期。这个功能还是比较实用的,如果能够自定义延迟时间就更好了。 总结 RocketMQ的这两个知识点还是比较简单的,大家要分清楚什么是消费者组,什么是消费者,什么是消费者线程。另外就是延迟消息是不支持自定义的,大家可以在Github上看一下源码。好了~今天就到这里了。
Spring AOP 面向切面编程,相信大家都不陌生,它和Spring IOC是Spring赖以成名的两个最基础的功能。在咱们平时的工作中,使用IOC的场景比较多,像咱们平时使用的@Controller、@Service、@Repository、@Component、@Autowired等,这些都和IOC相关。但是,使用AOP的场景却非常少,也就是在事务控制这里使用到了AOP,随着SpringBoot的流行,事务控制这块也不用自己配置了,SpringBoot内部已经给咱们配置好了,我们只需要使用@Transactional这个注解就可以了。 Spring AOP作为Spring的基础功能,大家在学习的时候肯定都学过,但是由于平时使用的比较少,渐渐的就遗忘了,今天我们就再来看看Spring AOP,全面的给大家讲一下,我本人也忘记的差不多了,在面试的时候,人家问我Spring AOP怎么使用,我回答:呵呵,忘得差不多了。对方也是微微一笑,回了一个呵呵。好了,咱们具体看看Spring AOP吧。 Spring AOP解决的问题 面向切面编程,通俗的讲就是将你执行的方法横切,在方法前、后或者抛出异常时,执行你额外的代码。比如:你想要在执行所有的方法前,要验证当前的用户有没有权限执行这个方法。如果没有AOP,你的做法是写个验证用户权限的方法,然后在所有的方法中,都去调用这个公共方法,如果有权限再去执行后面的方法。这样做是可以的,但是显得比较啰嗦,而且硬编码比较多,如果哪个小朋友忘了这个权限验证,那就麻烦了。 现在我们有了AOP,只需要几个简单的配置,就可以在所有的方法之前,去执行我们的验证权限的公共方法。 Sping AOP的核心概念 在AOP当中,核心的术语非常多,有8个,而且理解起来也是晦涩难懂,在这里给大家罗列一下,大家如果感兴趣可以去查阅一下其他的资料。 AOP的8个术语:切面(Aspect)、连接点(Join point)、通知(Advice)、切点(Pointcut)、引入(Introduction)、目标对象(Target object)、AOP代理(AOP proxy)、编织(Weaving)。 在这里,我个人觉得Spring AOP总结成以下几个概念就可以了。 切面(Aspect):在Spring AOP的实际使用中,只是标识的这一段是AOP的配置,没有其他的意义。 切点(Pointcut):就是我们的方法中,哪些方法需要被代理,它需要一个表达式,凡是匹配成功的方法都会执行你定义的通知。 通知(Advice):就是你要另外执行的方法,在前面的例子中,就是权限校验的方法。 好了,知道这几个概念,我个人觉得在平时工作中已经足够了。 Sping AOP的具体配置——注解 我们的实例采用SpringBoot项目搭建,首先我们要把Spring AOP的依赖添加到项目中,具体的maven配置如下: <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency> 具体的版本我们跟随SpringBoot的版本即可。既然它是个starter,我比较好奇它在配置文件中有哪些配置,我们到application.properties中去看一下, spring.aop.auto=true spring.aop.proxy-target-class=true spring.aop.auto:它的默认值是true,它的含义是:Add @EnableAspectJAutoProxy,也就是说,我们在配置类上不再需要标注@EnableAspectJAutoProxy了。这个算是Spring AOP中的一个知识点了,我们重点说一下。 我们在Spring中使用AOP,需要两个条件,第一个,要在我们的项目中引入aspectjweaver.jar,这个,我们在引入spring-boot-starter-aop的时候已经自动引入了。第二个,就是我们我们的配置类中,标注@EnableAspectJAutoProxy 这个注解,如果你使用的是xml的方式,需要你在xml文件中标明。这样才能在我们的项目使用Spring-AOP。 spring.aop.proxy-target-class:AOP代理的实现,这个值默认也是true。它的含义是是否使用CGLIB代理。这也是AOP中的一个知识点。 Spring AOP的代理有两种,一种是标准的JDK动态代理,它只能代理接口,也即是我们在使用的时候,必须写一个接口和实现类,在实现类中写自己的业务逻辑,然后通过接口实现AOP。另外一种是使用CGLIB 代理,它可以实现对类的代理,这样我们就不用去写接口了。 Spring AOP默认使用的是JDK动态代理,只能代理接口,而我们在开发的时候为了方便,希望可以直接代理类,这就需要引入CGLIB ,spring.aop.proxy-target-class默认是true,使用CGLIB,我们可以放心大胆的直接代理类了。 通过前面的步骤,我们已经可以在项目中使用Spring AOP代理了,下面我们先创建个Service,再写个方法,如下: @Service public class MyService { public void gotorun() { System.out.println("我要去跑步!"); } } 我们写了个“我要去跑步”的方法,然后再通过AOP,在方法执行之前,打印出“我穿上了跑鞋”。下面重点看一下这个AOP代理怎么写, @Component @Aspect public class MyAspect { @Pointcut("execution(public * com.example.springaopdemo.service.*.*(..))") private void shoes() {} @Before("com.example.springaopdemo.aspect.MyAspect.shoes()") public void putonshoes() { System.out.println("我穿上跑步鞋。"); } } 首先,要创建一个切面,我们在类上使用@Aspect注解,标识着这个类是一个切面,而@Component注解是将这个类实例化,这样这个切面才会起作用。如果只有@Aspect而没有被Spring实例是不起作用的。当然Spring实例化的方法有很多,不一定就非要使用@Component。 再来看看切点的声明,切点的作用是匹配哪些方法要使用这个代理,我们使用@Pointcut注解。@Pointcut注解中有个表达式就是匹配我们的方法用到,它的种类也有很多,这里我们给大家列出几个比较常用的execution表达式吧, execution(public * *(..)) //匹配所有的public方法 execution(* set*(..)) //匹配所有以set开头的方法 execution(* com.xyz.service.AccountService.*(..)) //匹配所有AccountService中的方法 execution(* com.xyz.service.*.*(..)) //匹配所有service包中的方法 execution(* com.xyz.service..*.*(..)) //匹配所有service包及其子包中的方法 示例中,我们匹配的是service包中的所有方法。 有没有同学比较好奇@Pointcut下面的那个方法?这个方法到底有没有用?方法中如果有其他的操作会不会执行?答案是:方法里的内容不会被执行。那么它有什么用呢?它仅仅是给@Pointcut一个落脚的地方,仅此而已。但是,Spring对一个方法也是有要求的,这个方法的返回值类型必须是void。原文是这么写的:** the method serving as the pointcut signature must have a void return type 最后我们再来看看通知,通知的种类有5种,分别为: @Before:前置通知,在方法之前执行; @AfterReturning:返回通知,方法正常返回以后,执行通知方法; @AfterThrowing:异常通知,方法抛出异常后,执行通知方法; @After:也是返回通知,不管方法是否正常结束,都会执行这个方法,类似于finally; @Around:环绕通知,在方法执行前后,都会执行通知方法; 在示例中,使用的是@Before前置通知,我们最关心的是@Before里的内容: @Before("com.example.springaopdemo.aspect.MyAspect.shoes()") public void putonshoes() { System.out.println("我穿上跑步鞋。"); } @Before里的内容是切点的方法,也就是我们定义的shoes()方法。那么所有匹配了shoes()切点的方法,都会执行@Before这个注解的方法,也就是putonshoes()。 在@Before里除了写切点的方法,还可以直接写切点表达式,例如: @Before("execution(public * com.example.springaopdemo.service.*.*(..))") public void putonshoes() { System.out.println("我穿上跑步鞋。"); } 如果我们使用这种表达式的写法,就可以省去前面的@Pointcut了,这种方法还是比较推荐的。我们再写个测试类运行一下,看看效果吧, @SpringBootTest class SpringAopDemoApplicationTests { @Autowired private MyService myService; @Test public void testAdvice() { myService.gotorun(); } } 运行结果如下: 我穿上跑步鞋。 我要去跑步! 没有问题,在执行“我要去跑步”之前,成功的执行了“我穿上跑步鞋”的方法。 好了,今天先到这里,下一篇我们看看如何使用xml的方式配置Spring AOP。
折腾了好长时间才写这篇文章,顺序消费,看上去挺好理解的,就是消费的时候按照队列中的顺序一个一个消费;而并发消费,则是消费者同时从队列中取消息,同时消费,没有先后顺序。RocketMQ也有这两种方式的实现,但是在实践的过程中,就是不能顺序消费,好不容易能够实现顺序消费了,发现采用并发消费的方式,消费的结果也是顺序的,顿时就蒙圈了,到底怎么回事?哪里出了问题?百思不得其解。 经过多次调试,查看资料,debug跟踪程序,最后终于搞清楚了,但是又不知道怎么去写这篇文章,是按部就班的讲原理,讲如何配置到最后实现,还是按照我的调试过程去写呢?我觉得还是按照我的调试过程去写这篇文章吧,因为我的调成过程应该和大多数人的理解思路是一致的,大家也更容易重视。 环境回顾 我们先来回顾一下前面搭建的RocketMQ的环境,这对于我们理解RocketMQ的顺序消费是至关重要的。我们的RocketMQ环境是一个两主两从的异步集群,其中有两个broker,broker-a和broker-b,另外,我们创建了两个Topic,“cluster-topic”,这个Topic我们在创建的时候指定的是集群,也就是说我们发送消息的时候,如果Topic指定为“cluster-topic”,那么这个消息应该在broker-a和broker-b之间负载;另外创建的一个Topic是“broker-a-topic”,这个Topic我们在创建的时候指定的是broker-a,当我们发送这个Topic的消息时,这个消息只会在broker-a当中,不会出现在broker-b中。 和大家罗嗦了这么多,大家只要记住,我们的环境中有两个broker,“broker-a”和“broker-b”,有两个Topic,“cluster-topic”和“broker-a-topic”就可以了。 cluster-topic可以顺序消费吗 我们发送的消息,如果指定Topic为“cluster-topic”,那么这种消息将在broker-a和broker-b直接负载,这种情况能够做到顺序消费吗?我们试验一下, 消费端的代码如下: @Bean(name = "pushConsumerOrderly", initMethod = "start",destroyMethod = "shutdown") public DefaultMQPushConsumer pushConsumerOrderly() throws MQClientException { DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("pushConsumerOrderly"); consumer.setNamesrvAddr("192.168.73.130:9876;192.168.73.131:9876;192.168.73.132:9876;"); consumer.subscribe("cluster-topic","*"); consumer.registerMessageListener((MessageListenerOrderly) (msgs, context) -> { Random random = new Random(); try { Thread.sleep(random.nextInt(5) * 1000); } catch (InterruptedException e) { e.printStackTrace(); } for (MessageExt msg : msgs) { System.out.println(new String(msg.getBody())); } return ConsumeOrderlyStatus.SUCCESS; }); return consumer; } 消费者组的名称,连接的NameServer,订阅的Topic,这里就不多说了; 再来看一下注册的消息监听器,它是MessageListenerOrderly,顺序消费,具体实现里我们打印出了消息体的内容,最后返回消费成功ConsumeOrderlyStatus.SUCCESS。 重点看一下打印语句之前的随机休眠,这是非常重要的一步,它可以验证消息是否是顺序消费的,如果消费者是消费完一个消息以后,再去取下一个消息,那么顺序是没有问题,但是如果消费者是并发地取消息,但是每个消费者的休眠时间又不一样,那么打印出来的就是乱序 生产端我们采用同步发送的方式,代码如下: @Test public void producerTest() throws Exception { for (int i = 0;i<5;i++) { Message message = new Message(); message.setTopic("cluster-topic"); message.setKeys("key-"+i); message.setBody(("this is simpleMQ,my NO is "+i+"---"+new Date()).getBytes()); SendResult sendResult = defaultMQProducer.send(message); System.out.println("i=" + i); System.out.println("BrokerName:" + sendResult.getMessageQueue().getBrokerName()); } } 和前面一样,我们发送5个消息,并且打印出i的值和broker的名称,发送消息的顺序是0,1,2,3,4,发送完成后,我们观察一下消费端的日志,如果顺序也是0,1,2,3,4,那么就是顺序消费。我们运行一下,看看结果吧。 生产者的发送日志如下: i=0 BrokerName:broker-a i=1 BrokerName:broker-a i=2 BrokerName:broker-a i=3 BrokerName:broker-a i=4 BrokerName:broker-b 发送5个消息,其中4个在broker-a,1个在broker-b。再来看看消费端的日志: this is simpleMQ,my NO is 3---Wed Jun 10 13:48:57 CST 2020 this is simpleMQ,my NO is 2---Wed Jun 10 13:48:57 CST 2020 this is simpleMQ,my NO is 4---Wed Jun 10 13:48:57 CST 2020 this is simpleMQ,my NO is 1---Wed Jun 10 13:48:57 CST 2020 this is simpleMQ,my NO is 0---Wed Jun 10 13:48:56 CST 2020 顺序是乱的?怎么回事?说明消费者在并不是一个消费完再去消费另一个,而是拉取了一个消息以后,并没有消费完就去拉取下一个消息了,那这不是并发消费吗?可是我们程序中设置的是顺序消费啊。这里我们就开始怀疑是broker的问题,难道是因为两个broker引起的?顺序消费只能在一个broker里才能实现吗?那我们使用broker-a-topic这个试一下吧。 broker-a-topic可以顺序消费吗? 我们把上面的程序稍作修改,只把订阅的Topic和发送消息时消息的Topic改为broker-a-topic即可。代码在这里就不给大家重复写了,重启一下程序,发送消息看看日志吧。 生产者端的日志如下: i=0 BrokerName:broker-a i=1 BrokerName:broker-a i=2 BrokerName:broker-a i=3 BrokerName:broker-a i=4 BrokerName:broker-a 我们看到5个消息都发送到了broker-a中,再来看看消费端的日志, this is simpleMQ,my NO is 0---Wed Jun 10 14:00:28 CST 2020 this is simpleMQ,my NO is 2---Wed Jun 10 14:00:29 CST 2020 this is simpleMQ,my NO is 3---Wed Jun 10 14:00:29 CST 2020 this is simpleMQ,my NO is 4---Wed Jun 10 14:00:29 CST 2020 this is simpleMQ,my NO is 1---Wed Jun 10 14:00:29 CST 2020 消费的顺序还是乱的,这是怎么回事?消息都在broker-a中了,为什么消费时顺序还是乱的?程序有问题吗?review了好几遍没有发现问题。 问题排查 问题卡在这个地方,卡了好长时间,最后在官网的示例中发现,它在发送消息时,使用了一个MessageQueueSelector,我们也实现一下试试吧,改造一下发送端的程序,如下: SendResult sendResult = defaultMQProducer.send(message, new MessageQueueSelector() { @Override public MessageQueue select(List<MessageQueue> mqs, Message msg, Object arg) { return mqs.get(0); } },i); 在发送的方法中,我们实现了MessageQueueSelector接口中的select方法,这个方法有3个参数,mq的集合,发送的消息msg,和我们传入的参数,这个参数就是最后的那个变量i,大家不要漏了。这个select方法需要返回的是MessageQueue,也就是mqs变量中的一个,那么mqs中有多少个MessageQueue呢?我们猜测是2个,因为我们只有broker-a和broker-b,到底是不是呢?我们打断点看一下, MessageQueue有8个,并且brokerName都是broker-a,原来Broker和MessageQueue不是相同的概念,之前我们都理解错了。我们可以用下面的方式理解, 集群 --------》 Broker ------------》 MessageQueue 一个RocketMQ集群里可以有多个Broker,一个Broker里可以有多个MessageQueue,默认是8个。 那现在对于顺序消费,就有了正确的理解了,顺序消费是只在一个MessageQueue内,顺序消费,我们验证一下吧,先看看发送端的日志, i=0 BrokerName:broker-a i=1 BrokerName:broker-a i=2 BrokerName:broker-a i=3 BrokerName:broker-a i=4 BrokerName:broker-a 5个消息都发送到了broker-a中,通过前面的改造程序,这5个消息应该都是在MessageQueue-0当中,再来看看消费端的日志, this is simpleMQ,my NO is 0---Wed Jun 10 14:21:40 CST 2020 this is simpleMQ,my NO is 1---Wed Jun 10 14:21:41 CST 2020 this is simpleMQ,my NO is 2---Wed Jun 10 14:21:41 CST 2020 this is simpleMQ,my NO is 3---Wed Jun 10 14:21:41 CST 2020 this is simpleMQ,my NO is 4---Wed Jun 10 14:21:41 CST 2020 这回是顺序消费了,每一个消费者都是等前面的消息消费完以后,才去消费下一个消息,这就完全解释的通了,我们再把消费端改成并发消费看看,如下: @Bean(name = "pushConsumerOrderly", initMethod = "start",destroyMethod = "shutdown") public DefaultMQPushConsumer pushConsumerOrderly() throws MQClientException { DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("pushConsumerOrderly"); consumer.setNamesrvAddr("192.168.73.130:9876;192.168.73.131:9876;192.168.73.132:9876;"); consumer.subscribe("broker-a-topic","*"); consumer.registerMessageListener((MessageListenerConcurrently) (msgs, context) -> { Random random = new Random(); try { Thread.sleep(random.nextInt(5) * 1000); } catch (InterruptedException e) { e.printStackTrace(); } for (MessageExt msg : msgs) { System.out.println(new String(msg.getBody())); } return ConsumeConcurrentlyStatus.CONSUME_SUCCESS; }); return consumer; } 这回使用的是并发消费,我们再看看结果, i=0 BrokerName:broker-a i=1 BrokerName:broker-a i=2 BrokerName:broker-a i=3 BrokerName:broker-a i=4 BrokerName:broker-a 5个消息都在broker-a中,并且知道它们都在同一个MessageQueue中,再看看消费端, this is simpleMQ,my NO is 1---Wed Jun 10 14:28:00 CST 2020 this is simpleMQ,my NO is 0---Wed Jun 10 14:28:00 CST 2020 this is simpleMQ,my NO is 3---Wed Jun 10 14:28:00 CST 2020 this is simpleMQ,my NO is 2---Wed Jun 10 14:28:00 CST 2020 this is simpleMQ,my NO is 4---Wed Jun 10 14:28:00 CST 2020 是乱序的,说明消费者是并发的消费这些消息的,即使它们在同一个MessageQueue中。 总结 好了,到这里终于把顺序消费搞明白了,其中的关键就是Broker中还有多个MessageQueue,同一个MessageQueue中的消息才能顺序消费。
前面的章节,我们已经把RocketMQ的环境搭建起来了,是一个两主两从的异步集群。接下来,我们就看看怎么去使用RocketMQ,在使用之前,先要在NameServer中创建Topic,我们知道RocketMQ是基于Topic的消息队列,在生产者发送消息的时候,要指定消息的Topic,这个Topic的路由规则是怎样的,这些都要在NameServer中去创建。 Topic的创建 我们先看看Topic的命令是如何使用的,如下: ./bin/mqadmin updateTopic -h usage: mqadmin updateTopic -b <arg> | -c <arg> [-h] [-n <arg>] [-o <arg>] [-p <arg>] [-r <arg>] [-s <arg>] -t <arg> [-u <arg>] [-w <arg>] -b,--brokerAddr <arg> create topic to which broker -c,--clusterName <arg> create topic to which cluster -h,--help Print help -n,--namesrvAddr <arg> Name server address list, eg: 192.168.0.1:9876;192.168.0.2:9876 -o,--order <arg> set topic's order(true|false) -p,--perm <arg> set topic's permission(2|4|6), intro[2:W 4:R; 6:RW] -r,--readQueueNums <arg> set read queue nums -s,--hasUnitSub <arg> has unit sub (true|false) -t,--topic <arg> topic name -u,--unit <arg> is unit topic (true|false) -w,--writeQueueNums <arg> set write queue nums 其中有一段,-b <arg> | -c <arg>,说明这个Topic可以指定集群,也可以指定队列,我们先创建一个Topic指定集群,因为集群中有两个队列broker-a和broker-b,看看我们的消息是否在两个队列中负载;然后再创建一个Topic指向broker-a,再看看这个Topic的消息是不是只在broker-a中。 创建两个Topic, ./bin/mqadmin updateTopic -c 'RocketMQ-Cluster' -t cluster-topic -n '192.168.73.130:9876;192.168.73.131:9876;192.168.73.132:9876' ./bin/mqadmin updateTopic -b 192.168.73.130:10911 -t broker-a-topic 第一个命令创建了一个集群的Topic,叫做cluster-topic;第二个命令创建了一个只在broker-a中才有的Topic,我们指定了-b 192.168.73.130:10911,这个是broker-a的地址和端口。 生产者发送消息 我们新建SpringBoot项目,然后引入RocketMQ的jar包, <dependency> <groupId>org.apache.rocketmq</groupId> <artifactId>rocketmq-client</artifactId> <version>4.3.0</version> </dependency> 然后配置一下生产者的客户端,在这里使用@Configuration这个注解,具体如下: @Configuration public class RocketMQConfig { @Bean(initMethod = "start",destroyMethod = "shutdown") public DefaultMQProducer producer() { DefaultMQProducer producer = new DefaultMQProducer("DefaultMQProducer"); producer.setNamesrvAddr("192.168.73.130:9876;192.168.73.131:9876;192.168.73.132:9876;"); return producer; } } 首先创建一个生产者组,名字叫做DefaultMQProducer; 然后指定NameServer,192.168.73.130:9876;192.168.73.131:9876;192.168.73.132:9876; 最后在@Bean注解中指定初始化的方法,和销毁的方法; 这样,生产者的客户端就配置好了,然后再写个Test类,在Test类中向MQ中发送消息,如下, @SpringBootTest class RocketmqDemoApplicationTests { @Autowired public DefaultMQProducer defaultMQProducer; @Test public void producerTest() throws Exception { for (int i = 0;i<5;i++) { Message message = new Message(); message.setTopic("cluster-topic"); message.setKeys("key-"+i); message.setBody(("this is simpleMQ,my NO is "+i).getBytes()); SendResult sendResult = defaultMQProducer.send(message); System.out.println("SendStatus:" + sendResult.getSendStatus()); System.out.println("BrokerName:" + sendResult.getMessageQueue().getBrokerName()); } } } 我们先自动注入前面配置DefaultMQProducer; 然后在Test方法中,循环5次,发送5个消息,消息的Topic指定为cluster-topic,是集群的消息,然后再设置消息的key和内容,最后调用send方法发送消息,这个send方法是同步方法,程序运行到这里会阻塞,等待返回的结果; 最后,我们打印出返回的结果和broker的名字; 运行一下,看看结果: SendStatus:SEND_OK BrokerName:broker-b SendStatus:SEND_OK BrokerName:broker-b SendStatus:SEND_OK BrokerName:broker-b SendStatus:SEND_OK BrokerName:broker-b SendStatus:SEND_OK BrokerName:broker-a 5个消息发送都是成功的,而发送的队列有4个是broker-b,1个broker-a,说明两个broker之间还是有负载的,负载的规则我们猜测是随机。 我们再写个测试方法,看看broker-a-topic这个Topic的发送结果是什么样子的,如下: @Test public void brokerTopicTest() throws Exception { for (int i = 0;i<5;i++) { Message message = new Message(); message.setTopic("broker-a-topic"); message.setKeys("key-"+i); message.setBody(("this is broker-a-topic's MQ,my NO is "+i).getBytes()); defaultMQProducer.send(message, new SendCallback() { @Override public void onSuccess(SendResult sendResult) { System.out.println("SendStatus:" + sendResult.getSendStatus()); System.out.println("BrokerName:" + sendResult.getMessageQueue().getBrokerName()); } @Override public void onException(Throwable e) { e.printStackTrace(); } }); System.out.println("异步发送 i="+i); } } 消息的Topic指定的是broker-a-topic,这个Topic我们只指定了broker-a这个队列; 发送的时候我们使用的是异步发送,程序到这里不会阻塞,而是继续向下执行,发送的结果正常或者异常,会调用对应的onSuccess和onException方法; 我们在onSuccess方法中,打印出发送的结果和队列的名称; 运行一下,看看结果: 异步发送 i=0异步发送 i=1异步发送 i=2异步发送 i=3异步发送 i=4SendStatus:SEND_OKSendStatus:SEND_OKSendStatus:SEND_OKSendStatus:SEND_OKBrokerName:broker-aSendStatus:SEND_OKBrokerName:broker-aBrokerName:broker-aBrokerName:broker-aBrokerName:broker-a 由于我们是异步发送,所以最后的日志先打印了出来,然后打印出返回的结果,都是发送成功的,并且队列都是broker-a,完全符合我们的预期。 消费者 生产的消息已经发送到了队列当中,再来看看消费者端如何消费这个消息,我们在这个配置类中配置消费者,如下: @Bean(initMethod = "start",destroyMethod = "shutdown") public DefaultMQPushConsumer pushConsumer() throws MQClientException { DefaultMQPushConsumer consumer = new DefaultMQPushConsumer("DefaultMQPushConsumer"); consumer.setNamesrvAddr("192.168.73.130:9876;192.168.73.131:9876;192.168.73.132:9876;"); consumer.subscribe("cluster-topic","*"); consumer.registerMessageListener(new MessageListenerConcurrently() { @Override public ConsumeConcurrentlyStatus consumeMessage(List<MessageExt> msgs, ConsumeConcurrentlyContext context) { if (msgs!=null&&msgs.size()>0) { for (MessageExt msg : msgs) { System.out.println(new String(msg.getBody())); System.out.println(context.getMessageQueue().getBrokerName()); } } return ConsumeConcurrentlyStatus.CONSUME_SUCCESS; } } ); return consumer; } 我们创建了一个消费者组,名字叫做DefaultMQPushConsumer; 然后指定NameServer集群,192.168.73.130:9876;192.168.73.131:9876;192.168.73.132:9876; 消费者订阅的Topic,这里我们订阅的是cluster-topic,后面的*号是对应的tag,代表我们订阅所有的tag; 最后注册一个并发执行的消息监听器,实现里边的consumeMessage方法,在方法中,我们打印出消息体的内容,和消息所在的队列; 如果消息消费成功,返回CONSUME_SUCCESS,如果出现异常等情况,我们要返回RECONSUME_LATER,说明这个消息还要再次消费; 好了,这个订阅了cluster-topic的消费者,配置完了,我们启动一下项目,看看消费的结果如何, this is simpleMQ,my NO is 2 broker-b this is simpleMQ,my NO is 3 broker-b this is simpleMQ,my NO is 1 broker-b this is simpleMQ,my NO is 0 broker-a this is simpleMQ,my NO is 4 broker-b 结果符合预期,cluster-topic中的5个消息全部消费成功,而且队列是4个broker-b,1个broker-a,和发送时的结果是一致的。 大家有问题欢迎评论区讨论~
RocketMQ的基本概念在上一篇中给大家介绍了,这一节将给大家介绍环境搭建。RocketMQ中最基础的就是NameServer,我们先来看看它是怎么搭建的。 NameServer RocketMQ要求的环境是JDK8以上,我们先检查一下环境, [root@centOS-1 ~]# java -version openjdk version "11.0.3" 2019-04-16 LTS OpenJDK Runtime Environment 18.9 (build 11.0.3+7-LTS) OpenJDK 64-Bit Server VM 18.9 (build 11.0.3+7-LTS, mixed mode, sharing) 我的这个机器并没有刻意的安装JDK,而是系统自带的OpenJDK 11,这应该也是没有问题的。然后我们从RocketMQ官网下载最新的安装包,并且上传到/opt目录下, [root@centOS-1 opt]# ll -rw-r--r--. 1 root root 13838456 6月 3 08:49 rocketmq-all-4.7.0-bin-release.zip 然后我们解压这个zip包, [root@centOS-1 opt]# unzip rocketmq-all-4.7.0-bin-release.zip 这里使用的是unzip命令,如果你的机器里没有这个命令,可以使用yum install安装一个。解压以后,进入到RocketMQ的主目录,并且启动一下NameServer。 [root@centOS-1 opt]# cd rocketmq-all-4.7.0-bin-release [root@centOS-1 rocketmq-all-4.7.0-bin-release]# ./bin/mqnamesrv OpenJDK 64-Bit Server VM warning: Option UseConcMarkSweepGC was deprecated in version 9.0 and will likely be removed in a future release. Unrecognized VM option 'UseCMSCompactAtFullCollection' Error: Could not create the Java Virtual Machine. Error: A fatal exception has occurred. Program will exit. 这里出了一个错误Error: Could not create the Java Virtual Machine,这是由于RocketMQ的启动文件都是按照JDK8配置的,而我们这里使用的是OpenJDK11,有很多命令参数不支持导致的,如果小伙伴们使用的是JDK8,正常启动是没有问题的。 在这里我们改一下RocketMQ的启动文件, [root@centOS-1 rocketmq-all-4.7.0-bin-release]# vim bin/runserver.sh export JAVA_HOME export JAVA="$JAVA_HOME/bin/java" export BASE_DIR=$(dirname $0)/.. #在CLASSPATH中添加RocketMQ的lib目录 #export CLASSPATH=.:${BASE_DIR}/conf:${CLASSPATH} export CLASSPATH=.:${BASE_DIR}/lib/*:${BASE_DIR}/conf:${CLASSPATH} 修改的地方我们增加了注释,在ClassPath里添加了lib目录,然后在这个文件的末尾,注释掉升级JDK后不支持的几个参数, JAVA_OPT="${JAVA_OPT} -server -Xms4g -Xmx4g -Xmn2g -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=320m" #JAVA_OPT="${JAVA_OPT} -XX:+UseConcMarkSweepGC -XX:+UseCMSCompactAtFullCollection -XX:CMSInitiatingOccupancyFraction=70 -XX:+CMSParallelRemarkEnabled -XX:SoftRefLRUPolicyMSPerMB=0 -XX:+CMSClassUnloadingEnabled -XX:SurvivorRatio=8 -XX:-UseParNewGC" JAVA_OPT="${JAVA_OPT} -verbose:gc -Xloggc:${GC_LOG_DIR}/rmq_srv_gc_%p_%t.log -XX:+PrintGCDetails" #JAVA_OPT="${JAVA_OPT} -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=5 -XX:GCLogFileSize=30m" JAVA_OPT="${JAVA_OPT} -XX:-OmitStackTraceInFastThrow" JAVA_OPT="${JAVA_OPT} -XX:-UseLargePages" #JAVA_OPT="${JAVA_OPT} -Djava.ext.dirs=${JAVA_HOME}/jre/lib/ext:${BASE_DIR}/lib" #JAVA_OPT="${JAVA_OPT} -Xdebug -Xrunjdwp:transport=dt_socket,address=9555,server=y,suspend=n" JAVA_OPT="${JAVA_OPT} ${JAVA_OPT_EXT}" JAVA_OPT="${JAVA_OPT} -cp ${CLASSPATH}" 好了,修改完以后,我们保存退出,再次启动,这次我们在后台启动NameServer, [root@centOS-1 rocketmq-all-4.7.0-bin-release]# nohup ./bin/mqnamesrv & [root@centOS-1 rocketmq-all-4.7.0-bin-release]# tail -500f ~/logs/rocketmqlogs/namesrv.log 然后查看一下日志,在日志中看到main - The Name Server boot success. serializeType=JSON,说明NameServer启动成功了。 单点的NameServer肯定是不能满足我们的要求的,怎么也要做个集群吧。NameServer是一个无状态的服务,节点之间没有任何数据往来,所以NameServer的集群搭建不需要任何的配置,只需要启动多个NameServer服务就可以了,它不像Zookeeper集群搭建那样,需要配置各个节点。在这里我们就启动3个NameServer节点吧,对应我们的3台机器,192.168.73.130,192.168.73.131,192.168.73.132。 Broker NameServer集群搭建完成,下面就搭建Broker了,Broker呢,我们要搭建一个两主两从结构的,主从之间异步备份,保存磁盘也是使用异步的方式。如果你对主从同步和保存磁盘的方式还不了解,看看上一节的内容吧。异步两主两从这种结构的配置,在RocketMQ中已经有例子了,我们先一下配置文件。 [root@centOS-1 rocketmq-all-4.7.0-bin-release]# vim conf/2m-2s-async/broker-a.properties 这个配置文件是broker-a“主”的配置文件, brokerClusterName=RocketMQ-Cluster brokerName=broker-a brokerId=0 deleteWhen=04 fileReservedTime=48 brokerRole=ASYNC_MASTER flushDiskType=ASYNC_FLUSH 其中, brokerClusterName是MQ集群的名称,我们改为RocketMQ-Cluster。 brokerName是队列的名字,配置为broker-a。 brokerId是队列的id,0代表是“主”,其他正整数代表着“从”。 deleteWhen=04 代表着commitLog过期了,就会被删除。 fileReservedTime是commitLog的过期时间,单位是小时,这里配置的是48小时。 brokerRole,队列的角色,ASYNC_MASTER是异步主。 flushDiskType,保存磁盘的方式,异步保存。 再看看broker-a的从配置, brokerClusterName=RocketMQ-Cluster brokerName=broker-a brokerId=1 deleteWhen=04 fileReservedTime=48 brokerRole=SLAVE flushDiskType=ASYNC_FLUSH 其中,集群的名字一样,队列的名字一样,只是brokerId和brokerRole不一样,这里的配置代表着它是队列broker-a的“从”。broker-b的配置和broker-a是一样的,只是brokerName不一样而已,在这里就不贴出来了。 两主两从的配置文件都已经配置好了,我们来规划一下,我们的NameServer是3台192.168.73.130,192.168.73.131,192.168.73.132,broker按照如下部署: broker-a(主):192.168.73.130 broker-a(从):192.168.73.131 broker-b(主):192.168.73.131 broker-b(从):192.168.73.130 接下来,我们启动broker,在192.168.73.130上启动 broker-a(主)和broker-b(从)。和NameServer一样,我们需要修改一下启动的脚本,否则也会报错误。我们修改的是runbroker.sh这个文件,修改的内容和前面是一样的,这里就不赘述了。在启动文件中,内存大小配置的是8g,如果机器的内存不够,可以适当减少一下内存。 这里还要做个说明,由于我们在一台机器上启动了两个broker实例,监听端口和日志存储的路径都会有冲突。那么我们在192.168.73.130的broker-b(从)的配置文件中,增加配置,如下: brokerClusterName=RocketMQ-Cluster brokerName=broker-b brokerId=1 deleteWhen=04 fileReservedTime=48 brokerRole=SLAVE flushDiskType=ASYNC_FLUSH listenPort=11911 storePathRootDir=~/store-b broker-b(从)的端口改为11911,区别默认的10911;storePathRootDir改为~/store-b,区分默认的~/store。 同样在192.168.73.131的broker-a(从)也要做修改,如下: brokerClusterName=RocketMQ-Cluster brokerName=broker-a brokerId=1 deleteWhen=04 fileReservedTime=48 brokerRole=SLAVE flushDiskType=ASYNC_FLUSH listenPort=11911 storePathRootDir=~/store-a 然后,我们在192.168.73.130上启动,如下, nohup ./bin/mqbroker -c conf/2m-2s-async/broker-a.properties -n '192.168.73.130:9876;192.168.73.131:9876;192.168.73.132:9876' & nohup ./bin/mqbroker -c conf/2m-2s-async/broker-b-s.properties -n '192.168.73.130:9876;192.168.73.131:9876;192.168.73.132:9876' & -c 指定的是配置文件,分别指定的是broker-a(主)和broker-b(从)。 -n 指定的是NameServer的地址,指定了3个,用,隔开。 再在192.168.73.131上启动,如下, nohup ./bin/mqbroker -c conf/2m-2s-async/broker-b.properties -n '192.168.73.130:9876;192.168.73.131:9876;192.168.73.132:9876' & nohup ./bin/mqbroker -c conf/2m-2s-async/broker-a-s.properties -n '192.168.73.130:9876;192.168.73.131:9876;192.168.73.132:9876' & 好,如果没有出现错误,到这里,集群就搭建成功了。这里边有个小坑,大家一定要注意,就是-n后面的地址一定要用''括起来,并且地址之间要用;,否则,我们在查看集群列表时,是看不到的。 mqadmin 集群已经搭建好了,我们可以查看一下集群的状态,查看集群的状态,我们可以使用mqadmin,命令如下: ./bin/mqadmin clusterlist -n '192.168.73.130:9876;192.168.73.131:9876;192.168.73.132:9876' clusterlist 是查看集群的命令 -n 后面是NameServer的地址,注意这里也要用''括起来,并且地址之间要用;隔开 执行结果如下: #Cluster Name #Broker Name #BID #Addr #Version #InTPS(LOAD) #OutTPS(LOAD) #PCWait(ms) #Hour #SPACE RocketMQ-Cluster broker-a 0 192.168.73.130:10911 V4_7_0 0.00(0,0ms) 0.00(0,0ms) 0 442039.47 -1.0000 RocketMQ-Cluster broker-a 1 192.168.73.131:11911 V4_7_0 0.00(0,0ms) 0.00(0,0ms) 0 442039.47 0.2956 RocketMQ-Cluster broker-b 0 192.168.73.131:10911 V4_7_0 0.00(0,0ms) 0.00(0,0ms) 0 442039.47 0.2956 RocketMQ-Cluster broker-b 1 192.168.73.130:11911 V4_7_0 0.00(0,0ms) 0.00(0,0ms) 0 442039.47 -1.0000 我们可以看到在这个NameServer中心中,只有一个broker集群RocketMQ-Cluster,有两个broker,broker-a和broker-b,而且每一个broker都有主从,broker的ip我们也可以看到。 好了~ 到这里RocketMQ的集群就搭建好了,有问题评论区留言哦~~
RocketMQ是阿里出品的一款开源的消息中间件,让其声名大噪的就是它的事务消息的功能。在企业中,消息中间件选择使用RocketMQ的还是挺多的,这一系列的文章都是针对RocketMQ的,咱们先从RocketMQ的一些基本概念和环境的搭建开始聊起。 RocketMQ由4部分组成,分别是:名称服务(Name Server)、消息队列(Brokers)、生产者(producer)和消费者(consumer)。这4部分都可以进行水平扩展,从而避免单点故障,如下图, 这是RocketMQ官网上的一张图,非常清晰的列出了4个部分,并且都是集群模式。下面我们就分别说一说这4部分。 名称服务(NameServer) Name Server扮演的角色是一个注册中心,和Zookeeper的作用差不多。它的主要功能有两个,如下: broker的管理:broker集群将自己的信息注册到NameServer,NameServer提供心跳机制检测每一个broker是否正常。 路由管理:每一个NameServer都有整个broker集群和队列的信息,以便客户端(生产者和消费者)查询。 NameServer协调着分布式系统中的每一个组件,并且管理着每一个Topic的路由信息。 Broker Broker主要是存储消息,并且提供Topic的机制。它提供推和拉两种模式,还有一些容灾的措施,比如可以配置消息副本。下面我们看一看Brokcer的主从机制。 Broker的角色分为“异步主”、“同步主”和“从”三个角色。如果你不能容忍消息的丢失,你可以配置一个“同步主”和“从”两个Broker,如果你觉得消息丢失也无所谓,只要队列可用就ok的话,你可以配置“异步主”和“从”两个broker。如果你只是想简单的搭建,只配置一个“异步主”,不配置“从”也是可以的。 上面提到的是broker之间的备份,broker里的信息也是可以保存到磁盘的,保存到磁盘的方式也有两种,推荐的方式是异步保存磁盘,同步保存磁盘是非常损耗性能的。 生产者 生产者支持集群部署,它们向broker集群发送消息,而且支持多种负载均衡的方式。 当生产者向broker发送消息时,会得到发送结果,发送结果中有一个发送状态。假设我们的配置中,消息的配置isWaitStoreMsgOK = true,这个配置默认也是true,如果你配置为false,在发送消息的过程中,只要不发生异常,发送结果都是SEND_OK。当isWaitStoreMsgOK = true,发送结果有以下几种, FLUSH_DISK_TIMEOUT:保存磁盘超时,当保存磁盘的方式设置为SYNC_FLUSH(同步),并且在syncFlushTimeout配置的时间内(默认5s),没有完成保存磁盘的动作,将会得到这个状态。 FLUSH_SLAVE_TIMEOUT:同步“从”超时,当broker的角色设置为“同步主”时,但是在设置的同步时间内,默认为5s,没有完成主从之间的同步,就会得到这个状态。 SLAVE_NOT_AVAILABLE:“从”不可用,当我们设置“同步主”,但是没有配置“从”broker时,会返回这个状态。 SEND_OK:消息发送成功。 再来看看消息重复与消息丢失,当你发现你的消息丢失时,通常有两个选择,一个是丢就丢吧,这样消息就真的丢了;另一个选择是消息重新发送,这样有可能引起消息重复。通常情况下,还是推荐重新发送的,我们在消费消息的时候要去除掉重复的消息。 发送message的大小一般不超过512k,默认的发送消息的方式是同步的,发送方法会一直阻塞,直到等到返回的响应。如果你比较在意性能,也可以用send(msg, callback)异步的方式发送消息。 消费者 多个消费者可以组成消费者组(consumer group),不同的消费者组可以订阅相同的Topic,也可以独立的消费Topic,每一个消费者组都有自己的消费偏移量。 消息的消费方式一般有两种,顺序消费和并发消费。 顺序消费:消费者将锁住消息队列,确保消息按照顺序一个一个的被消费掉,顺序消费会引起一部分性能损失。在消费消息的时候,如果出现异常,不建议直接抛出,而是应该返回SUSPEND_CURRENT_QUEUE_A_MOMENT 这个状态,它将告诉消费者过一段时间后,会重新消费这个消息。 并发消费:消费者将并发的消费消息,这种方式的性能非常好,也是推荐的消费方式。在消费的过程中,如果出现异常,不建议直接抛出,而是返回RECONSUME_LATER 状态,它告诉消费者现在不能正确的消费它,过一段时间后,会再次消费它。 在消费者内部,是使用ThreadPoolExecutor作为线程池的,我们可以通过setConsumeThreadMin 和setConsumeThreadMax 设置最小消费线程和最大消费线程。 当一个新的消费者组建立以后,它要决定是否消费之前的历史消息,CONSUME_FROM_LAST_OFFSET将忽略历史消息,消费新的消息。CONSUME_FROM_FIRST_OFFSET将消费队列中的每一个消息,之前的历史消息也会再消费一遍。CONSUME_FROM_TIMESTAMP可以指定消费消息的时间,指定时间以后的消息会被消费。 如果你的应用不能容忍重复消费,那么在消费消息的过程中,要做好消息的校验。 好了,今天就到这里吧,下一篇我们将介绍RocketMQ的环境搭建。
先问小伙伴们一个问题,登录难吗?“登录有什么难得?输入用户名和密码,后台检索出来,校验一下不就行了。”凡是这样回答的小伙伴,你明显就是产品思维,登录看似简单,用户名和密码,后台校验一下,完事了。但是,登录这个过程涵盖的知识点是非常多的,绝不是检索数据,校验一下这么简单的事。 那么登录都要哪些实现方式呢?i最传统的就要是Cookie-Session这种方式了,最早的登录方式都是这样实现的。但是随着手机端、H5端的兴起,前后端分离的模式越来越流行,基于Cookie-Session这种登录方式不是很方便,渐渐的JTW开始流行,现在大部分项目的登录方式都是基于JWT的了。那么Cookie和JWT都是怎样实现登录的呢?这两种方式有什么区别呢?我们在做登录的x时候该怎么选择呢?咱们先看看这两种方式的原理。 Cookie方式 因为Http协议是无状态的,我们后台的服务(如Tomcat)在接收到前端发送过来的Http请求时,是区分不出哪个请求是谁发出的,这和我们的登录功能是相违背的,登录的功能就是要区分每一个请求是由哪个用户发出的,这就变成了有状态,那怎么办呢?Cookie应运而生,Cookie是存储在浏览器端的,在Cookie中存储的内容是键值对,也就是name-value。浏览器在向后台发送请求的时候,会把Cookie放在请求头中,传送给后台的服务,后台的服务会从请求头中取到Cookie,再从Cookie中取出键值对中jsessionid对应的值。这个jsessionid的值就是你这次会话的id,对应着服务端的一个session。 好了,到这里session这个概念出来了,session是什么呢?session是存储在服务端的,每一个会话对应服务中的一个session。咱们可以把session理解为一个Map,它的key存储的session的id,value存储的东西就随便了,我们在写程序时想存啥就存啥。它的key存储的值就是Cookie中存储的jsessionid的值,这样,浏览器发送请求到后台服务,后台才能根据Cookie中的jsessionid取到对应的session,再从session中取到之前存储的状态,如存储在session中的登录状态、用户id等。Cookie-Session机制是通用的,所有的浏览器都支持Cookie,就连最低端的IE都支持,你说他普遍不普遍。Session是后端容器必须支持的,如Tomcat,还有像其他的如Resin、jetty等。这些对开发人员都是透明的,无需过多关注。 Cookie-Session的由来给大家说完了,我们看看基于Cookie这种方式的登录流程, 用户在浏览器输入用户名、密码,点击登录,发送请求到后台服务; 后台服务校验用户名、密码,将登录状态状态和用户id存储在session中; 将session的id存储在Cookie中,通过响应头返回到浏览器; 当用户点击其他功能时,向后台发送的请求中会自动带上Cookie; 后台通过Cookie中的jsessionid找到对应的session,开发人员可从session中取出当前会话的登录状态和用户id。 基于Cookie-Session机制的登录实现方式的整体流程就是这个样子。看上去很完美,但还是存在不少问题的,我们来看看这些问题。 分布式会话 上面的示例,我们的后台服务只有一个,一个服务往往很难支撑服务,为了保障可靠性,最少都是部署两个后台服务。但是当部署多个后台服务时,我们的session就会出现问题,看看下面的图, 假如用户登录的请求,分配到了后台服务1,后台服务1的session存了用户的登录状态和用户id。 用户在点击其他功能时,请求分配到了后台服务2,可是后台服务2的session并没有存储登录状态和用户id。 我们怎么解决这个问题呢?其实也很简单,第一,session集中管理,比如使用Redis;第二,所有的后台服务在获取session时,统一从Redis中获取。如下所示, 我们可以使用中间件Spring-Session和Redis就可以解决这个问题。 CORS 使用Cookie实现登录的另外一个问题就是跨域,现在往往都采用前后端分离的方式进行开发,在开发的过程中,前端和后端通常不在一个域下,由于浏览器的同源策略,Cookie不能传入到后端。至于同源策略,不明白的小伙伴可以问一下度娘,这里不过多介绍了。要解决这个问题,在前端、后端都要进行设置,在我的另一篇文章《前后端分离|关于登录状态那些事》中有详细的介绍。总体归纳为: 后端设置CORS允许跨域的域名,并且withCredentials设置为true; 前端在向后端发送请求时,也需要设置withCredentials = true; 这样,我们的Cookie就可以实现跨域了。不进行这些设置,Cookie跨域是不可能的,同源策略保证了我们Cookie的安全。 CSRF CSRF,这个CORS是不一样的,长的比较像,也比较容易混。CSRF往往和系统的安全扯上联系,也是等保测试中比较重要的测试内容,它也是和Cookie有关的,大体的流程是这样的, 用户登录了A网站,并没有退出; 此时,用户又访问了B网站; 在B网站有个隐藏的请求,请求了A网站的一个重要的接口,比如:转账、支付等。 在请求A网站的同时,带上了A网站的Cookie,所以一些危险的操作就成功了。 关于CSRF的攻防,在我前面的文章《CSRF的原理与防御 | 你想不想来一次CSRF攻击?》中有详细的介绍。总之,使用Cookie实现登录是需要重点防范一下CSRF攻击的。 JWT方式 近年来,由于手机端的兴起,前后端分离开发方式的流行,JWT这种登录的实现方式悄然兴起,那么什么是JWT呢?JWT是英文JSON Web Token的缩写,它由3部分组成, header,一般情况下存储两个信息,1类型,一般都是JWT;2加密算法,比如:HMAC、RSA等; payload,这里就存储登录的相关信息了,比如:登录状态、用户id、过期时间等。 signature,签名,这个是将header、payload和密钥的信息做一次加密,后台在接收到JWT的时候,一定要验签,谨防JWT的伪造。 下面咱们看看JWT的登录实现, 我们看到整体的流程和Cookie的实现方式是一样的,只不过是没有用到Cookie、Session。那么它与Cookie-Session的区别是什么呢? 登录状态、用户id并没有存储到session,而是存在JWT的payload里,返回给了前端。 在前端JWT不会自动存储到Cookie中,前端开发人员要处理JWT的存储问题,比如LocalStorage 再次发起请求,JWT不会自动放到请求头中,需前端同学手动设置 后端从请求头中取出JWT,验签通过后,拿到登录状态、用户id,不是从session中取 相比Cookie的方式,JWT的方式需要更多的开发工作量。那么其他的问题存在吗?我们一个一个看。 分布式会话 我们后台部署多个服务,会有分布式会话的问题吗? 无论请求被分配到哪一个后台服务中,登录状态和用户id都是从JWT中取出来的,不会出现分布式会话的问题。我们在后台部署集群的时候,根本不用care这个问题。 CORS Cookie的跨域受到同源策略的保护,不经过特殊的设置,是不能够跨域的。那么JWT呢?JWT是前端同学手动在请求头中设置的,如果向其他的域发送请求要注意,稍不注意,在请求的时候,调用了封装的公共方法,就会把JWT发送给其他域的后台,前端的小伙伴要打起精神啊。 CSRF Cookie的方式,B访问A网站,会把A的Cookie带上,从而造成了安全隐患。那么JWT呢?JWT在前端存储在A网站的域下,B在访问A网站时,是拿不到A网站的JWT的,那么也不可能在请求头中设置JWT,A网站的后台拿不到JWT,也不会做其他操作。所以,JWT可以很好的防止CSRF攻击。 总结 通过前面我们对Cookie和JWT的分析,可以总结成如下的表格, Cookie-Session JWT 工作量 浏览器和容器天然支持 需要额外开发,有一定工作量 分布式会话 需要借助中间件 无需关心,登录信息从JWT解出 CORS 不支持跨域、需特殊设置 开发人员设置请求头,可以跨域 CSRF 需特殊防范 无需防范,第三方拿不到JWT 好了,Cookie和JWT的特点都总结出来了,大家在实现登录的时候,就各取所需吧。结合自己的项目,选择适合自己项目的实现方式吧。
ES的基本内容介绍的已经差不多了,最后我们再来看看GEO位置搜索,现在大部分APP都有基于位置搜索的功能,比如:我们点外卖,可以按照离我们的距离进行排序,这样可以节省我们的配送费和送餐的时间;还有找工作时,也可以按照离自己家的距离进行排序,谁都想找个离家近的工作,对吧。这些功能都是基于GEO搜索实现的,目前支持GEO搜索功能的中间件有很多,像MySQL、Redis、ES等。我们看看在ES当中怎么实现GEO位置搜索。 GEO字段的创建 GEO类型的字段是不能使用动态映射自动生成的,我们需要在创建索引时指定字段的类型为geo_point,geo_point类型的字段存储的经纬度,我们看看经纬度是怎么定义的, 英文 简写 正数 负数 维度 latitude lat 北纬 南纬 经度 longitude lon或lng 东经 西经 经度的简写有2个,一般常用的是lon,lng则在第三方地图的开放平台中使用比较多。下面我们先创建一个带有geo_point类型字段的索引,如下: PUT /my_geo { "settings":{ "analysis":{ "analyzer":{ "default":{ "type":"ik_max_word" } } } }, "mappings":{ "dynamic_date_formats":[ "MM/dd/yyyy", "yyyy/MM/dd HH:mm:ss", "yyyy-MM-dd", "yyyy-MM-dd HH:mm:ss" ], "properties":{ "location":{ "type":"geo_point" } } } } 创建了一个my_geo索引,在索引中有一些基础的配置,默认IK分词器,动态映射的时间格式。重点是最后我们添加了一个字段location,它的类型是geo_point。 索引创建完了,我们添加两条数据吧,假设,路人甲在北京站,路人乙在朝阳公园。那么我们怎么“北京站”和“朝阳公园”的经纬度呢?我们在做项目时,前端都会接地图控件,经纬度的信息可以调用地图控件的API获取。在咱们的示例中,也不接地图控件了,太麻烦了,直接在网上找到“北京站”和“朝阳公园”的坐标吧。 我们查到“北京站”的坐标如下: 然后添加一条数据: POST /my_geo/_doc { "name":"路人甲", "location":{ "lat": 39.90279998006104, "lon": 116.42703999493406 } } 再查“朝阳公园”的坐标 再添加“路人乙”的信息 POST /my_geo/_doc { "name":"路人乙", "location":{ "lat": 39.93367367974064, "lon": 116.47845257733152 } } 我们再用elasticsearch-head插件看一下索引中的数据: GEO查询 “路人甲”和“路人乙”的信息都有了,但是没有location字段的信息,因为location是特性类型的字段,在这里是展示不出来的。我们搜索一下吧,看看怎么用geo搜索,假设“我”的位置在“工体”,我们先要查到“工体”的坐标, 然后再查询5km范围内都有谁,发送请求如下: POST /my_geo/_search { "query":{ "bool":{ "filter":{ "geo_distance":{ "distance":"5km", "location":{ "lat":39.93031708627304, "lon":116.4470385453491 } } } } } } 在查询的时候用的是filter查询,再filter查询里再使用geo_distance查询,我们定义距离distance为5km,再指定geo类型的字段location,当前的坐标为:39.93031708627304N,116.4470385453491E。查询一下,看看结果: { …… "hits":[ { "_index":"my_geo", "_type":"_doc", "_id":"AtgtXnIBOZNtuLQtIVdD", "_score":0, "_source":{ "name":"路人甲", "location":{ "lat": 39.90279998006104, "lon": 116.42703999493406 } } }, { "_index":"my_geo", "_type":"_doc", "_id":"ZdguXnIBOZNtuLQtMVfA", "_score":0, "_source":{ "name":"路人乙", "location":{ "lat": 39.93367367974064, "lon": 116.47845257733152 } } } ] } 看来,我们站在“工体”,“北京站”的路人甲和“朝阳公园”的路人乙都在5km的范围内。把范围缩短一点如何,改为3km看看,搜索的请求不变,只是把distance改为3km,看看结果吧, { …… "hits":[ { "_index":"my_geo", "_type":"_doc", "_id":"ZdguXnIBOZNtuLQtMVfA", "_score":0, "_source":{ "name":"路人乙", "location":{ "lat": 39.93367367974064, "lon": 116.47845257733152 } } } ] } 只有在“朝阳公园”的路人乙被搜索了出来。完全符合预期,我们再看看程序中怎么使用GEO搜索。 JAVA 代码 在定义实体类时,对应的GEO字段要使用特殊的类型,如下: @Setter@Getter public class MyGeo { private String name; private GeoPoint location; } location的类型是GeoPoint,添加数据的方法没有变化,转化成Json就可以了。再看看查询怎么用, public void searchGeo() throws IOException { SearchRequest searchRequest = new SearchRequest("my_geo"); SearchSourceBuilder ssb = new SearchSourceBuilder(); //工体的坐标 GeoPoint geoPoint = new GeoPoint(39.93367367974064d,116.47845257733152d); //geo距离查询 name=geo字段 QueryBuilder qb = QueryBuilders.geoDistanceQuery("location") //距离 3KM .distance(3d, DistanceUnit.KILOMETERS) //坐标工体 .point(geoPoint); ssb.query(qb); searchRequest.source(ssb); SearchResponse response = client.search(searchRequest, RequestOptions.DEFAULT); for (SearchHit hit : response.getHits().getHits()) { System.out.println(hit.getSourceAsString()); } } SearchRequest指定索引my_geo 创建工体的坐标点GeoPoint 创建geo距离查询,指定geo字段location,距离3km,坐标点工体 其他的地方没有变化 运行一下,看看结果, {"name":"路人乙","location":{"lat":39.93360786576342,"lon":116.47853840802}} 只有在“朝阳公园”的路人乙被查询了出来,符合预期。 距离排序 有的小伙伴可能会有这样的疑问,我不想按照距离去查询,只想把查询结果按照离“我”的距离排序,该怎么做呢?再看一下, public void searchGeoSort() throws IOException { SearchRequest searchRequest = new SearchRequest("my_geo"); SearchSourceBuilder ssb = new SearchSourceBuilder(); //工体的坐标 GeoPoint geoPoint = new GeoPoint(39.93367367974064d,116.47845257733152d); GeoDistanceSortBuilder sortBuilder = SortBuilders .geoDistanceSort("location", geoPoint) .order(SortOrder.ASC); ssb.sort(sortBuilder); searchRequest.source(ssb); SearchResponse response = client.search(searchRequest, RequestOptions.DEFAULT); for (SearchHit hit : response.getHits().getHits()) { System.out.println(hit.getSourceAsString()); } } 这次查询并没有设置查询条件,而是创建了一个geo距离排序,同样,先指定geo字段location,和当前的坐标工体,再设置排序是升序。运行一下,看看结果, {"name":"路人乙","location":{"lat":39.93360786576342,"lon":116.47853840802}} {"name":"路人甲","location":{"lat":39.902799980059335,"lon":116.42721165631102}} 离“工体”比较近的“路人乙”排在了第一个,也是符合预期的。有问题大家评论区留言吧~
ES当中大部分的内容都已经学习完了,今天呢算是对前面内容的查漏补缺,把ES中非常实用的功能整理一下,在以后的项目开发中,这些功能肯定是对你的项目加分的,我们来看看吧。 高亮 高亮在搜索功能中是十分重要的,我们希望搜索的内容在搜索结果中重点突出,让用户聚焦在搜索的内容上。我们看看在ES当中是怎么实现高亮的,我们还用之前的索引ik_index,前面的章节,我们搜索过香蕉好吃,但是返回的结果中并没有高亮,那么想要在搜索结果中,对香蕉好吃高亮该怎么办呢?我们看看, POST /ik_index/_search { "query": { "bool": { "must": { "match": { "desc": "香蕉好吃" } } } }, "highlight": { "fields": { "desc": {} } } } 我们重点看一下请求体中的highlight部分,这部分就是对返回结果高亮的设置,fields字段中,指定哪些字段需要高亮,我们指定了desc字段,执行一下,看看结果吧。 { "took": 73, "timed_out": false, "_shards": { "total": 1,"successful": 1,"skipped": 0,"failed": 0}, "hits": { "total": { "value": 5, "relation": "eq" }, "max_score": 1.3948275, "hits": [ { "_index": "ik_index", "_type": "_doc", "_id": "2", "_score": 1.3948275, "_source": { "id": 1, "title": "香蕉", "desc": "香蕉真好吃" }, "highlight": { "desc": [ "<em>香蕉</em>真<em>好吃</em>" ] } } …… 我们看到在返回的结果中,增加了highlight,highlight里有我们指定的高亮字段desc,它的值是<em>香蕉</em>真<em>好吃</em>,其中“香蕉”和“好吃”字段在<em>标签中,前端的小伙伴就可以针对这个<em>标签写样式了。我们再看看程序当中怎么设置高亮,继续使用上一节中的搜索的程序, public void searchIndex() throws IOException { SearchRequest searchRequest = new SearchRequest("ik_index"); SearchSourceBuilder ssb = new SearchSourceBuilder(); QueryBuilder qb = new MatchQueryBuilder("desc","香蕉好吃"); ssb.query(qb); HighlightBuilder highlightBuilder = new HighlightBuilder(); highlightBuilder.field("desc"); ssb.highlighter(highlightBuilder); searchRequest.source(ssb); SearchResponse response = client.search(searchRequest, RequestOptions.DEFAULT); SearchHit[] hits = response.getHits().getHits(); for (SearchHit hit : hits) { String record = hit.getSourceAsString(); HighlightField highlightField = hit.getHighlightFields().get("desc"); for (Text fragment : highlightField.getFragments()) { System.out.println(fragment.string()); } } } 我们重点关注一下HighlightBuilder,我们在发送请求前,创建HighlightBuilder,并指定高亮字段为desc。搜索结束后,我们取结果,从hit当中取出高亮字段desc,然后打印出fragment,运行一下,看看结果吧, <em>香蕉</em>真<em>好吃</em> <em>香蕉</em>真<em>好吃</em> 橘子真<em>好吃</em> 桃子真<em>好吃</em> 苹果真<em>好吃</em> 完全符合预期,“香蕉好吃”被分词后,在搜索结果中都增加了<em>标签,我们可不可以自定义高亮标签呢?当然是可以的,我们稍微改一下程序就可以了, HighlightBuilder highlightBuilder = new HighlightBuilder(); highlightBuilder.field("desc"); highlightBuilder.preTags("<b>"); highlightBuilder.postTags("</b>"); ssb.highlighter(highlightBuilder); 在HighlightBuilder中,使用preTags添加起始标签,指定为<b>,用postTags添加闭合标签,指定为</b>,再运行一下,看看结果, <b>香蕉</b>真<b>好吃</b> <b>香蕉</b>真<b>好吃</b> 橘子真<b>好吃</b> 桃子真<b>好吃</b> 苹果真<b>好吃</b> 结果完全正确,用<b>替换了<em>,是不是很灵活。接下来我们再看看搜索建议。 搜索建议 “搜索建议”这个功能也是相当实用的,当我们在搜索框中输入某个字时,与这个字的相关搜索内容就会罗列在下面,我们选择其中一个搜索就可以了,省去了敲其他字的时间。我们看看ES中是怎么实现“搜索建议”的。 如果要在ES中使用“搜索建议”功能,是需要特殊设置的,要设置一个类型为completion的字段,由于之前的索引中已经有了数据,再添加字段是会报错的,索引我们新建一个索引, PUT /my_suggester { "settings":{ "analysis":{ "analyzer":{ "default":{ "type":"ik_max_word" } } } }, "mappings":{ "dynamic_date_formats": [ "MM/dd/yyyy", "yyyy/MM/dd HH:mm:ss", "yyyy-MM-dd", "yyyy-MM-dd HH:mm:ss" ], "properties":{ "suggest":{ "type":"completion" } } } } 这已经成了我们新建索引的一个标配了,指定分词器为ik中文分词,动态字段的时间映射格式,以及搜索建议字段,注意suggest字段的类型为completion。我们再添加字段的时候,就要为suggest字段添加值了,如下: POST /my_suggester/_doc { "title":"天气", "desc":"今天天气不错", "suggest": { "input": "天气" } } POST /my_suggester/_doc { "title":"天空", "desc":"蓝蓝的天空,白白的云", "suggest": { "input": "天空" } } 我们向索引中添加了两条数据,大家需要额外注意的是suggest字段的赋值方法,要使用input,我们看一下数据, suggest字段并没有像其他字段那样展示出来,说明它和其他字段是不一样的。现在我们如果只输入一个“天”字,看看搜索建议能不能给出提示,如下: POST /my_suggester/_search { "suggest": { "s-test": { "prefix": "天", "completion": { "field": "suggest" } } } } 在请求体中,suggest就是“搜索建议”的标识,s-test是自定义的一个名称,prefix是前缀,也就是我们输入的“天”字,completion指定搜索建议的字段,我们看看查询的结果, …… "suggest": { "s-test": [ { "text": "天", "offset": 0, "length": 1, "options": [{ "text": "天气", "_index": "my_suggester", "_type": "_doc", "_id": "QtgAWnIBOZNtuLQtJgpt", "_score": 1, "_source": { "title": "天气","desc": "今天天气不错","suggest": { "input": "天气"}} } , { "text": "天空", "_index": "my_suggester", "_type": "_doc", "_id": "T9gAWnIBOZNtuLQtWQoX", "_score": 1, "_source": { "title": "天空","desc": "蓝蓝的天空,白白的云","suggest": { "input": "天空"}} } ] } ] } 在s-test.options里,包含了两条记录,text字段就是我们写的建议字段,后面_source里还包含对应的数据,下面我们再看看程序里怎么使用“搜索建议”, public void searchSuggest(String prefix) throws IOException { SearchRequest searchRequest = new SearchRequest("my_suggester"); SearchSourceBuilder ssb = new SearchSourceBuilder(); CompletionSuggestionBuilder suggest = SuggestBuilders .completionSuggestion("suggest") .prefix(prefix); SuggestBuilder suggestBuilder = new SuggestBuilder(); suggestBuilder.addSuggestion("s-test",suggest); ssb.suggest(suggestBuilder); searchRequest.source(ssb); SearchResponse response = client.search(searchRequest, RequestOptions.DEFAULT); CompletionSuggestion suggestion = response.getSuggest().getSuggestion("s-test"); for (CompletionSuggestion.Entry.Option option : suggestion.getOptions()) { System.out.println(option.getText().string()); } } @Test public void searchSuggest() throws IOException { eService.searchSuggest("天"); } 我们创建了CompletionSuggestionBuilder,通过方法completionSuggestion指定“搜索建议”字段suggest,并且指定前缀为方法传入的prefix,我们在测试的时候传入"天"字。然后,我们自定义“搜索建议”的名字为s-test,传入前面构造好的suggest。 发送请求后,在响应中获取前面自定义的s-test,然后循环options,取出text字段,这就是搜索建议的字段,我们运行一下,看看结果, 天气 天空 完全符合预期,这样用户在搜索的时候,就会给出提示信息了。 好了,今天这两个ES的知识点就全部OK了~ 大家有问题在评论区留言。
在前面的章节中,我们把ES的基本功能都给大家介绍完了,从ES的搭建、创建索引、分词器、到数据的查询,大家发现,我们都是通过ES的API去进行调用,那么,我们在项目当中怎么去使用ES呢?这一节,我们就看看ES如何与我们的SpringBoot项目结合。 版本依赖 SpringBoot默认是有ElasticSearch的Starter,但是它依赖的ES客户端的版本比较低,跟不上ES的更新速度,所以我们在SpringBoot项目中要指定ES的最新版本,如下: <properties> <elasticsearch.version>7.6.1</elasticsearch.version> </properties> <dependency> <groupId>org.elasticsearch.client</groupId> <artifactId>elasticsearch-rest-high-level-client</artifactId> </dependency> 我们在项目中指定ES客户端的版本为7.6.1。 配置文件 然后我们在SpringBoot的配置文件application.properties当中,配置ES集群的地址,如下: spring.elasticsearch.rest.uris=http://192.168.73.130:9200,http://192.168.73.131:9200,http://192.168.73.132:9200 多个地址之间我们使用,隔开即可。 与ES交互 所有配置的东西都准备好了,下面我们看看在程序当中如何交互,还记得前面咱们提到的动态映射吗?这个东西是非常的好用的,简化了我们不少的工作量。在这里我们还用前面的索引ik_index举例,我们先看看目前ik_index索引中有哪些字段, 在索引中只有3个字段,id、title和desc。接下来我们在创建索引ik_index对应的实体类,内容也很简单,具体如下: @Setter@Getter public class IkIndex { private Long id; private String title; private String desc; private String category; } 在实体类中,我们新添加了一个字段category表示分类,我们可以联想一下,category字段动态映射到ES当中会是什么类型?对了,就是text类型,我们再深入想一步,text类型会用到全文索引,会用到分词器,而在索引ik_index当中,我们配置了默认的分词器是IK中文分词器。能够想到这里,我觉得你对ES了解的比较深入了。 接下来,我们就要编写service了,并向ik_index索引中添加一条新的数据,如下: @Service public class EService { @Autowired private RestHighLevelClient client; /** * 添加索引数据 * @throws IOException */ public void insertIkIndex() throws IOException { IkIndex ikIndex = new IkIndex(); ikIndex.setId(10l); ikIndex.setTitle("足球"); ikIndex.setDesc("足球是世界第一运动"); ikIndex.setCategory("体育"); IndexRequest request = new IndexRequest("ik_index"); // request.id("1"); request.source(JSON.toJSONString(ikIndex), XContentType.JSON); IndexResponse indexResponse = client.index(request, RequestOptions.DEFAULT); System.out.println(indexResponse.status()); System.out.println(indexResponse.toString()); } } 首先,我们要引入ES的高等级的客户端RestHighLevelClient,由于我们在配置文件中配置了ES集群的地址,所以SpringBoot自动为我们创建了RestHighLevelClient的实例,我们直接自动注入就可以了。然后在添加索引数据的方法中,我们先把索引对应的实体创建好,并设置对应的值。 接下来我们就要构建索引的请求了,在IndexRequest的构造函数中,我们指定了索引的名称ik_index,索引的id被我们注释掉了,ES会给我们默认生成id,当然自己指定也可以。大家需要注意的是,这个id和IkIndex类里的id不是一个id,这个id是数据在ES索引里的唯一标识,而IkIndex实体类中的id只是一个数据而已,大家一定要区分开。然后我们使用request.source方法将实体类转化为JSON对象并封装到request当中,最后我们调用client的index方法完成数据的插入。我们看看执行结果吧。 CREATED IndexResponse[index=ik_index,type=_doc,id=f20EVHIBK8kOanEwfXbW,version=1,result=created,seqNo=9,primaryTerm=6,shards={"total":2,"successful":2,"failed":0}] status返回的值是CREATED,说明数据添加成功,而后面的响应信息中,包含了很多具体的信息,像每个分片是否成功都已经返回了。我们再用elasticsearch-head插件查询一下,结果如下: 数据插入成功,并且新添加的字段category也有了对应的值,这是我们期望的结果。下面我们再看看查询怎么使用。代码如下: public void searchIndex() throws IOException { SearchRequest searchRequest = new SearchRequest("ik_index"); SearchSourceBuilder ssb = new SearchSourceBuilder(); QueryBuilder qb = new MatchQueryBuilder("desc","香蕉好吃"); ssb.query(qb); searchRequest.source(ssb); SearchResponse response = client.search(searchRequest, RequestOptions.DEFAULT); SearchHit[] hits = response.getHits().getHits(); for (SearchHit hit : hits) { String record = hit.getSourceAsString(); System.out.println(record); } } 我们先创建一个查询请求,并指定索引为ik_index; 然后我们创建一个请求体SearchSourceBuilder,再构建我们的查询请求QueryBuilder,QueryBuilder是一个接口,它的实现类有很多,对应着ES中的不同种类的查询,比如咱们前面介绍的bool和boosting查询,都有对应的实现类。在这里,咱们使用MatchQueryBuilder并查询desc包含香蕉好吃的数据,这个查询咱们在前面通过API的方式也查询过。 最后我们封装好请求,并通过client.search方法进行查询,返回的结构是SearchResponse。 在返回的结果中,我们获取对应的数据,咦?这个为什么调用了两次Hits方法?咱们可以从API的返回值看出端倪,如下: 我们可以看到返回的结果中确实有两个hits,第一个hits中包含了数据的条数,第二个hits中才是我们想要的查询结果,所以在程序中,我们调用了两次hits。 在每一个hit当中,我们调用getSourceAsString方法,获取JSON格式的结果,我们可以用这个字符串通过JSON工具映射为实体。 我们看看程序运行的结果吧, {"id":1,"title":"香蕉","desc":"香蕉真好吃"} {"id":1,"title":"香蕉","desc":"香蕉真好吃"} {"id":1,"title":"橘子","desc":"橘子真好吃"} {"id":1,"title":"桃子","desc":"桃子真好吃"} {"id":1,"title":"苹果","desc":"苹果真好吃"} 查询出了5条数据,和我们的预期是一样的,由于使用IK中文分词器,所以desc中包含好吃的都被查询了出来,而我们新添加的足球数据并没有查询出来,这也是符合预期的。我们再来看看聚合查询怎么用, public void searchAggregation() throws IOException { SearchRequest searchRequest = new SearchRequest("ik_index"); SearchSourceBuilder ssb = new SearchSourceBuilder(); TermsAggregationBuilder category = AggregationBuilders.terms("category").field("category.keyword"); ssb.aggregation(category); searchRequest.source(ssb); SearchResponse response = client.search(searchRequest, RequestOptions.DEFAULT); Terms terms = response.getAggregations().get("category"); for (Terms.Bucket bucket : terms.getBuckets()) { System.out.println(bucket.getKey()); System.out.println(bucket.getDocCount()); } } 同样,我们创建一个SearchRequest,然后再创建一个TermsAggregationBuilder,TermsAggregationBuilder我们指定了name叫做category,这个name对应着上一节中的那个自定义的名称,大家还有印象吗? 后面的field是我们要聚合的字段,注意这里因为category字段是text类型,默认是不能够做聚合查询的,我们指定的是category.keyword,还记得这个keyword类型吗?它是不使用分词器的,我们使用这个keyword类型是可以的。 最后把AggregationBuilder封装到查询请求中,进行查询。 查询后,我们怎么去取这个aggregation呢?取查询结果我们是通过hits,取聚合查询,我们要使用aggregation了,然后再get我们的自定义名称response.getAggregations().get("category")。至于前面的类型,它是和AggregationBuilder对应的,在咱们的例子中使用的是TermsAggregationBuilder,那么我们在取结果时就要用Terms;如果查询时使用的是AvgAggregationBuilder,取结果时就要用Avg。 在取得Terms后,我们可以获取里边的值了。运行一下,看看结果。 体育 1 key是体育,doc_count是1,说明分类体育的数据只有1条。完全符合我们的预期,这个聚合查询的功能非常重要,在电商平台中,商品搜索页通常列出所有的商品类目,并且每个类目后面都有这个商品的数量,这个功能就是基于聚合查询实现的。 好了,到这里,ES已经结合到我们的SpringBoot项目中了,并且最基础的功能也已经实现了,大家放心的使用吧~
聚合查询,它是在搜索的结果上,提供的一些聚合数据信息的方法。比如:求和、最大值、平均数等。聚合查询的类型有很多种,每一种类型都有它自己的目的和输出。在ES中,也有很多种聚合查询,下面我们看看聚合查询的语法结构, "aggregations" : { "<aggregation_name>" : { "<aggregation_type>" : { <aggregation_body> } [,"meta" : { [<meta_data_body>] } ]? [,"aggregations" : { [<sub_aggregation>]+ } ]? } [,"<aggregation_name_2>" : { ... } ]* } aggregations实体包含了所有的聚合查询,如果是多个聚合查询可以用数组,如果只有一个聚合查询使用对象,aggregations也可以简写为aggs。aggregations里边的每一个聚合查询都有一个逻辑名称,这个名称是用户自定义的,在我们的语法结构中,对应的是<aggregation_name>。比如我们的聚合查询要计算平均价格,这时我们自定义的聚合查询的名字就可以叫做avg_price,这个名字要在聚合查询中保持唯一。 在自定义的聚合查询对象中,需要指定聚合查询的类型,这个类型字段往往是对象中的第一个字段,在上面的语法结构中,对应的是<aggregation_type>。在聚合查询的内部,还可以有子聚合查询,对应的是aggregations,但是只有Bucketing 类型的聚合查询才可以有子聚合查询。 metrics 聚合查询 metrics 我觉得在这里翻译成“指标”比较好,也不是太准确,我们还是用英文比较好。metrics 聚合查询的值都是从查询结果中的某一个字段(field)提炼出来的,下面我们就看看一些常用的metrics 聚合查询。我们有如下的一些索引数据,大家先看一下, 索引的名字叫做bank,一些关键的字段有account_number银行账号,balance账户余额,firstname和lastname等,大家可以直接看出它们代表的含义。假如我们想看看银行里所有人的平均余额是多少,那么查询的语句该怎么写呢? POST /bank/_search { "query": { "bool": { "must": { "match_all": {} } } }, "aggs": { "avg_balance": { "avg": { "field": "balance" } } } } 在查询语句中,查询的条件匹配的是全部,在聚合查询中,我们自定义了一个avg_balance的聚合查询,它的类型是avg,求平均数,然后我们指定字段是balance,也就是我们要计算平均数的字段。我们执行一下,然后看看返回的结果, { "took": 11, "timed_out": false, "_shards": { "total": 1, "successful": 1, "skipped": 0, "failed": 0 }, "hits": …… "aggregations": { "avg_balance": { "value": 25714.837 } } } 在返回的结果中,我们看到在aggregations中,返回了我们自定义的聚合查询avg_balance,并且计算的平均值是25714.837。 如果我们要查询balance的最大、最小、平均、求和、数量等,可以使用stats查询,我们来看一下如何发送这个请求, POST /bank/_search { "query": { "bool": { "must": { "match_all": {} } } }, "aggs": { "stats_balance": { "stats": { "field": "balance" } } } } 我们只需要把前面聚合查询的类型改为stats就可以了,我们看一下返回的结果, { "took": 20, "timed_out": false, "_shards": { "total": 1, "successful": 1, "skipped": 0, "failed": 0 }, "hits": …… "aggregations": { "stats_balance": { "count": 1000, "min": 1011, "max": 49989, "avg": 25714.837, "sum": 25714837 } } } 我们可以看到在返回的结果中,返回了5个字段,我们最常用的最大、最小、平均、求和、数量都包含在内,很方便是不是。 Bucket 聚合查询 Bucket 聚合不像metrics 那样基于某一个值去计算,每一个Bucket (桶)是按照我们定义的准则去判断数据是否会落入桶(bucket)中。一个单独的响应中,bucket(桶)的最大个数默认是10000,我们可以通过serarch.max_buckets去进行调整。 如果从定义来看,理解Bucket聚合查询还是比较难的,而且Bucket聚合查询的种类也有很多,给大家一一介绍不太可能,我们举两个实际中用的比较多的例子吧。在上面的metrics 聚合中,我们可以查询到数量(count),但是我们能不能分组呢?是不是和数据库中的group by联系起来了?对,Bucket 聚合查询就像是数据库中的group by,我们还用上面银行的索引,比如说我们要看各个年龄段的存款人数,那么查询语句我们该怎么写呢?这里就要使用Bucket 聚合中的Terms聚合查询,查询语句如下: POST /bank/_search { "query": { "bool": { "must": { "match_all": {} } } }, "aggs": { "ages": { "terms": { "field": "age" } } } } 其中,ages是我们定义的聚合查询的名称,terms指定要分组的列,我们运行一下,看看结果, …… { "aggregations": { "ages": { "doc_count_error_upper_bound": 0, "sum_other_doc_count": 463, "buckets": [ { "key": 31, "doc_count": 61 } , { "key": 39, "doc_count": 60 } , { "key": 26, "doc_count": 59 } , { "key": 32, "doc_count": 52 } , { "key": 35, "doc_count": 52 } , { "key": 36, "doc_count": 52 } , { "key": 22, "doc_count": 51 } , { "key": 28, "doc_count": 51 } , { "key": 33, "doc_count": 50 } , { "key": 34, "doc_count": 49 } ] } } 我们可以看到在返回的结果中,每个年龄的数据都汇总出来了。假如我们要看每个年龄段的存款余额,该怎么办呢?这里就要用到子聚合查询了,在Bucket 聚合中,再加入子聚合查询了,我们看看怎么写, POST /bank/_search { "query": { "bool": { "must": { "match_all": {} } } }, "aggs": { "ages": { "terms": { "field": "age" }, "aggs": { "sum_balance": { "sum": { "field": "balance" } } } } } } 我们在聚合类型terms的后面又加了子聚合查询,在子聚合查询中,又自定义了一个sum_balance的查询,它是一个metrics 聚合查询,要对字段balance进行求和。我们运行一下,看看结果。 "aggregations": { "ages": { "doc_count_error_upper_bound": 0, "sum_other_doc_count": 463, "buckets": [ { "key": 31, "doc_count": 61, "sum_balance": { "value": 1727088 } } , { "key": 39, "doc_count": 60, "sum_balance": { "value": 1516175 } } , { "key": 26, "doc_count": 59, "sum_balance": { "value": 1368494 } } , { "key": 32, "doc_count": 52, "sum_balance": { "value": 1245470 } } , { "key": 35, "doc_count": 52, "sum_balance": { "value": 1151108 } } , { "key": 36, "doc_count": 52, "sum_balance": { "value": 1153085 } } , { "key": 22, "doc_count": 51, "sum_balance": { "value": 1261285 } } , { "key": 28, "doc_count": 51, "sum_balance": { "value": 1441968 } } , { "key": 33, "doc_count": 50, "sum_balance": { "value": 1254697 } } , { "key": 34, "doc_count": 49, "sum_balance": { "value": 1313688 } } ] } } 我们看到返回结果中,增加了我们定义的sum_balance字段,它是balance余额的汇总。这个例子我们应该对bucket(桶)这个概念有了一个非常形象的认识了。还有一些其他的bucket聚合查询,这里就不给大家一一介绍了,比如:我们只想查某几个年龄段的余额汇总,就可以使用filters-aggregation。 好了,ES的一些基本的聚合查询就给大家介绍到这里了,如果要用到一些其他的聚合查询,可以参照ES的官方文档。
搜索是ES最最核心的内容,没有之一。前面章节的内容,索引、动态映射、分词器等都是铺垫,最重要的就是最后点击搜索这一下。下面我们就看看点击搜索这一下的背后,都做了哪些事情。 分数(score) ES的搜索结果是按照相关分数的高低进行排序的,咦?! 怎么没说搜索先说搜索结果的排序了?咱们这里先把这个概念提出来,因为在搜索的过程中,会计算这个分数。这个分数代表了这条记录匹配搜索内容的相关程度。分数是一个浮点型的数字,对应的是搜索结果中的_score字段,分数越高代表匹配度越高,排序越靠前。 在ES的搜索当中,分为两种,一种计算分数,而另外一种是不计算分数的。 查询(query context) 查询,代表的是这条记录与搜索内容匹配的怎么样,除了决定这条记录是否匹配外,还要计算这条记录的相关分数。这个和咱们平时的查询是一样的,比如我们搜索一个关键词,分词以后匹配到相关的记录,这些相关的记录都是查询的结果,那这些结果谁排名靠前,谁排名靠后呢?这个就要看匹配的程度,也就是计算的分数。 过滤(filter context) 过滤,代表的含义非常的简单,就是YES or NO,这条记录是否匹配查询条件,它不会计算分数。频繁使用的过滤还会被ES加入到缓存,以提升ES的性能。下面我们看一个查询和过滤的例子,这个也是ES官网中的例子。 GET /_search { "query": { "bool": { "must": [ { "match": { "title": "Search" }}, { "match": { "content": "Elasticsearch" }} ], "filter": [ { "term": { "status": "published" }}, { "range": { "publish_date": { "gte": "2015-01-01" }}} ] } } } 我们看一下请求的路径/_search,这个是请求的路径,而请求的方法是GET,我们再看请求体中,有一个query,这个代表着查询的条件。而bool中的must被用作query context,它在查询的时候会计算记录匹配的相关分数。filter中的条件用作过滤,只会把符合条件的记录检索出来,不会计算分数。 组合查询 组合查询包含了其他的查询,像我们前面提到的query context和filter context。在组合查询中,分为很多种类型,我们挑重点的类型给大家介绍一下。 Boolean Query boolean查询,前面我们写的查询语句就是一个boolean查询,boolean查询中有几个关键词,表格如下: 关键词 描述 must 必须满足的条件,而且会计算分数, filter 必须满足的条件,不会计算分数 should 可以满足的条件,会计算分数 must_not 必须不满足的条件,不会计算分数 我们看看下面的查询语句: POST _search { "query": { "bool" : { "must" : { "term" : { "user" : "kimchy" } }, "filter": { "term" : { "tag" : "tech" } }, "must_not" : { "range" : { "age" : { "gte" : 10, "lte" : 20 } } }, "should" : [ { "term" : { "tag" : "wow" } }, { "term" : { "tag" : "elasticsearch" } } ], "minimum_should_match" : 1, "boost" : 1.0 } } } 上面的查询是一个典型的boolean组合查询,里边的关键词都用上了。很多小伙伴们可能对must和should的区别不是很了解,must是必须满足的条件,我们的例子中must里只写了一个条件,如果是多个条件,那么里边的所有条件必须满足。而should就不一样了,should里边现在列出了两个条件,并不是说这两个条件必须满足,到底需要满足几个呢?我们看一下下面的关键字minimum_should_match,从字面上我们就可以看出它的含义,最小should匹配数,在这里设置的是1,也就是说,should里的条件只要满足1个,就算匹配成功。在boolean查询中,如果存在一个should条件,而没有filter和must条件的话,那么minimum_should_match的默认值是1,其他情况默认值是0。 我们再看一个实际的例子吧,还记得前面我们创建的ik_index索引吗?索引中存在着几条数据,数据如下: _index _type _id ▲_score id title desc ik_index _doc fEsN-HEBZl0Dh1ayKWZb 1 1 苹果 苹果真好吃 ik_index _doc 2 1 1 香蕉 香蕉真好吃 ik_index _doc 1 1 1 香蕉 香蕉真好吃 ik_index _doc 3 1 1 橘子 橘子真好吃 ik_index _doc 4 1 1 桃子 桃子真好吃 只有5条记录,我们新建一个查询语句,如下: POST /ik_index/_search { "query":{ "bool":{ "must":[ { "match":{ "desc":"香蕉好吃" } } ] } }, "from":0, "size":10, } 我们查询的条件是desc字段满足香蕉好吃,由于我们使用的ik分词器,查询条件香蕉好吃会被分词为香蕉和好吃,但是5的数据的desc中都有好吃字段,所有5条数据都会被查询出来,我们执行一下,看看结果: _index _type _id ▲_score id title desc ik_index _doc 2 0.98773474 1 香蕉 香蕉真好吃 ik_index _doc 1 0.98773474 1 香蕉 香蕉真好吃 ik_index _doc 3 0.08929447 1 橘子 橘子真好吃 ik_index _doc 4 0.08929447 1 桃子 桃子真好吃 ik_index _doc fEsN-HEBZl0Dh1ayKWZb 0.07893815 1 苹果 苹果真好吃 哈哈,5条数据全部查询出来了,和我们的预期是一样的,但是,我们需要注意一点的是_score字段,它们的分数是不一样的,我们的查询条件是香蕉好吃,所以既包含香蕉又包含好吃的数据分数高,我们看到分数到了0.98,而另外3条数据只匹配了好吃,所以分数只有0.7,0.8。 Boosting Query 这个查询比较有意思,它有两个关键词positive和negative,positive是“正”,所有满足positive条件的数据都会被查询出来,negative是“负”,满足negative条件的数据并不会被过滤掉,而是会扣减分数。那么扣减分数要扣减多少呢?这里边有另外一个字段negative_boost,这个字段是得分的系数,它的分数在0~1之间,满足了negative条件的数据,它们的分数会乘以这个系数,比如这个系数是0.5,原来100分的数据如果满足了negative条件,它的分数会乘以0.5,变成50分。我们看看下面的例子, POST /ik_index/_search { "query": { "boosting": { "positive": { "term": { "desc": "好吃" } }, "negative": { "term": { "desc": "香蕉" } }, "negative_boost": 0.5 } } } positive条件是好吃,只要desc中有“好吃”的数据都会被查询出来,而negative的条件是香蕉,只要desc中包含“香蕉”的数据都会被扣减分数,扣减多少分数呢?它的得分将会变为原分数*0.5。我们执行一下,看看效果, index type _id score _source.id source.title source.desc ik_index _doc 3 0.08929447 1 橘子 橘子真好吃 ik_index _doc 4 0.08929447 1 桃子 桃子真好吃 ik_index _doc fEsN-HEBZl0Dh1ayKWZb 0.07893815 1 苹果 苹果真好吃 ik_index _doc 2 0.044647235 1 香蕉 香蕉真好吃 ik_index _doc 1 0.044647235 1 香蕉 香蕉真好吃 我们可以看到前3条数据的分数都在0.09左右,而后两条的数据在0.044左右,很显然,后两条数据中的desc包含香蕉,它们的得分会乘以0.5的系数,所以分数只有前面数据的分数的一半。 全文检索 在前面几节的内容中,我们介绍过,只有字段的类型是text,才会使用全文检索,全文检索会使用到分析器,在我们的ik_index索引中,title和desc字段都是text类型,所以,这两个字段的搜索都会使用到ik中文分词器。全文检索比起前面的组合检索要简单一点,当然,在ES的官方文档中,全文检索中的内容还是挺多的,在这里我们只介绍一个标准的全文检索。 我们看看下面的语句, POST /ik_index/_search { "query": { "match": { "desc": { "query": "苹果" } } } } 在请求体中,match代替了之前的bool,match是标准的全文索引的查询。match后面跟的字段是要查询的字段名,在咱们的例子中,查询的字段是desc,如果有多个字段,可以列举多个。desc字段里,query就是要查询的内容。我们还可以在字段中指定分析器,使用analyzer关键字,如果不指定,默认就是索引的分析器。我们执行一下上面的查询,结果如下: index type _id score source.id source.title source.desc ik_index _doc fEsN-HEBZl0Dh1ayKWZb 1.2576691 1 苹果 苹果真好吃 我们可以看到相应的数据已经检索出来了。 最后 在ES中,检索的花样是比较多的,这里也不能一一给大家介绍了,只介绍一些最基本、最常用的查询功能。下一篇我们看一下ES的聚合查询功能。
在前面几节的内容中,我们学习索引、字段映射、分析器等,这些都是使用ES的基础,就像在数据库中创建表一样,基础工作做好以后,我们就要真正的使用它了,这一节我们要看看怎么向索引里写入数据、修改数据、删除数据,至于搜索嘛,因为ES的主要功能就是搜索,所以搜索的相关功能我们后面会展开讲。 Document的创建与更新 索引中的数据叫做document,和数据中的一条记录是一样的,而索引就像数据库中的一张表,我们向索引中添加数据,就像在数据库表中添加一条记录一样。下面我们看看怎么向索引中添加数据, PUT /<index>/_doc/<_id> POST /<index>/_doc/ PUT /<index>/_create/<_id> POST /<index>/_create/<_id> 在这个POST请求中,<index>也就是索引的名字是必须的,这就好比我们向数据库中插入记录,要知道往哪张表里插是一样的。<index>后面可以是_doc或者_create,这两个是什么意思呢?咱们慢慢看,除了这两个区别以外,再有就是请求的方法了,分为POST和PUT两种。一般情况下,POST用于数据的插入,PUT用户数据的修改,是不是这样呢?咱们把这4种方式都试一下,首先我们看一下POST /<index>/_doc/这种方式的请求, POST /ik_index/_doc { "id": 1, "title": "苹果", "desc": "苹果真好吃" } 在这里,索引我们使用的是上一节创建的ik_index,执行一下。然后我们再查询一下这个索引, GET /ik_index/_search 返回结果如下: { "took": 1000, "timed_out": false, "_shards": { "total": 1, "successful": 1, "skipped": 0, "failed": 0 }, "hits": { "total": { "value": 2, "relation": "eq" }, "max_score": 1, "hits": [ { "_index": "ik_index", "_type": "_doc", "_id": "1", "_score": 1, "_source": { "id": 1, "title": "大兴庞各庄的西瓜", "desc": "大兴庞各庄的西瓜真是好吃,脆沙瓤,甜掉牙" } }, { "_index": "ik_index", "_type": "_doc", "_id": "fEsN-HEBZl0Dh1ayKWZb", "_score": 1, "_source": { "id": 1, "title": "苹果", "desc": "苹果真好吃" } } ] } } 我们重点看一下hits,这是我们查询出的结果,第一条是我们上一节存入的数据,不用管它。我们看一下第二条记录,注意一下_id这个字段,这个_id是这条记录在索引里的唯一标识,在插入数据的请求中,我们没有指定这个id,ES给我们自动生成了fEsN-HEBZl0Dh1ayKWZb。那么我们可不可以指定呢?试一下, POST /ik_index/_doc/2 { "id": 1, "title": "香蕉", "desc": "香蕉真好吃" } 注意我们发送的请求,_doc后面加了2,这样就指定了id,执行一下。然后再次查询,返回的结果中,我们只截取hits的部分,如下: "hits": [ { "_index": "ik_index", "_type": "_doc", "_id": "1", "_score": 1, "_source": { "id": 1, "title": "大兴庞各庄的西瓜", "desc": "大兴庞各庄的西瓜真是好吃,脆沙瓤,甜掉牙" } }, { "_index": "ik_index", "_type": "_doc", "_id": "fEsN-HEBZl0Dh1ayKWZb", "_score": 1, "_source": { "id": 1, "title": "苹果", "desc": "苹果真好吃" } }, { "_index": "ik_index", "_type": "_doc", "_id": "2", "_score": 1, "_source": { "id": 1, "title": "香蕉", "desc": "香蕉真好吃" } } ] 我们看到插入的香蕉记录,它的_id是2。那么POST请求中指定的id在索引中存在,会是什么情况呢?我们再看一下, POST /ik_index/_doc/1 { "id": 1, "title": "香蕉", "desc": "香蕉真好吃" } 还是香蕉这条数据,我们指定id=1,id=1这条数据在索引中是存在的,我们执行一下,然后查询,返回的结果如下: "hits": [ { "_index": "ik_index", "_type": "_doc", "_id": "fEsN-HEBZl0Dh1ayKWZb", "_score": 1, "_source": { "id": 1, "title": "苹果", "desc": "苹果真好吃" } }, { "_index": "ik_index", "_type": "_doc", "_id": "2", "_score": 1, "_source": { "id": 1, "title": "香蕉", "desc": "香蕉真好吃" } }, { "_index": "ik_index", "_type": "_doc", "_id": "1", "_score": 1, "_source": { "id": 1, "title": "香蕉", "desc": "香蕉真好吃" } } ] 我们看到之前的那条数据被修改了,所以,关于POST /<index>/_doc/<_id>,这种添加数据的方式,我们得出结论如下: <_id>不指定时,ES会为我们自动生成id; 指定<_id>时,且id在索引中不存在,ES将添加一条指定id的数据; 指定<_id>时,但id在索引中存在,ES将会更新这条数据; 接下来我们再看看_doc方式的PUT请求方式,我们先不指定id,看看会是什么情况,请求如下: PUT /ik_index/_doc { "id": 1, "title": "葡萄", "desc": "葡萄真好吃" } 执行一下,返回如下结果: { "error": "Incorrect HTTP method for uri [/ik_index/_doc] and method [PUT], allowed: [POST]", "status": 405 } 错误信息说我们的请求不对,让我们使用POST请求,看来PUT请求不指定id是不行的。我们再看看指定一个不存在的id,是什么情况,如下: PUT /ik_index/_doc/3 { "id": 1, "title": "葡萄", "desc": "葡萄真好吃" } 执行成功,再查询一下, "hits": [ …… { "_index": "ik_index", "_type": "_doc", "_id": "3", "_score": 1, "_source": { "id": 1, "title": "葡萄", "desc": "葡萄真好吃" } } ] 数据添加成功。再看看指定一个存在的id是什么情况,那当然是修改了,我们再试一下, PUT /ik_index/_doc/3 { "id": 1, "title": "橘子", "desc": "橘子真好吃" } 执行成功,再查询一下, "hits": [ …… { "_index": "ik_index", "_type": "_doc", "_id": "3", "_score": 1, "_source": { "id": 1, "title": "橘子", "desc": "橘子真好吃" } } ] 没有问题,修改成功。POST /<index>/_doc/<_id>这种方式的总结如下: <_id>必须指定,不指定会报错; <_id>在索引中不存在,为添加新数据; <_id>在索引中存在,为修改数据; _doc这种请求的POST和PUT都尝试过了,再看看_create这种请求,先看看不指定id是什么情况,如下: POST /ik_index/_create { "id": 1, "title": "桃子", "desc": "桃子真好吃" } 返回错误信息如下: { "error": { "root_cause": [ { "type": "invalid_type_name_exception", "reason": "mapping type name [_create] can't start with '_' unless it is called [_doc]" } ], "type": "invalid_type_name_exception", "reason": "mapping type name [_create] can't start with '_' unless it is called [_doc]" }, "status": 400 } 具体内容我们也不去解读了,总之是不可以,然后加个索引中不存在id试一下, POST /ik_index/_create/4 { "id": 1, "title": "桃子", "desc": "桃子真好吃" } 返回结果创建成功,查询如下: "hits": [ …… { "_index": "ik_index", "_type": "_doc", "_id": "4", "_score": 1, "_source": { "id": 1, "title": "桃子", "desc": "桃子真好吃" } } ] 如果id在索引中存在呢?再试, POST /ik_index/_create/3 { "id": 1, "title": "桃子", "desc": "桃子真好吃" } 返回错误: { "error": { "root_cause": [ { "type": "version_conflict_engine_exception", "reason": "[3]: version conflict, document already exists (current version [2])", "index_uuid": "W2X_riHIT4u678p8HZwnEg", "shard": "0", "index": "ik_index" } ], "type": "version_conflict_engine_exception", "reason": "[3]: version conflict, document already exists (current version [2])", "index_uuid": "W2X_riHIT4u678p8HZwnEg", "shard": "0", "index": "ik_index" }, "status": 409 } 大致的意思是,数据已经存在了,不能再添加新记录,看来_create这种方式还是比较严格的,总结如下: id必须指定; 指定的id如果在索引中存在,报错,添加不成功; 指定的id在索引中不存在,添加成功,符合预期; 再看看_create的PUT,应该和POST正好相反吧?我们试一下,先不指定id,试一下, PUT /ik_index/_create { "id": 1, "title": "火龙果", "desc": "火龙果真好吃" } 返回错误,不指定id肯定是不行的,错误信息就不给大家贴出来了,然后再指定一个不存在的id, PUT /ik_index/_create/5 { "id": 1, "title": "火龙果", "desc": "火龙果真好吃" } 创建成功,查询结果就不给大家展示了,然后再换一个存在的id,如下: PUT /ik_index/_create/4 { "id": 1, "title": "火龙果", "desc": "火龙果真好吃" } 返回了错误的信息,如下,和POST请求是一样的, { "error": { "root_cause": [ { "type": "version_conflict_engine_exception", "reason": "[4]: version conflict, document already exists (current version [1])", "index_uuid": "W2X_riHIT4u678p8HZwnEg", "shard": "0", "index": "ik_index" } ], "type": "version_conflict_engine_exception", "reason": "[4]: version conflict, document already exists (current version [1])", "index_uuid": "W2X_riHIT4u678p8HZwnEg", "shard": "0", "index": "ik_index" }, "status": 409 } 我们得出如下的结论: _create这种形式的POST和PUT是一样的,没有区别; id必须指定; id必须在索引中不存在; Document的删除 有了添加,肯定会有删除,删除的方式很简单,请求格式如下: DELETE /<index>/_doc/<_id> 发送delete请求,指定数据的id,就可以了,我们试一下,删除刚刚添加的火龙果数据,它的id是5,我们发送请求如下: DELETE /ik_index/_doc/5 执行成功,数据被成功的删除。 根据id查询Document 根据id查询数据也很简单,发送如下请求就可以完成查询, GET <index>/_doc/<_id> 我们需要指定索引的名称,以及要查询数据的id,如下: GET ik_index/_doc/3 返回结果如下: { "_index": "ik_index", "_type": "_doc", "_id": "3", "_version": 2, "_seq_no": 5, "_primary_term": 3, "found": true, "_source": { "id": 1, "title": "橘子", "desc": "橘子真好吃" } } 根据id成功的查询出来结果。 好了~ 到这里,ES数据的增删改都介绍了,下节开始,我们看看ES的核心功能——搜索。
在上一节中,我们给大家介绍了ES的分析器,我相信大家对ES的全文搜索已经有了深刻的印象。分析器包含3个部分:字符过滤器、分词器、分词过滤器。在上一节的例子,大家发现了,都是英文的例子,是吧?因为ES是外国人写的嘛,中国如果要在这方面赶上来,还是需要屏幕前的小伙伴们的~ 英文呢,我们可以按照空格将一句话、一篇文章进行分词,然后对分词进行过滤,最后留下有意义的词。但是中文怎么分呢?中文的一句话是没有空格的,这就要有一个强大的中文词库,当你的内容中出现这个词时,就会将这个词提炼出来。这里大家也不用重复造轮子,经过前辈的努力,这个中文的分词器已经有了,它就是今天要给大家介绍的IK中文分词器。 IK中文分词器的安装 ES默认是没有IK中文分词器的,我们要将IK中文分词器作为一个插件安装到ES中,安装的步骤也很简单: 从GitHub上下载适合自己ES版本的IK中文分词器,地址如下:https://github.com/medcl/elasticsearch-analysis-ik/releases。 在我们的ES的插件目录中(${ES_HOME}/plugins)创建ik目录, mkdir ik 将我们下载好的IK分词器解压到ik目录,这里我们安装unzip命令,进行解压。 重启我们所有的ES服务。 到这里,我们的IK中文分词器就安装完了。 IK中文分词器初探 在上一节我们访问了ES的分析器接口,指定了分析器和文本的内容,我们就可以看到分词的结果。那么既然我们已经安装了Ik中文分词器,当然要看一下效果了。在看效果之前,我们先要说一下,IK中文分词器插件给我们提供了两个分析器。 ik_max_word: 会将文本做最细粒度的拆分 ik_smart:会做最粗粒度的拆分 我们先看看ik_max_word的分析效果吧, POST _analyze { "analyzer": "ik_max_word", "text": "中华人民共和国国歌" } 我们指定分词器为ik_max_word,文本内容为中华人民共和国国歌。我们看一下分词的结果: { "tokens": [ { "token": "中华人民共和国", "start_offset": 0, "end_offset": 7, "type": "CN_WORD", "position": 0 }, { "token": "中华人民", "start_offset": 0, "end_offset": 4, "type": "CN_WORD", "position": 1 }, { "token": "中华", "start_offset": 0, "end_offset": 2, "type": "CN_WORD", "position": 2 }, { "token": "华人", "start_offset": 1, "end_offset": 3, "type": "CN_WORD", "position": 3 }, { "token": "人民共和国", "start_offset": 2, "end_offset": 7, "type": "CN_WORD", "position": 4 }, { "token": "人民", "start_offset": 2, "end_offset": 4, "type": "CN_WORD", "position": 5 }, { "token": "共和国", "start_offset": 4, "end_offset": 7, "type": "CN_WORD", "position": 6 }, { "token": "共和", "start_offset": 4, "end_offset": 6, "type": "CN_WORD", "position": 7 }, { "token": "国", "start_offset": 6, "end_offset": 7, "type": "CN_CHAR", "position": 8 }, { "token": "国歌", "start_offset": 7, "end_offset": 9, "type": "CN_WORD", "position": 9 } ] } 我们可以看到,分词分的非常细,我们在使用上面的这些进行搜索时,都可以搜索到中华人民共和国国歌这个文本。我们再看一下另外一个分析器ik_smart, POST _analyze { "analyzer": "ik_smart", "text": "中华人民共和国国歌" } 我们的文本内容同样是中华人民共和国国歌,看一下分词的效果, { "tokens": [ { "token": "中华人民共和国", "start_offset": 0, "end_offset": 7, "type": "CN_WORD", "position": 0 }, { "token": "国歌", "start_offset": 7, "end_offset": 9, "type": "CN_WORD", "position": 1 } ] } 同样的文本,使用ik_smart进行分词时,只分成了两个词,和ik_max_word分词器比少了很多。这就是两个分词器的区别,不过这两个分析器都是可以对中文进行分词的。 创建索引时指定IK分词器 既然我们安装了IK中文分词器的插件,那么我们在创建索引时就可以为text类型的字段指定IK中文分词器了。来看看下面的例子, PUT ik_index { "mappings": { "properties": { "id": { "type": "long" }, "title": { "type": "text", "analyzer": "ik_max_word" } } } } 我们创建了索引ik_index,并且为字段title指定了分词器ik_max_word。我们执行一下,创建成功。然后我们再通过GET请求看一下这个索引的映射情况。 GET ik_index/_mapping 返回的结果如下: { "ik_index": { "mappings": { "properties": { "id": { "type": "long" }, "title": { "type": "text", "analyzer": "ik_max_word" } } } } } 我们可以看到title字段的分析器是ik_max_word。 为索引指定默认IK分词器 在上一节中,我们已经给大家介绍了为索引指定默认分词器的方法,这里我们直接把分词器改为IK分词器就可以了,如下: PUT ik_index { "settings": { "analysis": { "analyzer": { "default": { "type": "ik_max_word" } } } } } 这样我们在索引中就不用创建每一个字段,可以通过动态字段映射,将String类型的字段映射为text类型,同时分词器指定为ik_max_word。我们试一下,向ik_index索引中添加一条记录。 POST ik_index/_doc/1 { "id": 1, "title": "大兴庞各庄的西瓜", "desc": "大兴庞各庄的西瓜真是好吃,脆沙瓤,甜掉牙" } 执行成功。我们再执行搜索试一下,如下: POST ik_index/_search { "query": { "match": { "title": "西瓜" } } } 我们搜索title字段匹配西瓜,执行结果如下: { "took": 2, "timed_out": false, "_shards": { "total": 1, "successful": 1, "skipped": 0, "failed": 0 }, "hits": { "total": { "value": 1, "relation": "eq" }, "max_score": 0.2876821, "hits": [ { "_index": "ik_index", "_type": "_doc", "_id": "1", "_score": 0.2876821, "_source": { "id": 1, "title": "大兴庞各庄的西瓜", "desc": "大兴庞各庄的西瓜真是好吃,脆沙瓤,甜掉牙" } } ] } } 我们可以看到刚才插入的那条记录已经搜索出来了,看来我们的IK中文分词器起作用了,而且搜索的结果也符合我们的预期。我们再看看搜索西一个字的时候,能不能搜索到结果, POST ik_index/_search { "query": { "match": { "title": "西" } } } 执行结果如下: { "took": 4, "timed_out": false, "_shards": { "total": 1, "successful": 1, "skipped": 0, "failed": 0 }, "hits": { "total": { "value": 0, "relation": "eq" }, "max_score": null, "hits": [] } } 并没有搜索出结果,说明在进行分词时,西瓜是作为一个词出现的,并没有拆分成每一个字,这也是符合我们预期的。 好了~ 这一节的IK中文分词器就给大家介绍到这里了~~
在前面的章节中,我们给大家介绍了索引中的映射类型,也就是每一个字段都有一个类型,比如:long,text,date等。这和我们的数据库非常的相似,那么它的不同之处是什么呢?对了,就是全文索引,在ES当中,只有text类型的字段才会用的全文索引,那么这里就会引出ES中一个非常重要的概念,文本分析器(Text analysis)。 分析器使ES支持全文索引,搜索的结果是和你搜索的内容相关的,而不是你搜索内容的确切匹配。我们用ES官网中的例子给大家举例,假如你在搜索框中输入的内容是Quick fox jumps,你想得到的结果是A quick brown fox jumps over the lazy dog,或者结果中包含这样的词fast fox或foxes leap。 分析器之所以能够使搜索支持全文索引,都是因为有分词器(tokenization),它可以将一句话、一篇文章切分成不同的词语,每个词语都是独立的。假如你在ES索引中添加了一条记录the quick brown fox jumps,而用户搜索时输入的内容是quick fox,并没有完全匹配的内容,但是因为有了分词器,你索引的内容被切分成了不同的、独立的词,用户搜索的内容也会进行相应的切分,所以用户搜索的内容虽然没有完全匹配,但也能够搜索到想要的内容。 分析器除了要做分词,还要做归一化(Normalization)。分词器能够使搜索内容在每一个词上匹配,但这种匹配也只是在字面上进行的匹配。 比如你搜索Quick,但是不能匹配到quick,它们的大小写不同。 比如你搜索fox,但是不能匹配到foxes,它是复数形式。 比如你搜索jumps,不能匹配到leaps,虽然它们是同义词。 为了解决这些问题,分析器要把这些分词归一化到标准的格式。这样我们在搜索的时候就不用严格的匹配了,相似的词语我们也能够检索出来,上面的3种情况,我们也能够搜索出相应的结果。 分析器的组成 分析器,无论是内置的,还是自定义的,都是由3部分组成:字符过滤器(character filters)、分词器(tokenizers)、分词过滤器(token filters)。 字符过滤器 字符过滤器接收最原始的文档,并且可以改变其内容,比如:可以把中文的一二三四五六七八九,变成阿拉伯数字123456789。它还可以过滤html标签,并对其进行转义。还可以通过正则表达式,把匹配到的内容转化成其他的内容。一个分析器可以有多个字符过滤器,也可以没有字符过滤器。 分词器 一个分析器只能有一个确定的分词器,它可以把一句话分成若干个词,比如:空格分词器。当你输入一句话Quick brown fox!,它将被切分成[Quick, brown, fox!]。 分词过滤器 分词过滤器接收分词并且可以改变分词,比如:小写分词过滤器,它将接收到的分词全部转换成小写。助词过滤器,它将删除掉一些公共的助词,比如英语里的 the,is,are等,中文里的的,得等。同义词过滤器,它将在你的分词中,添加相应的同义词。一个分析器可以有多个分词过滤器,它们将按顺序执行。 我们在建立索引和搜索时,都会用的分析器。 配置文本分析器 前面我们讲了分析器的基本概念,也了解了全文搜索的基本步骤。下面我们看一下如何配置文本分析器,ES默认给我们配置的分析器是标准分析器。如果标准的分析器不适合你,你可以指定其他的分析器,或者自定义一个分析器。 ES有分析器的api,我们指定分析器和文本内容,就可以得到分词的结果。比如: POST _analyze { "analyzer": "whitespace", "text": "The quick brown fox." } 返回的结果如下: { "tokens": [ { "token": "The", "start_offset": 0, "end_offset": 3, "type": "word", "position": 0 }, { "token": "quick", "start_offset": 4, "end_offset": 9, "type": "word", "position": 1 }, { "token": "brown", "start_offset": 10, "end_offset": 15, "type": "word", "position": 2 }, { "token": "fox.", "start_offset": 16, "end_offset": 20, "type": "word", "position": 3 } ] } 我们指定的分析器是空格分析器,输入的文本内容是The quick brown fox.,返回结果是用空格切分的四个词。我们也可以测试分析器的组合,比如: POST _analyze { "tokenizer": "standard", "filter": [ "lowercase", "asciifolding" ], "text": "Is this déja vu?" } 我们指定了标准的分词器,小写过滤器和asciifolding过滤器。输入的内容是Is this déja vu?,我们执行一下,得到如下的结果: { "tokens": [ { "token": "is", "start_offset": 0, "end_offset": 2, "type": "<ALPHANUM>", "position": 0 }, { "token": "this", "start_offset": 3, "end_offset": 7, "type": "<ALPHANUM>", "position": 1 }, { "token": "deja", "start_offset": 8, "end_offset": 12, "type": "<ALPHANUM>", "position": 2 }, { "token": "vu", "start_offset": 13, "end_offset": 15, "type": "<ALPHANUM>", "position": 3 } ] } 我们可以看到结果中,is变成了小写,déja变成了deja,最后的?也过滤掉了。 为指定的字段配置分析器 我们在创建映射时,可以为每一个text类型的字段指定分析器,例如: PUT my_index { "mappings": { "properties": { "title": { "type": "text", "analyzer": "whitespace" } } } } 我们在my_index索引中,创建了title字段,它的类型是text,它的分析器是whitespace空格分析器。 为索引指定默认的分析器 如果我们觉得为每一个字段指定分析器过于麻烦,我们还可以为索引指定一个默认的分词器,如下: PUT my_index { "settings": { "analysis": { "analyzer": { "default": { "type": "whitespace" } } } } } 我们为my_index索引指定了默认的分析器whitespace。这样我们在创建text类型的字段时,就不用为其指定分析器了。 这一节给大家介绍了分析器,我们可以看到例子中都是使用的英文分析器,下一节我们一起看一下强大的中文分析器。
通常情况下,我们使用ES建立索引的步骤是,先创建索引,然后定义索引中的字段以及映射的类型,然后再向索引中导入数据。而动态映射是ES中一个非常重要的概念,你可以直接向文档中导入一条数据,与此同时,索引、字段、字段类型都会自动创建,无需你做其他的操作。这就是动态映射的神奇之处。 动态字段映射 ES的动态映射默认是开启的,动态映射的默认规则如下: JSON的数据类型 ES中的数据类型 null 不会映射字段 true 或 false boolean类型 浮点型数字 float 整型数字 long JSON对象 Object 数组 第一个非空值得类型 String 1、如果满足日期类型的格式,映射为日期类型 2、如果满足数字型的格式,映射为long或者float 3、如果就是字符串,会映射为一个text类型和一个keyword类型 接下来我们看看动态映射的一个例子,我们直接向dynamic-index索引中存放一条数据,注意,dynamic-index这个索引我们没有创建过,直接存放数据,索引会自动创建。接下来,我们看一下具体的请求: PUT /dynamic-index/_doc/1 { "my_null": null, "my_boolean": false, "my_float": 1.56, "my_long": 3, "my_object": { "my_first": "first value", "my_second": "second_value" }, "my_array": [1,2,3], "my_date_1": "2020-05-01", "my_date_2": "2020/05/01 12:03:03", "my_date_3": "05/01/2020", "my_string_long": "1", "my_string_float": "4.6", "my_string": "中华人民共和国" } 请求执行成功以后,我们先看一下索引的类型: GET /dynamic-index/_mapping 返回的结果如下: { "dynamic-index": { "mappings": { "properties": { "my_array": { "type": "long" }, "my_boolean": { "type": "boolean" }, "my_date_1": { "type": "date" }, "my_date_2": { "type": "date", "format": "yyyy/MM/dd HH:mm:ss||yyyy/MM/dd||epoch_millis" }, "my_date_3": { "type": "text", "fields": { "keyword": { "type": "keyword", "ignore_above": 256 } } }, "my_float": { "type": "float" }, "my_long": { "type": "long" }, "my_object": { "properties": { "my_first": { "type": "text", "fields": { "keyword": { "type": "keyword", "ignore_above": 256 } } }, "my_second": { "type": "text", "fields": { "keyword": { "type": "keyword", "ignore_above": 256 } } } } }, "my_string": { "type": "text", "fields": { "keyword": { "type": "keyword", "ignore_above": 256 } } }, "my_string_float": { "type": "text", "fields": { "keyword": { "type": "keyword", "ignore_above": 256 } } }, "my_string_long": { "type": "text", "fields": { "keyword": { "type": "keyword", "ignore_above": 256 } } } } } } } 返回的结果比较长,我们把每一个字段都看一下,看看动态映射的字段是否达到了我们的预期: 字段 映射结果 是否达到预期 原因 my_null 没有映射 是 null值不映射 my_boolean boolean 是 my_float float 是 my_long long 是 my_object object 是 my_object里自动生成了两个字段的映射 my_array long 是 数组中的数字是long型 my_date_1 date 是 my_date_2 date 是 my_date_3 text 否 没有指定这种日期格式,所以映射为text my_string_long text 否 数字探测默认关闭,没有打开 my_string_float text 否 数字探测默认关闭,没有打开 my_string text 是 普通字符串,映射为text 下面我们把数字探测打开,执行如下请求: PUT /dynamic-index { "mappings": { "numeric_detection": true } } 由于我们的索引dynamic-index中,存在了映射关系,再进行设置是会报错的,所以我们要将索引删除,执行如下请求: DELETE /dynamic-index 索引删除成功后,再执行前面的设置,执行成功,数字探测已经打开。然后再添加一种日期格式MM/dd/yyyy,请求如下: PUT /dynamic-index { "mappings": { "dynamic_date_formats": ["MM/dd/yyyy"] } } 执行报错,错误信息和之前一样,看来日期的设置要和数字探测一起才行,我们再将索引删除,然后再发送请求,两个设置一起: PUT /dynamic-index { "mappings": { "numeric_detection": true, "dynamic_date_formats": ["MM/dd/yyyy"] } } 执行成功,我们再发送之前创建索引数据的请求 PUT /dynamic-index/_doc/1 { "my_null": null, "my_boolean": false, "my_float": 1.56, "my_long": 3, "my_object": { "my_first": "first value", "my_second": "second_value" }, "my_array": [1,2,3], "my_date_1": "2020-05-01", "my_date_2": "2020/05/01 12:03:03", "my_date_3": "05/01/2020", "my_string_long": "1", "my_string_float": "4.6", "my_string": "中华人民共和国" } 执行成功,我们再看一下索引的映射, "my_string_float": { "type": "float" }, "my_string_long": { "type": "long" } "my_date_1": { "type": "text", "fields": { "keyword": { "type": "keyword", "ignore_above": 256 } } }, "my_date_2": { "type": "text", "fields": { "keyword": { "type": "keyword", "ignore_above": 256 } } }, "my_date_3": { "type": "date", "format": "MM/dd/yyyy" }, 我们重点看一下以上几个字段,my_string_float和my_string_long映射成我们想要的类型了,由于我们开启了数字探测。再看看我们映射的3个日期类型,咦?只有my_date_3映射了日期类型,其他两个都是映射成了text类型,这是由于我们在设置dynamic_date_formats时,只指定了一种格式。我们只需要把其他两种类型的日期格式也加上就可以了。 { "mappings": { "numeric_detection": true, "dynamic_date_formats": ["MM/dd/yyyy","yyyy/MM/dd HH:mm:ss","yyyy-MM-dd"] } } 这里就不给大家具体演示了,有兴趣的小伙伴去尝试一下把。 动态字段是ES中一个非常重要的功能,它给我们带来了极大的方便,也省去了我们在开发时创建索引字段的时间,真是事半功倍,小伙伴们要好好掌握哦~~
在上一节中,我们创建了索引,在创建索引的时候,我们指定了mapping属性,mapping属性中规定索引中有哪些字段,字段的类型是什么。在mapping中,我们可以定义如下内容: 类型为String的字段,将会被全文索引; 其他的字段类型包括:数字、日期和geo(地理坐标); 日期类型的格式; 动态添加字段的映射规则; 字段的可用类型如下: 简单的类型,比如:text,keyword,date,long,double,boolean,ip。我们可以看到,类型当中没有String,字符串的类型是text,所有text类型的字段都会被全文索引。数字类型有两个,long(长整型)和double(浮点型)。 JSON的层级类型:Object(对象)和Nested(数组对象)。Object类型时,该字段可以存储一个JSON对象;Nested类型时,该字段可以存储一个数组对象。 复杂的类型:包括 geo_point、geo_shape和completion。 在索引中创建映射 我们在创建索引的时候可以同时创建映射,就如同上一节的内容。也可以在索引创建好以后,再去创建映射,请求的方式如下: PUT /my-index { "mappings": { "properties": { "age": { "type": "integer" }, "email": { "type": "keyword" }, "name": { "type": "text" } } } } 请求的方法我们要使用PUT,路径是我们的索引名称,请求体当中是我们为索引添加的字段和字段的类型。 在存在的映射中添加字段 正如上面所示,我们在一个索引中添加了字段,但是现在我们要补充额外的字段,这时,我们要怎么做呢? PUT /my-index/_mapping { "properties": { "employee-id": { "type": "keyword", "index": false } } } 我们使用PUT方法,后面跟随我们的索引名称,再接上_mapping,请求体中是我们新添加的映射字段,我们指定了字段的类型为keyword,index索引为false,说明这个字段只用于存储,不会用于搜索,搜索这个字段是搜索不到的。 我们在更新字段时候,是不能修改字段的类型的。如果我们要修改字段的类型,最好是新建一个新的字段,指定正确的类型,然后再更新索引,以后我们只需要查询这个新增的字段就可以了。 查看索引中的字段映射 如果我们要查看已知索引的字段映射,可以向ES发送如下的请求: GET /my-index/_mapping 请求的方法是GET,请求的路径是我们索引的名称my-index,再加上一个_mapping,得到的返回结果如下: { "my-index" : { "mappings" : { "properties" : { "age" : { "type" : "integer" }, "email" : { "type" : "keyword" }, "employee-id" : { "type" : "keyword", "index" : false }, "name" : { "type" : "text" } } } } } 返回的结果中,我们可以看到索引的名称my-index,还有我们添加的字段,也包括后续补充的employee-id字段。 好了,关于索引的字段映射就先给大家介绍到这里。
与ES的交互方式 与es的交互方式采用http的请求方式,请求的格式如下: curl -X<VERB> '<PROTOCOL>://<HOST>:<PORT>/<PATH>?<QUERY_STRING>' -d '<BODY>' 是请求的方法,比如:GET、POST、DELETE、PUT等。 协议:http或者https。 主机地址。 端口 API的路径。比如查看集群状态:/_cluster/stats。 参数。比如:?pretty,打印出格式化以后的Json。 请求的内容。比如:添加索引时的数据。 创建索引 es创建索引的请求方式如下: PUT /<index> 请求的方法用PUT。 /后面直接跟索引的名称即可。 索引的设置和字段都放在Body中。 比如我们创建一个名字叫组织机构的索引,这个索引只有两个字段,一个id,一个name。并且这个索引设置为2个分片,2个副本。 我们使用POSTMAN发送请求,如下: http://192.168.73.130:9200/orgnization 请求的方法选择PUT。然后在请求体(Body)中,写上索引的字段名称,索引的分片数和副本数,如下: { "settings":{ "number_of_shards":2, "number_of_replicas":2 }, "mappings":{ "properties":{ "id":{ "type":"long" }, "name":{ "type":"text" } } } } 我们观察一下,请求体中分为两个部分:settings和mappings。在settings中,我们设置了分片数和副本数。 number_of_shards:分片的数量; number_of_replicas:副本的数量; 在mappings中,我们设置索引的字段,在这里,我们只设置了id和name,id的映射类型是long,name的映射类型是text。这些类型我们会在后续为大家介绍。 请求体写完后,我们点击发送,es返回的结果如下: { "acknowledged": true, "shards_acknowledged": true, "index": "orgnization" } 说明索引创建成功,索引的名字正是我们在请求中设置的orgnization。 然后,我们通过elasticsearch-head插件观察一下刚才创建的索引,如图: 我们可以看到索引orgnization已经创建好了,它有2个分片,分别是0和1,并且每个分片都是两个副本。如果我们仔细观察这个图,可以看出node-130节点中的0分片,和node-132节点中的1分片,它们的边框是加粗的,这说明它们是主节点,而边框没有加粗的节点是从节点,也就是我们说的副本节点。 查看索引 如果我们要查看一个索引的设置,可以通过如下请求方式: GET /<index> 在我们的例子中,查看orgnization索引的设置,我们在POSTMAN中发送如下的请求: 我们可以看到索引的具体设置,比如:mapping的设置,分片和副本的设置。这些和我们创建索引时候的设置是一样的。 修改索引 索引一旦创建,我们是无法修改里边的内容的,不如说修改索引字段的名称。但是我们是可以向索引中添加其他字段的,添加字段的方式如下: PUT /<index>/_mapping 然后在我们的请求体中,写好新添加的字段。比如,在我们的例子当中,新添加一个type字段,它的类型我们定义为long,请求如下: http://192.168.73.130:9200/orgnization/_mapping 请求类型要改为PUT,请求体如下: { "properties": { "type": { "type": "long" } } } 我们点击发送,返回的结果如图所示: 添加索引字段成功,我们再使用GET查看一下索引,如图: 我们可以成功的查询到新添加的索引字段了。 删除索引 如果我们要删除一个索引,请求方式如下: DELETE /<index> 假如我们要删除刚才创建的orgnization索引,我们只要把请求的方法改成DELETE,然后访问我们索引就可以, http://192.168.73.130:9200/orgnization 关闭索引 如果索引被关闭,那么关于这个索引的所有读写操作都会被阻断。索引的关闭也很简单,请求方式如下: POST /<index>/_close 在我们的例子中,如果要关闭索引,降请求方法改成POST,然后发送如下请求: http://192.168.73.130:9200/orgnization/_close 打开索引 与关闭索引相对应的是打开索引,请求方式如下: POST /<index>/_open 在我们的例子中,如果要打开索引,降请求方法改成POST,然后发送如下请求: http://192.168.73.130:9200/orgnization/_open 冻结索引 冻结索引和关闭索引类似,关闭索引是既不能读,也不能写。而冻结索引是可以读,但是不能写。冻结索引的请求方式如下: POST /<index>/_freeze 对应我们的例子当中: http://192.168.73.130:9200/orgnization/_freeze 解冻索引 与冻结索引对应的是解冻索引,方式如下: POST /<index>/_unfreeze 对应我们的例子: http://192.168.73.130:9200/orgnization/_unfreeze
发现 发现是节点之间彼此发现,形成集群的一个过程。这个过程发生的场景有很多,比如:你启动了一个集群节点,或者一个节点确认主节点已经挂掉了,或者一个新的主节点被选举了。 咱们在配置集群的时候在配置文件中配置了一个discovery.seed_hosts,这个就是种子地址列表,集群中的节点都在这个地址列表中。发现的过程分为两个阶段: 每一个节点都会去连接种子地址列表中的地址,并且去确认这些节点是不是具有主节点资格的节点。 如果确认成功,这个节点将会向远程节点分享种子地址列表,并且远程节点也会把他的种子地址列表分享给这个节点。然后这个节点将会询问他发现的新的节点,最后形成集群。 如果一个节点不具有主节点资格,那么他将去寻找已经选举出的主节点。如果没有发现主节点,它将会按照discovery.find_peers_interval配置的时间进行重试。 如果这个几点具有主节点资格,那么它将去寻找主节点(已选举出的),或者去发现所有具有主节点资格的,但是不是主节点的节点,并完成选举过程,选举出主节点。 主节点的作用 主节点主要负责集群方面的轻量级的动作,比如:创建或删除索引,跟踪集群中的节点,决定分片分配到哪一个节点,在集群再平衡的过程中,如何在节点间移动数据等。一个集群有一个稳定的主节点是非常重要的。
昨天接到阿里的电话面试,对方问了一个在MySQL当中,什么是幻读。当时一脸懵逼,凭着印象和对方胡扯了几句。面试结束后,赶紧去查资料,才发现之前对幻读的理解完全错误。下面,我们就聊聊幻读。 要说幻读,就要从MySQL的隔离级别说起。MySQL的4钟隔离级别分别是: Read Uncommitted(读取未提交内容) 在该隔离级别,所有事务都可以看到其他未提交事务的执行结果。本隔离级别很少用于实际应用,因为它的性能也不比其他级别好多少。读取未提交的数据,也被称之为脏读(Dirty Read)。 脏读的具体示例如下: 时间点 事务A 事务B 1 开启事务 2 开启事务 3 查询数据为100条 4 insert一条数据 5 再查询,结果为101条 在时间点5,事务A再次查询数据时,事务B并没有提交事务,但是,新的数据也被事务A查出来了。这就是脏读。 Read Committed(读取提交内容) 这是大多数数据库系统的默认隔离级别(但不是MySQL默认的)。它满足了隔离的简单定义:一个事务只能看见已经提交事务所做的改变。这种隔离级别 也支持所谓的不可重复读(Nonrepeatable Read),因为同一事务的其他实例在该实例处理其间可能会有新的commit,所以同一select可能返回不同结果。 时间点 事务A 事务B 1 开启事务 2 开启事务 3 查询数据为100条 4 insert一条数据 5 查询数据为100条 6 提交事务 7 查询数据为101条 我们可以看到,事务B在提交事务之前,事务A的两次查询结果是一致的。事务B提交事务以后,事务A再次查询,查询到了新增的这条数据。在事务A中,多次查询的结果不一致,这就是我们说的“不可重复读”。 Repeatable Read(可重读) 这是MySQL的默认事务隔离级别,它确保同一事务的多个实例在并发读取数据时,会看到同样的数据行。不过理论上,这会导致另一个棘手的问题:幻读 (Phantom Read)。简单的说,幻读指当用户读取某一范围的数据行时,另一个事务又在该范围内插入了新行,当用户再读取该范围的数据行时,会发现有新的“幻影” 行。 上面这一段是MySQL官方给出的解释,听着云里雾里。“可重读”这种隔离级别解决了上面例子中的问题,保证了同一事务内,多次查询的结果是一致的。也就是说,事务B插入数据提交事务后,事务A的查询结果也是100条,因为事务A在开启事务时,事务B插入的数据还没有提交。 但是,这又引出了另外一个情况,“幻读”。这个幻读我之前理解是有问题的,在面试时,被对方一顿质疑。现在我们就看看幻读的正确理解: 时间点 事务A 事务B 1 开启事务 2 开启事务 3 查询数据“张三”,不存在 4 插入数据“张三” 5 提交事务 6 查询数据“张三”,不存在 7 插入数据“张三”,不成功 事务A查询“张三”,查询不到,插入又不成功,“张三”这条数据就像幻觉一样出现。这就是所谓的“幻读”。网上对“幻读”还是其他的解释,都是错误的。比如像“幻读”和“不可重复读”是一样,只不过“幻读”是针对数据的个数。这些理解都是错误的。 Serializable(可串行化) 这是最高的隔离级别,它通过强制事务排序,使之不可能相互冲突,从而解决幻读问题。简言之,它是在每个读的数据行上加上共享锁。在这个级别,可能导致大量的超时现象和锁竞争。这种隔离级别很少使用,不给大家做过多的介绍了。
Elasticsearch是一个非常好用的搜索引擎,和Solr一样,他们都是基于倒排索引的。今天我们就看一看Elasticsearch如何进行安装。 下载和安装 今天我们的目的是搭建一个有3个节点的Elasticsearch集群,所以我们找了3台虚拟机,ip分别是: 192.168.73.130 192.168.73.131 192.168.73.132 然后我们要下载ES,这里我们采用的版本是7.6.0。我们进入到/opt目录下,下载elasticsearch7.6.0 curl -L -O https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-7.6.0-linux-x86_64.tar.gz 下载的过程比较慢。下载完成后,我们解压: tar -zxvf elasticsearch-7.6.0-linux-x86_64.tar.gz 在启动elasticsearch之前,这里有一个重点:ES在启动的时候是不允许使用root账户的,所以我们要新建一个elasticsearch用户: useradd elasticsearch 然后把elasticsearch-7.6.0这个目录和目录下所有的文件的拥有者都改成elasticsearch: chown elasticsearch:elasticsearch -R elasticsearch-7.6.0 然后,我们切换到elasticsearch用户: su - elasticsearch 我们将ES安装在/opt目录下,先进入到/opt目录, cd /opt/elasticsearch-7.6.0 我们启动一下,看看单机版能不能启动成功。 ./bin/elasticsearch 可以启动成功。但是我们通过浏览器访问这个ip的9200端口时,是不成功的。我们需要对elasticsearch进行配置,才可以在别的机器上访问成功。 ES的配置 es的所有配置文件都在${ES_HOME}/config这个目录下,首先我们设置一下jvm参数,打开jvm.options文件, vim jvm.options ################################################################ ## IMPORTANT: JVM heap size ################################################################ ## ## You should always set the min and max JVM heap ## size to the same value. For example, to set ## the heap to 4 GB, set: ## ## -Xms4g ## -Xmx4g ## ## See https://www.elastic.co/guide/en/elasticsearch/reference/current/heap-size.html ## for more information ## ################################################################ # Xms represents the initial size of total heap space # Xmx represents the maximum size of total heap space -Xms256m -Xmx256m 我们改一下堆内存的大小,我这里使用的虚拟机,只分配了1g的内存,所以,我这里统一调整为256m内存,大家可以根据自己机器的内存情况进行调整。 然后,我们再打开elasticsearch.yml文件,配置一下这里边的参数。 # ======================== Elasticsearch Configuration ========================= # # NOTE: Elasticsearch comes with reasonable defaults for most settings. # Before you set out to tweak and tune the configuration, make sure you # understand what are you trying to accomplish and the consequences. # # The primary way of configuring a node is via this file. This template lists # the most important settings you may want to configure for a production cluster. # # Please consult the documentation for further information on configuration options: # https://www.elastic.co/guide/en/elasticsearch/reference/index.html # # ---------------------------------- Cluster ----------------------------------- # # Use a descriptive name for your cluster: # cluster.name: cluster-a # # ------------------------------------ Node ------------------------------------ # # Use a descriptive name for the node: # node.name: node-130 # # Add custom attributes to the node: # #node.attr.rack: r1 # # ----------------------------------- Paths ------------------------------------ # 我们先配置一下集群的名字,也就是cluster.name,在这里,我们叫做cluster-a。在另外两台机器上,集群的名字也要叫做cluster-a,这样才能够组成一个集群。在ES中,集群名字相同的节点,会组成ES集群。 然后,我们再修改node.name节点名称,这个名称是每一个节点的,所以,每个节点的名称都不能相同。这里我们以ip命名,130这台机器,节点名称就叫node-130,另外两台叫做node-131和node-132。 我们再接着看后面的配置, # ----------------------------------- Paths ------------------------------------ # # Path to directory where to store the data (separate multiple locations by comma): # #path.data: /path/to/data # # Path to log files: # #path.logs: /path/to/logs # # ----------------------------------- Memory ----------------------------------- # # Lock the memory on startup: # #bootstrap.memory_lock: true # # Make sure that the heap size is set to about half the memory available # on the system and that the owner of the process is allowed to use this # limit. # # Elasticsearch performs poorly when the system is swapping the memory. # # ---------------------------------- Network ----------------------------------- # # Set the bind address to a specific IP (IPv4 or IPv6): # network.host: 192.168.73.130 # # Set a custom port for HTTP: # #http.port: 9200 路径和内存,咱们使用默认的就好,咱们重点看一下网络。我们需要指定一下ES绑定的地址,如果不设置,那么默认绑定的就是localhost,也就是127.0.0.1,这样就只有本机能够访问了,其他机器是访问不了的。所以这里我们要绑定每台机器的地址,分别是192.168.73.130,192.168.73.131,192.168.73.132。 接下来,我们看一下集群的相关配置, # --------------------------------- Discovery ---------------------------------- # # Pass an initial list of hosts to perform discovery when this node is started: # The default list of hosts is ["127.0.0.1", "[::1]"] # discovery.seed_hosts: ["192.168.73.130", "192.168.73.131","192.168.73.132"] # # Bootstrap the cluster using an initial set of master-eligible nodes: # cluster.initial_master_nodes: ["node-130", "node-131", "node-132"] # # For more information, consult the discovery and cluster formation module documentation. # 也就是Discovery这一段的配置,我们先设置一下集群中节点的地址,也就是discovery.seed_hosts这一段,我们把3台机器的ip写在这里。然后再把3台机器的节点名称写在cluster.initial_master_nodes,好了,集群的配置到这里就告一段落了。 系统配置 接下来我们再看看重要的系统配置。在ES的官网上,有这样一句话, Ideally, Elasticsearch should run alone on a server and use all of the resources available to it. 翻译过来是,合理的做法是,ES应该在一个服务中单独运行,并且可以使用这个机器中所有的可用资源。 只要你在配置文件中配置了network.host,ES就认为你将发布生产环境,如果你的一些配置不正确,那么ES就不会启动成功。在这里,ES也要求我们对系统的一些配置做出修改。 ulimit调整 首先,我们要修改Linux系统的文件打开数,将其调到65535。 su - ulimit -n 65535 exit 然后再修改limits.conf文件,我们同样切换到root用户,打开limits.conf文件, vim /etc/security/limits.conf 在文件的最后添加elasticsearch - nofile 65535,然后保存退出。 关闭swapping 其次,在ES的官方文档上,要求Disabled Swapping ,我们要关掉它。执行以下命令: sudo swapoff -a 这只是临时的关闭swapping,重启linux后,会失效。如果要永久的关闭swapping,需要编辑/etc/fstab文件,将包含swap的行的注释掉。 /dev/mapper/centos-root / xfs defaults 0 0 UUID=6a38540f-2ba9-437b-ac8b-8757f5754fff /boot xfs defaults 0 0 # /dev/mapper/centos-swap swap swap defaults 0 0 调整mmapfs的数值 由于ES是使用mmapfs存储索引,但是系统的默认值太低了,我们调高一点。 sysctl -w vm.max_map_count=262144 线程的数量 确保elasticsearch用户最少可创建4096个线程。我们还是要以root用户去设置。 su - ulimit -u 4096 同样,这知识临时的方案,linux重启后会失效,我们需要修改配置文件/etc/security/limits.conf,将nproc设置为4096。 elasticsearch - nproc 4096 好,到这里我们所有的配置就完成了,现在依次启动3个节点的ES。启动完成后,我们在浏览器中检查以下集群的状态,http://192.168.73.130:9200/_cluster/health, {"cluster_name":"cluster-a","status":"green","timed_out":false,"number_of_nodes":3,"number_of_data_nodes":3,"active_primary_shards":0,"active_shards":0,"relocating_shards":0,"initializing_shards":0,"unassigned_shards":0,"delayed_unassigned_shards":0,"number_of_pending_tasks":0,"number_of_in_flight_fetch":0,"task_max_waiting_in_queue_millis":0,"active_shards_percent_as_number":100.0} 我们看到status是green。说明我们的ES集群搭建成功了。
CSRF是Cross Site Request Forgery的缩写,中文翻译过来是跨站请求伪造。这个漏洞往往能给用户带来巨大的损失,CSRF在等保安全检测中,也是一个非常重要的检测项。但是在我们的网站中,大部分都没有做CSRF的防御,小伙伴们想不想来一次CSRF攻击,体验一下做黑客感觉?如果想要做黑客,可要仔细的往下看哟~ CSRF攻击的原理 要想理解CSRF攻击的原理,我们从一个经典的案例出发,看看它是如何进行攻击的。假设你的银行网站的域名是www.a-bank.com,这个银行网站提供了一个转账的功能,在这个功能页面中,有一个表单,表单中有两个输入框,一个是转账金额,另一个是对方账号,还有一个提交按钮。当你登录了你的银行网站,输入转账金额,对方账号,点击提交按钮,就会进行转账。 当然,现在的银行网站不会有这么简单的转账操作了,我们在这里只是举一个简单的例子,让大家明白CSRF的原理。咱们可以发散思维,联想到其他类似的操作。 这个转账的表单项,如下所示: <form method="post" action="/transfer"> <input type="text" name="amount"/> <input type="text" name="account"/> <input type="submit" value="Transfer"/> </form> 当我们输入金额和账号,点击提交按钮,表单就会提交,给后端的银行网站服务发送请求。请求的内容如下: POST /transfer HTTP/1.1 Host: www.a-bank.com Cookie: JSESSIONID=randomid Content-Type: application/x-www-form-urlencoded amount=100.00&account=9876 请求成功后,你输入的转账金额100元,将转账到9876这个账户当中。假如你完成转账操作后,并没有退出登录,而是访问了一个恶意网站,这时,你的银行网站www.a-bank.com还是处于登录状态,而这个恶意网站中,出现了一个带有”赢钱“字样的按钮,这个”赢钱“字样的按钮后面是一个form表单,表单如下: <form method="post" action="https://www.a-bank.com/transfer"> <input type="hidden" name="amount" value="100.00"/> <input type="hidden" name="account" value="黑客的银行账户"/> <input type="submit" value="赢钱!"/> </form> 我们可以看到这个表单中,金额和账户都是隐藏的,在网页上只看到了一个赢钱按钮。这时,你忍不住冲动,点了一个”赢钱“按钮,这时,将会发生什么操作呢?我们仔细看一下上面表单中的action写的是什么?action写的是你的银行网站的转账请求接口。你点了一下赢钱按钮,在这个不正规的网站中,将会发送https://www.a-bank.com/transfer这个请求,在发送这个请求的时候,会自动带上www.a-bank.com的cookie,不要问我为什么是这样,这是浏览器的标准,标准就是这样规定的。银行后台接到这个请求后,首先要判断用户是否登录,由于携带了cookie,是登录的,会继续执行后面的转账流程,最后转账成功。你点了一下”赢钱“按钮,自己没有赚到钱,而是给黑客转账了100元。 这就是CSRF攻击的原理,在其他的网站向你的网站发送请求,如果你的网站中的用户没有退出登录,而发送的请求又是一些敏感的操作请求,比如:转账,那么将会给你的网站的用户带来巨大的损失。 CSRF的防御 我们知道了CSRF攻击的原理,就可以做针对性的防御了。CSRF的防御可以从两个方面考虑,一个是后台接口层做防御;另一个则是在前端做防御,这种不同源的请求,不可以带cookie。 后端防御CSRF 我们先聊聊后端的防御,后端防御主要是区分哪些请求是恶意请求,哪些请求是自己网站的请求。区分恶意请求的方式有很多,在这里给大家介绍两种吧。 第一种,CSRF Token的方式。这种方式是在表单页面生成一个随机数,这个随机数一定要后端生成,并且对这个随机数进行存储。在前端页面中,对这个Token表单项进行隐藏。代码如下: <form method="post" action="/transfer"> <input type="hidden" name="_csrf" value="4bfd1575-3ad1-4d21-96c7-4ef2d9f86721"/> <input type="text" name="amount"/> <input type="hidden" name="account"/> <input type="submit" value="Transfer"/> </form> _csrf就是CSRF Token。我们看到他的value是一个UUID,这个UUID是后台生成的。当用户点击转账按钮时,会给银行的后台发送请求,请求中包含_csrf参数,如下: POST /transfer HTTP/1.1 Host: www.a-bank.com Cookie: JSESSIONID=randomid Content-Type: application/x-www-form-urlencoded amount=100.00&account=9876&_csrf=4bfd1575-3ad1-4d21-96c7-4ef2d9f86721 银行后台接收到这个请求后,判断_csrf的值是否存在,如果存在则是自己网站的请求,进行后续的流程;如果不存在,则是恶意网站的请求,直接忽略。 第二种,通过请求头中的referer字段判断请求的来源。每一个发送给后端的请求,在请求头中都会包含一个referer字段,这个字段标识着请求的来源。如果请求是从银行网站发出的,这个字段会是银行网站转账页的链接,比如:https://www.a-bank.com/transfer-view;如果是从恶意网站发出的,那么referer字段一定不会是银行网站。我们在做后端防御时,可以先取出每个请求的请求头中的referer字段,判断是不是以自己网站的域名开头,在咱们的示例中,如果referer字段是以https://www.a-bank.com/开头的,则继续执行转账操作;如果不是,则直接忽略掉这个请求。 以上就是后端防御CSRF攻击的两种方式,都需要在后端做特殊的处理。当然也可以在前端做处理,怎么做呢?我们接着往下看。 前端防御CSRF 既然CSRF攻击的危害这么大,为什么不能在前端禁止这种请求呢?各大浏览器厂商似乎也注意到了这个问题,谷歌提出了same-site cookies概念,same-site cookies 是基于 Chrome 和 Mozilla 开发者花了三年多时间制定的 IETF 标准。它是在原有的Cookie中,新添加了一个SameSite属性,它标识着在非同源的请求中,是否可以带上Cookie,它可以设置为3个值,分别为: Strict Lax None Cookie中的内容为: POST /transfer HTTP/1.1 Host: www.a-bank.com Cookie: JSESSIONID=randomid;SameSite=Strict; Strict是最严格的,它完全禁止在跨站情况下,发送Cookie。只有在自己的网站内部发送请求,才会带上Cookie。不过这个规则过于严格,会影响用户的体验。比如在一个网站中有一个链接,这个链接连接到了GitHub上,由于SameSite设置为Strict,跳转到GitHub后,GitHub总是未登录状态。 Lax的规则稍稍放宽了些,大部分跨站的请求也不会带上Cookie,但是一些导航的Get请求会带上Cookie,如下: 请求类型 示例 Lax情况 链接 <a href="..."></a> 发送 Cookie 预加载 <link rel="prerender" href="..."/> 发送 Cookie GET 表单 <form method="GET" action="..."> 发送 Cookie POST 表单 <form method="POST" action="..."> 不发送 iframe frameLabelStart--frameLabelEnd 不发送 AJAX $.get("...") 不发送 Image <img src="..."> 不发送 上面的表格就是SameSite设置为Lax的时候,Cookie的发送情况。 None就是关闭SameSite属性,所有的情况下都发送Cookie。不过SameSite设置None,还要同时设置Cookie的Secure属性,否则是不生效的。 以上就是在前端通过Cookie的SameSite属性防御CSRF攻击,不过大家在使用SameSite属性时,要注意浏览器是否支持SameSite属性。 总结 到这里CSRF的攻和防都已经介绍完了,大部分网站都是没有做CSRF防御的,小伙伴们有没有想当黑客的瘾,找几个网站搞一下试试吧~~
前言 在上一节中,我们给大家介绍了什么是锁,以及锁的使用场景,我相信大家对锁的定义,以及锁的重要性都有了比较清晰的认识。在这一节中,我们会给大家继续做深入的介绍,介绍JAVA为我们提供的不同种类的锁。 JAVA为我们提供了种类丰富的锁,每种锁都有不同的特性,锁的使用场景也各不相同。由于篇幅有限,在这里只给大家介绍比较常用的几种锁。我们会通过锁的定义,核心代码剖析,以及使用场景来给大家介绍JAVA中主流的几种锁。 乐观锁 与 悲观锁 乐观锁与悲观锁应该是每个开发人员最先接触的两种锁。小编最早接触的就是这两种锁,但是不是在JAVA中接触的,而是在数据库当中。当时的应用场景主要是在更新数据的时候,更新数据这个场景也是使用锁的非常主要的场景之一。更新数据的主要流程如下: 检索出要更新的数据,供操作人员查看; 操作人员更改需要修改的数值; 点击保存,更新数据; 这个流程看似简单,但是我们用多线程的思维去考虑,这也应该算是一种互联网思维吧,就会发现其中隐藏着问题。我们具体看一下, A检索出数据; B检索出数据; B修改了数据; A修改数据,系统会修改成功吗? 当然啦,A修改成功与否,要看程序怎么写。咱们抛开程序,从常理考虑,A保存数据的时候,系统要给提示,说“您修改的数据已被其他人修改过,请重新查询确认”。那么我们程序中怎么实现呢? 在检索数据,将数据的版本号(version)或者最后更新时间一并检索出来; 操作员更改数据以后,点击保存,在数据库执行update操作; 执行update操作时,用步骤1检索出的版本号或者最后更新时间与数据库中的记录作比较; 如果版本号或最后更新时间一致,则可以更新; 如果不一致,就要给出上面的提示; 上述的流程就是乐观锁的实现方式。在JAVA中乐观锁并没有确定的方法,或者关键字,它只是一个处理的流程、策略。咱们看懂上面的例子之后,再来看看JAVA中乐观锁。 乐观锁呢,它是假设一个线程在取数据的时候不会被其他线程更改数据,就像上面的例子那样,但是在更新数据的时候会校验数据有没有被修改过。它是一种比较交换的机制,简称CAS(Compare And Swap)机制。一旦检测到有冲突产生,也就是上面说到的版本号或者最后更新时间不一致,它就会进行重试,直到没有冲突为止。 乐观锁的机制如图所示: 咱们看一下JAVA中最常用的i++,咱们思考一个问题,i++它的执行顺序是什么样子的?它是线程安全的吗?当多个线程并发执行i++的时候,会不会有问题?接下来咱们通过程序看一下: public class Test { private int i=0; public static void main(String[] args) { Test test = new Test(); //线程池:50个线程 ExecutorService es = Executors.newFixedThreadPool(50); //闭锁 CountDownLatch cdl = new CountDownLatch(5000); for (int i = 0;i < 5000; i++){ es.execute(()->{ test.i++; cdl.countDown(); }); } es.shutdown(); try { //等待5000个任务执行完成后,打印出执行结果 cdl.await(); System.out.println("执行完成后,i="+test.i); } catch (InterruptedException e) { e.printStackTrace(); } } } 上面的程序中,我们模拟了50个线程同时执行i++,总共执行5000次,按照常规的理解,得到的结果应该是5000,我们运行一下程序,看看执行的结果如何? 执行完成后,i=4975 执行完成后,i=4986 执行完成后,i=4971 这是我们运行3次以后得到的结果,可以看到每次执行的结果都不一样,而且不是5000,这是为什么呢?这就说明i++并不是一个原子性的操作,在多线程的情况下并不安全。我们把i++的详细执行步骤拆解一下: 从内存中取出i的当前值; 将i的值加1; 将计算好的值放入到内存当中; 这个流程和我们上面讲解的数据库的操作流程是一样的。在多线程的场景下,我们可以想象一下,线程A和线程B同时从内存取出i的值,假如i的值是1000,然后线程A和线程B再同时执行+1的操作,然后把值再放入内存当中,这时,内存中的值是1001,而我们期望的是1002,正是这个原因导致了上面的错误。那么我们如何解决呢?在JAVA1.5以后,JDK官方提供了大量的原子类,这些类的内部都是基于CAS机制的,也就是使用了乐观锁。我们将上面的程序稍微改造一下,如下: public class Test { private AtomicInteger i = new AtomicInteger(0); public static void main(String[] args) { Test test = new Test(); ExecutorService es = Executors.newFixedThreadPool(50); CountDownLatch cdl = new CountDownLatch(5000); for (int i = 0;i < 5000; i++){ es.execute(()->{ test.i.incrementAndGet(); cdl.countDown(); }); } es.shutdown(); try { cdl.await(); System.out.println("执行完成后,i="+test.i); } catch (InterruptedException e) { e.printStackTrace(); } } } 我们将变量i的类型改为AtomicInteger,AtomicInteger是一个原子类。我们在之前调用i++的地方改成了i.incrementAndGet(),incrementAndGet()方法采用了CAS机制,也就是说使用了乐观锁。我们再运行一下程序,看看结果如何。 执行完成后,i=5000 执行完成后,i=5000 执行完成后,i=5000 我们同样执行了3次,3次的结果都是5000,符合了我们预期。这个就是乐观锁。我们对乐观锁稍加总结,乐观锁在读取数据的时候不做任何限制,而是在更新数据的时候,进行数据的比较,保证数据的版本一致时再更新数据。根据它的这个特点,可以看出乐观锁适用于读操作多,而写操作少的场景。 悲观锁与乐观锁恰恰相反,悲观锁从读取数据的时候就显示的加锁,直到数据更新完成,释放锁为止。在这期间只能有一个线程去操作,其他的线程只能等待。在JAVA中,悲观锁可以使用synchronized关键字或者ReentrantLock类来实现。还是上面的例子,我们分别使用这两种方式来实现一下。首先是使用synchronized关键字来实现: public class Test { private int i=0; public static void main(String[] args) { Test test = new Test(); ExecutorService es = Executors.newFixedThreadPool(50); CountDownLatch cdl = new CountDownLatch(5000); for (int i = 0;i < 5000; i++){ es.execute(()->{ //修改部分 开始 synchronized (test){ test.i++; } //修改部分 结束 cdl.countDown(); }); } es.shutdown(); try { cdl.await(); System.out.println("执行完成后,i="+test.i); } catch (InterruptedException e) { e.printStackTrace(); } } } 我们唯一的改动就是增加了synchronized块,它锁住的对象是test,在所有线程中,谁获得了test对象的锁,谁才能执行i++操作。我们使用了synchronized悲观锁的方式,使得i++线程安全。我们运行一下,看看结果如何。 执行完成后,i=5000 执行完成后,i=5000 执行完成后,i=5000 我们运行3次,结果都是5000,符合预期。接下来,我们再使用ReentrantLock类来实现悲观锁。代码如下: public class Test { //添加了ReentrantLock锁 Lock lock = new ReentrantLock(); private int i=0; public static void main(String[] args) { Test test = new Test(); ExecutorService es = Executors.newFixedThreadPool(50); CountDownLatch cdl = new CountDownLatch(5000); for (int i = 0;i < 5000; i++){ es.execute(()->{ //修改部分 开始 test.lock.lock(); test.i++; test.lock.unlock(); //修改部分 结束 cdl.countDown(); }); } es.shutdown(); try { cdl.await(); System.out.println("执行完成后,i="+test.i); } catch (InterruptedException e) { e.printStackTrace(); } } } 我们在类中显示的增加了Lock lock = new ReentrantLock();,而且在i++之前增加了lock.lock(),加锁操作,在i++之后增加了lock.unlock()释放锁的操作。我们同样运行3次,看看结果。 执行完成后,i=5000 执行完成后,i=5000 执行完成后,i=5000 3次运行结果都是5000,完全符合预期。我们再来总结一下悲观锁,悲观锁从读取数据的时候就加了锁,而且在更新数据的时候,保证只有一个线程在执行更新操作,没有像乐观锁那样进行数据版本的比较。所以乐观锁适用于读相对少,写相对多的操作。 公平锁 与 非公平锁 前面我们介绍了乐观锁与悲观锁,这一小节我们将从另外一个维度去讲解锁——公平锁与非公平锁。从名字不难看出,公平锁在多线程情况下,对待每一个线程都是公平的;而非公平锁恰好与之相反。从字面上理解还是有些晦涩难懂,我们还是举例说明,场景还是去超市买东西,在储物柜存储东西的例子。储物柜只有一个,同时来了3个人使用储物柜,这时A先抢到了柜子,A去使用,B和C自觉进行排队。A使用完以后,后面排队中的第一个人将继续使用柜子,这就是公平锁。在公平锁当中,所有的线程都自觉排队,一个线程执行完以后,排在后面的线程继续使用。 非公平锁则不然,A在使用柜子的时候,B和C并不会排队,A使用完以后,将柜子的钥匙往后一抛,B和C谁抢到了谁用,甚至可能突然跑来一个D,这个D抢到了钥匙,那么D将使用柜子,这个就是非公平锁。 公平锁如图所示: 多个线程同时执行方法,线程A抢到了锁,A可以执行方法。其他线程则在队列里进行排队,A执行完方法后,会从队列里取出下一个线程B,再去执行方法。以此类推,对于每一个线程来说都是公平的,不会存在后加入的线程先执行的情况。 非公平锁入下图所示: 多个线程同时执行方法,线程A抢到了锁,A可以执行方法。其他的线程并没有排队,A执行完方法,释放锁后,其他的线程谁抢到了锁,谁去执行方法。会存在后加入的线程,反而先抢到锁的情况。 公平锁与非公平锁都在ReentrantLock类里给出了实现,我们看一下ReentrantLock的源码。 /** * Creates an instance of {@code ReentrantLock}. * This is equivalent to using {@code ReentrantLock(false)}. */ public ReentrantLock() { sync = new NonfairSync(); } /** * Creates an instance of {@code ReentrantLock} with the * given fairness policy. * * @param fair {@code true} if this lock should use a fair ordering policy */ public ReentrantLock(boolean fair) { sync = fair ? new FairSync() : new NonfairSync(); } ReentrantLock有两个构造方法,默认的构造方法中,sync = new NonfairSync();我们可以从字面意思看出它是一个非公平锁。再看看第二个构造方法,它需要传入一个参数,参数是一个布尔型,true是公平锁,false是非公平锁。从上面的源码我们可以看出sync有两个实现类,分别是FairSync和NonfairSync,我们再看看获取锁的核心方法,首先是公平锁FairSync的, @ReservedStackAccess protected final boolean tryAcquire(int acquires) { final Thread current = Thread.currentThread(); int c = getState(); if (c == 0) { if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } } else if (current == getExclusiveOwnerThread()) { int nextc = c + acquires; if (nextc < 0) throw new Error("Maximum lock count exceeded"); setState(nextc); return true; } return false; } 然后是非公平锁NonfairSync的, @ReservedStackAccess final boolean nonfairTryAcquire(int acquires) { final Thread current = Thread.currentThread(); int c = getState(); if (c == 0) { if (compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } } else if (current == getExclusiveOwnerThread()) { int nextc = c + acquires; if (nextc < 0) // overflow throw new Error("Maximum lock count exceeded"); setState(nextc); return true; } return false; } 通过对比两个方法,我们可以看出唯一的不同之处在于!hasQueuedPredecessors()这个方法,很明显这个方法是一个队列,由此可以推断,公平锁是将所有的线程放在一个队列中,一个线程执行完成后,从队列中取出下一个线程,而非公平锁则没有这个队列。这些都是公平锁与非公平锁底层的实现原理,我们在使用的时候不用追到这么深层次的代码,只需要了解公平锁与非公平锁的含义,并且在调用构造方法时,传入true和false即可。 总结 JAVA中锁的种类非常多,在这一节中,我们找了非常典型的几个锁的类型给大家做了介绍。乐观锁与悲观锁是最基础的,也是大家必须掌握的。大家在工作中不可避免的都要使用到乐观锁和悲观锁。从公平锁与非公平锁这个维度上看,大家平时使用的都是非公平锁,这也是默认的锁的类型。如果要使用公平锁,大家可以在秒杀的场景下使用,在秒杀的场景下,是遵循先到先得的原则,是需要排队的,所以这种场景下是最适合使用公平锁的。 如想深入了解,请关注我们的《架构师课程》
场景描述 锁在JAVA中是一个非常重要的概念,尤其是在当今的互联网时代,高并发的场景下,更是离不开锁。那么锁到底是什么呢?在计算机科学中,锁(lock)或互斥(mutex)是一种同步机制,用于在有许多执行线程的环境中强制对资源的访问限制。锁旨在强制实施互斥排他、并发控制策略。咱们举一个生活中的例子:大家都去过超市买东西,如果你随身带了包呢,要放到储物柜里。咱们把这个例子再极端一下,假如柜子只有一个,现在同时来了3个人A,B,C,都要往这个柜子里放东西。这个场景就构造了一个多线程,多线程自然离不开锁。如下图所示: A,B,C都要往柜子里放东西,可是柜子只能放一件东西,那怎么办呢?这个时候呢就引出了锁的概念,3个人中谁抢到了柜子的锁,谁就可以使用这个柜子,其他的人只能等待。比如:C抢到了锁,C可以使用这个柜子。A和B只能等待,等C使用完了,释放锁以后,A和B再争抢锁,谁抢到了,再继续使用柜子。 代码示例 我们再将上面的场景反应到程序中,首先创建一个柜子的类: public class Cabinet { //柜子中存储的数字 private int storeNumber; public void setStoreNumber(int storeNumber){ this.storeNumber = storeNumber; } public int getStoreNumber(){ return this.storeNumber; } } 柜子中存储的是数字。 然后我们将3个用户抽象成一个类: public class User { //柜子 private Cabinet cabinet; //存储的数字 private int storeNumber; public User(Cabinet cabinet,int storeNumber){ this.cabinet = cabinet; this.storeNumber = storeNumber; } //使用柜子 public void useCabinet(){ cabinet.setStoreNumber(storeNumber); } } 在用户的构造方法中,需要传入两个参数,一个是要使用的柜子,另一个是要存储的数字。到这里,柜子和用户都已经抽象成了类,接下来我们再写一个启动类,模拟一下3个用户使用柜子的场景: public class Starter { public static void main(String[] args){ Cabinet cabinet = new Cabinet(); ExecutorService es = Executors.newFixedThreadPool(3); for (int i = 0; i < 3; i++){ final int storeNumber = i; es.execute(()->{ User user = new User(cabinet,storeNumber); user.useCabinet(); System.out.println("我是用户"+storeNumber+",我存储的数字是:"+cabinet.getStoreNumber()); }); } es.shutdown(); } } 我们仔细的看一下这个main函数的过程, 首先创建一个柜子的实例,由于场景中只有一个柜子,所以我们只创建了一个柜子实例。 然后我们新建一个线程池,线程池中有3个线程,每个线程执行一个用户的操作。 再来看看每个线程具体的执行过程,新建用户实例,传入的是用户使用的柜子,我们这里只有一个柜子,所以传入这个柜子的实例,然后传入这个用户要存储的数字,分别是1,2,3,也分别对应着用户A,用户B,和用户C。 再调用使用柜子的操作,也就是向柜子中放入要存储的数字,然后立刻从柜子中取出数字,并打印出来。 我们运行一下main函数,看看打印的结果是什么? 我是用户0,我存储的数字是:2 我是用户2,我存储的数字是:2 我是用户1,我存储的数字是:2 从结果中我们可以看出,3个用户在柜子中存储的数字都变成了2。我们再次运行程序,结果如下: 我是用户1,我存储的数字是:1 我是用户2,我存储的数字是:1 我是用户0,我存储的数字是:1 这次又变成了1。这是为什么呢?问题就出在user.useCabinet()这个方法上,这是因为柜子这个实例没有加锁的原因,3个用户并行的执行,向柜子中存储他们的数字,虽然是3个用户并行的同时操作,但是在具体赋值时,也是有顺序的,因为变量storeNumber只占有一块内存,storeNumber只存储一个值,存储最后的线程所设置的值。至于哪个线程排在最后,则完全不确定。赋值语句执行完成后,进入到打印语句,打印语句取storeNumber的值并打印,这时storeNumber存储的是最后一个线程所设置的值,3个线程取到的值是相同的,就像上面打印的结果一样。 那么如何解决这个问题?这就引出了我们本文的重点内容——锁。我们在赋值语句上加锁,这样当多个线程(本文当中的多个用户)同时赋值时,谁抢到了这把锁,谁才能赋值。这样保证同一时刻只能有一个线程进行赋值操作,避免了之前的混乱的情况。 那么在程序中如何加锁呢?这就要使用JAVA中的一个关键字了——synchronized。synchronized分为synchronized方法和synchronized同步代码块。下面我们看一下两者的具体用法: synchronized方法,顾名思义,是把synchronized关键字写在方法上,它表示这个方法是加了锁的,当多个线程同时调用这个方法时,只有获得锁的线程才可以执行。我们看一下下面的例子: public synchronized String getTicket(){ return "xxx"; } 我们可以看到getTicket()方法加了锁,当多个线程并发执行的时候,只有获得到锁的线程才可以执行,其他的线程只能等待。 我们再来看看synchronized块,synchronized块的语法是: synchronized (对象锁){ …… } 我们将需要加锁的语句都写在synchronized块内,而在对象锁的位置,需要填写加锁的对象,它的含义是,当多个线程并发执行时,只有获得你写的这个对象的锁,才能执行后面的语句,其他的线程只能等待。synchronized块通常的写法是synchronized(this),这个this是当前类的实例,也就是说获得当前这个类的对象的锁,才能执行这个方法,这样写的效果和synchronized方法是一样的。 再回到我们的示例当中,如何解决storeNumber混乱的问题呢?咱们可以在设置storeNumber的方法上加上锁,这样保证同时只有一个线程能调用这个方法。如下所示: public class Cabinet { //柜子中存储的数字 private int storeNumber; public synchronized void setStoreNumber(int storeNumber){ this.storeNumber = storeNumber; } public int getStoreNumber(){ return this.storeNumber; } } 我们在set方法上加了synchronized关键字,这样在存储数字时,就不会并行的去执行了,而是哪个用户抢到锁,哪个用户执行存储数字的方法。我们再运行一下main函数,看看运行的结果: 我是用户1,我存储的数字是:1 我是用户2,我存储的数字是:2 我是用户0,我存储的数字是:0 由于set方法上加了锁,不会并发的执行这个方法,而是一个线程一个线程的去执行,这样用户存储的数字,和取出的数字就对应上了,不会造成混乱。 最后我们通过一张图上面示例的整体情况。 如上图所示,线程A,线程B,线程C同时调用Cabinet类的setStoreNumber方法,线程B获得了锁,所以线程B可以执行setStoreNumber的方法,线程A和线程C只能等待。 总结 通过上面的场景与示例,我们可以了解多线程情况下,造成的变量值前后不一致的问题,以及锁的作用。在使用了锁以后,可以避免这种混乱的现象。在下一节中,我们将给大家介绍JAVA中都有哪些关于锁的解决方案。
背景 在互联网初创时期,企业往往采用单体架构去搭建自己的应用系统,但是,随着企业的不断壮大,系统访问量不断随之上升,数据量也急剧增长。数据的存储是首先要解决的问题,在这个大数据时代,数据就是企业的命根子,数据库的单体架构很难满足数据的存储,这时,我们要对数据进行切分,数据的切分又分为垂直切分和水平切分。 数据切分和数据库架构 在数据切分之前,我们的所有业务都放在一个数据库中,比如:我们的用户业务,商品业务,订单业务。数据库的架构如下: 在业务发展到一定规模时,一个数据库很难满足数据的存储,并且导致数据的访问比较慢,导致用户的流失。这时,我们要对数据进行切分,使其从单一的数据库的存储分散到多个数据库的存储。在进行数据切分时,我们要遵循先垂直后水平的原则。 数据的垂直切分也就是数据的纵向切分,按照业务将数据进行切分。在上面的例子中,我们将一个数据库切分为:用户库,商品库,订单库。将原来的一个数据库分为了三个数据库,分散了数据的存储压力,同时也分散了数据的读取压力。如图所示: 但是,随着业务的发展,单个业务库也会遇到存储的瓶颈,比如:用户的急剧增长,导致单一的用户库无法存储,用户访问的速度变慢等。这时,我们就要对数据进行水平切分了,将用户按照某种规则平均分配到多个数据库中,也就是将原来的单一的用户库进行了水平扩展。如图所示: 这里,我们只是水平的拆分了两个库,大家可以根据自己的系统情况,拆分成更多的数据库。 分库分表中间件MyCAT 数据库的整体架构我们规划好了,那么我们在进行开发的时候,怎么确定一条数据从哪个数据库读取呢?或者插入一条数据的时候,这条数据要插入到哪一个数据库呢?数据库的选择是交给开发人员负责呢?还是统一的设置一个代理层呢?开发人员在开发的时候,关注的焦点是业务,复杂的业务已经占据了他们大部分的精力,如果再让他们去考虑数据库的问题,对他们的压力是非常大的,而且每个开发人员的代码风格也不一样,导致项目混乱,臃肿,难以维护。所以,我们往往采用代理层统一处理数据的分片,这时,我们的MyCAT分库分表中间件就登场了,它去做统一的数据库层的代理。如图: MyCAT统一做数据库层的代理,对外暴露一个地址,应用系统直接连接MyCAT,就像连接普通的MySQL一样,没有任何的区别。所有的CRUD操作都直接对应MyCAT,再由MyCAT做具体的数据分片,数据分片的过程对于开发人员来说是透明的,不需要额外的处理,这样,开发人员只需要关注业务就可以了。 MyCAT集群 可用性对于一个系统来说是非常重要的,尤其是在当今的互联网时代,系统宕机1分钟,带来的损失都是非常严重的,所以,我们在搭建系统时,往往采用集群方式,某一个节点的不可用,不影响整体系统的可用性。在前面的例子中,我们所有的节点都是单节点,存在着单点故障,这是我们不希望看到的,所以我们要搭建集群。6个业务数据库我们都可以做主从,这时,用户1库可以搭建为 用户1(主)和用户1(从),用户2库可以搭建为 用户2(主)和用户2(从)。订单库和商品库也可以做同样的操作,如图: 这样我们的业务数据库不存在单点故障了,但是MyCAT成为了单点,如果MyCAT发生故障,或者MyCAT承载了大量的数据库的请求,MyCAT成了整个系统的唯一瓶颈。那么MyCAT我们如何搭建集群呢?有的小伙伴可能会说了,我们再部署一个MyCAT,这个MyCAT和前一个MyCAT配置一样就可以了。是的,这只是其中的第一步,我们有了两个MyCAT连接数据库,那么我们的应用系统也需要连接两个MyCAT吗?两个MyCAT我们要如何分配请求呢?这是不是又增加了应用系统的复杂性呢?所以,我们在两个MyCAT上面再增加一个负载均衡器,它可以将请求按照某种规则分配到两个MyCAT上,这个负载均衡器我们采用HAProxy。整体架构如图: 这样MyCAT的单点故障解决了,但是HAProxy又成了单点,这是不是很有意思,似乎总有一个单点解决不了。在这里最后一个单点HAProxy,我们使用KeepAlived做故障转移就可以解决了,两个KeepAlived可以提供一个虚拟IP,业务系统直接连接这个虚拟IP,后面的过程对于应用系统是透明的。如图所示: 这就是我们最终的数据库架构,不存在任何的单点故障。 分布式事务与分布式ID 进行了分库分表后,随之而来的问题也就出现了,那就是ID的问题和分布式事务的问题,分布式ID和分布式事务在MyCAT中都有相应的解决方案,我们在MyCAT中进行配置就可以了。如果想要深入学习,请关注《JAVA架构师成长体系课》。
大家在项目开发过程中,或多或少都用过缓存,为了减少数据库的压力,把数据放在缓存当中,当访问的请求过来时,直接从缓存读取。缓存一般都是基于内存的,读取速度比较快,市面上比较常见的缓存有:memcache、redis、mongodb、guava cache等。 缓存的常规用法 大家使用缓存时,常用的逻辑时这样的: 根据条件生成key; 从缓存中读取数据,若成功读取数据,则返回; 若数据不存在,根据条件从数据库读取; 将从数据库中读取的数据放入缓存; 返回数据; 每一个使用缓存的场景,上面的逻辑都要重写一遍,是不是很烦躁,是不是很浪费时间。有没有简单的方法完成上面的逻辑?当然有了,这就是今天要向大家介绍的Spring Cache。 Spring Cache Spring Cache并不神秘,而且使用起来非常的方便。它是注解组成的,最常用的一个注解是@Cacheable。这个注解是在方法上使用的,当使用了注解的方法被调用时,会先从缓存中查询,如果缓存中不存在,则执行方法,然后将方法的返回值放入缓存中。具体的使用方法如下: 首先,我们在IDEA中使用Spring Boot搭建环境,在选择依赖的页面中,我们选择了Lombok和Cache,最主要的选择Cache哦~ 项目搭建完毕后,我们看一下pom.xml的依赖: 我们看到在依赖中自动添加了cache。接下来我们要在SpringBoot的启动类上加上使用cache的注解@EnableCaching,如图: 然后我们编写测试的controller,如下: 我们使用@RestController注解,所以它返回的是Json格式的结果。然后在方法上使用了@Cacheable注解,这是我们今天的主角。 cacheNames:当系统中有多个缓存时,指定该方法使用其中的哪几个缓存。 key:缓存的key,可以使用spEL表达式,上面的例子中,使用了入参name。 还有其他的关键字,在这里没有列出来,比如: sync :true或false,当并发量非常大时,将同步开启,可以保证只有一个线程执行方法,其他线程将等待,然后从缓存中读取数据。 condition:使用缓存的条件。 keyGenerator:指定key的生成器。 我们启动项目,并在浏览器第一次访问http://localhost:8080/cache/test?name=allen,结果响应很慢,过了5秒后,页面显示结果: 我们在看一下后台日志: 打印语句打印出来了,说明第一次访问时,是执行了方法的。我们再在浏览器请求相同的地址,结果返回了相同的结果,而且后台没有打印出日志,和上面两张图一模一样。说明这次请求走了缓存,方法并没有执行。 总结 怎么样?@Cacheable很好用吧,大家赶快动手,在项目中实践一下吧,有问题评论区留言哦
系统要求 本安装教程仅限于CentOS7,其他系统不适用。centos-extras仓库必须是启用状态,这个仓库默认状态是启用,如果不是启用状态,请修改。 卸载旧版本的Docker Docker的旧版本叫做docker或者docker-engine。现在的Docker版本是Docker CE(社区版)和Docker EE(企业版)。一般情况下,咱们使用Docker CE(社区版)就可以了。如果你的系统安装了旧版本,卸载它们以及与它们相关的依赖。命令如下: $ sudo yum remove docker \ docker-client \ docker-client-latest \ docker-common \ docker-latest \ docker-latest-logrotate \ docker-logrotate \ docker-engine 如果你的系统中没有安装旧版本的Docker,将会出现如下提示: 已加载插件:fastestmirror 参数 docker 没有匹配 参数 docker-client 没有匹配 参数 docker-client-latest 没有匹配 参数 docker-common 没有匹配 参数 docker-latest 没有匹配 参数 docker-latest-logrotate 没有匹配 参数 docker-logrotate 没有匹配 参数 docker-engine 没有匹配 不删除任何软件包 安装Docker 如果你是在主机上第一次安装Docker CE,需要设置Docker的仓库。以后就可以从这个仓库安装和更新Docker了。 设置仓库 安装所需的包,yum-utils提供yum-config-manager工具,device-mapper-persistent-data和lvm2是devicemapper存储驱动所需要的。安装命令如下: $ sudo yum install -y yum-utils \ device-mapper-persistent-data \ lvm2 通过如下的命令设置稳定的仓库 $ sudo yum-config-manager \ --add-repo \ https://download.docker.com/linux/centos/docker-ce.repo 安装Docker CE 安装最新版本的Docker CE $ sudo yum install docker-ce docker-ce-cli containerd.io 命令完成后,Docker已经安装,但是并没有启动。 启动Docker $ sudo systemctl start docker 通过运行hello-world镜像验证Docker CE是否安装成功。 $ sudo docker run hello-world 运行结果如下: Unable to find image 'hello-world:latest' locally latest: Pulling from library/hello-world 1b930d010525: Pull complete Digest: sha256:92695bc579f31df7a63da6922075d0666e565ceccad16b59c3374d2cf4e8e50e Status: Downloaded newer image for hello-world:latest Hello from Docker! This message shows that your installation appears to be working correctly. To generate this message, Docker took the following steps: 1. The Docker client contacted the Docker daemon. 2. The Docker daemon pulled the "hello-world" image from the Docker Hub. (amd64) 3. The Docker daemon created a new container from that image which runs the executable that produces the output you are currently reading. 4. The Docker daemon streamed that output to the Docker client, which sent it to your terminal. To try something more ambitious, you can run an Ubuntu container with: $ docker run -it ubuntu bash Share images, automate workflows, and more with a free Docker ID: https://hub.docker.com/ For more examples and ideas, visit: https://docs.docker.com/get-started/ 表示安装成功。 Docker的卸载 卸载Docker包 $ sudo yum remove docker-ce 镜像、容器、自定义配置文件等并不会随着Docker的卸载自动删除,你需要执行一下命令手动删除: $ sudo rm -rf /var/lib/docker 到这里,Docker的安装与卸载过程就给大家介绍完了。
生产者 生产者发送业务系统产生的消息给broker。RocketMQ提供了多种发送方式:同步的、异步的、单向的。 生产者组 具有相同角色的生产者被分到一组。假如原始的生产者在事务后崩溃,broker会联系同一生产者组中的不同生产者实例,继续提交或回滚事务。 消费者 一个消费者从broker拉取信息,并将信息返还给应用。为了我们应用的正确性,提供了两种消费者类型: 拉式消费者 拉式消费者从broker拉取消息,一旦一批消息被拉取,用户应用系统将发起消费过程。 推式消费者 推式消费者,从另一方面讲,囊括了消息的拉取、消费过程,并保持了内部的其他工作,留下了一个回调接口给终端用户去实现,实现在消息到达时要执行的内容。 消费者组 具有相同角色的消费者被组在一起,称为消费者组。它是一个伟大的概念,它完成了负载均衡和容错的目标。就消费消息而言,它是非常容易的。 一个消费组中的消费者实例必须有确定的相同的订阅topic。 Topic Topic是一个消息的目录,在这个目录中,生产者传送消息,消费者拉取消息。Topic与生产者和消费者之间的关系非常的宽松。明确的,一个Topic可以有0个,1个或多个生产者向它发送消息。相反的,一个生产者可以发送不同Topic的消息。在消费者方面,一个Topic可以被0个,1个或多个消费者组订阅。相似的,一个消费者组可以订阅1个或多个Topic,只要组内的消费者实例保持订阅的一致性。 Message(消息) 消息是被传递的信息。一个消息必须有一个Topic,它可以理解为信件上的地址。一个消息也可以有一个可选的tag,和额外的key-value对。例如:你可以设置业务中的键到你的消息中,在broker服务中查找消息,以便在开发期间诊断问题。 消息队列 Topic被分割成一个或多个消息队列。队列分为3中角色:异步主、同步主、从。如果你不能容忍消息丢失,我们建议你部署同步主,并加一个从队列。如果你容忍丢失,但你希望队列总是可用,你可以部署异步主和从队列。如果你想最简单,你只需要一个异步主,不需要从队列。消息保存磁盘的方式也有两种,推荐使用的是异步保存,同步保存是昂贵的并会导致性能损失,如果你想要可靠性,我们推荐你使用同步主+从的方式。 Tag(标签) 标签,用另外一个词来说,就是子主题,为用户提供额外的灵活性。具有相同Topic的消息可以有不同的tag。 Broker(队列) Broker是RocketMQ的一个主要组件,它接收生产者发送的消息,存储它们并准备处理消费者的拉取请求。它也存储消息相关的元数据,包括消费组,消费成功的偏移量,主题、队列的信息。 名称服务 名称服务主要提供路由信息。生产者/消费者客户端寻找topic,并找到通信的队列列表。 消息顺序 当DefaultMQPushConsumer 被使用,你就要决定消费消息时,是顺序消费还是同时消费。 顺序消费 顺序消费消息的意思是 消息将按照生产者发送到队列时的顺序被消费掉。如果你被强制要求使用全局的顺序,你要确保你的topic只有一个消息队列。 如果指定顺序消费,消息被同时消费的数量就是订阅这个topic的消费组的数量。 同时消费 当同时消费消息时,消息同时消费的最大数量取决于消费客户端指定的线程池的大小。
在单机时代,采用单块磁盘进行数据存储和读写的方式,由于寻址和读写的时间消耗,导致I/O性能非常低,且存储容量还会受到限制。另外,单块磁盘极其容易出现物理故障,经常导致数据的丢失。因此大家就在想,有没有一种办法将多块独立的磁盘结合在一起组成一个技术方案,来提高数据的可靠性和I/O性能呢。 在这种情况下,RAID技术就应运而生了。 一、RAID 是什么? RAID ( Redundant Array of Independent Disks )即独立磁盘冗余阵列,简称为「磁盘阵列」,其实就是用多个独立的磁盘组成在一起形成一个大的磁盘系统,从而实现比单块磁盘更好的存储性能和更高的可靠性。 二、RAID 有哪些? RAID方案常见的可以分为: RAID0 RAID1 RAID5 RAID6 RAID10 下面来分别介绍一下。 RAID0 RAID0 是一种非常简单的的方式,它将多块磁盘组合在一起形成一个大容量的存储。当我们要写数据的时候,会将数据分为N份,以独立的方式实现N块磁盘的读写,那么这N份数据会同时并发的写到磁盘中,因此执行性能非常的高。 RAID0 的读写性能理论上是单块磁盘的N倍(仅限理论,因为实际中磁盘的寻址时间也是性能占用的大头) 但RAID0的问题是,它并不提供数据校验或冗余备份,因此一旦某块磁盘损坏了,数据就直接丢失,无法恢复了。因此RAID0就不可能用于高要求的业务中,但可以用在对可靠性要求不高,对读写性能要求高的场景中。 那有没有可以让存储可靠性变高的方案呢?有的,下面的RAID1就是。 RAID1 如图,RAID1 是磁盘阵列中单位成本最高的一种方式。因为它的原理是在往磁盘写数据的时候,将同一份数据无差别的写两份到磁盘,分别写到工作磁盘和镜像磁盘,那么它的实际空间使用率只有50%了,两块磁盘当做一块用,这是一种比较昂贵的方案。 RAID1其实与RAID0效果刚好相反。RAID1 这种写双份的做法,就给数据做了一个冗余备份。这样的话,任何一块磁盘损坏了,都可以再基于另外一块磁盘去恢复数据,数据的可靠性非常强,但性能就没那么好了。 了解了RAID0和RAID1之后,我们发现这两个方案都不完美啊。这时候就该 性能又好、可靠性也高 的方案 RAID5 登场了。 RAID5 这是目前用的最多的一种方式。因为 RAID5 是一种将 存储性能、数据安全、存储成本 兼顾的一种方案。 在了解RAID5之前,我们可以先简单看一下RAID3,虽然RAID3用的很少,但弄清楚了RAID3就很容易明白RAID5的思路。 RAID3的方式是:将数据按照RAID0的形式,分成多份同时写入多块磁盘,但是还会另外再留出一块磁盘用于写「奇偶校验码」。例如总共有N块磁盘,那么就会让其中额度N-1块用来并发的写数据,第N块磁盘用记录校验码数据。一旦某一块磁盘坏掉了,就可以利用其它的N-1块磁盘去恢复数据。 但是由于第N块磁盘是校验码磁盘,因此有任何数据的写入都会要去更新这块磁盘,导致这块磁盘的读写是最频繁的,也就非常的容易损坏。 RAID5的方式可以说是对RAID3进行了改进。 RAID5模式中,不再需要用单独的磁盘写校验码了。它把校验码信息分布到各个磁盘上。例如,总共有N块磁盘,那么会将要写入的数据分成N份,并发的写入到N块磁盘中,同时还将数据的校验码信息也写入到这N块磁盘中(数据与对应的校验码信息必须得分开存储在不同的磁盘上)。一旦某一块磁盘损坏了,就可以用剩下的数据和对应的奇偶校验码信息去恢复损坏的数据。 RAID5校验位算法原理:P = D1 xor D2 xor D3 … xor Dn (D1,D2,D3 … Dn为数据块,P为校验,xor为异或运算) RAID5的方式,最少需要三块磁盘来组建磁盘阵列,允许最多同时坏一块磁盘。如果有两块磁盘同时损坏了,那数据就无法恢复了。 RAID6 为了进一步提高存储的高可用,聪明的人们又提出了RAID6方案,可以在有两块磁盘同时损坏的情况下,也能保障数据可恢复。 为什么RAID6这么牛呢,因为RAID6在RAID5的基础上再次改进,引入了双重校验的概念。 RAID6除了每块磁盘上都有同级数据XOR校验区以外,还有针对每个数据块的XOR校验区,这样的话,相当于每个数据块有两个校验保护措施,因此数据的冗余性更高了。 但是RAID6的这种设计也带来了很高的复杂度,虽然数据冗余性好,读取的效率也比较高,但是写数据的性能就很差。因此RAID6在实际环境中应用的比较少。 RAID10 RAID10其实就是RAID1与RAID0的一个合体。 我们看图就明白了: RAID10兼备了RAID1和RAID0的有优点。首先基于RAID1模式将磁盘分为2份,当要写入数据的时候,将所有的数据在两份磁盘上同时写入,相当于写了双份数据,起到了数据保障的作用。且在每一份磁盘上又会基于RAID0技术讲数据分为N份并发的读写,这样也保障了数据的效率。 但也可以看出RAID10模式是有一半的磁盘空间用于存储冗余数据的,浪费的很严重,因此用的也不是很多。
背景 登录是一个网站最基础的功能。有人说它很简单,其实不然,登录逻辑很简单,但涉及知识点比较多,如:密码加密、cookie、session、token、JWT等。 我们看一下传统的做法,前后端统一在一个服务中: 如图所示,逻辑处理和页面放在一个服务中,用户输入用户名、密码后,后台服务在session中设置登录状态,和用户的一些基本信息,然后将响应(Response)返回到浏览器(Browser),并设置Cookie。下次用户在这个浏览器(Browser)中,再次访问服务时,请求中会带上这个Cookie,服务端根据这个Cookie就能找到对应的session,从session中取得用户的信息,从而维持了用户的登录状态。这种机制被称作Cookie-Session机制。 近几年,随着前后端分离的流行,我们的项目结构也发生了变化,如下图: 我们访问一个网站时,先去请求静态服务,拿到页面后,再异步去后台请求数据,最后渲染成我们看到的带有数据的网站。在这种结构下,我们的登录状态怎么维持呢?上面的Cookie-Session机制还适不适用? 这里又分两种情况,服务A和服务B在同一域下,服务A和服务B在不同域下。在详细介绍之前,我们先普及一下浏览器的同源策略。 同源策略 同源策略是浏览器保证安全的基础,它的含义是指,A网页设置的 Cookie,B网页不能打开,除非这两个网页同源。所谓同源是指: 协议相同 域名相同 端口相同 例如:http://www.a.com/login,协议是http,域名是www.a.com,端口是80。只要这3个相同,我们就可以在请求(Request)时带上Cookie,在响应(Response)时设置Cookie。 同域下的前后端分离 我们了解了浏览器的同源策略,接下来就看一看同域下的前后端分离,首先看服务端能不能设置Cookie,具体代码如下: 后端代码: @RequestMapping("setCookie") public String setCookie(HttpServletResponse response){ Cookie cookie = new Cookie("test","same"); cookie.setPath("/"); response.addCookie(cookie); return "success"; } 我们设置Cookie的path为根目录"/",以便在该域的所有路径下都能看到这个Cookie。 前端代码: <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>test</title> <script src="http://ajax.aspnetcdn.com/ajax/jQuery/jquery-1.8.0.js"></script> <script> $(function () { $.ajax({ url : "/test/setCookie", method: "get", success : function (json) { console.log(json); } }); }) </script> </head> <body> aaa </body> </html> 我们在浏览器访问http://www.a.com:8888/index.html,访问前先设置hosts,将www.a.com指向我们本机。访问结果如图所示: 我们可以看到服务器成功设置了Cookie。然后我们再看看同域下,异步请求能不能带上Cookie,代码如下: 后端代码: @RequestMapping("getCookie") public String getCookie(HttpServletRequest request,HttpServletResponse response){ Cookie[] cookies = request.getCookies(); if (cookies != null && cookies.length >0) { for (Cookie cookie : cookies) { System.out.println("name:" + cookie.getName() + "-----value:" + cookie.getValue()); } } return "success"; } 前端代码如下: <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>user</title> <script src="http://ajax.aspnetcdn.com/ajax/jQuery/jquery-1.8.0.js"></script> <script> $(function () { $.ajax({ url : "http://www.b.com:8888/test/getCookie", method: "get", success : function (json) { console.log(json); } }); }) </script> </head> <body> </body> </html> 访问结果如图所示: 再看看后台打印的日志: name:test-----value:same 同域下,异步请求时,Cookie也能带到服务端。 所以,我们在做前后端分离时,前端和后端部署在同一域下,满足浏览器的同源策略,登录不需要做特殊的处理。 不同域下的前后端分离 不同域下,我们的响应(Response)能不能设置Cookie呢?请求时能不能带上Cookie呢?我们实验结果如下,这里就不给大家贴代码了。 由于我们在a.com域下的页面跨域访问b.com的服务,b.com的服务不能设置Cookie。 如果b.com域下有Cookie,我们在a.com域下的页面跨域访问b.com的服务,能不能把b.com的Cookie带上吗?答案是也带不上。那么我们怎么解决跨域问题呢? JSONP解决跨域 JSONP的原理我们可以在维基百科上查看,上面写的很清楚,我们不做过多的介绍。我们改造接口,在每个接口上增加callback参数: @RequestMapping("setCookie") public String setCookie(HttpServletResponse response,String callback){ Cookie cookie = new Cookie("test","same"); cookie.setPath("/"); response.addCookie(cookie); if (StringUtils.isNotBlank(callback)){ return callback+"('success')"; } return "success"; } @RequestMapping("getCookie") public String getCookie(HttpServletRequest request,HttpServletResponse response,String callback){ Cookie[] cookies = request.getCookies(); if (cookies != null && cookies.length >0) { for (Cookie cookie : cookies) { System.out.println("name:" + cookie.getName() + "-----value:" + cookie.getValue()); } } if (StringUtils.isNotBlank(callback)){ return callback+"('success')"; } return "success"; } 如果callback参数不为空,将返回js函数。前端改造如下: 设置Cookie页面改造如下: <script> $(function () { $.ajax({ url : "http://www.b.com:8888/test/setCookie?callback=?", method: "get", dataType : 'jsonp', success : function (json) { console.log(json); } }); }) </script> 请求Cookie时改造如下: <script> $(function () { $.ajax({ url : "http://www.b.com:8888/test/getCookie?callback=?", method: "get", dataType : 'jsonp', success : function (json) { console.log(json); } }); }) </script> 所有的请求都加了callback参数,请求的结果如下: 很神奇吧!我们设置了b.com域下的Cookie。 如果想知道为什么?还是看一看JSONP的原理吧。我们再访问第二个页面,看看Cookie能不能传到服务。后台打印日志为: name:test-----value:same 好了,不同域下的前后端分离,可以通过JSONP跨域,从而保持登录状态。 但是,jsonp本身没有跨域安全规范,一般都是后端进行安全限制,处理不当很容易造成安全问题。 CORS解决跨域 CORS是一个W3C标准,全称是"跨域资源共享"(Cross-origin resource sharing)。CORS需要浏览器和服务器同时支持。目前,所有浏览器都支持该功能,IE浏览器不能低于IE10。整个CORS通信过程,都是浏览器自动完成,不需要用户参与。对于开发者来说,CORS通信与同源的AJAX通信没有差别,代码完全一样。浏览器一旦发现AJAX请求跨源,就会自动添加一些附加的头信息,有时还会多出一次附加的请求,但用户不会有感觉。如果想要详细理解原理,请参考维基百科 CORS请求默认不发送Cookie和HTTP认证信息。若要发送Cookie,浏览器和服务端都要做设置,咱们要解决的是跨域后的登录问题,所以要允许跨域发送Cookie。 后端要设置允许跨域请求的域和允许设置和接受Cookie。 @RequestMapping("setCookie") @CrossOrigin(origins="http://www.a.com:8888",allowCredentials = "true") public String setCookie(HttpServletResponse response){ Cookie cookie = new Cookie("test","same"); cookie.setPath("/"); response.addCookie(cookie); return "success"; } @RequestMapping("getCookie") @CrossOrigin(origins="http://www.a.com:8888",allowCredentials = "true") public String getCookie(HttpServletRequest request,HttpServletResponse response){ Cookie[] cookies = request.getCookies(); if (cookies != null && cookies.length >0) { for (Cookie cookie : cookies) { System.out.println("name:" + cookie.getName() + "-----value:" + cookie.getValue()); } } return "success"; } 我们通过@CrossOrigin注解允许跨域,origins设置了允许跨域请求的域,allowCredentials允许设置和接受Cookie。 前端要设置允许发送和接受Cookie。 <script> $(function () { $.ajax({ url : "http://www.b.com:8888/test/setCookie", method: "get", success : function (json) { console.log(json); }, xhrFields: { withCredentials: true } }); }) </script> <script> $(function () { $.ajax({ url : "http://www.b.com:8888/test/getCookie", method: "get", success : function (json) { console.log(json); }, xhrFields: { withCredentials: true } }); }) </script> 我们访问页面看一下效果。 没有Cookie吗?别急,我们再从浏览器的设置里看一下。 有Cookie了,我们再看看访问能不能带上Cookie,后台打印结果如下: name:test-----value:same 我们使用CORS,也解决了跨域。 总结 前后端分离,基于Cookie-Session机制的登录总结如下 前后端同域——与普通登录没有区别 前后端不同域 JSONP方式实现 CORS方式实现
随着JAVA每半年发布一次新版本,前几天JAVA 11隆重登场。在JAVA 11中,增加了一些新的特性和api,同时也删除了一些特性和api,还有一些性能和垃圾回收的改进。 作为一名一线的开发人员,JAVA 11给我们带来哪些便利之处呢?下面我们来体验一下。 在Lambda表达式中使用var 本地变量类型var是java 10提出的新概念,它可以从上下文中推断出本地变量的类型,从而提高代码可读性。我们看看下面的例子: public class Main { public static void main(String[] args) throws Exception { URL url = new URL("http://www.oracle.com/"); URLConnection conn = url.openConnection(); Reader reader = new BufferedReader( new InputStreamReader(conn.getInputStream())); } } 使用var声明后,上面的代码可以改写成: public class Main { public static void main(String[] args) throws Exception { var url = new URL("http://www.oracle.com/"); var conn = url.openConnection(); var reader = new BufferedReader( new InputStreamReader(conn.getInputStream())); } } 我们使用var代替了URL、URLConnection、Reader,提高了代码的可读性,也方便了开发。但是在JAVA 10中,var变量不能在lambda表达式中声明,在JAVA 11中,解决了这个问题。我们可以在lambda表达式中使用var,如下: (var x, var y) -> x.process(y) 上面的例子等同于 (x, y) -> x.process(y) 但是我们不能混合使用,下面的两个例子都是错误的: //含蓄型的lambda表达式中,要么全使用var,要么全不使用var (var x, y) -> x.process(y) //在lambda表达式中,不能即使用含蓄型,又使用明确型 (var x, int y) -> x.process(y) 标准化HTTP Client API 以前我们在程序中使用HttpClient时,通常会引入apache的HttpClient工具包。在JAVA 11中,我们可以使用JDK原生的HttpClient了。 public class HttpTest { public static void main(String[] args) throws Exception { String uri = "http://www.baidu.com"; HttpClient client = HttpClient.newHttpClient(); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(uri)) .build(); HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString()); System.out.println(response.body()); } } 上面的例子是同步的get请求,还有其他的方法HttpClient也是提供的,例如: 异步get post提交 并发请求 Get Json Post Json 这些例子这里不做详细介绍了,如有需要请参考官方例子。功能很强大吧,我们不用再引入其他的HttpClient的jar包了。 总结 对于一线开发者而言,JAVA 11的体验就这么多,如有遗漏,会在以后补充。
背景 我们在开发的过程中使用分页是不可避免的,通常情况下我们的做法是使用limit加偏移量:select * from table where column=xxx order by xxx limit 1,20。当数据量比较小时(100万以内),无论你翻到哪一页,性能都是很快的。如果查询慢,只要在where条件和order by 的列上加上索引就可以解决。但是,当数据量大的时候(小编遇到的情况是500万数据),如果翻到最后几页,即使加了索引,查询也是非常慢的,这是什么原因导致的呢?我们该如何解决呢? limit分页原理 当我们翻到最后几页时,查询的sql通常是:select * from table where column=xxx order by xxx limit 1000000,20。查询非常慢。但是我们查看前几页的时候,速度并不慢。这是因为limit的偏移量太大导致的。MySql使用limit时的原理是(用上面的例子举例): MySql将查询出1000020条记录。 然后舍掉前面的1000000条记录。 返回剩下的20条记录。 上述的过程是在《高性能MySql》书中确认的。 解决方案 解决的方法就是尽量使用索引覆盖扫描,就是我们select后面检出的是索引列,而不是所有的列,而且这个索引的列最好是id。然后再做一次关联查询返回所有的列。上述的sql可以写成: SELECT * FROM table t INNER JOIN ( SELECT id FROM table WHERE xxx_id = 143381 LIMIT 800000,20 ) t1 ON t.id = t1.id 我们在mysql中做的真实的实验: 上图是没有优化过的sql,执行时间为2s多。经过优化后如下: 执行时间为0.3s,性能有了大幅度的提升。虽然做了优化,但是随着偏移量的增加,性能也会随着下降,MySql官方虽然也给出了其他的解决方案,但是在实际开发中很难使用。 有的同学可能会问,能不能使用IN嵌套子查询,而不使用INNER JOIN的方式,答案是不可以,因为MySql在子查询中不能使用LIMIT。 MySql分页优化就先介绍到这里了。
背景 上一篇我们介绍了单点登录(SSO),它能够实现多个系统的统一认证。今天我们来谈一谈近几年来非常流行的,大名鼎鼎的OAuth。它也能完成统一认证,而且还能做更多的事情。至于OAuth与SSO的区别,将在文章最后总结。 如上图所示,用户通过浏览器(Browser)访问app1,他想用微信的账号直接登录,这样就免去了在app1系统的注册流程。这样的流程完全符合单点登录(SSO),但我们今天要看看OAuth是怎么做的。 具体流程 流程比单点登录(SSO)复杂了很多,但是它比SSO更强大。接下来我们好好捋一捋这个流程: 用户访问app1系统,app1返回登录页,让用户登录。 用户点击微信登录,这里的微信就是OAuth Server,跳转到微信登录页,带上参数appid和回调地址(backUrl)。关于appid我们要详细说一下,我们在与OAuth Server做对接的时候,先要在OAuth Server上注册自己的系统(app1),需要填写应用的名称、回调地址等, OAuth Server会生成appid和appSecret,这两个变量是非常关键的。 微信后台(OAuth Server)根据appid查找到app1的注册信息,校验参数backUrl和注册的回调地址是否一致(这里可以只校验域名或者一级域名,具体要看OAuth Server怎么设计),如果校验不通过则返回错误,校验成功则返回授权页。 微信弹出授权页,如果微信没有登录则弹出登录并授权页。这个过程是微信询问用户,是否同意app1系统访问微信的资源。用户授权后, 微信后台(OAuth Server)会生成这个用户对应的code,并通过app1的backUrl返回app1系统。 app1系统拿到code后,再带上appid和appSecret从后台访问微信后台(OAuth Server),换取用户的token。 app1拿到token后,再去微信后台(OAuth Server)获取用户的信息。 app1设置session为登录状态,并将用户信息(昵称、头像等)返回给Browser。 到这里,OAuth的授权流程就结束了。有的同学可能很快就会问到:用户授权后,为什么不直接返回token,而是要用code换取token?这是因为如果直接返回token,token会先到浏览器(Browser),然后再到app1系统,到了浏览器,这个token就是不安全的了,有可能被窃取,token被窃取后,微信后台(OAuth Server)中,这个用户的信息就不安全了。然而在用code换取token时,是带上了appid和appSecret的,微信后台(OAuth Server)可以判断appid和appSecret的合法性,确认无误后,再将token返回给app1。这里是直接返回app1,没有经过浏览器,token是安全的。 静默登录 上面的流程是用户先访问app1,点击微信登录后,跳到微信。如果我们先打开了微信,在微信里边再打开app1,这个流程就好像我们在微信里打开了京东,这里微信就是OAuth Server,京东就是app1。在这个流程中,我们可以省略掉询问用户是否授权的过程,也就是在微信里打开京东(app1)的时候,京东(app1)带着appid和backUrl访问微信(OAuth Server),微信(OAuth Server)验证appid和backUrl,并且微信(OAuth Server)已经是登录的,这里并没有询问用户是否授权京东访问自己在微信的资源,直接将code返回给了京东。从而使京东登录,并且在京东里显示的用户在微信中的昵称和头像等信息。 使用静默登录一般都是你的系统做的比较牛了,被资源方看中了,要把你的系统嵌入到资源方去。比如:微信中嵌入了京东。 上面的例子中,我们只做了获取用户信息,其实还可以开放很多信息,例如:用户的账户余额等。要开放哪些资源就看OAuth Server的了。这也就是我们常说的open api。要做open api,上面的OAuth流程是必不可少的。 与单点登录(SSO)的对比 单点登录(SSO)是保障客户端(app1)的用户资源的安全 。 OAuth则是保障服务端(OAuth)的用户资源的安全 。 单点登录(SSO)的客户端(app1)要获取的最终信息是,这个用户到底有没有权限访问我(app1)的资源。 OAuth获取的最终信息是,我(OAuth Server)的用户的资源到底能不能让你(app1)访问。 单点登录(SSO),资源都在客户端(app1)这边,不在SSO Server那一方。 用户在给SSO Server提供了用户名密码后,作为客户端app1并不知道这件事。 随便给客户端(app1)个ST,那么客户端(app1)是不能确定这个ST是否有效,所以要拿着这个ST去SSO Server再问一下,这个ST是否有效,是有效的我(app1)才能让这个用户访问。 OAuth认证,资源都在OAuth Server那一方,客户端(app1)想获取OAuth Server的用户资源。 所以在最安全的模式下,用户授权之后,服务端(OAuth Server)并不能通过重定向将token发给客户端(app1),因为这个token有可能被黑客截获,如果黑客截获了这个token,那么用户的资源(OAuth Server)也就暴露在这个黑客之下了。 于是OAuth Server通过重定向发送了一个认证code给客户端(app1),客户端(app1)在后台,通过https的方式,用这个code,以及客户端和服务端预先商量好的密码(appid和appSecret),才能获取到token,这个过程是非常安全的。 如果黑客截获了code,他没有那串预先商量好的密码(appid和appSecret),他也是无法获取token的。这样OAuth就能保证请求资源这件事,是用户同意的,客户端(app1)也是被认可的,可以放心的把资源发给这个客户端(app1)了。 总结:所以单点登录(SSO)和OAuth在流程上的最大区别就是,通过ST或者code去认证的时候,需不需要预先商量好的密码(appid和appSecret)。 总结 OAuth和SSO都可以做统一认证登录,但是OAuth的流程比SSO复杂。SSO只能做用户的认证登录,OAuth不仅能做用户的认证登录,开可以做open api开放更多的用户资源。
背景 在企业发展初期,企业使用的系统很少,通常一个或者两个,每个系统都有自己的登录模块,运营人员每天用自己的账号登录,很方便。但随着企业的发展,用到的系统随之增多,运营人员在操作不同的系统时,需要多次登录,而且每个系统的账号都不一样,这对于运营人员来说,很不方便。于是,就想到是不是可以在一个系统登录,其他系统就不用登录了呢?这就是单点登录要解决的问题。 单点登录英文全称Single Sign On,简称就是SSO。它的解释是:在多个应用系统中,只需要登录一次,就可以访问其他相互信任的应用系统。 如图所示,图中有4个系统,分别是Application1、Application2、Application3、和SSO。Application1、Application2、Application3没有登录模块,而SSO只有登录模块,没有其他的业务模块,当Application1、Application2、Application3需要登录时,将跳到SSO系统,SSO系统完成登录,其他的应用系统也就随之登录了。这完全符合我们对单点登录(SSO)的定义。 技术实现 在说单点登录(SSO)的技术实现之前,我们先说一说普通的登录认证机制。 如上图所示,我们在浏览器(Browser)中访问一个应用,这个应用需要登录,我们填写完用户名和密码后,完成登录认证。这时,我们在这个用户的session中标记登录状态为yes(已登录),同时在浏览器(Browser)中写入Cookie,这个Cookie是这个用户的唯一标识。下次我们再访问这个应用的时候,请求中会带上这个Cookie,服务端会根据这个Cookie找到对应的session,通过session来判断这个用户是否登录。如果不做特殊配置,这个Cookie的名字叫做jsessionid,值在服务端(server)是唯一的。 同域下的单点登录 一个企业一般情况下只有一个域名,通过二级域名区分不同的系统。比如我们有个域名叫做:a.com,同时有两个业务系统分别为:app1.a.com和app2.a.com。我们要做单点登录(SSO),需要一个登录系统,叫做:sso.a.com。 我们只要在sso.a.com登录,app1.a.com和app2.a.com就也登录了。通过上面的登陆认证机制,我们可以知道,在sso.a.com中登录了,其实是在sso.a.com的服务端的session中记录了登录状态,同时在浏览器端(Browser)的sso.a.com下写入了Cookie。那么我们怎么才能让app1.a.com和app2.a.com登录呢?这里有两个问题: Cookie是不能跨域的,我们Cookie的domain属性是sso.a.com,在给app1.a.com和app2.a.com发送请求是带不上的。 sso、app1和app2是不同的应用,它们的session存在自己的应用内,是不共享的。 那么我们如何解决这两个问题呢?针对第一个问题,sso登录以后,可以将Cookie的域设置为顶域,即.a.com,这样所有子域的系统都可以访问到顶域的Cookie。我们在设置Cookie时,只能设置顶域和自己的域,不能设置其他的域。比如:我们不能在自己的系统中给baidu.com的域设置Cookie。 Cookie的问题解决了,我们再来看看session的问题。我们在sso系统登录了,这时再访问app1,Cookie也带到了app1的服务端(Server),app1的服务端怎么找到这个Cookie对应的Session呢?这里就要把3个系统的Session共享,如图所示。共享Session的解决方案有很多,例如:Spring-Session。这样第2个问题也解决了。 同域下的单点登录就实现了,但这还不是真正的单点登录。 不同域下的单点登录 同域下的单点登录是巧用了Cookie顶域的特性。如果是不同域呢?不同域之间Cookie是不共享的,怎么办? 这里我们就要说一说CAS流程了,这个流程是单点登录的标准流程。 上图是CAS官网上的标准流程,具体流程如下: 用户访问app系统,app系统是需要登录的,但用户现在没有登录。 跳转到CAS server,即SSO登录系统,以后图中的CAS Server我们统一叫做SSO系统。 SSO系统也没有登录,弹出用户登录页。 用户填写用户名、密码,SSO系统进行认证后,将登录状态写入SSO的session,浏览器(Browser)中写入SSO域下的Cookie。 SSO系统登录完成后会生成一个ST(Service Ticket),然后跳转到app系统,同时将ST作为参数传递给app系统。 app系统拿到ST后,从后台向SSO发送请求,验证ST是否有效。 验证通过后,app系统将登录状态写入session并设置app域下的Cookie。 至此,跨域单点登录就完成了。以后我们再访问app系统时,app就是登录的。接下来,我们再看看访问app2系统时的流程。 用户访问app2系统,app2系统没有登录,跳转到SSO。 由于SSO已经登录了,不需要重新登录认证。 SSO生成ST,浏览器跳转到app2系统,并将ST作为参数传递给app2。 app2拿到ST,后台访问SSO,验证ST是否有效。 验证成功后,app2将登录状态写入session,并在app2域下写入Cookie。 这样,app2系统不需要走登录流程,就已经是登录了。SSO,app和app2在不同的域,它们之间的session不共享也是没问题的。 有的同学问我,SSO系统登录后,跳回原业务系统时,带了个参数ST,业务系统还要拿ST再次访问SSO进行验证,觉得这个步骤有点多余。他想SSO登录认证通过后,通过回调地址将用户信息返回给原业务系统,原业务系统直接设置登录状态,这样流程简单,也完成了登录,不是很好吗? 其实这样问题时很严重的,如果我在SSO没有登录,而是直接在浏览器中敲入回调的地址,并带上伪造的用户信息,是不是业务系统也认为登录了呢?这是很可怕的。 总结 单点登录(SSO)的所有流程都介绍完了,原理大家都清楚了。总结一下单点登录要做的事情: 单点登录(SSO系统)是保障各业务系统的用户资源的安全 。 各个业务系统获得的信息是,这个用户能不能访问我的资源。 单点登录,资源都在各个业务系统这边,不在SSO那一方。 用户在给SSO服务器提供了用户名密码后,作为业务系统并不知道这件事。 SSO随便给业务系统一个ST,那么业务系统是不能确定这个ST是用户伪造的,还是真的有效,所以要拿着这个ST去SSO服务器再问一下,这个用户给我的ST是否有效,是有效的我才能让这个用户访问。
maven构建的生命周期 maven是围绕着构建生命周期这个核心概念为基础的。maven里有3个内嵌的构建生命周期,default,clean和site。default是处理你项目部署的;clean生命周期是清楚你项目的;site生命周期是生成你的项目文档的。 default生命周期由一下的阶段组成: validate:验证项目正确性和所有需要的信息是否正确; compile:编译项目源代码; test:用单元测试框架测试编译后的代码,测试阶段不需要代码打包和部署; package:把编译后的代码按照发行版本的格式打包,例如:jar; verify:检验集成测试的结果,确保质量可以接受; install:安装包到本地仓库,为本地的其他项目依赖使用; deploy:把最终的包复制到远程仓库,为其他的项目和开发者共享。 default生命周期按照上面的顺序执行。 使用下面的命令构建项目并发布到本地仓库: mvn install 上面的命令在执行install之前,将执行默认的生命周期(validate, compile, package等)。你只需要调用最后一个执行的命令即可。 下面的命令可以清除本地构建并重新打包发布到远程仓库: mvn clean deploy 每一个构建阶段都是由插件目标组成的,一个插件目标代表着一个特殊的工作。它可以被绑定到多个构建阶段中,如果插件目标没有绑定到构建阶段中,可以直接使用命令去执行。它们执行的顺序取决于命令的顺序。例如: mvn clean dependency:copy-dependencies package 上例中,先执行clean,再执行dependency:copy-dependencies,最后执行package。 pom文件 pom是Project Object Model的缩写。它包含了项目的信息和详细配置。 super pom是maven的默认pom,所有的pom都继承super pom。super pom中的配置在你的pom中是有效的。 你能创建的最小pom的格式如下: <project> <modelVersion>4.0.0</modelVersion> <groupId>com.mycompany.app</groupId> <artifactId>my-app</artifactId> <version>1</version> </project> 每一个pom都需要配置groupId, artifactId, 和 version。它代表这一个工件,工件的名称格式如下:<groupId>:<artifactId>:<version>。上例中由于没有指定打包的类型,将使用super pom的默认配置,所以它的类型是jar。由于仓库也没有指定,将使用super pom中配置的仓库,我们可以看到super pom中配置了http://repo.maven.apache.org/maven2。 super pom是项目继承的一个例子,你也可以在项目中指定自己的父pom,例子如下: . |-- my-module | `-- pom.xml `-- pom.xml 我们沿用上面的例子,项目的结构如上图所示,根目录下的pom是com.mycompany.app:my-app:1的pom,my-module/pom.xml是com.mycompany.app:my-module:1的pom。my-module的pom如下: <project> <parent> <groupId>com.mycompany.app</groupId> <artifactId>my-app</artifactId> <version>1</version> </parent> <modelVersion>4.0.0</modelVersion> <groupId>com.mycompany.app</groupId> <artifactId>my-module</artifactId> <version>1</version> </project> 它指定了父pom为my-app,并且指定自己的groupId,artifactId,version。如果你想要groupId,version沿用父pom的,可以将其省略掉,如下: <project> <parent> <groupId>com.mycompany.app</groupId> <artifactId>my-app</artifactId> <version>1</version> </parent> <modelVersion>4.0.0</modelVersion> <artifactId>my-module</artifactId> </project> 上面的例子中,父pom的位置在module的上一级目录,如果父pom不在上一级目录,该如何配置呢? . |-- my-module | `-- pom.xml `-- parent `-- pom.xml 我们可以指定<relativePath>元素,如下: <project> <parent> <groupId>com.mycompany.app</groupId> <artifactId>my-app</artifactId> <version>1</version> <relativePath>../parent/pom.xml</relativePath> </parent> <modelVersion>4.0.0</modelVersion> <artifactId>my-module</artifactId> </project> 推荐使用相对路径指定父pom。 项目集合与项目的继承非常像,不同点在于它在父pom中指定模块,为了配置项目集合,你需要做两点: 父pom的packaging改为pom。 在父pom中指定它的模块。 如果目录结构为: . |-- my-module | `-- pom.xml `-- pom.xml 父pom的配置如下: <project> <modelVersion>4.0.0</modelVersion> <groupId>com.mycompany.app</groupId> <artifactId>my-app</artifactId> <version>1</version> <packaging>pom</packaging> <modules> <module>my-module</module> </modules> </project> 如果目录结构为: . |-- my-module | `-- pom.xml `-- parent `-- pom.xml 父pom结构如下: <project> <modelVersion>4.0.0</modelVersion> <groupId>com.mycompany.app</groupId> <artifactId>my-app</artifactId> <version>1</version> <packaging>pom</packaging> <modules> <module>../my-module</module> </modules> </project> profile profile是环境配置,它配置了不同的环境下,项目中使用的值。profile可以定义的位置: 每个项目:pom文件; 每个用户:%USER_HOME%/.m2/settings.xml中; 全局配置:${maven.home}/conf/settings.xml中。 要使profile被触发,通常是在maven打包编译时指定profile-id。例如: mvn clean install -P profile-1,profile-2 上面的例子将触发两个profile:profile-1和profile-2。 还有就是通过settings文件触发,例如: <settings> ... <activeProfiles> <activeProfile>profile-1</activeProfile> </activeProfiles> ... </settings> 一般情况下,这两种方式就够用了,还有其他的方式这里不做过多介绍。 配置profile的地方通常有两个:settings和pom。settings因为时所有项目共同依赖的,所以在这里配置profile的元素时有限制的,可配置的元素只能是:<repositories>,<pluginRepositories>和<properties>。而在pom中可以配置所有的元素。 依赖机制 传递依赖 传递依赖的意思是,你依赖的包需要的依赖是不需要指定的,它们会自动的包含进来。maven会读取你依赖包中的项目文件,通过项目文件找到依赖包所需要的依赖包。当发生循环依赖的时候,会产生问题。 由于传递依赖,项目依赖包的图会非常的巨大。正是因为这个原因,依赖的传递机制加入了额外的特性。 依赖调解——当依赖的多个版本同时出现时,决定哪个版本被使用。当前的maven版本使用的是“最近原则”。举例说明,比如, A-\>B-\>C-\>D 2.0,并且A-\>E-\>D 1.0。最后,D1.0将被使用,因为D1.0离A是最近的。你可以在A中强制指定依赖D2.0。 在距离相同的情况下,最先被声明的那个依赖被使用。 依赖管理——在项目中可以直接指定依赖的版本,如上例所示。 依赖范围——下面会详细介绍 排除依赖——如果A->B->C,在项目A中可以通过exclusion元素排除掉C。 选择依赖——如果项目Y->Z,项目Y可以配置Z为可选依赖(通过optional),当项目X->Y时,X仅依赖Y,而不依赖Z,如果X想要依赖Z,必须指定依赖。 依赖范围有6个可选项 compile:默认的依赖范围,它的依赖在项目的类路径下都是可用的。这些依赖将传播到依赖的工程。 provided:非常像compile,标志着你希望JDK或者容器在运行时提供依赖。例如,你在构建web项目时,Servlet API和Java EE API的范围设置成provided,因为在运行时,容器提供了这些类。 runtime:标志着这个依赖在编译期是不需要的,在运行期需要。 test:标志着应用的正常使用是不需要这个依赖的,仅仅在测试时需要。 system:这个与provided相似,除了那些你必须显示提供的,包含它的jar。这个工件时可用的,不会在仓库中寻找。 import:这个范围仅支持在依赖类型是pom,且在<dependencyManagement>元素中。它将被<dependencyManagement>中的具体的依赖所取代。 今天就先介绍到这里,如有疑问,欢迎在评论区留言。
概述 Maven的settings.xml配置了Maven执行的方式,像pom.xml一样,但是它是一个通用的配置,不能绑定到任何特殊的项目。它通常包括本地仓库地址,远程仓库服务,认证信息等。 settings.xml存在于两个位置: maven目录下的/conf/settings.xml 用户目录下的/.m2/settings.xml maven目录下的称为全局配置,用户目录下的称为用户配置。如果两个配置都存在,它们的内容将合并,有冲突的以用户配置优先。通常情况下,用户目录下的/.m2/settings.xml是不存在的,如果你需要,可以从maven目录下的/conf/settings.xml复制过来。maven的默认settings模板中,包含了所有的配置的例子,它们都被注释掉了,如果你需要,可以打开注释,配置你自己的信息。 下面是settings文件的顶层元素: <settings xmlns="http://maven.apache.org/SETTINGS/1.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0 https://maven.apache.org/xsd/settings-1.0.0.xsd"> <localRepository/> <interactiveMode/> <usePluginRegistry/> <offline/> <pluginGroups/> <servers/> <mirrors/> <proxies/> <profiles/> <activeProfiles/> </settings> settings文件中的内容可以使用插值替换,例如: ${user.home}或者其他的系统属性(3.0以上) ${env.HOME}等环境变量 注意:profile中定义的properties不能使用插值 详细设置 简单值(simple value) settings文件中,顶层元素中的一半以上都是简单值。接下来让我们看一看吧。 <settings xmlns="http://maven.apache.org/SETTINGS/1.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0 https://maven.apache.org/xsd/settings-1.0.0.xsd"> <localRepository>${user.home}/.m2/repository</localRepository> <interactiveMode>true</interactiveMode> <usePluginRegistry>false</usePluginRegistry> <offline>false</offline> ... </settings> localRepository:本地仓库路径,默认值为:${user.home}/.m2/repository。它允许所有的用户从这个公共的本地仓库构建系统。 interactiveMode:默认为true,代表maven是否可以和用户通过输入进行交互。 usePluginRegistry:默认为false,maven是否可以使用${user.home}/.m2/plugin-registry.xml管理插件版本。从2.0以后,我们是不需要使用这个属性的,可以认为它废弃了。 offline:默认false,构建系统是否可以使用离线模式。在不能连接远程仓库的情况下,这个属性是非常有用的。 插件组(Plugin Groups) pluginGroups包含了一组pluginGroup元素,每一个都包含一个groupId。当你在命令行使用插件,没有提供groupId时,maven将搜索这个列表。列表默认包含org.apache.maven.plugins和org.codehaus.mojo。 <settings xmlns="http://maven.apache.org/SETTINGS/1.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0 https://maven.apache.org/xsd/settings-1.0.0.xsd"> ... <pluginGroups> <pluginGroup>org.mortbay.jetty</pluginGroup> </pluginGroups> ... </settings> 例如:我们执行org.mortbay.jetty:jetty-maven-plugin:run时,可以使用短命令:mvn jetty:run。 服务(Servers) 下载和部署的仓库通常在pom.xml中的repositories和distributionManagement元素中定义,但是像username和password时不应该在单独的pom文件中定义,这种配置信息应该在settings中定义。 <settings xmlns="http://maven.apache.org/SETTINGS/1.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0 https://maven.apache.org/xsd/settings-1.0.0.xsd"> ... <servers> <server> <id>server001</id> <username>my_login</username> <password>my_password</password> <privateKey>${user.home}/.ssh/id_dsa</privateKey> <passphrase>some_passphrase</passphrase> <filePermissions>664</filePermissions> <directoryPermissions>775</directoryPermissions> <configuration></configuration> </server> </servers> ... </settings> id:server的id,它和maven连接的repository或mirror的id匹配。 username, password:用户名和密码,这两个元素成对出现。 privateKey, passphrase:私钥文件和私钥密码,也是成对出现。 filePermissions, directoryPermissions:当通过maven部署到远程仓库的时候,文件和目录的权限通过这两个元素指定。 当使用私钥文件的时候,不要使用password,要使用passphrase。 镜像(Mirrors) <settings xmlns="http://maven.apache.org/SETTINGS/1.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0 https://maven.apache.org/xsd/settings-1.0.0.xsd"> ... <mirrors> <mirror> <id>planetmirror.com</id> <name>PlanetMirror Australia</name> <url>http://downloads.planetmirror.com/pub/maven2</url> <mirrorOf>central</mirrorOf> </mirror> </mirrors> ... </settings> id, name:mirror的唯一标识和用户设置的别名。当连接镜像需要用户名密码或私钥时,id要和<servers>中配置的id一致。 url:镜像的url。构建系统时将使用这个地址,而不是原始的仓库地址。 mirrorOf:仓库镜像的id。例如:指向maven的中央仓库(https://repo.maven.apache.org/maven2/),设置为center。也可以使用一些高级的语法:repo1,repo2 或 *,!inhouse。 代理(Proxies) <settings xmlns="http://maven.apache.org/SETTINGS/1.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0 https://maven.apache.org/xsd/settings-1.0.0.xsd"> ... <proxies> <proxy> <id>myproxy</id> <active>true</active> <protocol>http</protocol> <host>proxy.somewhere.com</host> <port>8080</port> <username>proxyuser</username> <password>somepassword</password> <nonProxyHosts>*.google.com|ibiblio.org</nonProxyHosts> </proxy> </proxies> ... </settings> id:proxy的唯一标识。 active:代理是否有效。多个代理的情况下,只能有一个代理有效。 protocol, host, port:代理的protocol://host:port,分隔成了多个元素。 username, password:代理的用户名和密码,成对出现。 nonProxyHosts:不使用代理的主机。使用逗号“,”分隔也可以。 镜像和代理的区别:镜像:改变原始的仓库地址;代理:有些公司是不能上网的,他们需要配置代理才能访问外网。 用户配置(Profiles) settings.xml文件中的profile是pom.xml中的删减版。它由activation, repositories, pluginRepositories 和 properties组成。而且只包含这4个元素,因为settings中的是全局配置,不是单个项目的配置。 如果settings中的profile是有效的,它将覆盖掉pom中的相同id的profile。 激活(Activation) 它是profile中的一个元素,会在满足activation的条件时,激活状态。 <settings xmlns="http://maven.apache.org/SETTINGS/1.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0 https://maven.apache.org/xsd/settings-1.0.0.xsd"> ... <profiles> <profile> <id>test</id> <activation> <activeByDefault>false</activeByDefault> <jdk>1.5</jdk> <os> <name>Windows XP</name> <family>Windows</family> <arch>x86</arch> <version>5.1.2600</version> </os> <property> <name>mavenVersion</name> <value>2.0.3</value> </property> <file> <exists>${basedir}/file2.properties</exists> <missing>${basedir}/file1.properties</missing> </file> </activation> ... </profile> </profiles> ... </settings> 当activation的条件满足时,该profile将激活。 jdk:activation有一个内嵌的,在jdk元素中已java为中心的检查。当jdk的版本与配置的版本前缀匹配时,这个profile将被激活。上面的例子中,jdk的版本1.5.0_06将匹配。范围配置也是可以的,这里不做详细介绍了。 os:os可以定义一些运行系统的特殊属性。由于比较少用,不做过多介绍,有兴趣的可以查阅官方文档。 property:如果maven探测到一个属性(这个属性的值可以在pom.xml中配置),它的值与配置的值匹配,这个profile将被激活。上面的例子中,mavenVersion=2.0.3时,profile将激活。 file:existence的文件存在,或者missing的文件不存在,条件将激活。 activation不是profile激活的唯一方式,settings.xml文件中的activeProfile元素包含了一个profile的id,可以同过命令行指定这个id来激活profile。例如:-P test,将激活id为test的profile。 属性(Properties) maven的属性是一个占位符,它可以在pom文件中,通过${X}进行访问,X是属性的名称。它们有5中不同的形式: env.X:前缀是一个env,它将返回系统的环境变量。例如:${env.PATH}将返回系统的环境变量$path。 project.x:访问pom嗯我那件,点(.)在pom中代表层级的分隔。例如:<project><version>1.0</version></project>可以通过${project.version}访问。 settings.x:同上,只是访问的是settings文件。例如:<settings><offline>false</offline></settings>可以通过${settings.offline}访问。 Java System Properties:java系统属性,所有通过java.lang.System.getProperties()可以访问到的属性,在pom文件中都可以访问。例如:${java.home}。 x:<properties>元素里配置的属性。通过${someVal}访问。 <settings xmlns="http://maven.apache.org/SETTINGS/1.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0 https://maven.apache.org/xsd/settings-1.0.0.xsd"> ... <profiles> <profile> ... <properties> <user.install>${user.home}/our-project</user.install> </properties> ... </profile> </profiles> ... </settings> 上面的例子中,如果profile被激活,在pom中可以访问${user.install}。 仓库(Repositories) Repositories在这里不是本地仓库的意思,而是远程仓库的集合。它在本地仓库配置,maven通过它从远程下载插件或者依赖。不同的仓库包含不同的项目,在激活的profile下,它们能被搜索到。 <settings xmlns="http://maven.apache.org/SETTINGS/1.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0 https://maven.apache.org/xsd/settings-1.0.0.xsd"> ... <profiles> <profile> ... <repositories> <repository> <id>codehausSnapshots</id> <name>Codehaus Snapshots</name> <releases> <enabled>false</enabled> <updatePolicy>always</updatePolicy> <checksumPolicy>warn</checksumPolicy> </releases> <snapshots> <enabled>true</enabled> <updatePolicy>never</updatePolicy> <checksumPolicy>fail</checksumPolicy> </snapshots> <url>http://snapshots.maven.codehaus.org/maven2</url> <layout>default</layout> </repository> </repositories> <pluginRepositories> ... </pluginRepositories> ... </profile> </profiles> ... </settings> releases, snapshots:稳定版本或快照版本对应的配置。 enabled:true或者false。对应版本的仓库是否可用。 updatePolicy:更新策略。它指定了多长时间更新一次,maven经常比较本地pom和远程pom的时间戳。它的选项有:always、daily(默认)、interval:X(X是分钟)、never。 checksumPolicy:当maven部署文件到仓库时,它还会部署相对应的checksum文件。选项有:ignore, fail, 或 warn,在checksum丢失或不正确的情况下执行。 layout:在上面的配置中,它们都跟随一个公共的布局。这在大多数情况下是正确的。Maven 2有一个仓库的默认布局,但是maven 1.x有一个不同的布局。使用这个元素可以选择使用哪个版本的布局,default 或 legacy。 插件仓库(Plugin Repositories) 仓库有两种主要的类型。第一种是工件作为依赖,常说的jar包依赖。第二种是插件,maven的插件是一种特殊类型的工件,正因如此,maven把插件类型的仓库单独提了出来。pluginRepositories的元素和repositories的元素非常的相似,它指定一个远程插件仓库的地址,可以在那里找到相应的maven插件。 激活profile(Active Profiles) <settings xmlns="http://maven.apache.org/SETTINGS/1.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/SETTINGS/1.0.0 https://maven.apache.org/xsd/settings-1.0.0.xsd"> ... <activeProfiles> <activeProfile>env-test</activeProfile> </activeProfiles> </settings> activeProfiles元素包含了activeProfile元素的集合,activeProfile有一个profile的id值。在activeProfile里定义的id都将被激活。如果没有找到匹配的profile,什么都不会生效。 好了,maven的settings.xml就为大家介绍的这里,有疑问可以随时评论、留言。接下来还会介绍maven的pom.xml。
解决IDEA无法安装插件的问题 进入2018年以来,在IDEA插件中心中,安装插件经常安装失败,报连接超时的错误。如下: 我们发现连接IDEA的插件中心使用的是https的链接,我们在浏览器中使用https访问插件中心并不能访问。而使用普通的http是可以访问插件中心的。 因此,我们需要在IDEA中设置不使用https。具体如下: 我们在settings中,找到如图所示位置,去掉use secure connection前面的勾,这样我们就可以使用普通的http连接插件中心了。插件可以顺利下载安装了。 赶快试一下吧!!
一、简介 SpringBoot自从问世以来,以其方便的配置受到了广大开发者的青睐。它提供了各种starter简化很多繁琐的配置。SpringBoot整合Druid、Mybatis已经司空见惯,在这里就不详细介绍了。今天我们要介绍的是使用SpringBoot整合Redis、ApacheSolr和SpringSession。 二、SpringBoot整合Redis Redis是大家比较常用的缓存之一,一般Redis都会搭建高可用(HA),Cluster或者Sentinel。具体的搭建方法请参照Redis官方文档。我们这里已Sentinel举例,搭建RedisSentinel一般都是3个节点,Redis的端口一般是6379,Sentinel的端口一般是26379。 我们要使用SpringBoot整合Redis,首先要把对应的Redis的starter加入到POM中: <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis</artifactId> </dependency> 引入jar包以后,我们直接在application.properties文件中,添加RedisSentinel的配置即可完成整合。 spring.redis.sentinel.master=mymaster spring.redis.sentinel.nodes=192.168.2.233:26379,192.168.2.234:26379,192.168.2.235:26379 spring.redis.pool.max-active=1024 spring.redis.pool.max-idle=200 spring.redis.pool.min-idle=100 spring.redis.pool.max-wait=10000 sentinel.master是master的名称,我们搭建RedisSentinel时使用的默认的名称mymaster。 sentinel.nodes是sentinel的节点,注意是sentinel的节点,不是redis的节点。用ip:端口的格式,多个节点用“,”隔开。 下面则是一些连接池的信息: pool.max-active:最大活跃数 pool.max-idle:最大空闲数 pool.min-idle:最小空闲数 pool.max-wait:最大等待时间 在程序中,我们可以直接注入redisTemplate,对Redis进行操作 @Autowired private StringRedisTemplate stringRedisTemplate; 至此,Redis整合完了。 三、SpringBoot整合SpringSession SpringSession提供了集群Session的管理,无需通过容器。它可以接入不同的存储层,例如:数据库、Redis、MongoDB等。它可以和SpringBoot无缝结合。 首先,我们将SpringSession引入到项目中,在POM中加入如下配置: <dependency> <groupId>org.springframework.session</groupId> <artifactId>spring-session</artifactId> </dependency> 然后在application.properties中指定一下SpringSession的存储类型: spring.session.store-type=redis 这样就非常简单的整合了SpringSession,如果对cookie有特别的要求,可以在项目中新建cookie的Bean来代替SpringBoot自动创建的bean。具体如下: @Bean public DefaultCookieSerializer cookieSerializer(){ DefaultCookieSerializer cookie = new DefaultCookieSerializer(); cookie.setCookieName("springboot_id"); return cookie; } 上述的例子,我们修改了cookie的名字。如需修改其他属性,请set相关的属性值。 四、SpringBoot整合Solr ApacheSolr是比较常见的搜索引擎,SpringBoot也可以非常方便的整合solr,方便大家的开发。具体的ApacheSolr的概念以及用法请自行查阅相关文档。在搭建solr时,我们一般都会借助zookeeper来搭建SolrCloud,以提高Solr的可用性。在这里我们整理SolrCloud。 首先我们引入ApacheSolr的starter: <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-solr</artifactId> </dependency> 在application.properties中,添加zookeeper的信息,如下: spring.data.solr.zk-host=192.168.2.233:2181,192.168.2.234:2181,192.168.2.235:2181 多个zookeeper时,用“,”隔开。 这样,SpringBoot整合ApacheSolr就完成了,非常方便吧。接下来我们就可以用Spring-data来访问solr了。 1、编写自己的实体类对应solr返回的数据,具体代码如下: @Setter@Getter @SolrDocument(solrCoreName = "xy_company") public class SolrCompany { @Field("id") private String id; @Field("companyName_txt") private String companyName; } @Setter@Getter这两个注解大家比较常见,用于生成get、set方法。 @SolrDocument(solrCoreName = "xy_company"),用于指定这个实体对应solr中的core或collection,core是单实例中的称呼,collection是SolrCloud中的称呼,意思大体一样。 @Field("id"),用于指定对应solr中的字段。 2、编写自己的存储层,继承SolrCrudRepository,如下: public interface CompanyRepository extends SolrCrudRepository<SolrCompany,String> { List<SolrCompany> findByCompanyName(String companyName); } 这样,这个存储层就可以访问solr了,如果多个存储层共用一个实体,可以写多个存储层,继承不同Repository,具体请查阅Spring-data。 3、在自己的业务中,使用solr public List<SolrCompany> getCompanyByName(String companyName){ return companyRepository.findByCompanyName(companyName); } 至此,SpringBoot整合Solr就完成了,很简单吧。
我的博客即将入驻“云栖社区”,诚邀技术同仁一同入驻。
请注意,此篇文章并不是介绍Zookeeper集群内部Leader的选举机制,而是应用程序使用Zookeeper作为选举。 使用Zookeeper进行选举,主要用到了Znode的两个性质: 临时节点(EPHEMERAL) 序列化节点(SEQUENCE) 每一个临时的序列化节点代表着一个客户端(client),也就是选民。主要的设计思路如下: 首先,创建一个选举的节点,我们叫做/election。 然后,每有一个客户端加入,就创建一个子节点/election/n_xxx,这个节点是EPHEMERAL并且SEQUENCE,xxx就是序列化产生的单调递增的数字。 在所有子节点中,序列数字做小的被选举成Leader。 上面的并不是重点,重点是Leader失败的检测,Leader失败后,一个新的客户端(client)将被选举成Leader。实现这个过程的一个最简单的方式是 所有的客户端(client)都监听Leader节点,一旦Leader节点消失,将通知所有的客户端(client)执行Leader选举过程,序列数字最小的将被选举成Leader。 这样实现看似没有问题,但是当客户端(client)数量非常庞大时,所有客户端(client)都将在/election节点执行getChildren(),这对Zookeeper 的压力是非常大的。为了避免这种“惊群效应”,我们可以让客户端只监听它前一个节点(所有序列数字比当前节点小,并且是其中最大的那个节点)。 这样,Leader节点消失后,哪个节点收到了通知,哪个节点就变成Leader,因为所有节点中,没有比它序列更小的节点了。 具体步骤如下: 使用EPHEMERAL和SEQUENCE创建节点/election/n_xxx,我们叫做z。 C为/election的子节点集合,i是z的序列数字。 监听/election/n_j,j是C中小于i的最大数字。 接收到节点消失的事件后: C为新的/election的子节点集合 如果z是集合中最小的节点,则z被选举成Leader 如果z不是最小节点,则继续监听/election/n_j,j是C中小于i的最大数字。 具体代码如下: public class Candidate implements Runnable, Watcher { //zk private ZooKeeper zk; //临时节点前缀 private String perfix = "n_"; //当前节点 private String currentNode; //前一个最大节点 private String lastNode; /** * 构造函数 * @param address zk地址 */ public Candidate(String address) { try { this.zk = new ZooKeeper(address, 3000, this); } catch (IOException e) { e.printStackTrace(); } } /** * 加入选举 */ @Override public void run() { try { //创建临时节点 currentNode = zk.create("/zookeeper/election/" + perfix, Thread.currentThread().getName().getBytes(), ZooDefs.Ids.OPEN_ACL_UNSAFE, CreateMode.EPHEMERAL_SEQUENTIAL); //选举 election(); } catch (KeeperException e) { e.printStackTrace(); } catch (InterruptedException e) { e.printStackTrace(); } } /** * 从小到大排序临时节点 * @param children * @return */ private List<String> getSortedNode(List<String> children) { return children.stream().sorted(((o1, o2) -> { String sequence1 = o1.split(perfix)[1]; String sequence2 = o2.split(perfix)[1]; BigDecimal decimal1 = new BigDecimal(sequence1); BigDecimal decimal2 = new BigDecimal(sequence2); int result = decimal1.compareTo(decimal2); return result; })).collect(toList()); } /** * 选举过程 */ private void election(){ try{ while (true){ //获取/election节点中的所有子节点 List<String> children = zk.getChildren("/zookeeper/election", false); //所有子节点排序(从小到大) List<String> sortedNodes = getSortedNode(children); //获取最小节点 String smallestNode = sortedNodes.get(0); //当前节点就是最小节点,被选举成Leader if (currentNode.equals("/zookeeper/election/"+smallestNode)) { System.out.println(currentNode + "被选举成Leader。"); Thread.sleep(5000); //模拟Leader节点死去 System.out.println(currentNode+"已离去"); zk.close(); break; } //当前节点不是最小节点,监听前一个最大节点 else { //前一个最大节点 lastNode = smallestNode; //找到前一个最大节点,并监听 for (int i = 1; i < sortedNodes.size(); i++) { String z = sortedNodes.get(i); //找到前一个最大节点,并监听 if (currentNode.equals("/zookeeper/election/"+z)) { zk.exists("/zookeeper/election/" + lastNode, true); System.out.println(currentNode+"监听"+lastNode); //等待被唤起执行Leader选举 synchronized (this){ wait(); } break; } lastNode = z; } } } }catch (Exception e) { e.printStackTrace(); } } /** * 观察器通知 * @param event */ @Override public void process(WatchedEvent event) { //监听节点删除事件 if (event.getType().equals(Event.EventType.NodeDeleted)) { //被删除的节点是前一个最大节点,唤起线程执行选举 if (event.getPath().equals("/zookeeper/election/" + lastNode)) { System.out.println(currentNode+"被唤起"); synchronized (this){ notify(); } } } } } 我们将启动5个线程作为参选者,模拟每一个Leader死去,并重新选举的过程。启动程序如下: public class Application { private static final String ADDRESS = "149.28.37.147:2181"; public static void main(String[] args) throws InterruptedException { setLog(); ExecutorService es = Executors.newFixedThreadPool(5); for (int i=0;i<5;i++){ es.execute(new Candidate(ADDRESS)); } es.shutdown(); } /** * 设置log级别为Error */ public static void setLog(){ //1.logback LoggerContext loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory(); //获取应用中的所有logger实例 List<Logger> loggerList = loggerContext.getLoggerList(); //遍历更改每个logger实例的级别,可以通过http请求传递参数进行动态配置 for (ch.qos.logback.classic.Logger logger:loggerList){ logger.setLevel(Level.toLevel("ERROR")); } } } 运行结果如下: /zookeeper/election/n_0000000133被选举成Leader。 /zookeeper/election/n_0000000134监听n_0000000133 /zookeeper/election/n_0000000137监听n_0000000136 /zookeeper/election/n_0000000135监听n_0000000134 /zookeeper/election/n_0000000136监听n_0000000135 /zookeeper/election/n_0000000133已离去 /zookeeper/election/n_0000000134被唤起 /zookeeper/election/n_0000000134被选举成Leader。 /zookeeper/election/n_0000000134已离去 /zookeeper/election/n_0000000135被唤起 /zookeeper/election/n_0000000135被选举成Leader。 /zookeeper/election/n_0000000135已离去 /zookeeper/election/n_0000000136被唤起 /zookeeper/election/n_0000000136被选举成Leader。 /zookeeper/election/n_0000000136已离去 /zookeeper/election/n_0000000137被唤起 /zookeeper/election/n_0000000137被选举成Leader。 /zookeeper/election/n_0000000137已离去 Zookeeper作为选举的应用就介绍完了,项目示例请参考:https://github.com/liubo-tech/zookeeper-application。
Zookeeper应用之——队列(Queue) 为了在Zookeeper中实现分布式队列,首先需要设计一个znode来存放数据,这个节点叫做队列节点,我们的例子中这个节点是/zookeeper/queue。 生产者向队列中存放数据,每一个消息都是队列节点下的一个新节点,叫做消息节点。消息节点的命名规则为:queue-xxx,xxx是一个单调 递增的序列,我们可以在创建节点时指定创建模式为PERSISTENT_SEQUENTIAL来实现。这样,生产者不断的向队列节点中发送消息,消息为queue-xxx, 队列中,生产者这一端就解决了,我们具体看一下代码: Producer(生产者) public class Producer implements Runnable,Watcher { private ZooKeeper zk; public Producer(String address){ try { this.zk = new ZooKeeper(address,3000,this); } catch (IOException e) { e.printStackTrace(); } } @Override public void run() { int i = 0; //每隔10s向队列中放入数据 while (true){ try { zk.create("/zookeeper/queue/queue-",(Thread.currentThread().getName()+"-"+i).getBytes(), ZooDefs.Ids.OPEN_ACL_UNSAFE,CreateMode.PERSISTENT_SEQUENTIAL); Thread.sleep(10000); i++; } catch (KeeperException e) { e.printStackTrace(); } catch (InterruptedException e) { e.printStackTrace(); } } } @Override public void process(WatchedEvent event) { } } 生产者每隔10s向队列中存放消息,消息节点的类型为PERSISTENT_SEQUENTIAL,消息节点中的数据为Thread.currentThread().getName()+"-"+i。 消费者 消费者从队列节点中获取消息,我们使用getChildren()方法获取到队列节点中的所有消息,然后获取消息节点数据,消费消息,并删除消息节点。 如果getChildren()没有获取到数据,说明队列是空的,则消费者等待,然后再调用getChildren()方法设置观察者监听队列节点,队列节点发生变化后 (子节点改变),触发监听事件,唤起消费者。消费者实现如下: public class Consumer implements Runnable,Watcher { private ZooKeeper zk; private List<String> children; public Consumer(String address){ try { this.zk = new ZooKeeper(address,3000,this); } catch (IOException e) { e.printStackTrace(); } } @Override public void run() { int i = 1; while (true){ try { //获取所有子节点 children = zk.getChildren("/zookeeper/queue", false); int size = CollectionUtils.isEmpty(children) ? 0 : children.size(); System.out.println("第"+i+"次获取数据"+size+"条"); //队列中没有数据,设置观察器并等待 if (CollectionUtils.isEmpty(children)){ System.out.println("队列为空,消费者等待"); zk.getChildren("/zookeeper/queue", true); synchronized (this){ wait(); } }else { //循环获取队列中消息,进行业务处理,并从结果集合中删除 Iterator<String> iterator = children.iterator(); while (iterator.hasNext()){ String childNode = iterator.next(); handleBusiness(childNode); iterator.remove(); } } } catch (KeeperException e) { e.printStackTrace(); } catch (InterruptedException e) { e.printStackTrace(); } i++; } } /** * 从节点获取数据,执行业务,并删除节点 * @param childNode */ private void handleBusiness(String childNode) { try { Stat stat = new Stat(); byte[] data = zk.getData("/zookeeper/queue/"+childNode, false, stat); String str = new String(data); System.out.println("获取节点数据:"+str); zk.delete("/zookeeper/queue/"+childNode,-1); } catch (KeeperException e) { e.printStackTrace(); } catch (InterruptedException e) { e.printStackTrace(); } } /** * 子节点发生变化,且取得结果为空时,说明消费者等待,唤起消费者 * @param event */ @Override public void process(WatchedEvent event) { if (event.getType().equals(Event.EventType.NodeChildrenChanged)){ synchronized (this){ notify(); } } } } 上面的例子中有一个局限性,就是 消费者只能有一个 。队列的用户有两个:广播和队列。 广播是所有消费者都拿到消息并消费,我们的例子在删除消息节点时,不能保证其他消费者都拿到了这个消息。 队列是一个消息只能被一个消费者消费,我们的例子中,消费者获取消息时,并没有加锁。 所以我们只启动一个消费者来演示,主函数如下: public class Application { private static final String ADDRESS = "149.28.37.147:2181"; public static void main(String[] args) { //设置日志级别 setLog(); //启动一个消费者 new Thread(new Consumer(ADDRESS)).start(); //启动4个生产者 ExecutorService es = Executors.newFixedThreadPool(4); for (int i=0;i<4;i++){ es.execute(new Producer(ADDRESS)); } es.shutdown(); } /** * 设置log级别为Error */ public static void setLog(){ //1.logback LoggerContext loggerContext = (LoggerContext) LoggerFactory.getILoggerFactory(); //获取应用中的所有logger实例 List<Logger> loggerList = loggerContext.getLoggerList(); //遍历更改每个logger实例的级别,可以通过http请求传递参数进行动态配置 for (ch.qos.logback.classic.Logger logger:loggerList){ logger.setLevel(Level.toLevel("ERROR")); } } } 后台打印结果如下: 第1次获取数据2条 获取节点数据:pool-1-thread-4-118 获取节点数据:pool-1-thread-1-0 第2次获取数据3条 获取节点数据:pool-1-thread-4-0 获取节点数据:pool-1-thread-2-0 获取节点数据:pool-1-thread-3-0 第3次获取数据0条 队列为空,消费者等待 第4次获取数据4条 获取节点数据:pool-1-thread-3-1 获取节点数据:pool-1-thread-1-1 获取节点数据:pool-1-thread-4-1 获取节点数据:pool-1-thread-2-1 Zookeeper实现队列就介绍完了,项目地址:https://github.com/liubo-tech/zookeeper-application。
2020年11月
2020年10月
2020年08月