开发者博客 – IT技术 尽在开发者博客

开发者博客 – 科技是第一生产力


  • 首页

  • 归档

  • 搜索

前端仔必备Nginx避坑指南 写在前面 一、Nginx连接限

发表于 2021-10-22

本文正在参与 “性能优化实战记录-NGINX专属赛道”话题征文活动

写在前面

Nginx是一款开源的、高性能的HTTP服务器和反向代理服务器;同时也是一个IMAP、POP3、SMTP代理服务器;Nginx可以作为一个HTTP服务器进行网站的发布处理,另外Nginx也能作为反向代理进行负载均衡的实现。很多网站和平台都选用nginx来做代理服务器,以便优化提升系统性能,今天就讲讲nginx使用过程中爬过的坑。

一、Nginx连接限制

Nginx在高并发场景下极易遇到连接过多的情况,用户发起一个请求,就会产生一个文件句柄,文件句柄会随着请求连接的增加而增加,但是Nginx和系统的文件句柄是有限制的。超出限制Nginx就会报错。

以CentOS为例,可以在命令行输入如下命令来查看服务器默认配置的最大文件句柄数。

1
2
shell复制代码# ulimit -n
1024

可以看到,在CentOS服务器中,默认的最大文件句柄数为1024。当Nginx的连接数超过1024时,Nginx的错误日志中就会输出如下错误信息。

1
csharp复制代码[alert] 13576#0: accept() failed (24: Too many open files)

解决方案一:

可以把打开文件句柄数设置的足够大,命令如下:

1
bash复制代码ulimit -n 655350

同时修改nginx.conf,添加如下配置项。

1
ini复制代码worker_rlimit_nofile 655350;

这样就可以解决Nginx连接过多的问题,就能支持高并发。
需要注意的是:用ulimit -n 655350命令修改只对当前的shell有效,退出后失效。

解决方案二:

要想让修改的数值永久生效,则必须修改配置文件,可以修改/etc/security/limits.conf配置文件,如下所示。

1
bash复制代码vim /etc/security/limits.conf

在文件最后添加如下配置项。

1
2
markdown复制代码* soft nofile 655360
* hard nofile 655360

星号代表全局,soft为软件,hard为硬件,nofile为这里指可打开的文件句柄数。

二、虚拟主机访问优先级

同一个server_name下多个虚拟主机的访问优先级是不一样的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
ini复制代码#/etc/nginx/conf.d文件夹下面文件排序方式
testserver1.conf testserver2.conf

#testserver1.conf
server{
listen 80;
server_name testserver1 juejin.com;
location {
root /opt/app/code1;
index index.html;
}
}

#testserver2.conf
server{
listen 80;
server_name testserver2 juejin.com;
location {
root /opt/app/code2
index index.html;
}
}

此时nginx是按照读取/etc/nginx/conf.d文件下面配置文件顺序决定的。所以先读取testserver1.conf,默认访问/opt/app/code1

三、Nginx Proxy内存占用过大

开启Nginx Proxy Cache后,过一段时间内存使用率到达98%,进而导致性能剧烈下降。
内存占用率高的问题是内核问题,内核使用LRU机制,可以通过修改内核参数来改善:

1
2
ini复制代码sysctl -w vm.extra_free_kbytes=6463787
sysctl -w vm.vfs_cache_pressure=10000

如果部署在硬盘上使用Proxy Cache性能差,可以通过tmpfs缓存或nginx共享字典缓存元数据,或者直接上SSD。

四、Nginx Try_files路径匹配如何使用

try_files的语法规则:

1
2
3
4
less复制代码//格式1
try_files *file* ... *uri*;
//格式2
try_files *file* ... =*code*;

可应用的上下文:server,location段

执行流程:

按指定的file顺序查找存在的文件,并使用第一个找到的文件进行请求处理

查找路径是按照给定的root或alias为根路径来查找的

如果给出的file都没有匹配到,则重新请求最后一个参数给定的uri,就是新的location匹配

如果是格式2,如果最后一个参数是 = 404 ,若给出的file都没有匹配到,则最后返回404的响应码

1
2
3
4
bash复制代码location /images/ {
root /ht/;
try_files $uri $uri/ /images/default.gif;
}

例如请求127.0.0.1/images/test.gif 会依次查

1.文件/ht/test.gif

2.文件夹 /ht/下的index文件

3.如果都找不到会请求127.0.0.1/images/default.gif
需要注意的是,try-files如果不写上$uri/,当直接访问一个目录路径时,并不会去匹配目录下的索引页 。
即访问127.0.0.1/images/ 不会去访问 127.0.0.1/images/index.html。

五、Nginx如何禁止IP直接访问

当用户通过访问IP或者未知域名访问网站的时候,可以禁止显示任何有效内容,给他返回500。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
perl复制代码server {
listen 80;
server_name www.juejin.com # 这里指定自己的域名
}
server{
listen 80 default_server; # 默认优先返回
server_name _; # 空主机头或IP
return 500; # 返回500错误
}
也可以将流量集中导入自己的网站,只要做以下跳转设置就可以

server {
listen 80 default_server;
return 302 https://www.juejin.com;
}

六、Nginx封掉真实恶意攻击的IP地址

针对恶意行为如:爬虫、而已抓取、资源盗用和恶意攻击行为,可以直接封禁IP

1
2
3
4
5
6
7
8
9
10
11
12
13
bash复制代码server {
listen 80;
server_name juejin.com;
location / {
set $allow true;
if ($http_x_forwarded_for ~ "127.0.*.*|127.0.0.120|127.0.0.130"){
set $allow false;
}
if ($allow = false){
return 403;
}
}
}

七、“惊群”问题

Nginx里的工作进程一般是按系统CPU核数配置的,有多少个CPU核心,就会配置多少个工作进程,工作进程启动时就会利用fork函数创建多个工作进程,并且所有的工作进程都监听在 nginx.conf内配置的监听端口,充分利用多核机器的性能。当客户端连接请求到来时, 一个新连接事件会上报,各个作进程就会发生对事件的抢夺,这就是“惊群”问题。工作进程越多,问题越明显,这会造成系统性能下降,所以, 必须避免“惊群”问题

例如:在没有用户请求的时候,所有的工作进程都在休眠,此时,一个用户向服务器发起了连接请求,例如,在poll模式下,内核在收到了TCP的SYN包时, 会唤起所有休眠的作进程,最先接收连接请求的工作进程可以成功建立新连接,其他工作进程的连接会失败。这些失败的唤醒是不必要的,引发了不必要的进程上下文切换,增加了系统开销,这就是“惊群”问题典型场景。

Nginx应用层制定了一个机制解决这个问题:规定同一时刻只能有唯一一个工作进程监听Web 端口,这样,新的连接事件只能唤醒唯一一个工作进程。内部的实现实际上是使用了一个进程间的同步锁,工作进程每次唤醒都先尝试这把锁,保证同一时间只有一个工作进程可以进入锁,获得锁的进程设置监昕连接的读事件,以处理未来的新连接请求,并处理已连接上的事件;未能进入监听锁的工作进程则不监听新连接事件,只处理已连接上的事件,将唤醒的工作进程分为了两类,一类(只有1个)是可以监听新连接的,另一类是正常处理已有连接请求的。设置了连接事件监听的进程在连接事件到来时会被唤醒并检查系统变量,发现新连接队列中有连接则释放锁,并调用对应事件的handler方法。这种技术既解决了叫”惊群”问题,也避免了一个进程过长占用锁使新连接得不到及时处理的问题,接收了一个连接后,把连接放入队列后马上释放锁,如果恰巧有新连接马上进来, 则会由一个新的工作进程接收连接,起到一定的负载均衡作用,放入队列的请求事件会在后续阶段处理。

八、location匹配规则

1
bash复制代码location [=|~|~*|^~] /uri/ { … }

= 开头表示精确匹配

^~ 开头表示uri以某个常规字符串开头,理解为匹配 url路径即可。nginx不对url做编码,因此请求为/static/20%/aa,可以被规则^~ /static/ /aa匹配到(注意是空格)。

~ 开头表示区分大小写的正则匹配

~* 开头表示不区分大小写的正则匹配

!和!*分别为区分大小写不匹配及不区分大小写不匹配 的正则

/ 通用匹配,任何请求都会匹配到。

多个 location 配置的情况下匹配顺序为:
首先匹配 =

其次匹配 ^~

其次是按文件中顺序的正则匹配

最后是交给 / 通用匹配

当有匹配成功时候,停止匹配,按当前匹配规则处理请求

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ini复制代码location = / {
#规则A
}
location = /login {
#规则B
}
location ^~ /static/ {
#规则C
}
location ~ \.(gif|jpg|png|js|css)$ {
#规则D
}
location ~* \.png$ {
#规则E
}
location / {
#规则F
}

那么产生的效果如下:
访问根目录 /, 比如 http://localhost/ 将匹配规则A

访问 http://localhost/login 将匹配规则B,http://localhost/register 则匹配规则F

访问 http://localhost/static/a.html 将匹配规则C

访问 http://localhost/a.gif, http://localhost/b.jpg 将匹配规则D和规则E,但是规则D顺序优先,规则E不起作用,而 http://localhost/static/c.png则优先匹配到规则 C

访问 http://localhost/a.PNG 则匹配规则E,而不会匹配规则D,因为规则E不区分大小写

访问 http://localhost/category/id/1111 则最终匹配到规则F,因为以上规则都不匹配,这个时候应该是nginx转发请求给后端应用服务器。

九、Nginx指定路径时,root与alias的区别

root与alias路径匹配主要区别在于nginx如何解释location后面的uri,这会使两者分别以不同的方式将请求映射到服务器文件上,alias是一个目录别名的定义,root则是最上层目录的定义。

root的处理结果是:root路径+location路径

alias的处理结果是:使用alias路径替换location路径

1.root路径配置实例: 用户访问www.juejin.com/image/test.gif,实际上Nginx会上/code/image/目录下找去找test.gif文件

1
2
3
4
5
6
7
ini复制代码server {
listen 80;
server_name www.juejin.com;
location /image/ {
root /code;
}
}

2.alias配置实例: 用户访问www.juejin.com/image/test.gif,实际上Nginx会上/code/目录下找去找test.gif文件。

1
2
3
4
5
6
7
bash复制代码server {
listen 80;
server_name www.juejin.com;
location /image/ {
alias /code;
}
}

十、nginx反向代理设置自定义错误页面

#代理的配置

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ini复制代码# cat proxy.conf
server {
listen 80;
server_name test.juejin.com;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_intercept_errors on; #接收后端web4xx,5xx错误
error_page 500 502 403 404 = /proxy_error.html; #将后端web抛出的错误定向到指定的页面
}
#如果有请求proxy_error.html文件的则指定到对应的目录
location = /proxy_error.html {
root /code/proxy;
}
}

#后端web节点配置

1
2
3
4
5
6
7
8
ini复制代码# cat web.conf
server {
listen 8080;
server_name test.juejin.com;
root /code/web;
index index.html;
error_page 404 /404.html; #如果代理开启proxy_intercept_errors则后端web配置error_page无效
}

总结

以上是Nginx使用过程中经常会遇到的问题,你还遇到过什么Nginx“坑”,欢迎评论区一起交流血泪教训。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

springmvc过滤器,拦截器,监听器作用与区别

发表于 2021-10-22

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。

1.过滤器

配置在web.xml中。依赖于servlet容器。在实现上基于函数回调,可以对几乎所有请求进行过滤,但是缺点是一个过滤器实例只能在容器初始化时调用一次。使用过滤器的目的是用来做一些过滤操作,获取我们想要获取的数据,比如:在过滤器中修改字符编码;在过滤器中修改HttpServletRequest的一些参数,包括:过滤低俗文字、危险字符等,最常用的过滤字符串的拦截器。

1
2
3
4
5
6
7
8
9
10
11
12
js复制代码       <filter>
<filter-name>CharacterEncodingFilter</filter-name>
<filter-class>org.springframework.web.filter.CharacterEncodingFilter</filter-class>
<init-param>
<param-name>encoding</param-name>
<param-value>utf-8</param-value>
</init-param>
</filter>
<filter-mapping>
<filter-name>CharacterEncodingFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>

1.自定义过滤器

我们还可以自定义过滤器,以下一个登陆过滤器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
js复制代码  @RequestMapping(value="/login",method=RequestMethod.GET)
public String login(HttpServletRequest request,HttpServletResponse response){
//获取Cookie
Cookie[] cookies = request.getCookies();
for(Cookie cookie : cookies){
System.out.println("cookie>>"+cookie.getValue());
//从数据库获取保存的cookie
Session session = iSessionDAO.getSession(cookie.getValue());
if(session!=null){
//如果存在,就跳转到首页
return "index";
}
}
return "login";
}

@RequestMapping(value="/login",method=RequestMethod.POST)
public String loginPOST(HttpServletRequest request, HttpServletResponse response,Model model){
//用户名
String username=request.getParameter("username");
System.out.println("username>>>"+username);
//密码
String password=request.getParameter("password");
System.out.println("password>>>"+password);
//先从数据库查找该账号信息
User user = null;
try {
user = iUserDAO.queryForUser(username);
} catch (NullPointerException e) {
e.printStackTrace();
model.addAttribute("message", "No account");
}
if(user==null){
model.addAttribute("message", "No account");
}else{
// 匹配密码
if (user.getPassword().equals(password)) {
//登录成功,保存session
request.getSession().setAttribute("user", user);
// 保存cookie
Cookie[] cookies = request.getCookies();
Cookie cookie = cookies[0];//获得最新的那个cookie
Session isSession = iSessionDAO.getSessionByUserId(user.getId());
//没有session,就添加
if(isSession==null){
Session session = new Session();
session.setId(UUID.randomUUID().toString());
session.setSession(cookie.getValue());
session.setUser_id(user.getId());
System.out.println("cookie>>" + cookie.getValue());
iSessionDAO.save(session);
System.out.println("==添加session==");
}else{
//如果已经有session,就更新
isSession.setSession(cookie.getValue());
iSessionDAO.update(isSession);
System.out.println("==更新session==");
}
model.addAttribute("message", user.getUsername());
return "index";
}else{
model.addAttribute("message", "Wrong password");
}
}
return "login";
}

@RequestMapping(value="/sessionTest",method=RequestMethod.GET)
public String sessionTest(HttpServletRequest request,HttpServletResponse response,Model model){
System.out.println(">>>sessionTest");
model.addAttribute("message", "sessionTest");
return "index";

}

2.如何配置

在使用中,需要在web.xml中配置才生效。

1
2
3
4
5
6
7
8
9
10
js复制代码    <filter>
<description>session过滤器</description>
<filter-name>sessionFilter</filter-name>
<filter-class>com.mocha.filter.SessionFilter</filter-class>
</filter>

<filter-mapping>
<filter-name>sessionFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>

2.拦截器

依赖于web框架,在SpringMVC中就是依赖于SpringMVC框架。在实现上基于Java的反射机制,属于面向切面编程(AOP)的一种运用。由于拦截器是基于web框架的调用,因此可以使用Spring的依赖注入(DI)进行一些业务操作,同时一个拦截器实例在一个controller生命周期之内可以多次调用。但是缺点是只能对controller请求进行拦截,对其他的一些比如直接访问静态资源的请求则没办法进行拦截处理。

1.实现方式

  1. 通过实现HandlerInterceptor接口,或继承HandlerInterceptor接口的实现类(如HandlerInterceptorAdapter)来定义。
  2. 通过实现WebRequestInterceptor接口,或继承WebRequestInterceptor接口的实现类来定义。
    以下是一个拦截器。

2.代码实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
js复制代码@Component
public class HandlerInterceptor extends HandlerInterceptorAdapter {

public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
ModelAndView modelAndView) throws Exception {

System.out.println("11111111");
}
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex)
throws Exception {

System.out.println("222222222");
}

public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
System.out.println("44444");
return true;
}
}

同时也需要在mvc的配置文件中加入配置。

1
2
3
4
5
6
7
8
js复制代码    <mvc:interceptors>
<mvc:interceptor>
<!--配置拦截器的作用路径 -->
<mvc:mapping path="/**" />
<!--定义在<mvc:interceptor>下面的表示匹配指定路径的请求才进行拦截 -->
<bean class="com.HandlerInterceptor" />
</mvc:interceptor>
</mvc:interceptors>

3.监听器

监听器是一个实现特定接口的普通java程序,这个程序专门用于监听另一个java对象的方法调用或者属性改变,当被监听对象发生上述事件后,监听器某个方法将立即执行。
监听器用于监听web应用中某些对象、信息的创建、销毁、增加,修改,删除等动作的发生,然后作出相应的响应处理。当范围对象的状态发生变化的时候,服务器自动调用监听器对象中的方法。常用于统计在线人数和在线用户,系统加载时进行信息初始化,统计网站的访问量等等。或者在开发工作中,会遇到一种场景,做完某一件事情以后,需要广播一些消息或者通知,告诉其他的模块进行一些事件处理,一般来说,可以一个一个发送请求去通知,但是有一种更好的方式,那就是事件监听,事件监听也是设计模式中 发布-订阅模式、观察者模式的一种实现。

1.代码实现

监听事件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
js复制代码import org.springframework.context.ApplicationEvent;
public class MyTestEvent extends ApplicationEvent{
/**
*
*/
private static final long serialVersionUID = 1L;
private String msg ;
public MyTestEvent(Object source,String msg) {
super(source);
this.msg = msg;
}
public String getMsg() {
return msg;
}
public void setMsg(String msg) {
this.msg = msg;
}
}

定义监听器

1
2
3
4
5
6
7
8
9
10
11
12
13
js复制代码import org.springframework.context.ApplicationListener;
import org.springframework.stereotype.Component;

import com.mu.event.MyTestEvent;

@Component
public class MyNoAnnotationListener implements ApplicationListener<MyTestEvent>{

@Override
public void onApplicationEvent(MyTestEvent event) {
System.out.println("非注解监听器:" + event.getMsg());
}
}

事件发布

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
js复制代码package com.mu.event;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationContext;
import org.springframework.stereotype.Component;

@Component
public class MyTestEventPubLisher {
@Autowired
private ApplicationContext applicationContext;
// 事件发布方法
public void pushListener(String msg) {
applicationContext.publishEvent(new MyTestEvent(this, msg));
}
}

调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
js复制代码import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;

import com.mu.event.MyTestEventPubLisher;
@Controller
public class TestEventListenerController {
@Autowired
private MyTestEventPubLisher publisher;
@RequestMapping(value = "/test/testPublishEvent1" )
public void testPublishEvent(){
publisher.pushListener("我来了!");
}
}

也可以使用注解实现

1
2
3
4
5
6
7
8
9
10
11
12
13
14
js复制代码import org.springframework.context.event.EventListener;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;

import com.mu.event.MyTestEvent;

@Component
public class MyAnnotationListener {

@EventListener
public void listener1(MyTestEvent event) {
System.out.println("注解监听器1:" + event.getMsg());
}
}

4.过滤器与拦截器的区别

过滤器可以简单的理解为“取你所想取”,过滤器关注的是web请求;拦截器可以简单的理解为“拒你所想拒”,拦截器关注的是方法调用,比如拦截敏感词汇。

  1. 拦截器是基于java反射机制来实现的,而过滤器是基于函数回调来实现的。(有人说,拦截器是基于动态代理来实现的)
  2. 拦截器不依赖servlet容器,过滤器依赖于servlet容器。
  3. 拦截器只对Action起作用,过滤器可以对所有请求起作用。
  4. 拦截器可以访问Action上下文和值栈中的对象,过滤器不能。
  5. 在Action的生命周期中,拦截器可以多次调用,而过滤器只能在容器初始化时调用一次。

5.AOP与拦截器的区别

Filter过滤器:拦截web访问url地址。
Interceptor拦截器:拦截以 .action结尾的url,拦截Action的访问。
Spring AOP拦截器:只能拦截Spring管理Bean的访问(业务层Service)

拦截顺序:监听器>filter—>Interceptor—->@Aspect

aop与拦截器的实现方式都是动态代理实现的。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Servletcontext,ApplicationCont

发表于 2021-10-22

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。

在springmvc中有很多概念,其中Servletcontext,ApplicationContext和DispatcherServlet是被提到最多的概念。下文将介绍这些概念的作用与区别。(spring boot中相同)

1.Servletcontext

说到Servletcontext,首先需要了解浏览器请求web的过程。

  1. 浏览器发送http请求到web容器。并将请求发送给tomcat等web容器。
  2. tomcat将http请求封装成httpServletRequest并发送给web项目。
    在这里插入图片描述
    而Servletcontext就是tomcat给web项目创建的全局环境。他有以下特点。
  3. 全局共享数据。
  4. 包含着web.xml里面的初始值。

2.ApplicationContext

在web.xml中,有以下代码。

1
2
3
js复制代码 <listener>    
<listener-class>org.springframework.web.context.ContextLoaderListener</listener-class>
</listener>

我们可以点击进去看源代码。然后进入。

1
2
3
4
js复制代码    @Override
public void contextInitialized(ServletContextEvent event) {
initWebApplicationContext(event.getServletContext());
}

然后在点击进入。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
js复制代码     public WebApplicationContext initWebApplicationContext(ServletContext servletContext) {
if (servletContext.getAttribute(WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE) != null) {
throw new IllegalStateException(
"Cannot initialize context because there is already a root application context present - " +
"check whether you have multiple ContextLoader* definitions in your web.xml!");
}

Log logger = LogFactory.getLog(ContextLoader.class);
servletContext.log("Initializing Spring root WebApplicationContext");
if (logger.isInfoEnabled()) {
logger.info("Root WebApplicationContext: initialization started");
}
long startTime = System.currentTimeMillis();

try {
// Store context in local instance variable, to guarantee that
// it is available on ServletContext shutdown.
if (this.context == null) {
this.context = createWebApplicationContext(servletContext);
}
if (this.context instanceof ConfigurableWebApplicationContext) {
ConfigurableWebApplicationContext cwac = (ConfigurableWebApplicationContext) this.context;
if (!cwac.isActive()) {
// The context has not yet been refreshed -> provide services such as
// setting the parent context, setting the application context id, etc
if (cwac.getParent() == null) {
// The context instance was injected without an explicit parent ->
// determine parent for root web application context, if any.
ApplicationContext parent = loadParentContext(servletContext);
cwac.setParent(parent);
}
configureAndRefreshWebApplicationContext(cwac, servletContext);
}
}
servletContext.setAttribute(WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE, this.context);

ClassLoader ccl = Thread.currentThread().getContextClassLoader();
if (ccl == ContextLoader.class.getClassLoader()) {
currentContext = this.context;
}
else if (ccl != null) {
currentContextPerThread.put(ccl, this.context);
}

if (logger.isDebugEnabled()) {
logger.debug("Published root WebApplicationContext as ServletContext attribute with name [" +
WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE + "]");
}
if (logger.isInfoEnabled()) {
long elapsedTime = System.currentTimeMillis() - startTime;
logger.info("Root WebApplicationContext: initialization completed in " + elapsedTime + " ms");
}

return this.context;
}
catch (RuntimeException ex) {
logger.error("Context initialization failed", ex);
servletContext.setAttribute(WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE, ex);
throw ex;
}
catch (Error err) {
logger.error("Context initialization failed", err);
servletContext.setAttribute(WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE, err);
throw err;
}
}

这段代码很简单,我们可以看到在servletContext中以key-value方式放入了一个WebApplicationContext对象,同时这也是spring的IOC环境。

1
js复制代码servletContext.setAttribute(WebApplicationContext.ROOT_WEB_APPLICATION_CONTEXT_ATTRIBUTE, this.context);

3.DispatcherServlet

在进行以上流程之后,web.xml继续配置其他servlet,如DispatcherServlet(大家都拿这个举例。。。。),然后会找到WebApplicationContext。并把它作为自己的父上下文。并在WebApplicationContext中加载。
下图大概表明他们之间的关系。
在这里插入图片描述

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

JavaCPP快速入门(官方demo增强版)

发表于 2021-10-22

欢迎访问我的GitHub

github.com/zq2599/blog…

内容:所有原创文章分类汇总及配套源码,涉及Java、Docker、Kubernetes、DevOPS等;

关于JavaCPP

  • JavaCPP 使得Java 应用可以在高效的访问本地C++方法,JavaCPP底层使用了JNI技术,可以广泛的用在Java SE应用中(也包括安卓),以下两个特性是JavaCPP的关键,稍后咱们会用到:
  1. 提供一些注解,将Java代码映射为C++代码
  2. 提供一个jar,用java -jar命令可以将C++代码转为java应用可以访问的动态链接库文件;
  • 目前JavaCPP团队已经用JavaCPP为多个著名C++项目生成了完整的接口,这意味着咱们的java应用可以很方便的使用这些C++库,这里截取部分项目如下图,更详细的列表请访问:github.com/bytedeco/ja…

在这里插入图片描述

本篇概览

  • 今天咱们先写C++函数,再写Java类,该Java类用JavaCPP调用C++函数;
  • 提前小结JavaCPP开发的基本步骤如下图,稍后就按这些步骤去做:

在这里插入图片描述

与官方demo的差异

  • 聪明的您应该会想到:入门demo,JavaCPP官方也有啊(github.com/bytedeco/ja…%EF%BC%8C%E9%9A%BE%E9%81%93%E6%AC%A3%E5%AE%B8%E8%BF%98%E8%83%BD%E6%AF%94%E5%AE%98%E6%96%B9%E7%9A%84%E5%A5%BD%EF%BC%9F)
  • 官方的入门demo一定是最好的,这个毋庸置疑,我这里与官方的不同之处,是添加了下面这些官方没提到的内容,更符合自己的开发习惯(官方没有这些的原因,我觉得应该是更关注JavaCPP本身,而不是一些其他的细枝末节):
  1. 如下图,官方的C++代码只有一个NativeLibrary.h文件,函数功能也在这个文件中,最终生成了一个jni的so文件,而实际上,应该是头文件与功能代码分离,因此本文中的头文件和C++函数的源码是分开的,先生成函数功能的so,再在java中生成jni的so,一共会有两个so文件,至于这两个so如何配置和访问,也是本文的重点之一:

在这里插入图片描述

  1. 官方demo的java源码如下图,是没有package信息的,而实际java工程中都会有package,由此带来的路径问题,例如头文件放哪里?编译和生成so文件时的命令行怎么处理package信息,等等官方并没有提到,而在本篇咱们的java类是有package的,与之相关的路径问题也会解决:

在这里插入图片描述

  1. 官方demo在运行时使用的依赖库是org.bytedeco:javacpp:1.5.5,运行时会输出以下警告信息,本篇会解决这个告警问题:
1
shell复制代码Warning: Could not load Loader: java.lang.UnsatisfiedLinkError: no jnijavacpp in java.library.path

环境信息

  • 这里给出我的环境信息,您可以作为参考:
  1. 操作系统:Ubuntu 16.04.5 LTS (server版,64位)
  2. g++:(Ubuntu 5.4.0-6ubuntu1~16.04.10) 5.4.0 20160609
  3. JDK:1.8.0_291
  4. JavaCPP:1.5.5
  5. 操作账号:root

javacpp-1.5.5.jar文件下载

  • 本篇不会用到maven或者gradle,因此所需的jar文件需要自行准备,您可以从官网、maven中央仓库等地方下载,也可以从下面两个地方任选一个下载:
  1. CSDN(不用积分):download.csdn.net/download/bo…
  2. GitHub:raw.githubusercontent.com/zq2599/blog…

完整源码和相关文件下载

  • 本次实战的所有源码以及相关文件,我这里都按照实战的目录位置打包上传到服务器,如果有需要,您可以从下面两个地方任选一个下载,用以参考,
  1. CSDN(不用积分):download.csdn.net/download/bo…
  2. GitHub:raw.githubusercontent.com/zq2599/blog…
  • 接下进入实战环节

C++开发

  • 新建一个文件夹,我这边是/root/javacpp/cpp,C++开发都在此文件夹下进行
  • C++部分总共要写三个文件,分别是:
  1. C++函数的源码:NativeLibrary.cpp
  2. 头文件:NativeLibrary.h
  3. 测试函数功能的文件:test.cpp(该文件仅用于测试C++函数是否正常可用,与JavcCPP无关)
  • 接下来分别编写,首先是NativeLibrary.cpp,如下,仅有加法的方法:
1
2
3
4
5
6
7
8
c复制代码#include "NativeLibrary.h" 

namespace NativeLibrary {

int MyFunc::add(int a, int b) {
return a + b;
}
}
  • 头文件:
1
2
3
4
5
6
7
8
9
10
11
c复制代码#include<iostream>

namespace NativeLibrary {

class MyFunc{
public:
MyFunc(){};
~MyFunc(){};
int add(int a, int b);
};
}
  • 测试文件test.cpp,可见是验证MyFunc类的方法是否正常:
1
2
3
4
5
6
7
8
9
10
11
c复制代码#include<iostream>
#include"NativeLibrary.h"

using namespace NativeLibrary;

int main(){
MyFunc myFunc;
int value = myFunc.add(1, 2);
std::cout << "add value " << value << std::endl;
return 0;
}
  • 执行以下命令,编译NativeLibrary.cpp,得到so文件libMyFunc.so:
1
shell复制代码g++ -std=c++11 -fPIC -shared NativeLibrary.cpp -o libMyFunc.so
  • 执行以下命令,编译和链接test.cpp,得到可执行文件test:
1
shell复制代码g++ test.cpp -o test ./libMyFunc.so
  • 运行可执行文件试试,命令是./test:
1
2
shell复制代码root@docker:~/javacpp/cpp# ./test
add value 3
  • 将libMyFunc.so文件复制到/usr/lib/目录下
  • test的执行结果符合预期,证明so文件创建成功,记住下面两个关键信息,稍后会用到:
  1. 头文件是NativeLibrary.h
  2. so文件是libMyFunc.so
  • 接下来是java部分

Java开发

  • 简单起见,咱们手写java文件,不创建maven工程
  • 新建一个文件夹,我这边是/root/javacpp/java,java开发都在此文件夹下进行
  • 将文件javacpp-1.5.5.jar复制到/root/javacpp/java/目录下
  • 出于个人习惯,喜欢将java类放在packgage下,因此建好package目录,我这里是com/bolingcavalry/javacppdemo,在我这里的绝对路径就是/root/javacpp/java/com/bolingcavalry/javacppdemo
  • 将文件NativeLibrary.h复制到com/bolingcavalry/javacppdemo目录下
  • 在com/bolingcavalry/javacppdemo目录下新建Test.java,有几处要注意的地方稍后会提到:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
java复制代码package com.bolingcavalry.javacppdemo;

import org.bytedeco.javacpp.*;
import org.bytedeco.javacpp.annotation.*;

@Platform(include="NativeLibrary.h",link="MyFunc")
@Namespace("NativeLibrary")
public class Test {
public static class MyFunc extends Pointer {
static { Loader.load(); }
public MyFunc() { allocate(); }
private native void allocate();

// to call add functions
public native int add(int a, int b);
}

public static void main(String[] args) {
MyFunc myFunc = new MyFunc();
System.out.println(myFunc .add(111,222));
}
}
  • Test.java有以下几处需要注意:
  1. Namespace注解的值是命名空间,要与前面C++代码保持一致
  2. 静态类名为MyFunc,这个要和C++中声明的类保持一致
  3. Platform注解的include属性是NativeLibrary.h,作用是指定头文件
  4. Platform注解的link属性的值是MyFunc,和so文件名libMyFunc.so相比,少了前面的lib前缀,以及so后缀,这是容易出错的地方,要千万小心,需要按照这个规则来设置link属性的值
  5. 对so中的add方法,通过native关键字做声明,然后就可以使用了
  • 现在开发工作已经完成,接下来开始编译和运行

编译和运行

  • 首先是编译java文件,进入目录/root/javacpp/java,执行以下命令,即可生成class文件:
1
shell复制代码javac -cp javacpp-1.5.5.jar com/bolingcavalry/javacppdemo/Test.java
  • 接下来要用javacpp-1.5.5.jar完成c++文件的创建和编译,生成linux下的so文件:
1
2
3
shell复制代码java \
-jar javacpp-1.5.5.jar \
com/bolingcavalry/javacppdemo/Test.java
  • 控制台输出以下信息,表名so文件已经生成,并且清理掉了中间过程产生的临时文件:
1
2
3
4
5
6
7
8
9
10
shell复制代码root@docker:~/javacpp/java# java \
> -jar javacpp-1.5.5.jar \
> com/bolingcavalry/javacppdemo/Test.java
Info: javac -cp javacpp-1.5.5.jar:/root/javacpp/java com/bolingcavalry/javacppdemo/Test.java
Info: Generating /root/javacpp/java/jnijavacpp.cpp
Info: Generating /root/javacpp/java/com/bolingcavalry/javacppdemo/jniTest.cpp
Info: Compiling /root/javacpp/java/com/bolingcavalry/javacppdemo/linux-x86_64/libjniTest.so
Info: g++ -I/usr/lib/jvm/jdk1.8.0_291/include -I/usr/lib/jvm/jdk1.8.0_291/include/linux /root/javacpp/java/com/bolingcavalry/javacppdemo/jniTest.cpp /root/javacpp/java/jnijavacpp.cpp -march=x86-64 -m64 -O3 -s -Wl,-rpath,$ORIGIN/ -Wl,-z,noexecstack -Wl,-Bsymbolic -Wall -fPIC -pthread -shared -o libjniTest.so -lMyFunc
Info: Deleting /root/javacpp/java/com/bolingcavalry/javacppdemo/jniTest.cpp
Info: Deleting /root/javacpp/java/jnijavacpp.cpp
  • 此时的com/bolingcavalry/javacppdemo目录下新增了一个名为linux-x86_64的文件夹,里面的libjniTest.so是javacpp-1.5.5.jar生成的
  • 您可以将/usr/lib/目录下的libMyFunc.so文件移动到linux-x86_64目录下(不移动也可以,只是个人觉得业务so文件放在/usr/lib/这种公共目录下不太合适)
  • 将java应用运行起来:
1
shell复制代码java -cp javacpp-1.5.5.jar:. com.bolingcavalry.javacppdemo.Test
  • 控制台输出的信息如下所示,333表示调用so中的方法成功了:
1
2
3
shell复制代码root@docker:~/javacpp/java# java -cp javacpp-1.5.5.jar:. com.bolingcavalry.javacppdemo.Test
Warning: Could not load Loader: java.lang.UnsatisfiedLinkError: no jnijavacpp in java.library.path
333
  • 最后,将我这里c++和java的文件夹和文件的信息详细列出来,您可以参考:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
shell复制代码root@docker:~# tree /root/javacpp
/root/javacpp
├── cpp
│ ├── libMyFunc.so
│ ├── NativeLibrary.cpp
│ ├── NativeLibrary.h
│ ├── test
│ └── test.cpp
└── java
├── com
│ └── bolingcavalry
│ └── javacppdemo
│ ├── linux-x86_64
│ │ ├── libjniTest.so
│ │ └── libMyFunc.so
│ ├── NativeLibrary.h
│ ├── Test.class
│ ├── Test.java
│ └── Test$MyFunc.class
└── javacpp-1.5.5.jar

6 directories, 12 files

一点小问题

  • 咱们回顾一下java应用的输出,如下所示,其中有一段告警信息:
1
2
3
shell复制代码root@docker:~/javacpp/java# java -cp javacpp-1.5.5.jar:. com.bolingcavalry.javacppdemo.Test
Warning: Could not load Loader: java.lang.UnsatisfiedLinkError: no jnijavacpp in java.library.path
333
  • 上述告警信息不会影响功能,如果想消除掉,就不能只用org.bytedeco:javacpp:1.5.5这一个库,而是org.bytedeco:javacpp-platform:1.5.5,以及它的依赖库
  • 由于本篇没有用到maven或者gradle,因此很难将org.bytedeco:javacpp-platform:1.5.5及其依赖库集齐,我这里已经将所有jar文件打包上传,您可以选择下面任意一种方式下载:
  1. CSDN(不用积分):download.csdn.net/download/bo…
  2. GitHub:raw.githubusercontent.com/zq2599/blog…
  • 下载下来后解压,是个名为lib的文件夹,将此文件夹放在/root/javacpp/java/目录下
  • lib文件夹下的jar只是在运行时用到,编译时用不上,因此现在可以再次运行java应用了,命令如下:
1
shell复制代码java -cp lib/*:. com.bolingcavalry.javacppdemo.Test
  • 在看控制台输出如下图,这次没有告警了:

在这里插入图片描述

  • 本次实战最终所有文件与目录信息如下,供您参考:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
shell复制代码root@docker:~/javacpp# tree /root/javacpp
/root/javacpp
├── cpp
│ ├── libMyFunc.so
│ ├── NativeLibrary.cpp
│ ├── NativeLibrary.h
│ ├── test
│ └── test.cpp
└── java
├── com
│ └── bolingcavalry
│ └── javacppdemo
│ ├── linux-x86_64
│ │ ├── libjniTest.so
│ │ └── libMyFunc.so
│ ├── NativeLibrary.h
│ ├── Test.class
│ ├── Test.java
│ └── Test$MyFunc.class
├── javacpp-1.5.5.jar
└── lib
├── javacpp-1.5.5-android-arm64.jar
├── javacpp-1.5.5-android-arm.jar
├── javacpp-1.5.5-android-x86_64.jar
├── javacpp-1.5.5-android-x86.jar
├── javacpp-1.5.5-ios-arm64.jar
├── javacpp-1.5.5-ios-x86_64.jar
├── javacpp-1.5.5.jar
├── javacpp-1.5.5-linux-arm64.jar
├── javacpp-1.5.5-linux-armhf.jar
├── javacpp-1.5.5-linux-ppc64le.jar
├── javacpp-1.5.5-linux-x86_64.jar
├── javacpp-1.5.5-linux-x86.jar
├── javacpp-1.5.5-macosx-arm64.jar
├── javacpp-1.5.5-macosx-x86_64.jar
├── javacpp-1.5.5-windows-x86_64.jar
├── javacpp-1.5.5-windows-x86.jar
└── javacpp-platform-1.5.5.jar

7 directories, 29 files
  • 至此,JavaCPP入门体验已经完成,接下来做个小结,将关键点列出来

关键点小结

  • 今天的实战,咱们借助JavaCPP,在java应用中使用c++的函数,有以下几处需要重点关注:
  1. 在Java代码中,要有与C++中同名的静态类
  2. 注意Java代码中Namespace注解和C++中的namespace一致
  3. C++的头文件要和Java类放在同一个目录下
  4. 使用so库的时候,库名为libMyFunc.so,Platform注解的link参数的值就是库名去掉lib前缀和.so后缀
  5. C++函数的so文件可以放在/usr/lib目录,也可以移至linux-x86_64目录
  • 至此,JavaCPP快速入门就完成了,如果您正在学习JavaCPP技术,希望本篇能给您一些参考;

你不孤单,欣宸原创一路相伴

  1. Java系列
  2. Spring系列
  3. Docker系列
  4. kubernetes系列
  5. 数据库+中间件系列
  6. DevOps系列

欢迎关注公众号:程序员欣宸

微信搜索「程序员欣宸」,我是欣宸,期待与您一同畅游Java世界…
github.com/zq2599/blog…

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

innodb重做日志实现原理(下)

发表于 2021-10-21

1.概述

上一篇介绍了重做日志写入相关原理,本文主要介绍如何从磁盘进行重做日志的恢复。做好数据的恢复,才能保证节点故障不会丢数据。

2.实现细节

每次innodb启动的时候都会尝试进行重做日志的恢复。

We always try to do a recovery, even if the database had been shut down normally: this is the normal startup path

核心就是通过检查点从磁盘中加载数据到内存。

log_checkpointer线程负责检查点的写入,有相应的判断算法, 本质就是已经持久化到磁盘的脏页对应的lsn会被写入检查点,如果系统故障,我们从此对应lsn恢复即可保证数据不丢失。

3.源码解析

fil_write_flushed_lsn_to_data_files

数据库正常结束之前,会调用该方法,将lsn写入表空间,该lsn主要用来判断是否需要进行数据的的恢复。如果正常结束,lsn和重做日志检查点一致(正常shutdown会将buffer pool刷新到磁盘并且更新检查点),就不需要进行数据恢复,如果不一致,则说明数据库异常关闭,则需要进行数据的恢复。

recv_recovery_from_checkpoint_start

数据恢复主要通过此方法实现

  1. 首先创建并初始化recv_sys数据结构,该数据结构主要用来数据的恢复。所有等待恢复的日志数据最终都先加载到redolog buf,再解析buf到recv_sys的哈希表中。最终通过哈希表存储的日志数据,来进行数据的恢复。为什么要用hash?以为对于相同页的数据,方便查找。
1
2
3
4
cpp复制代码if (type == LOG_CHECKPOINT) {
    recv_sys_create();
    recv_sys_init(FALSE, buf_pool_get_curr_size());
}

哈希表结构,n_cells为槽数量,array为槽数据,每个槽存放一个链表,解决hash冲突。链表的每个node存储对应块的日志信息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
cpp复制代码struct hash_table_struct {
    ibool        adaptive;/* TRUE if this is the hash table of the
                adaptive hash index */
    ulint        n_cells;/* number of cells in the hash table */
    hash_cell_t*    array;    /* pointer to cell array */

    ulint        n_mutexes;/* if mutexes != NULL, then the number of
                mutexes, must be a power of 2 */
    mutex_t*    mutexes;/* NULL, or an array of mutexes used to
                protect segments of the hash table */
    mem_heap_t**    heaps;    /* if this is non-NULL, hash chain nodes for

                external chaining can be allocated from these
                memory heaps; there are then n_mutexes many of
                these heaps */
    mem_heap_t*    heap;
    ulint        magic_n;

};
  1. 查找最大的checkpoint

redolog-group.png
如上图,因为innodb会保存两个checkpoint,所以需要从所有group找到最大的那个。该方法比较简单,其实就是遍历所有group,从文件读取checkpoint信息到对应的checkpoint_buf。然后对数据进行一致性check。找到最大的checkpoint,返回该checkpoint对应的group和field。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
cpp复制代码for (field = LOG_CHECKPOINT_1; field <= LOG_CHECKPOINT_2; field += LOG_CHECKPOINT_2 - LOG_CHECKPOINT_1) {
            log_group_read_checkpoint_info(group, field);
            if (!recv_check_cp_is_consistent(buf)) {
                goto not_consistent;
            }
            group->state = LOG_GROUP_OK;
            group->lsn = mach_read_from_8(buf + LOG_CHECKPOINT_LSN);
            group->lsn_offset = mach_read_from_4(buf + LOG_CHECKPOINT_OFFSET);
            checkpoint_no = mach_read_from_8(buf + LOG_CHECKPOINT_NO);
            if (ut_dulint_cmp(checkpoint_no, max_no) >= 0) {
                *max_group = group;
                *max_field = field;
                max_no = checkpoint_no;
            }
        not_consistent:
            ;
}
  1. log_group_read_checkpoint_info,根据返回的group和field,读取该checkpoint的信息。
1
2
3
4
5
6
7
8
9
10
cpp复制代码void log_group_read_checkpoint_info(
    log_group_t*    group,    /* in: log group */
    ulint        field)    /* in: LOG_CHECKPOINT_1 or LOG_CHECKPOINT_2 */

{
    log_sys->n_log_ios++;
    fil_io(OS_FILE_READ | OS_FILE_LOG, TRUE, group->space_id,
            field / UNIV_PAGE_SIZE, field % UNIV_PAGE_SIZE,
            OS_FILE_LOG_BLOCK_SIZE, log_sys->checkpoint_buf, NULL);
}
  1. recv_group_scan_log_recs

该方法主要是根据读取到的信息扫描并加载group保存的日志信息,最终插入到recv_sys的hash表中。整个流程如下:

(1)调用log_group_read_log_seg读取group日志到log_sys->buf中

1
2
3
4
5
6
7
8
9
10
11
cpp复制代码while (!finished)
{
    end_lsn = ut_dulint_add(start_lsn, RECV_SCAN_SIZE);
    log_group_read_log_seg(LOG_RECOVER, log_sys->buf, group, start_lsn, end_lsn);
    finished = recv_scan_log_recs(TRUE,
    (buf_pool->n_frames - recv_n_pool_free_frames) * UNIV_PAGE_SIZE,
    TRUE, log_sys->buf,
    RECV_SCAN_SIZE, start_lsn,
    contiguous_lsn, group_scanned_lsn);
    start_lsn = end_lsn;
}

(2)调用recv_scan_log_recs,循环处理log_sys->buf中所有log_block。

每次循环的逻辑如下:

先对日志的校验操作。如果校验不成功则恢复失败。对于校验通过的log_block,调用recv_sys_add_to_parsing_buf将log_block的buf数据拷贝到recv_sys的buf中。

recv_sys_add_to_parsing_buf方法逻辑:

主要是计算拷贝的start_offset和end_offset,然后进行调用memcpy方法拷贝。因为lsn包括了头部和尾部的数据,但是拷贝的时候只需要数据部分,所以需要进一步计算才可以。

(3)调用recv_parse_log_recs解析所有日志,并将日志存储到hash table。

主要就是遍历所有日志,根据日志规则解析出日志的type,space,page_no,然后调用recv_add_to_hash_table初始化hash结构并插入hash表中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
cpp复制代码old_lsn = recv_sys->recovered_lsn;
len = recv_parse_log_rec(ptr, end_ptr, &type, &space, &page_no, &body);
if (recv_sys->found_corrupt_log) {
recv_report_corrupt_log(ptr,
type, space, page_no);
}

ut_a(len != 0);
ut_a(0 == ((ulint)*ptr & MLOG_SINGLE_REC_FLAG));
recv_sys->recovered_offset += len;
recv_sys->recovered_lsn = recv_calc_lsn_on_data_add(old_lsn, len);
if (type == MLOG_MULTI_REC_END) {
/* Found the end mark for the records */
break;
}
if (store_to_hash) {
recv_add_to_hash_table(type, space, page_no, body, ptr + len, old_lsn, new_recovered_lsn);
}
ptr += len;
  1. recv_apply_hashed_log_recs

这个方法将hash table中的数据刷新到page中,进行日志的恢复。遍历所有的hashtable,对于在内存中的page,直接调用recv_recover_page进行恢复,对于不在内存中的页调用recv_read_in_area方法。

(1)先说recv_read_in_area这个方法

这个方法主要就是遍历该页所相邻的32个页,如果此页不在内存中,则将页编号其加入到page_nos数组,然后异步加载页并刷新数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
cpp复制代码static ulint recv_read_in_area(
ulint space, /* in: space */
ulint page_no)/* in: page number */
{
recv_addr_t* recv_addr;
ulint page_nos[RECV_READ_AHEAD_AREA];
ulint low_limit;
ulint n;
low_limit = page_no - (page_no % RECV_READ_AHEAD_AREA);
n = 0;
for (page_no = low_limit; page_no < low_limit + RECV_READ_AHEAD_AREA;page_no++) {
recv_addr = recv_get_fil_addr_struct(space, page_no);
if (recv_addr && !buf_page_peek(space, page_no)) {

mutex_enter(&(recv_sys->mutex));

if (recv_addr->state == RECV_NOT_PROCESSED) {
recv_addr->state = RECV_BEING_READ;

page_nos[n] = page_no;
n++;
}

mutex_exit(&(recv_sys->mutex));
}
}
buf_read_recv_pages(FALSE, space, page_nos, n);
return(n);
}

(2)recv_recover_page

页的恢复逻辑。启动mini事务,设置为非log模式,也就是恢复时候不需要再记录重做日志。

核心就是调用recv_parse_or_apply_log_rec_body。该方法是根据重做日志的类型进行不同的恢复操作。细节后续会说。写入完成之后,更新page的checksum以及lsn。并将其插入到flush列表等待刷新,最终提交mini事务。

总结

文章串了一下整个恢复的流程,根据检查点从redolog文件记载道redo log的buf,然后读取buf到recv_sys中。整个恢复操作围绕recv_sys开展,对于在内存中的页,直接刷新数据,对于不在内存的页异步去做。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

从源码层面深度剖析Redisson实现分布式锁的原理(全程干

发表于 2021-10-21

Redis实现分布式锁的原理

前面讲了Redis在实际业务场景中的应用,那么下面再来了解一下Redisson功能性场景的应用,也就是大家经常使用的分布式锁的实现场景。

  • 引入redisson依赖
1
2
3
4
5
xml复制代码<dependency>
<groupId>org.redisson</groupId>
<artifactId>redisson</artifactId>
<version>3.16.0</version>
</dependency>
  • 编写简单的测试代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
java复制代码public class RedissonTest {

private static RedissonClient redissonClient;

static {
Config config=new Config();
config.useSingleServer().setAddress("redis://192.168.221.128:6379");
redissonClient=Redisson.create(config);
}

public static void main(String[] args) throws InterruptedException {
RLock rLock=redissonClient.getLock("updateOrder");
//最多等待100秒、上锁10s以后自动解锁
if(rLock.tryLock(100,10,TimeUnit.SECONDS)){
System.out.println("获取锁成功");
}
Thread.sleep(2000);
rLock.unlock();

redissonClient.shutdown();
}
}

Redisson分布式锁的实现原理

你们会发现,通过redisson,非常简单就可以实现我们所需要的功能,当然这只是redisson的冰山一角,redisson最强大的地方就是提供了分布式特性的常用工具类。使得原本作为协调单机多线程并发程序的并发程序的工具包获得了协调分布式多级多线程并发系统的能力,降低了程序员在分布式环境下解决分布式问题的难度,下面分析一下RedissonLock的实现原理

RedissonLock.tryLock

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码@Override
public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
long time = unit.toMillis(waitTime);
long current = System.currentTimeMillis();
long threadId = Thread.currentThread().getId();
//通过tryAcquire方法尝试获取锁
Long ttl = tryAcquire(waitTime, leaseTime, unit, threadId);
// lock acquired
if (ttl == null) { //表示成功获取到锁,直接返回
return true;
}
//省略部分代码....
}

tryAcquire

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
java复制代码private <T> RFuture<Long> tryAcquireAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
RFuture<Long> ttlRemainingFuture;
//leaseTime就是租约时间,就是redis key的过期时间。
if (leaseTime != -1) { //如果设置过期时间
ttlRemainingFuture = tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
} else {//如果没设置了过期时间,则从配置中获取key超时时间,默认是30s过期
ttlRemainingFuture = tryLockInnerAsync(waitTime, internalLockLeaseTime,
TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
}
//当tryLockInnerAsync执行结束后,触发下面回调
ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
if (e != null) { //说明出现异常,直接返回
return;
}
// lock acquired
if (ttlRemaining == null) { //表示第一次设置锁键
if (leaseTime != -1) { //表示设置过超时时间,更新internalLockLeaseTime,并返回
internalLockLeaseTime = unit.toMillis(leaseTime);
} else { //leaseTime=-1,启动Watch Dog
scheduleExpirationRenewal(threadId);
}
}
});
return ttlRemainingFuture;
}

tryLockInnerAsync

通过lua脚本来实现加锁的操作

  1. 判断lock键是否存在,不存在直接调用hset存储当前线程信息并且设置过期时间,返回nil,告诉客户端直接获取到锁。
  2. 判断lock键是否存在,存在则将重入次数加1,并重新设置过期时间,返回nil,告诉客户端直接获取到锁。
  3. 被其它线程已经锁定,返回锁有效期的剩余时间,告诉客户端需要等待。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码<T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
return evalWriteAsync(getRawName(), LongCodec.INSTANCE, command,
"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"return redis.call('pttl', KEYS[1]);",
Collections.singletonList(getRawName()), unit.toMillis(leaseTime), getLockName(threadId));
}

关于Lua脚本,我们稍后再解释。

unlock释放锁流程

释放锁的流程,脚本看起来会稍微复杂一点

  1. 如果lock键不存在,通过publish指令发送一个消息表示锁已经可用。
  2. 如果锁不是被当前线程锁定,则返回nil
  3. 由于支持可重入,在解锁时将重入次数需要减1
  4. 如果计算后的重入次数>0,则重新设置过期时间
  5. 如果计算后的重入次数<=0,则发消息说锁已经可用
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
java复制代码protected RFuture<Boolean> unlockInnerAsync(long threadId) {
return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
"return nil;" +
"end; " +
"local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
"if (counter > 0) then " +
"redis.call('pexpire', KEYS[1], ARGV[2]); " +
"return 0; " +
"else " +
"redis.call('del', KEYS[1]); " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; " +
"end; " +
"return nil;",
Arrays.asList(getRawName(), getChannelName()), LockPubSub.UNLOCK_MESSAGE, internalLockLeaseTime, getLockName(threadId));
}

RedissonLock有竞争的情况

有竞争的情况在redis端的lua脚本是相同的,只是不同的条件执行不同的redis命令。当通过tryAcquire发现锁被其它线程申请时,需要进入等待竞争逻辑中

  1. this.await返回false,说明等待时间已经超出获取锁最大等待时间,取消订阅并返回获取锁失败
  2. this.await返回true,进入循环尝试获取锁。

继续看RedissonLock.tryLock后半部分代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
java复制代码public boolean tryLock(long waitTime, long leaseTime, TimeUnit unit) throws InterruptedException {
//省略部分代码
time -= System.currentTimeMillis() - current;
if (time <= 0) {
acquireFailed(waitTime, unit, threadId);
return false;
}
current = System.currentTimeMillis();
// 订阅监听redis消息,并且创建RedissonLockEntry
RFuture<RedissonLockEntry> subscribeFuture = subscribe(threadId);
// 阻塞等待subscribe的future的结果对象,如果subscribe方法调用超过了time,说明已经超过了客户端设置的最大wait time,则直接返回false,取消订阅,不再继续申请锁了。
if (!subscribeFuture.await(time, TimeUnit.MILLISECONDS)) {
if (!subscribeFuture.cancel(false)) { //取消订阅
subscribeFuture.onComplete((res, e) -> {
if (e == null) {
unsubscribe(subscribeFuture, threadId);
}
});
}
acquireFailed(waitTime, unit, threadId); //表示抢占锁失败
return false; //返回false
}
try {
//判断是否超时,如果等待超时,返回获的锁失败
time -= System.currentTimeMillis() - current;
if (time <= 0) {
acquireFailed(waitTime, unit, threadId);
return false;
}
//通过while循环再次尝试竞争锁
while (true) {
long currentTime = System.currentTimeMillis();
ttl = tryAcquire(waitTime, leaseTime, unit, threadId); //竞争锁,返回锁超时时间
// lock acquired
if (ttl == null) { //如果超时时间为null,说明获得锁成功
return true;
}
//判断是否超时,如果超时,表示获取锁失败
time -= System.currentTimeMillis() - currentTime;
if (time <= 0) {
acquireFailed(waitTime, unit, threadId);
return false;
}

// 通过信号量(共享锁)阻塞,等待解锁消息. (减少申请锁调用的频率)
// 如果剩余时间(ttl)小于wait time ,就在 ttl 时间内,从Entry的信号量获取一个许可(除非被中断或者一直没有可用的许可)。
// 否则就在wait time 时间范围内等待可以通过信号量
currentTime = System.currentTimeMillis();
if (ttl >= 0 && ttl < time) {
subscribeFuture.getNow().getLatch().tryAcquire(ttl, TimeUnit.MILLISECONDS);
} else {
subscribeFuture.getNow().getLatch().tryAcquire(time, TimeUnit.MILLISECONDS);
}
// 更新等待时间(最大等待时间-已经消耗的阻塞时间)
time -= System.currentTimeMillis() - currentTime;
if (time <= 0) { //获取锁失败
acquireFailed(waitTime, unit, threadId);
return false;
}
}
} finally {
unsubscribe(subscribeFuture, threadId); //取消订阅
}
// return get(tryLockAsync(waitTime, leaseTime, unit));
}

锁过期了怎么办?

一般来说,我们去获得分布式锁时,为了避免死锁的情况,我们会对锁设置一个超时时间,但是有一种情况是,如果在指定时间内当前线程没有执行完,由于锁超时导致锁被释放,那么其他线程就会拿到这把锁,从而导致一些故障。

为了避免这种情况,Redisson引入了一个Watch Dog机制,这个机制是针对分布式锁来实现锁的自动续约,简单来说,如果当前获得锁的线程没有执行完,那么Redisson会自动给Redis中目标key延长超时时间。

默认情况下,看门狗的续期时间是30s,也可以通过修改Config.lockWatchdogTimeout来另行指定。

1
2
3
4
java复制代码@Override
public boolean tryLock(long waitTime, TimeUnit unit) throws InterruptedException {
return tryLock(waitTime, -1, unit); //leaseTime=-1
}

实际上,当我们通过tryLock方法没有传递超时时间时,默认会设置一个30s的超时时间,避免出现死锁的问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
java复制代码private <T> RFuture<Long> tryAcquireAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId) {
RFuture<Long> ttlRemainingFuture;
if (leaseTime != -1) {
ttlRemainingFuture = tryLockInnerAsync(waitTime, leaseTime, unit, threadId, RedisCommands.EVAL_LONG);
} else { //当leaseTime为-1时,leaseTime=internalLockLeaseTime,默认是30s,表示当前锁的过期时间。

//this.internalLockLeaseTime = commandExecutor.getConnectionManager().getCfg().getLockWatchdogTimeout();
ttlRemainingFuture = tryLockInnerAsync(waitTime, internalLockLeaseTime,
TimeUnit.MILLISECONDS, threadId, RedisCommands.EVAL_LONG);
}
ttlRemainingFuture.onComplete((ttlRemaining, e) -> {
if (e != null) { //说明出现异常,直接返回
return;
}
// lock acquired
if (ttlRemaining == null) { //表示第一次设置锁键
if (leaseTime != -1) { //表示设置过超时时间,更新internalLockLeaseTime,并返回
internalLockLeaseTime = unit.toMillis(leaseTime);
} else { //leaseTime=-1,启动Watch Dog
scheduleExpirationRenewal(threadId);
}
}
});
return ttlRemainingFuture;
}

由于默认设置了一个30s的过期时间,为了防止过期之后当前线程还未执行完,所以通过定时任务对过期时间进行续约。

  • 首先,会先判断在expirationRenewalMap中是否存在了entryName,这是个map结构,主要还是判断在这个服务实例中的加锁客户端的锁key是否存在,
  • 如果已经存在了,就直接返回;主要是考虑到RedissonLock是可重入锁。
1
2
3
4
5
6
7
8
9
10
11
java复制代码protected void scheduleExpirationRenewal(long threadId) {
ExpirationEntry entry = new ExpirationEntry();
ExpirationEntry oldEntry = EXPIRATION_RENEWAL_MAP.putIfAbsent(getEntryName(), entry);
if (oldEntry != null) {
oldEntry.addThreadId(threadId);
} else {// 第一次加锁的时候会调用,内部会启动WatchDog
entry.addThreadId(threadId);
renewExpiration();

}
}

定义一个定时任务,该任务中调用renewExpirationAsync方法进行续约。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
java复制代码private void renewExpiration() {
ExpirationEntry ee = EXPIRATION_RENEWAL_MAP.get(getEntryName());
if (ee == null) {
return;
}
//用到了时间轮机制
Timeout task = commandExecutor.getConnectionManager().newTimeout(new TimerTask() {
@Override
public void run(Timeout timeout) throws Exception {
ExpirationEntry ent = EXPIRATION_RENEWAL_MAP.get(getEntryName());
if (ent == null) {
return;
}
Long threadId = ent.getFirstThreadId();
if (threadId == null) {
return;
}
// renewExpirationAsync续约租期
RFuture<Boolean> future = renewExpirationAsync(threadId);
future.onComplete((res, e) -> {
if (e != null) {
log.error("Can't update lock " + getRawName() + " expiration", e);
EXPIRATION_RENEWAL_MAP.remove(getEntryName());
return;
}

if (res) {
// reschedule itself
renewExpiration();
}
});
}
}, internalLockLeaseTime / 3, TimeUnit.MILLISECONDS);//每次间隔租期的1/3时间执行

ee.setTimeout(task);
}

执行Lua脚本,对指定的key进行续约。

1
2
3
4
5
6
7
8
9
10
java复制代码protected RFuture<Boolean> renewExpirationAsync(long threadId) {
return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return 1; " +
"end; " +
"return 0;",
Collections.singletonList(getRawName()),
internalLockLeaseTime, getLockName(threadId));
}

Lua脚本

Lua是一个高效的轻量级脚本语言(和JavaScript类似),用标准C语言编写并以源代码形式开放, 其设计目的是为了嵌入应用程序中,从而为应用程序提供灵活的扩展和定制功能。Lua在葡萄牙语中是“月亮”的意思,它的logo形式卫星,寓意是Lua是一个“卫星语言”,能够方便地嵌入到其他语言中使用;其实在很多常见的框架中,都有嵌入Lua脚本的功能,比如OpenResty、Redis等。

使用Lua脚本的好处:

  1. 减少网络开销,在Lua脚本中可以把多个命令放在同一个脚本中运行
  2. 原子操作,redis会将整个脚本作为一个整体执行,中间不会被其他命令插入。换句话说,编写脚本的过程中无需担心会出现竞态条件
  3. 复用性,客户端发送的脚本会永远存储在redis中,这意味着其他客户端可以复用这一脚本来完成同样的逻辑

Lua的下载和安装

Lua是一个独立的脚本语言,所以它有专门的编译执行工具,下面简单带大家安装一下。

  • 下载Lua源码包: www.lua.org/download.ht…

www.lua.org/ftp/lua-5.4…

  • 安装步骤如下
1
2
3
4
shell复制代码tar -zxvf lua-5.4.3.tar.gz
cd lua-5.4.3
make linux
make install

如果报错,说找不到readline/readline.h, 可以通过yum命令安装

1
java复制代码yum -y install readline-devel ncurses-devel

最后,直接输入lua命令即可进入lua的控制台。Lua脚本有自己的语法、变量、逻辑运算符、函数等,这块我就不在这里做过多的说明,用过JavaScript的同学,应该只需要花几个小时就可以全部学完,简单演示两个案例如下。

1
2
3
4
lua复制代码array = {"Lua", "mic"}
for i= 0, 2 do
print(array[i])
end
1
2
3
4
5
6
shell复制代码array = {"mic", "redis"}

for key,value in ipairs(array)
do
print(key, value)
end

Redis与Lua

Redis中集成了Lua的编译和执行器,所以我们可以在Redis中定义Lua脚本去执行。同时,在Lua脚本中,可以直接调用Redis的命令,来操作Redis中的数据。

1
2
3
shell复制代码redis.call(‘set’,'hello','world')

local value=redis.call(‘get’,’hello’)

redis.call 函数的返回值就是redis命令的执行结果,前面我们介绍过redis的5中类型的数据返回的值的类型也都不一样,redis.call函数会将这5种类型的返回值转化对应的Lua的数据类型

在很多情况下我们都需要脚本可以有返回值,毕竟这个脚本也是一个我们所编写的命令集,我们可以像调用其他redis内置命令一样调用我们自己写的脚本,所以同样redis会自动将脚本返回值的Lua数据类型转化为Redis的返回值类型。 在脚本中可以使用return 语句将值返回给redis客户端,通过return语句来执行,如果没有执行return,默认返回为nil。

Redis中执行Lua脚本相关的命令

编写完脚本后最重要的就是在程序中执行脚本。Redis提供了EVAL命令可以使开发者像调用其他Redis内置命令一样调用脚本。

EVAL命令-执行脚本

[EVAL] [脚本内容] [key参数的数量] [key …] [arg …]

可以通过key和arg这两个参数向脚本中传递数据,他们的值可以在脚本中分别使用KEYS和ARGV 这两个类型的全局变量访问。

比如我们通过脚本实现一个set命令,通过在redis客户端中调用,那么执行的语句是:

1
shell复制代码eval "return redis.call('set',KEYS[1],ARGV[1])" 1 lua hello

上述脚本相当于使用Lua脚本调用了Redis的set命令,存储了一个key=lua,value=hello到Redis中。

EVALSHA命令

考虑到我们通过eval执行lua脚本,脚本比较长的情况下,每次调用脚本都需要把整个脚本传给redis,比较占用带宽。为了解决这个问题,redis提供了EVALSHA命令允许开发者通过脚本内容的SHA1摘要来执行脚本。该命令的用法和EVAL一样,只不过是将脚本内容替换成脚本内容的SHA1摘要

  1. Redis在执行EVAL命令时会计算脚本的SHA1摘要并记录在脚本缓存中
  2. 执行EVALSHA命令时Redis会根据提供的摘要从脚本缓存中查找对应的脚本内容,如果找到了就执行脚本,否则返回“NOSCRIPT No matching script,Please use EVAL”
1
2
3
4
5
shell复制代码# 将脚本加入缓存并生成sha1命令
script load "return redis.call('get','lua')"
# ["13bd040587b891aedc00a72458cbf8588a27df90"]
# 传递sha1的值来执行该命令
evalsha "13bd040587b891aedc00a72458cbf8588a27df90" 0

Redisson执行Lua脚本

通过lua脚本来实现一个访问频率限制功能。

思路,定义一个key,key中包含ip地址。 value为指定时间内的访问次数,比如说是10秒内只能访问3次。

  • 定义Lua脚本。
1
2
3
4
5
6
7
8
9
10
11
lua复制代码local times=redis.call('incr',KEYS[1])
-- 如果是第一次进来,设置一个过期时间
if times == 1 then
redis.call('expire',KEYS[1],ARGV[1])
end
-- 如果在指定时间内访问次数大于指定次数,则返回0,表示访问被限制
if times > tonumber(ARGV[2]) then
return 0
end
-- 返回1,允许被访问
return 1
  • 定义controller,提供访问测试方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
java复制代码@RestController
public class RedissonController {
@Autowired
RedissonClient redissonClient;

private final String LIMIT_LUA=
"local times=redis.call('incr',KEYS[1])\n" +
"if times == 1 then\n" +
" redis.call('expire',KEYS[1],ARGV[1])\n" +
"end\n" +
"if times > tonumber(ARGV[2]) then\n" +
" return 0\n" +
"end\n" +
"return 1";

@GetMapping("/lua/{id}")
public String lua(@PathVariable("id") Integer id) throws ExecutionException, InterruptedException {
List<Object> keys= Arrays.asList("LIMIT:"+id);
RFuture<Object> future=redissonClient.getScript().
evalAsync(RScript.Mode.READ_WRITE,LIMIT_LUA, RScript.ReturnType.INTEGER,keys,10,3);
return future.get().toString();
}

}

需要注意,上述脚本执行的时候会有问题,因为redis默认的序列化方式导致value的值在传递到脚本中时,转成了对象类型,需要修改redisson.yml文件,增加codec的序列化方式。

  • application.yml
1
2
3
4
yml复制代码spring:
redis:
redisson:
file: classpath:redisson.yml
  • redisson.yml
1
2
3
4
yml复制代码singleServerConfig:
address: redis://192.168.221.128:6379

codec: !<org.redisson.codec.JsonJacksonCodec> {}

Lua脚本的原子性

redis的脚本执行是原子的,即脚本执行期间Redis不会执行其他命令。所有的命令必须等待脚本执行完以后才能执行。为了防止某个脚本执行时间过程导致Redis无法提供服务。Redis提供了lua-time-limit参数限制脚本的最长运行时间。默认是5秒钟。

非事务性操作

当脚本运行时间超过这个限制后,Redis将开始接受其他命令但不会执行(以确保脚本的原子性),而是返回BUSY的错误,下面演示一下这种情况。

打开两个客户端窗口,在第一个窗口中执行lua脚本的死循环

1
shell复制代码eval "while true do end" 0

在第二个窗口中运行get lua,会得到如下的异常。

1
txt复制代码(error) BUSY Redis is busy running a script. You can only call SCRIPT KILL or SHUTDOWN NOSAVE.

我们会发现执行结果是Busy, 接着我们通过script kill 的命令终止当前执行的脚本,第二个窗口的显示又恢复正常了。

存在事务性操作

如果当前执行的Lua脚本对Redis的数据进行了修改(SET、DEL等),那么通过SCRIPT KILL 命令是不能终止脚本运行的,因为要保证脚本运行的原子性,如果脚本执行了一部分终止,那就违背了脚本原子性的要求。最终要保证脚本要么都执行,要么都不执行

同样打开两个窗口,第一个窗口运行如下命令

1
shell复制代码eval "redis.call('set','name','mic') while true do end" 0

在第二个窗口运行

1
shell复制代码get lua

结果一样,仍然是busy,但是这个时候通过script kill命令,会发现报错,没办法kill。

1
txt复制代码(error) UNKILLABLE Sorry the script already executed write commands against the dataset. You can either wait the script termination or kill the server in a hard way using the SHUTDOWN NOSAVE command.

遇到这种情况,只能通过shutdown nosave命令来强行终止redis。

shutdown nosave和shutdown的区别在于 shutdown nosave不会进行持久化操作,意味着发生在上一次快照后的数据库修改都会丢失。

Redisson的Lua脚本

了解了lua之后,我们再回过头来看看Redisson的Lua脚本,就不难理解了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
java复制代码<T> RFuture<T> tryLockInnerAsync(long waitTime, long leaseTime, TimeUnit unit, long threadId, RedisStrictCommand<T> command) {
return evalWriteAsync(getRawName(), LongCodec.INSTANCE, command,
"if (redis.call('exists', KEYS[1]) == 0) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then " +
"redis.call('hincrby', KEYS[1], ARGV[2], 1); " +
"redis.call('pexpire', KEYS[1], ARGV[1]); " +
"return nil; " +
"end; " +
"return redis.call('pttl', KEYS[1]);",
Collections.singletonList(getRawName()), unit.toMillis(leaseTime), getLockName(threadId));
}

Redis中的Pub/Sub机制

下面是Redisson中释放锁的代码,在代码中我们发现一个publish的指令redis.call('publish', KEYS[2], ARGV[1]),这个指令是干啥的呢?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
java复制代码protected RFuture<Boolean> unlockInnerAsync(long threadId) {
return evalWriteAsync(getRawName(), LongCodec.INSTANCE, RedisCommands.EVAL_BOOLEAN,
"if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then " +
"return nil;" +
"end; " +
"local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1); " +
"if (counter > 0) then " +
"redis.call('pexpire', KEYS[1], ARGV[2]); " +
"return 0; " +
"else " +
"redis.call('del', KEYS[1]); " +
"redis.call('publish', KEYS[2], ARGV[1]); " +
"return 1; " +
"end; " +
"return nil;",
Arrays.asList(getRawName(), getChannelName()), LockPubSub.UNLOCK_MESSAGE, internalLockLeaseTime, getLockName(threadId));
}

Redis提供了一组命令可以让开发者实现“发布/订阅”模式(publish/subscribe) . 该模式同样可以实现进程间的消息传递,它的实现原理是:

  • 发布/订阅模式包含两种角色,分别是发布者和订阅者。订阅者可以订阅一个或多个频道,而发布者可以向指定的频道发送消息,所有订阅此频道的订阅者都会收到该消息
  • 发布者发布消息的命令是PUBLISH, 用法是
1
shell复制代码PUBLISH channel message

比如向channel.1发一条消息:hello

1
shell复制代码PUBLISH channel.1 “hello”

这样就实现了消息的发送,该命令的返回值表示接收到这条消息的订阅者数量。因为在执行这条命令的时候还没有订阅者订阅该频道,所以返回为0. 另外值得注意的是消息发送出去不会持久化,如果发送之前没有订阅者,那么后续再有订阅者订阅该频道,之前的消息就收不到了

订阅者订阅消息的命令是:

1
shell复制代码SUBSCRIBE channel [channel …]

该命令同时可以订阅多个频道,比如订阅channel.1的频道:SUBSCRIBE channel.1,执行SUBSCRIBE命令后客户端会进入订阅状态。

一般情况下,我们不会用pub/sub来做消息发送机制,毕竟有这么多MQ技术在。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

go并发之路(六)——实例:比较二叉查找树是否等价

发表于 2021-10-21

本文同时参与「掘力星计划」,赢取创作大礼包,挑战创作激励金。

题意分析

  • 需要实现一个walk函数
1
2
go复制代码// Walk 步进 tree t 将所有的值从 tree 发送到 channel ch。
func Walk(t *tree.Tree, ch chan int)
  • 需要利用之前生成的walk函数实现一个same函数,来验证两个二叉树是否等价

思路分析

我们已知的其实只有二叉查找树一个条件,我们不妨看下二叉查找树的性质:

1
2
3
4
5
6
7
8
9
复制代码一棵空树,或者是具有下列性质的二叉树:

(1)若左子树不空,则左子树上所有结点的值均小于它的根结点的值;

(2)若右子树不空,则右子树上所有结点的值均大于它的根结点的值;

(3)左、右子树也分别为二叉排序树;

(4)没有键值相等的结点。

对树数据结构有兴趣或者需要进一步学习的也可以移步我之前的文章:

  • 数据结构树(1)
  • 数据结构树(2)
  • 数据结构树(3)

因此二叉查找树一定没有相同的节点,并且前序遍历一定有序。

我们可以按顺序将二叉树的节点依次写入通道,再从通道中读取并写入数组中。

代码实现

1
2
3
4
5
6
7
8
9
10
11
12
go复制代码  func DFS(t *tree.Tree, ch chan int){
if t == nil {
return
}
if t.Left != nil {
DFS(t.Left, ch)
}
ch <- t.Value
if t.Right != nil {
DFS(t.Right, ch)
}
}

一个简单的dfs,可以按顺序读取二叉树的节点。

1
2
3
4
go复制代码func Walk(t *tree.Tree, ch chan int) {
DFS(t,ch)
close(ch)
}

walk函数调用dfs,遍历树的节点。

接下来从通道中读取数据,并将其放入数组中。不过想想并不需要切片,只需要for循环中去读取第二个通道就可以了

1
2
3
4
5
6
7
8
9
10
go复制代码for i :=range ch1{
j,ok := <- ch2
if !ok {
return false
}
if i!=j {
return false
}
fmt.Printf("ch1: %v - ch2: %v\n", i, j)
}

总体实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
go复制代码func Same(t1, t2 *tree.Tree) bool{
ch1 := make(chan int)
ch2 := make(chan int)
go Walk(t1, ch1)
go Walk(t2, ch2)
for i :=range ch1{
j,ok := <- ch2
if !ok {
return false
}
if i!=j {
return false
}
fmt.Printf("ch1: %v - ch2: %v\n", i, j)
}
return true
}

总结

最开始的想法是通过将遍历后的树写入通道,再从通道读取并写入数组中,这其实是一个很“单线程”的想法,因为如果这是一道算法题,那自然是通过dfs写入数组再比较数组的方式来读取数据。此刻通道并没有产生任何作用,但是在后来想到了我们可以通过在读取通道时再读取另一个通道,来实现两棵树的比较。本题让笔者对通道又有了进一步的认识。

在查阅网上资料时,发现有些人用从通道读取数据,再对数据做异或的方式实现,因为这利用了二叉查找树无重复的性质,因此当异或结果为0时,两颗树必定相等。不过这其实是错的,因为题目中并没有表明树的数量,比如树A为1,2,两者异或为 01 ^ 10: 11,此时如果树B为3,则依旧会得到相等的结果。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Hands-on Rust 学习之旅(4) ——小鸟飞起来了

发表于 2021-10-21

在继续上一篇的游戏开发之前,我们来看看成品效果:

fly

主要增加了三个角色:

  1. ‘@’字符充当小鸟,负责上下移动,来躲避迎面而来的障碍物——墙壁;
  2. 就是我们的障碍物,障碍物随着时间从屏幕右侧不断左移;
  3. 还有就是得分,只要小鸟穿过障碍物,即得一分;如果没躲过,则,游戏 Game Over。

Adding the Player

‘@’字符充当小鸟

小鸟的作用在于需要上下移动,正常情况下处于“自由落地”状态,当我们点击空格键,让它飞起来,不至于落下。

跟之前一样,用一个 struct 结构体来表示 Player,主要是坐标位置和上下移动的加速度变化值:

1
2
3
4
5
rust复制代码struct Player {
x: i32,
y: i32,
velocity: f32,
}

首先定一个@字符显示效果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
rust复制代码impl Player {
fn new(x: i32, y: i32) -> Self {
Player {
x,
y,
velocity: 0.0,
}
}

fn render(&mut self, ctx: &mut BTerm) {
ctx.set(
0,
self.y,
YELLOW,
BLACK,
to_cp437('@')
);
}
}

这个比较好理解,在之后的代码过程中,去重点解释bracket-lib 引擎。

接下来就是定义两个动作,主要是加速度的变化问题,相信看代码能懂:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
rust复制代码fn gravity_and_move(&mut self) {
if self.velocity < 2.0 {
self.velocity += 0.2;
}
self.y += self.velocity as i32;
self.x += 1;

if self.y < 0 {
self.y = 0;
}
}

fn flap(&mut self) {
self.velocity = -2.0;
}

Creating Obstacles

创建障碍

障碍物,主要考虑的是缺口的随机出现和缺口得大小。

1
2
3
4
5
rust复制代码struct Obstacle {
x: i32,
gap_y: i32,
size: i32,
}

本文中,缺口的大小和得分有关,随着得分的越多,缺口越小,这也是游戏的难度不断增加,具体看 new 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
rust复制代码impl Obstacle {
fn new(x: i32, score: i32) -> Self {
let mut random = RandomNumberGenerator::new();
Obstacle {
x,
gap_y: random.range(10, 40),
size: i32::max(2, 20 -score)
}
}

fn render(&mut self, ctx: &mut BTerm, player_x: i32) {
let screen_x = self.x - player_x;
let half_size = self.size / 2;

for y in 0..self.gap_y - half_size {
ctx.set(
screen_x,
y,
RED,
BLACK,
to_cp437('/'),
);
}

for y in self.gap_y + half_size..SCREEN_HEIGHT {
ctx.set(
screen_x,
y,
RED,
BLACK,
to_cp437('/'),
);
}
}
}

墙体的设计,主要以缺口大小分成两段来控制 y 坐标,以自己的 x 坐标和小鸟的坐标确定每一祯 x 值,实现不断靠近小鸟的效果。

还需要增加一个小鸟和障碍物碰到的逻辑:

1
2
3
4
5
6
7
8
ini复制代码fn hit_obstacle(&self, player: &Player) -> bool {
let half_size = self.size / 2;
let does_x_match = player.x == self.x;
let player_above_gap = player.y < self.gap_y - half_size;
let player_below_gap = player.y > self.gap_y + half_size;

does_x_match && (player_above_gap || player_below_gap)
}

这代码好理解,就不解释了。

Keeping Score

得分逻辑

得分逻辑就比较简单了,只要小鸟的 x 坐标 > 障碍物的 x 坐标,表示小鸟“飞过”障碍物,并且小鸟没碰上障碍物,则得分+1,并且重置新的障碍物:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
python复制代码fn play(&mut self, ctx: &mut BTerm) {
// TODO: Fill in this stub later
ctx.cls_bg(NAVY);
self.frame_time += ctx.frame_time_ms;

if self.frame_time > FRAME_DURATION {
self.frame_time = 0.0;
self.player.gravity_and_move();
}

if let Some(VirtualKeyCode::Space) = ctx.key {
self.player.flap();
}
self.player.render(ctx);
ctx.print(0, 0, "按住空格保持飞翔");
ctx.print(0, 1, &format!("得分:{}", self.score));

self.obstacle.render(ctx, self.player.x);
if self.player.x > self.obstacle.x {
self.score += 1;
self.obstacle = Obstacle::new(self.player.x + SCREEN_WIDTH, self.score);
}

if self.player.y > SCREEN_HEIGHT || self.obstacle.hit_obstacle(&self.player) {
self.mode = GameMode::End;
}
}

其他的参数都是辅助于逻辑的完成,下面我把整个代码贴出来,大家看看也就懂了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
rust复制代码use bracket_lib::prelude::*;

const SCREEN_WIDTH: i32 = 80;
const SCREEN_HEIGHT: i32 = 50;
const FRAME_DURATION: f32 = 75.0;
struct Player {
x: i32,
y: i32,
velocity: f32,
}

impl Player {
fn new(x: i32, y: i32) -> Self {
Player {
x,
y,
velocity: 0.0,
}
}

fn render(&mut self, ctx: &mut BTerm) {
ctx.set(
0,
self.y,
YELLOW,
BLACK,
to_cp437('@')
);
}

fn gravity_and_move(&mut self) {
if self.velocity < 2.0 {
self.velocity += 0.2;
}
self.y += self.velocity as i32;
self.x += 1;

if self.y < 0 {
self.y = 0;
}
}

fn flap(&mut self) {
self.velocity = -2.0;
}
}

struct Obstacle {
x: i32,
gap_y: i32,
size: i32,
}

impl Obstacle {
fn new(x: i32, score: i32) -> Self {
let mut random = RandomNumberGenerator::new();
Obstacle {
x,
gap_y: random.range(10, 40),
size: i32::max(2, 20 -score)
}
}

fn render(&mut self, ctx: &mut BTerm, player_x: i32) {
let screen_x = self.x - player_x;
let half_size = self.size / 2;

for y in 0..self.gap_y - half_size {
ctx.set(
screen_x,
y,
RED,
BLACK,
to_cp437('/'),
);
}

for y in self.gap_y + half_size..SCREEN_HEIGHT {
ctx.set(
screen_x,
y,
RED,
BLACK,
to_cp437('/'),
);
}
}

fn hit_obstacle(&self, player: &Player) -> bool {
let half_size = self.size / 2;
let does_x_match = player.x == self.x;
let player_above_gap = player.y < self.gap_y - half_size;
let player_below_gap = player.y > self.gap_y + half_size;

does_x_match && (player_above_gap || player_below_gap)
}
}

enum GameMode {
Menu,
Playing,
End,
}

struct State {
player: Player,
frame_time: f32,
obstacle: Obstacle,
mode: GameMode,
score: i32,
}

impl State {
fn new() -> Self {
State {
player: Player::new(5, 25),
frame_time: 0.0,
obstacle: Obstacle::new(SCREEN_WIDTH, 0),
mode: GameMode::Menu,
score: 0,
}
}

fn restart(&mut self) {
self.player = Player::new(5, 25);
self.frame_time = 0.0;
self.mode = GameMode::Playing;
}

fn main_menu(&mut self, ctx: &mut BTerm) {
ctx.cls();
ctx.print_centered(5, "Welcome to Flappy Dragon");
ctx.print_centered(8, "(P) Play Game");
ctx.print_centered(9, "(Q) Quit Game");

if let Some(key) = ctx.key {
match key {
VirtualKeyCode::P => self.restart(),
VirtualKeyCode::Q => ctx.quitting = true,
_ => {}
}
}
self.player = Player::new(5, 25);
self.frame_time = 0.0;
self.obstacle = Obstacle::new(SCREEN_WIDTH, 0);
self.mode = GameMode::Playing;
self.score = 0;
}

fn dead(&mut self, ctx: &mut BTerm) {
ctx.cls();
ctx.print_centered(5, "You are dead!");
ctx.print_centered(6, &format!("You earned {} points", self.score));
ctx.print_centered(8, "(P) Play Again");
ctx.print_centered(9, "(Q) Quit Game");

if let Some(key) = ctx.key {
match key {
VirtualKeyCode::P => self.restart(),
VirtualKeyCode::Q => ctx.quitting = true,
_ => {}
}
}
}

fn play(&mut self, ctx: &mut BTerm) {
// TODO: Fill in this stub later
ctx.cls_bg(NAVY);
self.frame_time += ctx.frame_time_ms;

if self.frame_time > FRAME_DURATION {
self.frame_time = 0.0;
self.player.gravity_and_move();
}

if let Some(VirtualKeyCode::Space) = ctx.key {
self.player.flap();
}
self.player.render(ctx);
ctx.print(0, 0, "按住空格保持飞翔");
ctx.print(0, 1, &format!("得分:{}", self.score));

self.obstacle.render(ctx, self.player.x);
if self.player.x > self.obstacle.x {
self.score += 1;
self.obstacle = Obstacle::new(self.player.x + SCREEN_WIDTH, self.score);
}

if self.player.y > SCREEN_HEIGHT || self.obstacle.hit_obstacle(&self.player) {
self.mode = GameMode::End;
}
}
}

impl GameState for State {
fn tick(&mut self, ctx: &mut BTerm) {
// ctx.cls();
// ctx.print(1, 1, "Hello, Bracket Terminal!");
match self.mode {
GameMode::Menu => self.main_menu(ctx),
GameMode::End => self.dead(ctx),
GameMode::Playing => self.play(ctx),
}
}
}

fn main() ->BError {
println!("Hello, world!");

let context = BTermBuilder::simple80x50()
.with_title("Flappy Dragon")
.build()?;

main_loop(context, State::new())
}

整个运行效果,就是开篇的那样:

fly

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

Java中的函数式编程(二)函数式接口Functional

发表于 2021-10-21

写在前面

前面说过,判断一门语言是否支持函数式编程,一个重要的判断标准就是:它是否将函数看做是“第一等公民(first-class citizens)”。

函数是“第一等公民”,意味着函数和其它数据类型具备同等的地位——可以赋值给某个变量,可以作为另一个函数的参数,也可以作为另一个函数的返回值。

Java 8是通过函数式接口,赋予了函数“第一等公民”的特性。

本文将详细介绍Java 8中的函数式接口。

本文的示例代码可从gitee上获取,gitee.com/cnmemset/ja…

完整专栏文章获取,可关注公众号【员说】。

函数式接口

什么是函数式接口(function interface)?只有一个抽象方法的接口都属于函数式接口。

按照规范,我们强烈建议在定义函数式接口时,加上注解 @FunctionalInterface,这样在编译阶段就可以判断该接口是否符合函数式接口的规范。当然,也可以不加注解 @FunctionalInterface,这并不影响函数式接口的定义和使用。

以下是一个典型的函数式接口 Consumer:

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码// 强烈建议加上注解 @FunctionalInterface
@FunctionalInterface
public interface Consumer {
    // 唯一的抽象方法
    void accept(T t);

    // 可以有多个非抽象方法(默认方法)
    default Consumer andThen(Consumer after) {
        Objects.requireNonNull(after);
        return (T t) -> { accept(t); after.accept(t); };
    }
}

函数式接口本质是一个接口(interface),所以我们可以通过一个具体的类(包括匿名类)来实现一个函数式接口。但与普通接口不同,函数式接口的实现还可以是一个lambda表达式,甚至可以是一个方法引用(method reference)。

下面,我们逐一介绍JDK中内置的一些典型的函数式接口。

Java 8中内置的函数式接口

Java 8新增的内置函数式接口都在包 java.util.function 中定义,主要包括:

  1. Functions

Function

在代码世界,最为常见的一种函数式接口是接收一个参数值,然后返回一个响应值。JDK提供了一个标准的泛型函数式接口 Function:

1
2
3
4
5
6
7
8
9
10
java复制代码@FunctionalInterface
public interface Function<T, R> {
    /**
     * 给定一个类型为 T 的参数 t ,返回一个类型为 R 的响应值。
     *
     * @param t 函数参数
     * @return 执行结果
     */
    R apply(T t);
}

Function的一个经典应用场景是Map的computeIfAbsent函数。

1
2
java复制代码public V computeIfAbsent(K key,
                         Function<? super K, ? extends V> mappingFunction);

computeIfAbsent函数会先判断对应key在map中是否存在,如果key不存在,则通过参数 mappingFunction 来计算得出一个value,并将这个键值对写入到map中,并返回计算出来的value。如果key已存在,则返回map中key对应的value。

假设一个应用场景,我们要构建一个HashMap,key是某个单词,value是单词的字母长度。实例代码如下:

1
2
3
4
5
6
7
java复制代码public static void testFunctionWithLambda() {
// 构建一个HashMap,key是某个单词,value是单词的字母长度
Map wordMap = new HashMap<>();
Integer wordLen = wordMap.computeIfAbsent("hello", s -> s.length());
System.out.println(wordLen);
System.out.println(wordMap);
}

上面的实例会输出:

1
2
ini复制代码5
{hello=5}

注意到代码片段“s -> s.length()”,这是一个典型的lambda表达式,含义等同于函数:

1
2
3
java复制代码public static int getStringLength(String s) {
    return s.length();
}

更详尽具体的lambda表达式的介绍可以参考随后的系列文章。

之前提到过,函数式接口也可以通过一个方法引用(method reference)来实现。实例代码如下:

1
2
3
4
5
6
java复制代码public static void testFunctionWithMethodReference() {
    Map wordMap = new HashMap<>();
    Integer wordLen = wordMap.computeIfAbsent("hello", String::length);
    System.out.println(wordLen);
    System.out.println(wordMap);
}

注意到方法引用“String::length”,Java 8允许我们将一个实例方法转化成一个函数式接口的实现。 它的含义和 lambda 表达式 “s -> s.length()” 是相同的。

更详尽具体的方法引用的介绍可以参考随后的系列文章。

BiFunction

Function 限制了只能有一个参数,但两个参数的情形也非常常见,所以就有了BiFunction,它接收两个参数值,然后返回一个响应值。

1
2
3
4
5
6
7
8
9
10
11
12
13
java复制代码@FunctionalInterface
public interface BiFunction<T, U, R> {
    /**
     * 给定类型分别为 T 的参数 t 和 类型为 U 的参数 u,返回一个类型为 R 的响应值。
     *
     * @param t 第一个参数
     * @param u 第二个参数
     * @return 执行结果
     */
    R apply(T t, U u);

    ...
}

Function的一个经典应用场景是Map的replaceAll函数。

1
java复制代码public void replaceAll(BiFunction<? super K, ? super V, ? extends V> function)

Map的replaceAll函数,会遍历Map中的所有Entry,通过BiFunction类型的参数 function 计算出一个新值,然后用新值替换旧值。

假设一个应用场景,我们使用一个HashMap,记录了一些单词和它们的长度,接着产品经理提了一个新需求,要求对某些指定的单词,长度统一记录为0。实例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
java复制代码public static void testBiFunctionWithLambda() {
    Map wordMap = new HashMap<>();
    wordMap.put("hello", 5);
    wordMap.put("world", 5);
    wordMap.put("on", 2);
    wordMap.put("at", 2);

// lambda表达式中的k和v,分别是Map中Entry的key和原值value。
// lambda表达式的返回值是一个新值value。
    wordMap.replaceAll((k, v) -> {
        if ("on".equals(k) || "at".equals(k)) {
// 对应单词 on 和 at,单词长度统一记录为 0
            return 0;
        } else {
// 其它单词,单词长度保持原值
            return v;
        }
    });

    System.out.println(wordMap);
}

上述代码的输出为:

1
ini复制代码{world=5, at=0, hello=5, on=0}
  1. Supplier

除了Function和BiFunction,还有一种常见的函数式接口是不需要任何参数,直接返回一个响应值。这就是Supplier:

1
2
3
4
5
6
7
8
9
java复制代码@FunctionalInterface
public interface Supplier<T> {
    /**
     * 获取一个类型为 T 的对象实例。
     *
     * @return 对象实例
     */
    T get();
}

Supplier的一个典型应用场景是快速实现了工厂类的生产方法,包括延时的或者异步的生产方法。实例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
java复制代码public class SupplierExample {
    public static void main(String[] args) {
        testSupplierWithLambda();
    }

    public static void testSupplierWithLambda() {
        final Random random = new Random();

// 生成一个随机整数
        lazyPrint(() -> {
            return random.nextInt(100);
        });

// 延时3秒,生成一个随机整数
        lazyPrint(() -> {
            try {
                System.out.println("waiting for 3s...");
                Thread.sleep(3*1000);
            } catch (InterruptedException e) {
                // do nothing
            }

            return random.nextInt(100);
        });
    }

    public static void lazyPrint(Supplier lazyValue) {
        System.out.println(lazyValue.get());
    }
}

上述代码输出类似:

1
2
3
erlang复制代码26
waiting for 3s...
27
  1. Consumers

如果说Supplier属于生产者,那与之相对的是消费者Consumer。

Consumer

与Supplier相反,Consumer 接收一个参数,而不返回任何值。

1
2
3
4
5
6
7
8
9
10
11
java复制代码@FunctionalInterface
public interface Consumer<T> {
    /**
     * 对给定的单一参数执行相关操作。
     *
     * @param t 输入参数
     */
    void accept(T t);

    ...
}

示例代码:

1
2
3
4
5
6
java复制代码public static void testConsumer() {
List list = Arrays.asList("Guangdong", "Zhejiang", "Jiangsu");

// 消费 list 中的每一个元素
list.forEach(s -> System.out.println(s));
}

上述代码的输出为:

1
2
3
复制代码Guangdong
Zhejiang
Jiangsu

BiConsumer

还有BiConsumer,语义和Consumer一致,不同的是BiConsumer接收2个参数。

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码@FunctionalInterface
public interface BiConsumer<T, U> {
    /**
     * 对给定的2个参数执行相关操作。
     *
     * @param t 第一个参数
     * @param u 第二个参数
     */
    void accept(T t, U u);

    ...
}

示例代码:

1
2
3
4
5
6
7
8
9
10
11
java复制代码public static void testBiConsumer() {
Map cityMap = new HashMap<>();
cityMap.put("Guangdong", "Guangzhou");
cityMap.put("Zhejiang", "Hangzhou");
cityMap.put("Jiangsu", "Nanjing");

// 消费 map中的每一个(key, value)键值对
cityMap.forEach((key, value) -> {
System.out.println(String.format("%s 的省会是 %s", key, value));
});
}

上述代码的输出是:

1
2
3
复制代码Guangdong 的省会是 Guangzhou
Zhejiang 的省会是 Hangzhou
Jiangsu 的省会是 Nanjing
  1. Predicate

Predicate 的含义是接收一个参数值,然后依据给定的断言条件,返回一个boolean值。它实质上一个特殊的 Function,一个指定了返回值类型为boolean的 Function。

1
2
3
4
5
6
7
8
9
10
11
12
java复制代码@FunctionalInterface
public interface Predicate<T> {
    /**
     * 根据给定参数,计算得到一个boolean结果。
     *
     * @param t 输入参数
     * @return 如果参数符合断言条件,返回 true,否则返回 false
     */
    boolean test(T t);

    ...
}

Predicate 的使用场景通常是用来作为某种过滤条件。实例代码:

1
2
3
4
5
6
7
8
9
10
java复制代码public static void testPredicate() {
List provinces = new ArrayList<>(Arrays.asList("Guangdong", "Jiangsu", "Guangxi", "Jiangxi", "Shandong"));

boolean removed = provinces.removeIf(s -> {
return s.startsWith("G");
});

System.out.println(removed);
System.out.println(provinces);
}

上述代码是过滤掉以字母 G 开头的省份,输出为:

1
2
csharp复制代码true
[Jiangsu, Jiangxi, Shandong]
  1. Operators

Operator 函数式接口是一种特殊的 Function,要求返回值类型和参数类型是相同的。

和 Function/BiFunction 一样,Operators 也支持1个或2个参数。

UnaryOperator

UnaryOperator 支持1个参数,UnaryOperator 等同于 Function<T, T>:

1
2
java复制代码@FunctionalInterface
public interface UnaryOperator<T> extends Function<T, T> { ... }

UnaryOperator的示例代码——将省份拼音转换大写与小写字母:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
java复制代码public static void testUnaryOperator() {
    List provinces = Arrays.asList("Guangdong", "Jiangsu", "Guangxi", "Jiangxi", "Shandong");

    // 将省份的字母转换成大写字母
// 使用lambda表达式来实现 UnaryOperator
    provinces.replaceAll(s -> s.toUpperCase());
System.out.println(provinces);

// 将省份的字母转换成小写字母。
// 使用方法引用(method reference)来实现 UnaryOperator
    provinces.replaceAll(String::toLowerCase);

    System.out.println(provinces);
}

上述代码输出为:

1
2
csharp复制代码[GUANGDONG, JIANGSU, GUANGXI, JIANGXI, SHANDONG]
[guangdong, jiangsu, guangxi, jiangxi, shandong]

BinaryOperator

BinaryOperator 支持2个参数,BinaryOperator 等同于 BiFunction<T, T, T>

1
2
java复制代码@FunctionalInterface
public interface BinaryOperator<T> extends BiFunction<T, T, T> { ... }

BinaryOperator的示例代码 —— 计算List中的所有整数的和:

1
2
3
4
5
6
7
8
9
java复制代码public static void testBinaryOperator() {
    List values = Arrays.asList(1, 3, 5, 7, 11);

    // 使用 reduce 方法进行求和:0+1+3+5+7+11 = 27
int sum = values.stream()
            .reduce(0, (a, b) -> a + b);

    System.out.println(sum);
}

上述代码的输出为:

1
复制代码27
  1. Java 7及之前版本遗留的函数式接口

前面提到过函数式接口的定义:只有一个抽象方法的接口都属于函数式接口。

按照这个定义,在Java 7或之前版本中定义的一些“老”接口也属于函数式接口,包括:

Runnable、Callable、Comparator等等。

当然,这些遗留的函数式接口,在Java 8中也加上了注解 @FunctionalInterface 。

组合函数式接口

我们在第一篇提到过:函数式编程是一种编程范式(programming paradigm),追求的目标是整个程序都由函数调用以及函数组合构成的。

函数组合(function composing),指的是将一系列简单函数组合起来形成一个复合函数。

Java 8中的函数式接口也提供了函数组合的功能。大家注意观察,可以发现基本每个内置的函数式接口都有一个非抽象的方法 andThen。andThen方法的功能是将多个函数式接口组合在一起,以串行的顺序逐一执行,从而形成一个新的函数式接口。

以Consumer.andThen方法为例,它返回一个新的Consumer实例。新的Consumer实例会先执行当前的accpet方法,然后再执行 after 的accpet方法。源码片段如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
java复制代码@FunctionalInterface
public interface Consumer<T> {

...

    default Consumer<T> andThen(Consumer<? super T> after) {
        Objects.requireNonNull(after);

// 先执行当前Consumer的accept方法,再执行 after 的accept方法
// 特别要注意的是,accept(t) 不能写在 return 语句之前,否则accept(t)将会被提前执行
        return (T t) -> { accept(t); after.accept(t); };
    }

...

}

示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
java复制代码public static void testConsumerAndThen() {
    Consumer printUpperCase = s -> System.out.println(s.toUpperCase());
    Consumer printLowerCase = s -> System.out.println(s.toLowerCase());

    // 组合得到一个新的 Consumer :先打印大写样式,再打印小写样式
    Consumer prints = printUpperCase.andThen(printLowerCase);

    List list = Arrays.asList("Guangdong", "Zhejiang", "Jiangsu");

    list.forEach(prints);
}

上述代码的输出是:

1
2
3
4
5
6
复制代码GUANGDONG
guangdong
ZHEJIANG
zhejiang
JIANGSU
jiangsu

Function.andThen 方法则更复杂一些,它返回一个新的Function实例,在新的Function中,会先用类型为 T 的参数 t 执行当前的apply方法,得到一个类型为 R 的返回值 r,然后将 r 作为输入参数,继续执行 after 的apply方法,最终得到一个类型为 V 的返回值:

1
2
3
4
5
6
7
8
9
10
11
java复制代码@FunctionalInterface
public interface Function<T, R> {
    default <V> Function<T, V> andThen(Function<? super R, ? extends V> after) {
        Objects.requireNonNull(after);

// 先用类型为 T 的参数 t 执行当前的apply方法,得到一个类型为 R 的返回值 r ;
// 然后将 r 作为输入参数,继续执行 after 的apply方法,最终得到一个类型为 V 的返回值;
// 特别要注意的是,apply(t) 不能写在 return 语句之前,否则apply(t)将会被提前执行。
        return (T t) -> after.apply(apply(t));
    }
}

代码示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
java复制代码public static void testFunctionAndThen() {
    // wordLen 计算单词的长度
    Function wordLen = s -> s.length(); // 等同于 s -> { return s.length(); }

    // effectiveWord 单词长度大于等于4,才认为是有效单词
    Function effectiveWordLen = len -> len >= 4;

    // Function 和 Function 组合得到一个新的 Function ,
// 像是消消乐: 遇到了 ,消去了 Integer 类型后,得到了 。
    Function effectiveWord = wordLen.andThen(effectiveWordLen);

    Map wordMap = new HashMap<>();
    wordMap.computeIfAbsent("hello", effectiveWord);
    wordMap.computeIfAbsent("world", effectiveWord);
    wordMap.computeIfAbsent("on", effectiveWord);
    wordMap.computeIfAbsent("at", effectiveWord);

    System.out.println(wordMap);
}

上述代码输出为:

1
ini复制代码{at=false, world=true, hello=true, on=false}

结语

Java 8是通过函数式接口,赋予了函数“第一等公民”的特性。

通过函数式接口,使得函数和其它数据类型一样,可以赋值给某个变量、可以作为另一个函数的参数、也可以作为另一个函数的返回值。

函数式接口的实现,可以是一个类(包括匿名类),但更多的是一个lambda表达式或者一个方法引用(method reference)。

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

面试官:如何实现扫码登录功能? 扫码登录场景 扫码登录分析

发表于 2021-10-21

真实面试小场景:

经过八股和算法的交锋,老三松了口气,都hold住了。只见面试官微微一笑,“其实,我真正想问的是……你觉得扫码登录应该怎么实现。”

老三:“啊……这个,哦……那个,这个就这么,然后……额……嗯……”

面试官:“了解了,回去等通知吧。”

完……


好了,铺垫结束,进入我们今天的主题,扫码登录功能该如何实现?

扫码登录场景

扫码登录场景想必我们都不陌生——很多PC端的网站都提供了扫码登录的功能,无需在网页上输入任何账号和密码,只需要通过手机上的APP,如微信、淘宝、QQ等等,使用扫描功能,扫描网页上的二维码,确认登录,就可以完成网页端登录。

扫码登录QQ邮箱

扫码登录分析

我们来分析一下,扫码登录,其实涉及到三种角色,需要解决两个问题。

三种角色

很明显,扫码登录当中涉及到的三种角色:PC端、手机端、服务端。

三端

相关的设计都要围绕这三端来展开,具体的设计其实就是每一端应该完成什么功能?应该怎么实现?端和端应该如何交互?

两个问题

扫码登录本质上是一种特殊的登录认证方式,我们面对的是两个问题

  • 手机端如何完成认证
  • PC端如何完成登录

如果用普通的账号密码方式登录认证,PC端通过账号密码完成认证,然后服务端给PC端同步返回token key之类的标识,PC端再次请求服务端,需要携带token key,用于标识和证明自己登录的状态。

服务端响应的时候,需要对token key进行校验,通过则正常响应;校验不通过,认证失败;或者token过期,PC端需要再次登录认证,获取新的token key。

账号/密码登录过程

现在换成了扫码登录:

  • 认证不是通过账号密码了,而是由手机端扫码来完成
  • PC端没法同步获取认证成功之后的凭据,必须用某种方式来让PC端获取认证的凭据。

扫码登录实现

手机端如何完成认证

二维码怎么生成

二维码和超市里的条形码类似,超市的条形码实际是一串数字,上面存储了商品的序列号。

二维码的内容就比较自由,里面不止可以存数字,还可以存任何的字符串。我们可以认为,它就是字符的另外一种表现形式。

下面我通过一个网站把文字转成了二维码:

文字转二维码

所以,我们手机扫码这个过程,其实是对二维码的解码,获取二维码中包含的数据。

那么二维码怎么生成呢?

首先,二维码是展示在我们的PC端,所以生成这个操作应该由PC端去请求服务端,获取相应的数据,再由PC端生成这个二维码。

二维码包含什么呢?

二维码在我们这个场景里面是一个重要的媒介,服务端必须给这个数据生成惟一的标识作为二维码ID,同时还应该设置过期的时间。PC端根据二维码ID等数据生成二维码。

二维码生成

同时,服务端也应该保存二维码的一些状态:未扫描、已成功、已失效。

APP认证机制

我们还得认识一下基于APP的移动互联网认证机制。

首先,手机端一般是不会存储登录密码的,我们我们发现,只有装载APP,第一次登录的时候,才需要进行基于账号密码的登录,之后即使这个清理掉这个应用进程,甚至手机重启,都是不需要再次输入账号密码的,它可以自动登录。

这背后有一套基于token的认证机制,和PC有些类似,但又有一些不同。

APP端登录认证

  • APP登录认证的时候除了账号密码,还有设备信息
  • 账号密码校验通过,服务端会把账号与设备进行一个绑定,进行持久化的保存,包含了账号ID,设备ID,设备类型等等
  • APP每次请求除了携带token key,还需要携带设备信息。

因为移动端的设备具备唯一性,可以为每个客户端生成专属token,这个token也不用过期,所以这就是我们可以一次登录,长久使用的原理。

手机扫码干了什么

那这下就清楚了,我们手机扫码干了两件事:

  • 扫描二维码:识别PC端展示的二维码,获取二维码ID

扫描

  • 确认登录:手机端通过带认证信息(token key、设备信息)、二维码信息(二维码ID)请求服务端,完成认证过程,确认PC端的登录。

确认登录

ps: 关于手机扫码和确认,不是重点,所以这里进行了简化,一种说法是扫码时同时向服务端申请一次性临时token,确认登录的时候携带这个临时token来访问服务端。

PC端如何完成登录

接下来到我们的重头戏了,手机端完成了它的工作,我们服务端的登录怎么进入登录状态呢?

我们前面讲了,PC端通过token来标识登录状态。那么手机端扫码确认之后,我们的服务端就应该给PC生成相应的token。

那么,这个PC端又如何获取它所需的token key,来完成登录呢?

如何获取PC token

PC端可以通过获取二维码的状态来进行相应的响应:

  • 二维码未扫描:无操作
  • 二维码已失效:提示刷新二维码
  • 二维码已成功:从服务端获取PC token

获取二维码状态,主要有三种方式:

轮询

轮询方式是指客户端会每隔一段时间就主动给服务端发送一次二维码状态的查询请求。

轮询

长轮询

长轮询是指客户端主动给服务端发送二维码状态的查询请求,服务端会按情况对请求进行阻塞,直至二维码信息更新或超时。当客户端接收到返回结果后,若二维码仍未被扫描,则会继续发送查询请求,直至状态变化(已失效或已成功)。

长轮询

Websocket

Websocket是指前端在生成二维码后,会与后端建立连接,一旦后端发现二维码状态变化,可直接通过建立的连接主动推送信息给前端。

Websocket

总结

通过前面的分析,我们已经知道了二维码扫码登录的一些关键点,现在我们把这些点串起来,来看一看二维码扫码登录的整体的实现流程。

以常用的轮询方式获取二维码状态为例:

扫码登录

  1. 访问PC端二维码生成页面,PC端请求服务端获取二维码ID
  2. 服务端生成相应的二维码ID,设置二维码的过期时间,状态等。
  3. PC获取二维码ID,生成相应的二维码。
  4. 手机端扫描二维码,获取二维码ID。
  5. 手机端将手机端token和二维码ID发送给服务端,确认登录。
  6. 服务端校验手机端token,根据手机端token和二维码ID生成PC端token
  7. PC端通过轮询方式请求服务端,通过二维码ID获取二维码状态,如果已成功,返回PC token,登录成功。

好了,这样我们一个扫描登录的功能就设计完成了。


由于博主对移动端的相关认证机制了解不多,如有错漏,欢迎和博主沟通!

参考:

[1].三种方式实现扫码登录: forthe77.github.io/2019/05/23/…

[2].二维码扫码登录是什么原理 ?: juejin.cn/post/694097…

本文转载自: 掘金

开发者博客 – 和开发相关的 这里全都有

1…478479480…956

开发者博客

9558 日志
1953 标签
RSS
© 2025 开发者博客
本站总访问量次
由 Hexo 强力驱动
|
主题 — NexT.Muse v5.1.4
0%