2024年2月

OpenResty是一个基于Nginx与Lua的高性能Web平台,它通过LuaJIT在Nginx中运行高效的Lua脚本和模块,可以用来处理复杂的网络请求,并且支持各种流量控制和限制的功能。

近期研究在OpenResty中如何实现,按QPS、时间范围、来源IP进行限流,以及动态更新限流策略。今天将实现方案分享给大家。

一、在OpenResty中如何实现,按QPS、时间范围、来源IP进行限流

使用OpenResty进行限流的几种常见方法:

  1. 按QPS(每秒查询率)限流:
    使用
    ngx_http_limit_req_module
    模块,可以限制每个客户端的请求速率。这个模块使用漏桶算法来控制请求的速率。

    在Nginx配置文件中,你可以这样设置:

    http {
        limit_req_zone $binary_remote_addr zone=mylimit:10m rate=1r/s;
    
        server {
            location / {
                limit_req zone=mylimit burst=5 nodelay;
            }
        }
    }
    

    上面的配置定义了一个名为
    mylimit
    的区域,它根据客户端的IP地址来限流,并且设置了每秒可以处理的请求数(rate)为1。
    burst
    参数定义了可以累积的最大请求数,而
    nodelay
    表示不对超出速率的请求进行延迟处理。

  2. 按时间范围限流:
    如果你想在特定时间范围内限流,你可能需要编写一些Lua脚本来实现这个逻辑。例如,你可以使用
    lua-resty-limit-traffic
    库的
    limit.req
    模块,并结合时间判断逻辑:

    local limit_req = require "resty.limit.req"
    local lim, err = limit_req.new("my_limit_req_store", 2, 0)
    if not lim then
    ngx.log(ngx.ERR, "failed to instantiate a resty.limit.req object: ", err)
    return ngx.exit(500)
    end

    local key = ngx.var.binary_remote_addr
    local delay, err = lim:incoming(key, true)

    -- 检查当前时间是否在限流时间范围内
    local current_hour = os.date("%H")
    if current_hour >= "09" and current_hour <= "18" then
    -- 在工作时间进行限流
    if delay then
    if delay >= 0.001 then
    -- 延迟处理
    ngx.sleep(delay)
    end
    else
    if err == "rejected" then
    -- 请求超出速率限制
    return ngx.exit(503)
    end
    ngx.log(ngx.ERR, "failed to limit req: ", err)
    return ngx.exit(500)
    end
    end
  3. 按来源IP限流:
    使用
    ngx_http_limit_conn_module
    模块,可以限制同时处理的连接数。如果你想根据来源IP地址进行限流,可以像这样配置:

    http {
        limit_conn_zone $binary_remote_addr zone=addr:10m;
    
        server {
            location / {
                limit_conn addr 3;
            }
        }
    }
    

    这个配置限制了每个IP地址同时只能有3个活跃连接。

在实际部署时,需要根据自己的业务需求调整这些配置参数。需要注意的是,对于复杂的限流规则,可能需要结合多个Nginx模块和Lua脚本来实现。

而且,由于限流策略可能会影响用户体验,应谨慎设计限流规则,确保它们既能保护后端服务,又不会对合法用户造成不必要的麻烦。

二、限流后提示信息处理和请求状态

在OpenResty中,如果你使用了内置的限流模块(如
ngx_http_limit_req_module

ngx_http_limit_conn_module
)并且请求被限流,你可以通过返回特定的状态码和错误页面来通知用户。

例如,如果使用
limit_req

limit_conn
指令,你可以设置返回503状态码(服务不可用),然后定义一个自定义的错误页面:

http {
    limit_req_zone $binary_remote_addr zone=one:10m rate=1r/s;
    limit_conn_zone $binary_remote_addr zone=addr:10m;

    server {
        location / {
            limit_req zone=one burst=5 nodelay;
            limit_conn addr 3;

            error_page 503 /custom_503.html;
        }

        location = /custom_503.html {
            root /path/to/your/error/pages;
            internal;
        }
    }
}

在上面的配置中,当请求被限流并返回503状态码时,Nginx将会发送
/path/to/your/error/pages/custom_503.html
文件的内容作为响应。

如果你使用Lua脚本来处理限流,你可以更加灵活地设置返回的内容。例如,你可以使用
ngx.exit
来返回状态码,同时使用
ngx.say

ngx.send_headers
来发送自定义的响应体或者响应头。

access_by_lua_block {
    -- 假设你已经进行了一些限流判断...
if should_limit then
ngx.status = ngx.HTTP_SERVICE_UNAVAILABLE
ngx.header.content_type = 'text/html'
ngx.say("<html><body>Sorry, we are currently receiving too many requests. Please try again later.</body></html>")
ngx.exit(ngx.HTTP_SERVICE_UNAVAILABLE)
end
}

在这个Lua代码块中,如果
should_limit
变量为
true
,则返回503状态码,并显示一个自定义的HTML错误消息。

要注意的是,返回给用户的信息应该既明确又友好,以确保用户理解为什么他们的请求没有成功,并且知道下一步该做什么。对于API服务,通常返回一个JSON对象,包含错误码和错误信息会更加合适:

access_by_lua_block {
    -- 假设你已经进行了一些限流判断...
if should_limit then
ngx.status = ngx.HTTP_TOO_MANY_REQUESTS -- 429 Too Many Requests
ngx.header.content_type = 'application/json'
ngx.say([[{"error": "rate_limit", "error_description": "Too many requests. Please try again later."}]])
ngx.exit(ngx.HTTP_TOO_MANY_REQUESTS)
end
}

在这个例子中,我们使用了429状态码(太多请求),这是一个更具体的状态码,用来表示客户端发送的请求已经超过了服务器愿意处理的频率。

三、如何动态更新限流策略,实时生效,不需要重启Nginx

动态更新限流策略而不重启Nginx服务,可以通过以下几种方式实现:

  1. Lua共享字典(shared dictionaries):
    OpenResty提供了共享内存字典,这是一种在Nginx工作进程之间共享数据的机制。你可以使用共享字典来存储限流配置,并且在Lua代码中动态读取这些配置。这样,当你更新了共享字典中的配置时,不需要重启Nginx,新的请求将会使用新的限流配置。

    例如,你可以定义一个共享字典来存储限流速率:

    http {
        lua_shared_dict my_limit_req_store 10m;
    
        init_by_lua_block {
            local dict = ngx.shared.my_limit_req_store
            dict:set("rate", 1) -- 设置每秒请求数为1
        }
    
        server {
            location / {
                access_by_lua_block {
                    local dict = ngx.shared.my_limit_req_store
                    local rate = dict:get("rate") -- 动态获取当前的限流速率
                    -- 接下来使用这个rate值来进行限流...
                }
            }
        }
    }
    

    当你需要更新限流策略时,只需修改共享字典中的值即可。

  2. OpenResty的控制API:
    OpenResty提供了一个控制API,可以用来动态地调整运行时的Nginx配置。这个API可以通过Lua代码来调用,从而实现不重启服务的情况下更新配置。

  3. 外部配置服务:
    你可以将限流配置存储在外部服务中,比如数据库、配置文件或者分布式配置系统(如etcd、Consul)。然后在Nginx的Lua代码中定期轮询这些服务,获取最新的限流配置。

    access_by_lua_block {
        local http = require "resty.http"
    local httpc = http.new()
    local res, err = httpc:request_uri("http://config-service/get_rate_limit", {
    method = "GET",
    headers = {
    ["Content-Type"] = "application/json",
    }
    })

    if not res then
    ngx.log(ngx.ERR, "failed to request: ", err)
    return
    end

    local rate_limit = tonumber(res.body)
    if rate_limit then
    -- 应用新的限流策略...
    end
    }
  4. 信号控制:
    Nginx支持通过信号来进行控制,例如重新加载配置文件(
    nginx -s reload
    )。虽然这不是实时的,但是它不需要完全重启Nginx进程,只是重新加载配置文件。如果限流策略是通过Nginx配置文件中的参数来控制的,这是一个可行的方法。

选择哪种方法取决于你的具体需求和环境。如果你需要非常快速地更新配置,并且配置更新操作非常频繁,那么使用Lua共享字典或者外部配置服务可能是更好的选择。如果配置更新不是很频繁,使用信号控制来重新加载Nginx配置可能就足够了。

周国庆

2024/2/25

Ad-hoc

Ad-hoc简介

  • Ad-hoc是Ansible下临时执行的一条命令,对于复杂的命令会使用playbook。Ad-hoc的执行依赖于模块,ansible官方提供了大量的模块。如command,file,copy,shell等
  • 帮助查询
    • ansible-doc -l 列出所有模块
    • ansible-doc -s module 查看某个模块的参数
    • ansible-doc module 查看某个模块更详细的信息

Ad-hoc命令说明

  • 命令说明
    ansible 主机或组 -m 模块名 -a '模块参数' ansible参数
    • 主机和组:就是你要在哪些主机上执行这个命令,必须是配置文件里面定义好的
    • 模块名:可以通过ansibel-doc -l查看目前安装的模块,然后过滤出你想要的,默认不指定时,使用的时command模块,可以在配置文件里面找到 "module_name = command"改掉这个
    • 模块参数:可以通过ansible-doc 来查看模块需要使用哪些参数,以及具体用法
    • ansible参数:可以通过ansible 的帮助查到,有很多参数可以使用,比如是否提权,是否输入密码等等

Ad-hoc示例

[devops@node1 ansible]$ ansible node1 -m shell -a 'whoami'
node1 | CHANGED | rc=0 >>
root
# 使用ad-hoc创建一个目录
[devops@node1 ansible]$ ansible node1 -m shell -a 'mkdir /ansibel'
[WARNING]: Consider using the file module with state=directory rather than running 'mkdir'.  If you
need to use command because file is insufficient you can add 'warn: false' to this command task or
set 'command_warnings=False' in ansible.cfg to get rid of this message.
node1 | CHANGED | rc=0 >>

这里我们可以看到,使用shell模块创建一个目录的时候他有一个警告,大概就是说让我们考虑使用file模块去创建目录,但是这个是警告,并不是报错,这个目录也是被创建出来了

[devops@node1 ansible]$ sudo ls /ansibel -d
/ansibel

这就是一些简单的使用案例

命令执行模块

1. command模块

该模块通过-a跟上要执行的命令可以直接执行,不过命令里面不能带一些符号(|,>,<,&),否则会不成功。

# 正常的
[devops@node1 ansible]$ ansible all -m command -a 'whoami'
node1 | CHANGED | rc=0 >>
root
node2 | CHANGED | rc=0 >>
root
# 错误的
[devops@node1 ansible]$ ansible all -m command -a 'whoami > /opt/who.txt'
node2 | FAILED | rc=1 >>
whoami: extra operand ‘>’
Try 'whoami --help' for more information.non-zero return code
node1 | FAILED | rc=1 >>
whoami: extra operand ‘>’
Try 'whoami --help' for more information.non-zero return code

看到了吧,我想把输出内容重定向到某个文件内他是会报错的,那我们如果有这种需求改怎么做呢?来看下一个模块

2. shell模块

shell模块的用法基本和command一样,不过他是通过/bin/sh执行,所以shell模块可以执行任何命令,报错刚刚command不能执行的重定向操作

# 不带重定向
[devops@node1 ansible]$ ansible node1 -m shell -a 'whoami'
node1 | CHANGED | rc=0 >>
root
# 带重定向
[devops@node1 ansible]$ ansible node1 -m shell -a 'whoami > /opt/who.txt'
node1 | CHANGED | rc=0 >>
# 查看文件内容
[devops@node1 ansible]$ cat /opt/who.txt 
root

这个模块确实是可以执行一些command执行不了的操作,shell模块还有一些选项

  • chdir: 在执行命令前,先切换到指定的目录,默认工作目录是用户的home目录
  • creates:一个文件名,当该文件存在,那么命令就不会执行
  • removes:一个文件名,当该文件不存在,那么命令不会执行
    后面这两个可能有点难以理解,我们通过实验来看看
    creates实验:
# 我们刚刚不是在node1上创建了一个/opt/who.txt吗?node2上是没有的,那么我们选择就来指定这个文件
[devops@node1 ansible]$ ansible all -m shell -a 'creates=/opt/who.txt whoami'
node1 | SUCCESS | rc=0 >>
skipped, since /opt/who.txt exists
node2 | CHANGED | rc=0 >>
root

看到了吧,node1上有这个文件,那么它是没有去执行的,他直接跳过了,但是node2上没有这个文件,那么它执行了whoami这个命令

removes实验:

[devops@node1 ansible]$ ansible all -m shell -a 'removes=/opt/who.txt whoami'
node1 | CHANGED | rc=0 >>
root
node2 | SUCCESS | rc=0 >>
skipped, since /opt/who.txt does not exist

通过这2个实验应该能理解这俩选项的作用了吧

3. raw模块

这个模块的用法和shell是一样的,不同点在于它没有chdir,creates和removes选项,其他都是一样一样的

4. script模块

这个模块就比较有意思了,他是将你主控端的脚本直接在被控端上执行,注意,他并不会将这个文件传过去

[devops@node1 ansible]$ cat test.sh 
#!/bin/bash
ifconfig |grep netmask | awk -F" " '{print $2}'

注意看这个脚本,是在node1上的,也就是主控端,我们来使用过scripts模块来执行一下这个脚本,看看它是不是没有将文件传过去

[devops@node1 ansible]$ ansible all -m script -a 'test.sh'
node2 | CHANGED => {
    "changed": true,
    "rc": 0,
    "stderr": "Shared connection to node2 closed.\r\n",
    "stderr_lines": [
        "Shared connection to node2 closed."
    ],
    "stdout": "/home/devops/.ansible/tmp/ansible-tmp-1708832697.440758-6677-176602102106477/test.sh: line 2: ifconfig: command not found\r\n",
    "stdout_lines": [
        "/home/devops/.ansible/tmp/ansible-tmp-1708832697.440758-6677-176602102106477/test.sh: line 2: ifconfig: command not found"
    ]
}
node1 | CHANGED => {
    "changed": true,
    "rc": 0,
    "stderr": "Shared connection to node1 closed.\r\n",
    "stderr_lines": [
        "Shared connection to node1 closed."
    ],
    "stdout": "192.168.100.210\r\n192.168.200.131\r\n127.0.0.1\r\n192.168.122.1\r\n",
    "stdout_lines": [
        "192.168.100.210",
        "192.168.200.131",
        "127.0.0.1",
        "192.168.122.1"
    ]
}

这里可以看到,他执行成功了,返回的是主机上所有的IP地址,然后我们去node2上看看这个文件是否不存在,不在node1上看是因为这个文件本来就是node1上写的,因为node1就是主控节点,它同时也是被控

[devops@node1 ansible]$ ansible node2 -m shell -a 'find / -name test.sh'
node2 | CHANGED | rc=0 >>

我们可以看到,使用find去查到这个文件名他没有任何的输出,那么就是没有找到,也就是没有这个文件

本文首发于公众号:Hunter后端

原文链接:
Python笔记五之正则表达式

这一篇笔记介绍在 Python 里使用正则表达式。

正则表达式,Regular Expression,可用于在一个目标字符串里对于指定模式的字符进行查找、替换、分割等操作。

比如,判断某个字符串里是否都是数字,或者是否包含指定字符串,又或者更直接的例子是判断电话号码或者邮箱是否合法等。

这一篇笔记里,我们将先介绍一个正则表达式的函数,并以此来引入正则表达式的各种模式,并辅以各个例子进行介绍。

u1s1,正则表达式,我学了三四遍,学一遍忘一遍,忘一遍学一遍,只要隔一阵子不用就会忘,所以这一篇笔记力求将正则表达式的所有模式和用法都记录下来,用作之后的查找笔记。

以下笔记使用 Python 版本是 3.8。

以下是本篇笔记目录:

  1. re.findall
  2. 表达式语法
    1. 匹配字符串类型
      1. \b-匹配空字符串,但只在单词开始或结尾
      2. \B-匹配空字符串,不能在开头或结尾
      3. \d-匹配十进制数字
      4. \D-匹配非十进制字符
      5. \s-匹配空白字符
      6. \S-匹配非空白字符
      7. \w-匹配字符
      8. \W-匹配非单词字符
    2. 匹配字符串出现位置
      1. \A-只匹配字符串开始
      2. \Z-只匹配字符串结尾
      3. ^-匹配字符串开头
      4. $-匹配字符串结尾
    3. 匹配字符串数量
      1. *-匹配重复 0 到 n 次
      2. +-匹配重复 1 到 n 次
      3. ?-匹配重复 0 到 1 次
      4. {}-指定匹配重复次数
        a. {m}-只匹配重复 m 次
        b. {m,n}-匹配重复 m 到 n 次
    4. 匹配字符串集合
      1. 字符可以单独列出
      2. 可以表示字符范围
      3. 特殊字符在集合里只会匹配其原始字符含义
      4. 字符类 \s 或 \w 可以在集合里使用
      5. 取反操作可以使用 ^
    5. 其他匹配类型
      1. |-表达式的或操作
      2. ()-匹配括号内的任意正则表达式
  3. 常用正则方法
    1. re.search
    2. re.match
    3. re.split
    4. re.findall
    5. re.finditer
    6. re.sub
    7. re.subn
    8. re.compile
    9. 其他参数
      1. re.I
      2. re.M
  4. re.Match 匹配对象
    1. Match.group()
    2. Match.__getitem__(g)
    3. Match.groups()
    4. Match.re
    5. Match.string
    6. Match.start() 和 Match.end()
    7. Match.span()

1、re.findall

使用正则表达式,首先要引入模块:

import re

这里从
re.findall
开始介绍,findall 方法表示的是找到目标字符串里符合指定模式的全部数据。

比如我们有一个字符串
abcdefg
,想要从其中找到
de
,就可以如下操作:

str_1 = "abcdefg"
target_str = "de"
print(re.findall(target_str, str_1))

返回的就是一个列表,列表元素是我们的目标字符串
de

['de']

我们的 target_str 就是一个匹配模式,这里是一个纯字符串,我们可以将其替换成其他的模式字符串。

接下来我们将分类介绍正则表达式语法。

2、表达式语法

正则表达式有很多种,比如表示匹配数量的
+

*

?
,还有表示匹配字符串内容的
\s

\w
等。

下面将从这些类别分别开始介绍:匹配字符串类型、匹配字符串出现位置、匹配字符串数量、匹配字符串集合等

1. 匹配字符串类型

1) \b-匹配空字符串,但只在单词开始或结尾

\b 可以匹配空字符串,但是只在字符串开始或者结尾的位置,这里的空字符串涵盖了标签符号,比如
, . ,。?
等,也包括换行制表符
\n \t
等,也包含 '',可以理解为字符串的边界部分。

所以
\b
的作用其实就可用于匹配特定字符串的前缀或者后缀,这里的前缀和后缀并不仅仅是指整个字符串的前缀和后缀,也包括字符串内部被分割的前缀和后缀。

比如对于下面这个字符串:

i have a apple

我们想找到是否有
ha
开头的单词,可以如此操作:

str_1 = "i have a apple"
target_pattern = r"\bha"
print(re.findall(target_pattern, str_1))

字符串如果是以下几种情况,也可以匹配上:

str_1 = "i ,have a apple"
str_1 = "i \thave a apple"
str_1 = "i ha"

我们还可以使用 \b 来匹配特定的单词,在英文中,单词的出现是前后都有空格或者标点符号的,那么我们就可以前后都加上 \b 来限定匹配是否出现过此单词:

str_1 = "i have an apple"
str_1 = "i have an apple, "
str_1 = "i have an apple how are you"

target_pattern = r"\bapple\b"
print(re.findall(target_pattern, str_1))

2) \B-匹配空字符串,不能在开头或结尾

\B 是 \b 的取非操作,含义是匹配空字符串,但是不能出现在开头或者结尾,也就是说 \B 所在的位置必须有 1 至多个非空字符串来替代:

str_1 = "i have an apple"
target_pattern = r"app\B"
print(re.findall(target_pattern, str_1))

3) \d-匹配十进制数字

\d 用来匹配十进制数字,也就是 0-9 这些,比如下面的操作:

str_1 = "asdas98123asa978d"
target_pattern = r"\d"
print(re.findall(target_pattern, str_1))
# ['9', '8', '1', '2', '3', '9', '7', '8']

可以看到返回的结果是分隔开的数字,如果想要他们在一起返回,我们可以使用
\d+
来操作,
+
表示的匹配 1 到 n 次,这个后面再介绍。

4) \D-匹配非十进制字符

\D 表示的是 \d 相反的操作,非十进制字符,可以使用上面的示例进行测试。

5) \s-匹配空白字符

\s 匹配的空白字符不包括标点符号,常见的有换行符,制表符,回车符等转义字符,
\n \t \r \f

str_1 = "asdas9812\v3a\rs,.\ta9\n78\fd"
target_pattern = r"\s"
print(re.findall(target_pattern, str_1))

6) \S-匹配非空白字符

\S 是 \s 取非操作,除了上面的换行符、制表符等字符外,包括标点符号皆可被匹配上

7) \w-匹配字符

\w 不匹配换行符、制表符等转义字符,不匹配中英文常见标点符号,比如
, . ; "
等,但是可以匹配中英文字符、数字和下划线,比如下面的示例:

str_1 = "asd—— _a你‘'好,s9。8?12\v3a\rs,.\ta9\n78\fd"
target_pattern = r"\w"
print(re.findall(target_pattern, str_1))
# ['a', 's', 'd', '_', 'a', '你', '好', 's', '9', '8', '1', '2', '3', 'a', 's', 'a', '9', '7', '8', 'd']

8) \W-匹配非单词字符

\W 是 \w 的取反操作。

2. 匹配字符串出现位置

前面介绍的匹配字符串的类型,这里介绍匹配出现的位置,比如开头或者结尾。

1) \A-只匹配字符串开始

\A 只匹配字符串的开始,也就是我们所说的字符串前缀:

str_1 = "asd—— _a你‘'好,\ns9。8?12\v3a\rs,.\ta9\n78\fd"
target_pattern = r"\Aasd"
print(re.findall(target_pattern, str_1))

与字符串的 startswith() 函数在匹配功能上是一样的。

2) \Z-只匹配字符串结尾

\Z 只匹配字符串结尾部分,也就是所说的字符串后缀:

str_1 = "asd—— _a你‘'好,\ns9。8?12\v3a\rs,.\ta9\n78d"
target_pattern = r"d\Z"
print(re.findall(target_pattern, str_1))

3) ^-匹配字符串开头

^ 也是只匹配字符串的开头,但是与 \A 不同的是,使用 re.M 模式下,也可以匹配换行符后的开头,比如下面的示例,就可以返回两个结果:

str_1 = "asd—— _\na你‘'好,\ns9。8?12\v3a\rs,.\ta9\n78d"
target_pattern = r"^a"
print(re.findall(target_pattern, str_1, re.M))
# ['a', 'a']

如果去掉 re.M,则会退化成 \A 的功能

4) $-匹配字符串结尾

$ 也是只匹配字符串的结尾,但是在 re.M 模式下,也可以匹配换行符后新一行的结尾,比如下面的示例,可以返回两个结果:

str_1 = "asd—— _\na你‘'好,\ns9。8?12\v3a\rs,.\ta9d\n78d"
target_pattern = r"d$"
print(re.findall(target_pattern, str_1, re.M))

同理,如果去掉 re.M,则会退化成 \Z 的功能。

3. 匹配字符串数量

除了匹配指定模式的内容,字符位置,我们还可以匹配指定模式的数量,比如想只要满足一个即可返回,或者尽可能多的将满足的字符返回等。

接下来一一介绍如何匹配字符串数量。

1) *-匹配重复 0 到 n 次

*
表示匹配的数量可以重复从 0 到 n 次,这个 n 为任意次,尽量多的匹配字符串。

比如我们想匹配
a
以及
a
后面可以加上任意个
b
字符,比如希望
a

ab

abb

abbbb
等都可以被匹配上。

那么就可以使用下面的操作:

str_1 = "axxxxabbxxxxabbbb"
target_pattern = r"ab*"
print(re.findall(target_pattern, str_1))
# ['a', 'abb', 'abbbb']

2) +-匹配重复 1 到 n 次

+
表示匹配的数量可以重复从 1 到 n 次,提前条件是匹配模式必须出现一个。

还是上面的例子,我们希望可以匹配上
ab

abbb
,以及无限多个
b
,但是不可匹配
a
,可以如下操作:

str_1 = "axxxxabbxxxxabbbb"
target_pattern = r"ab+"
print(re.findall(target_pattern, str_1))
# ['abb', 'abbbb']

3) ?-匹配重复 0 到 1 次

?
表示匹配的数量只能出现 0 到 1 次。

比如对于一个字符串,我们想匹配
apple
这个单词,但是我们也想使
apple
的复数形式
apples
也能被匹配上,所以我们这里的
s
希望它出现的次数是 0 到 1 次

str_1 = "i have an apple, he has two apples, she has three apples"
target_pattern = r"apples?"
print(re.findall(target_pattern, str_1))
# ['apple', 'apples', 'apples']

4) {}-指定匹配重复次数

使用花括号可以指定重复的次数,可以是一个固定的值,也可以是一个范围,下面分别介绍一下。

a. {m}-只匹配重复 m 次

{m}
表示匹配重复次数为 m,比如我们想要匹配
abbb
,也就是
a
字符后重复出现三个
b
,可以如下操作:

str_1 = "abbxxxxabbbbaxxxabbb"
target_pattern = r"ab{3}"
print(re.findall(target_pattern, str_1))

b. {m,n}-匹配重复 m 到 n 次

{m,n}
表示匹配重复次数为 m 到 n 次,比如我们想要
a
后面跟着 3,4,5个
b
都可以接受,可以如下操作:

str_1 = "abbbxxxxabbbbbbaxxxabbbb"
target_pattern = r"ab{3,5}"
print(re.findall(target_pattern, str_1))
# ['abbb', 'abbbbb', 'abbbb']

4. 匹配字符串集合

我们可以使用中括号
[]
来限定字符串或者匹配模式的集合,也就是说我们可以将我们想要匹配的字符串或者类型都加到
[]
里,满足条件的都可以被匹配返回。

1) 字符可以单独列出

如果我们想匹配某些单个字符,可以单独列出来操作,比如 a, t, w, q,可以使用 [atwq],以下是示例:

str_1 = "asdqweasdaterq"
target_pattern = r"[atwq]"
print(re.findall(target_pattern, str_1))
# ['a', 'q', 'w', 'a', 'a', 't', 'q']

2) 可以表示字符范围

我们可以使用
-
来表示字符的范围进行匹配,比如
a-z
,或者数字类型的
0-9
,比如下面的操作:

str_1 = "asdqweasdaterq"
target_pattern = r"[a-j]"
print(re.findall(target_pattern, str_1))
# ['a', 'd', 'e', 'a', 'd', 'a', 'e']
str_1 = "asd136q78w9ea95sd6ater"
target_pattern = r"[4-9]"
print(re.findall(target_pattern, str_1))
# ['6', '7', '8', '9', '9', '5', '6']

注意:
在这里的连接符
-
是表示范围的,如果只是想匹配
-
字符,需要使用
\
进行转义,或者将
-
放在首位或者末位:

str_1 = "asd136q-78w-9e-a95sd6zater"
target_pattern = r"[a\-z]"
print(re.findall(target_pattern, str_1))
# ['a', '-', '-', '-', 'a', 'z', 'a']

上面的这个操作表示的是希望匹配上
-
a

z
三个字符。

3) 特殊字符在集合里只会匹配其原始字符含义

特殊字符,比如前面表示数量的
*
+
等字符在中括号里就匹配的是对应的星号和加号:

str_1 = "asdas*adas+asds(das)dasd"
target_pattern = r"[*+()]"
print(re.findall(target_pattern, str_1))
# ['*', '+', '(', ')']

4) 字符类 \s 或 \w 可以在集合里使用

比如下面的操作,可以 \W 和 0-9 之间的字符:

str_1 = "asdas*adas+asds(90123das)dasd"
target_pattern = r"[\W0-9]"
print(re.findall(target_pattern, str_1))
# ['*', '+', '(', '9', '0', '1', '2', '3', ')']

5) 取反操作可以使用 ^

如果要取反,意思是集合里的匹配模式都不匹配,比如我们想匹配字符串里的非数字,可以如下操作:

str_1 = "asdas*adas+asds(90123das)dasd"
target_pattern = r"[^\d]"
print(re.findall(target_pattern, str_1))
# ['a', 's', 'd', 'a', 's', '*', 'a', 'd', 'a', 's', 'a', 's', 'd', 's', '(', 'd', 'a', 's', ')', 'd', 'a', 's', 'd']

5. 其他匹配类型

1) |-表达式的或操作

比如我们有两个表达式 A 和 B,只要有一个符合条件即可,即匹配模式的或操作:

re.findall(r"\d+|[a-z]+", "asdas234asd2341")
# ['asdas', '234', 'asd', '2341']

2) ()-匹配括号内的任意正则表达式

小括号表示分组,可以将匹配模式分组放到多个小括号内进行匹配,匹配后的结果也会以元组的形式分组返回。

比如我们想匹配
英文字母+数字
的形式,并且以括号的形式将其分隔开,那么返回的匹配结果也会以元组的形式将其返回:

str_1 = "asdas9872"
target_pattern = r"([a-z]+)(\d+)"
result = re.findall(target_pattern, str_1)print(result)
# [('asdas', '9872')]

如果是匹配上了多个结果,那么多个结果会以列表的形式返回,元素也是匹配的结果以元组的形式返回:

str_1 = "asdas9872asdasklqw8267"
target_pattern = r"([a-z]+)(\d+)"
result = re.findall(target_pattern, str_1)print(result)
# [('asdas', '9872'), ('asdasklqw', '8267')]

3、常用正则方法

前面介绍了 re.findall() 方法,返回的是一个列表,元素是所有匹配上的结果,接下来介绍一些其他常用的正则方法。

1. re.search

re.search() 方法的作用是扫描整个目标字符串,找到匹配上的第一个位置,然后返回一个匹配对象,如果没有满足要求的数据,则返回 None。

这里要注意三点,一点是扫描整个字符串,直到找到匹配的对象,另一点是找到一个符合条件的字符串以后就停止扫描,即便后面还有符合条件的,三是返回一个匹配对象,关于匹配对象下面再介绍。

比如下面的示例是匹配 英文字母+数字:

str_1 = "..()-+/?asdas9872asdasklqw8267"
target_pattern = r"[a-z]+\d+"
result = re.search(target_pattern, str_1)
# <re.Match object; span=(8, 17), match='asdas9872'>

可以看到这里,其实有多个符合匹配模式的数据,如果这里使用 re.findall() 会返回多个值,但这里返回的只是字符串里第一个符合条件的数据。

至于如何获取返回的这个 re.Match 对象详情数据,见第四节
re.Match 匹配对象
,建议先阅读该章节再往后读。

2. re.match

re.match() 方法也是用于匹配指定模式,效果上与 re.search() 无异,唯一不同的一点是 match() 方法只能从字符串的头部开始匹配,返回的也是一个 re.Match 对象。

比如下面的操作,第一种形式匹配不到数据,返回 None,第二种就可以返回 re.Match 对象:

str_1 = "..()-+/?asdas9872asdasklqw8267"
target_pattern = r"[a-z]+\d+"
result = re.match(target_pattern, str_1)
# None

str_1 = "asdas9872asdasklqw8267"
target_pattern = r"[a-z]+\d+"
result = re.match(target_pattern, str_1)
# <re.Match object; span=(0, 9), match='asdas9872'>

3. re.split

根据匹配模式将指定字符串进行分割,在效果上相当于 string.split() 的增强版。

因为 string.split() 不能使用正则对象来对字符串进行切割,而 re.split() 可以实现。

比如我们想要根据 1,2,3 对指定字符串进行切割,就可以用到
|
这个操作:

re.split(r"1|2|3", "as2da1s3asdsa")
# ['as', 'da', 's', 'asdsa']

我们也可以使用其他正则对象,比如我们想要根据字符串中的标点符号,空格等对字符串进行切割:

re.split(r"\W+", "i have an apple, you have two apples!")
# ['i', 'have', 'an', 'apple', 'you', 'have', 'two', 'apples', '']

re.split() 还可以接受 maxsplit 参数,表示最多切割的次数:

re.split(r"\W+", "i have an apple, you have two apples!", maxsplit=2)
# ['i', 'have', 'an apple, you have two apples!']

4. re.findall

re.findall() 前面有过介绍,根据匹配模式获取所有的匹配字符,结果以列表形式返回,元素为匹配的字符串:

re.findall(r"\d+", "asd12asxda45asd456sd23")
# ['12', '45', '456', '23']

5. re.finditer

re.finditer() 函数与 re.findall() 函数效果类似,都是找到目标字符串里全部满足条件的字符串,但是不同的是 finditer() 返回的一个迭代器,迭代器保存的是匹配对象,也就是 re.Match 对象:

for item in re.finditer(r"\d+", "asd12asxda45asd456sd23"):
    print(item)
# <re.Match object; span=(3, 5), match='12'>
# <re.Match object; span=(10, 12), match='45'>
# <re.Match object; span=(15, 18), match='456'>
# <re.Match object; span=(20, 22), match='23'>

6. re.sub

替换函数,将字符串里的指定模式替换成目标字符串,然后返回:

re.sub(r"\d+", "***", "asd12asxda45asd456sd23")
# 'asd***asxda***asd***sd***'

7. re.subn

re.subn() 与 re.sub() 函数作用一致,都是替换目标字符串,但是返回的是一个元组,分别是替换后的字符串和替换的次数:

re.subn(r"\d+", "***", "asd12asxda45asd456sd23")
# ('asd***asxda***asd***sd***', 4)

8. re.compile

re.compile() 函数将正则表达式编译为一个正则表达式对象,然后可以调用前面介绍过的这些正则函数,比如 re.search(),re.match(),re.findall() 等。

pattern = re.compile(r"\d+")
pattern.match("3432asdas334asd34asd")
# <re.Match object; span=(0, 4), match='3432'>

re.complie() 这个函数的操作可以用于永福匹配模式,使程序更加高效。

9. 其他参数

接下来介绍一下正则模块在匹配情况下的一些其他参数。

1) re.I

忽略大小写。

对于目标字符串,如果存在字母大小写的情况,我们可以对原始字符串统一进行大写或者小写的操作,然后进行匹配,以忽略大小写的影响,也可以使用 re.I 参数:

re.search(r"[a-z]+", "123SDAFSDsa234ASDd34".lower())
# <re.Match object; span=(3, 11), match='sdafsdsa'>

re.search(r"[a-z]+", "123SDAFSDsa234ASDd34", re.I)
# <re.Match object; span=(3, 11), match='SDAFSDsa'>

2) re.M

多行匹配模式。

前面介绍
^

$
这两个符号的匹配的时候介绍过,如果加上 re.M 参数,表示的是可以匹配字符串内部有换行符的开头和结尾的数据:

str_1 = "asd—— _\na你‘'好,\ns9。8?12\v3a\rs,.\ta9\n78d"
target_pattern = r"^a"
print(re.findall(target_pattern, str_1, re.M))
# ['a', 'a']

4、re.Match 匹配对象

对于一些函数,比如前面介绍的 re.search(),返回的就是一个 re.Match 对象,还是以上面的示例为例,如何获取 re.Match 中的数据呢,下面开始介绍。

1. Match.group()

group() 函数直接调用,或者添加参数 0,返回的是匹配的字符串数据:

print(result.group())
# asdas9872

print(result.group(0))
# asdas9872

而如果我们匹配的模式使用了小括号
()
进行了分组,那么则可以对 group() 函数添加其他 index 表示匹配的分组的结果,比如下面的操作:

str_1 = "..()-+/?asdas9872asdasklqw8267"
target_pattern = r"([a-z]+)(\d+)"
result1 = re.search(target_pattern, str_1)

print(result1.group(0))
# asdas9872

print(result1.group(1))
# asdas

print(result1.group(2))
# 9872

2.
Match.__getitem__(g)

re.Match 对象实现了
getitem
这个魔术方法,所以我们可以通过索引的方式对来访问匹配的结果,其效果和 group(index) 是一致的:

print(result1[0])
print(result1.group(0))

print(result1[2])
print(result1.group(2))

3. Match.groups()

groups() 函数返回的是进行了分组匹配的结果,以元组的形式返回。

比如这里分组的 result1 对象:

print(result1.groups())
# ('asdas', '9872')

而 result 这个 Match 对象匹配模式里并没有使用小括号进行分组,所以返回的结果是空元组:

print(result.groups())
# ()

4. Match.re

返回的是生成这个 Match 对象的正则对象,也就是我们输入的 target_pattern:

print(result.re)
# re.compile('[a-z]+\\d+')

5. Match.string

返回的是传递到生成这个 Match 对象的原始字符串:

print(result.string)
# ..()-+/?asdas9872asdasklqw8267

6. Match.start() 和 Match.end()

这两个函数表示的是匹配上的字符串的开始位置和结束位置:

print(result.start(), result.end())
# 8 17

我们对原始字符串进行起始位置的截取就是 Match.group() 的结果:

print(str_1[result.start(): result.end()])
# asdas9872

print(result.group())
# asdas9872

而如果我们前面对匹配模式进行了分组的操作,也就是使用了小括号,比如返回的 result1,我们可以想 start() 和 end() 函数添加索引参数分别表示这两个分组结果开始和结束的下标位置:

print(result1.groups())
# ('asdas', '9872')

print(str_1[result1.start(1): result1.end(1)])
# asdas

print(str_1[result1.start(2): result1.end(2)])
# 9872

7. Match.span()

返回的是匹配结果的开始和结束位置,以元组的形式返回,其实就是一次性返回 Match.start() 和 Match.end() 的结果。

span() 函数也可以接受分组的参数返回分组的起始位置。

如果想获取更多相关文章,可扫码关注阅读:
image

FastGateway 发布v0.0.0.5

  • 修复构建错误
  • 修复
    docker-compose
    执行目录
  • 修改请求来源分析数据列表展示
  • update README.md.
  • 增加默认证书
  • 修复构建脚本目录错误

FastGateway提供了基本的管理服务,提供简单的登录授权,和实时配置管理,从而实现动态路由的管理。

支持功能










技术栈

后端技术栈

  • Asp.Net 8.0 用于提供基础服务
  • Yarp 用于提供反向代理服务
  • FreeSql 用于提供数据库服务
  • JWT 用于提供登录授权服务
  • MiniApis 提供WebApi服务

前端技术栈

  • reset-css 用于重置浏览器默认样式
  • axios 用于发送http请求
  • semi 用于提供基础组件
  • react-router-dom 用于路由管理

镜像执行指令

mkdir data
docker run -d --restart always --name gateway-api -p 8000:8000 -p 8200:8080 -p 8300:8081 -v $(pwd)/data:/data/ registry.cn-shenzhen.aliyuncs.com/tokengo/gateway-api
docker run -d --restart always --privileged --name gateway-web -p 10800:80 -e api_url=http://localhost:8000 registry.cn-shenzhen.aliyuncs.com/tokengo/gateway-web

Docker-Compose文件

services:
  gateway-api:
    image: registry.cn-shenzhen.aliyuncs.com/tokengo/gateway-api
    restart: always
    container_name: gateway-api
    environment:
      USER: root
      PASS: Aa010426.
    ports:
      - 8000:8000 # 提供给web端调用的管理接口
      - 8200:8080 # Http代理端口
      - 8300:8081 # Https代理端口
    volumes:
      - ./data:/data/ # 请注意手动创建data目录,负责在Linux下可能出现权限问题导致无法写入

  gateway-web:
    image: registry.cn-shenzhen.aliyuncs.com/tokengo/gateway-web
    restart: always
    build:
      context: ../web
      dockerfile: Dockerfile
    privileged: true
    environment:
      api_url: http://localhost:8000
    ports:
      - 10800:80

如果并没有提供账号密码则默认

账号:root

密码:Aa010426.

自带管理界面的docker-compose

services:
  gateway-api:
    image: registry.cn-shenzhen.aliyuncs.com/tokengo/gateway-api:8.0-admin
    restart: always
    container_name: gateway-api
    build:
      context: ../
      dockerfile: src/FastGateway/Dockerfile-admin
    ports:
      - 8000:8000 # 提供给web端调用的管理接口
      - 8200:8080 # Http代理端口
      - 8300:8081/udp # Https代理端口
      - 8300:8081/tcp # Https代理端口 Http3需要开启UDP和TCP,请注意防火墙设置是否允许
    volumes:
      - ./data:/data/ # 请注意手动创建data目录,负责在Linux下可能出现权限问题导致无法写入

支持HTTP3的docker-compose

services:
  gateway-api:
    image: registry.cn-shenzhen.aliyuncs.com/tokengo/gateway-api:8.0-http3
    restart: always
    container_name: gateway-api
    build:
      context: ../
      dockerfile: src/FastGateway/Dockerfile-http3
    ports:
      - 8000:8000 # 提供给web端调用的管理接口
      - 8200:8080 # Http代理端口
      - 8300:8081/udp # Https代理端口
      - 8300:8081/tcp # Https代理端口 Http3需要开启UDP和TCP,请注意防火墙设置是否允许
    volumes:
      - ./data:/data/ # 请注意手动创建data目录,负责在Linux下可能出现权限问题导致无法写入

  gateway-web:
    image: registry.cn-shenzhen.aliyuncs.com/tokengo/gateway-web
    restart: always
    build:
      context: ../web
      dockerfile: Dockerfile
    privileged: true
    environment:
      api_url: http://localhost:8000
    ports:
      - 10800:80

替换默认的https证书

由于需要使用https,为了方便系统默认提供了一个pfx证书,如果你需要提供的话可以按照以下操作进行,如果是Docker执行的话证书的目录则是
/app/certificates/gateway.pfx

services:
  gateway-api:
    image: registry.cn-shenzhen.aliyuncs.com/tokengo/gateway-api
    restart: always
    container_name: gateway-api
    ports:
      - 8000:8000 # 提供给web端调用的管理接口
      - 8200:8080 # Http代理端口
      - 8300:8081 # Https代理端口
    environment:
      USER: root
      PASS: Aa010426.
      HTTPS_PASSWORD: dd666666
      HTTPS_FILE: gateway.pfx
    ports:
      - 8200:8080
    volumes:
      - ./data:/data/
      - ./app/certificates:/app/certificates

  gateway-web:
    image: registry.cn-shenzhen.aliyuncs.com/tokengo/gateway-web
    restart: always
    container_name: gateway-web
    privileged: true
    environment:
      api_url: http://localhost:8000
    ports:
      - 10800:80

参考上面的docker-compose文件,我们提供了俩个环境变量
HTTPS_PASSWORD

HTTPS_FILE

HTTPS_FILE

  • 在系统中已经指定了容器的
    /app/certificates
    目录,你只想要挂在目录中的文件名即可

HTTPS_PASSWORD

  • Pfx证书的密码,如果修改了证书请填写证书的密码。

/app/certificates

  • 这个是系统证书默认存放目录,如果映射了目录则需要提供自己的证书。

使用隧道

services:
  gateway-api:
    image: registry.cn-shenzhen.aliyuncs.com/tokengo/gateway-api
    restart: always
    container_name: gateway-api
    environment:
      USER: root
      PASS: Aa010426.
      HTTPS_PASSWORD: dd666666
      TUNNEL_PASSWORD: dd666666
      HTTPS_FILE: gateway.pfx
    ports:
      - 8000:8000 # 提供给web端调用的管理接口
      - 8200:8080 # Http代理端口
      - 8300:8081 # Https代理端口
    volumes:
      - ./data:/data/
      - ./app/certificates:/app/certificates

  gateway-web:
    image: registry.cn-shenzhen.aliyuncs.com/tokengo/gateway-web
    restart: always
    container_name: gateway-web
    privileged: true
    environment:
      api_url: http://localhost:8000
    ports:
      - 10800:80

增加
TUNNEL_PASSWORD
环境变量,默认为空不设置密码

下载隧道客户端
https://gitee.com/hejiale010426/Gateway/releases
然后解压压缩包,打开appsettings.json文件修改Tunnel节点的Url,如果Gateway使用了TUNNEL_PASSWORD,那么你的URL应该是
https://localhost:8081/api/gateway/connect-h2?host=backend1.app&password=dd666666

host
是在集群中的集群端点的域名,这个域名就是定义到我们的隧道客户端的
host
的这个参数,请保证值的唯一性,当绑定集群的路由匹配成功以后则会访问图片定义的端点,如果并没有存在节点那么他会直接代理。

输入图片说明

出入流量监控

使用环境变量控制是否启用流量监控,使用环境变量
ENABLE_FLOW_MONITORING
设置我们是否启用流量监控,如果为空则默认启动流量监控,然后可以打开我们的控制面板查看流量监控的数据。

services:
  gateway-api:
    image: registry.cn-shenzhen.aliyuncs.com/tokengo/gateway-api
    restart: always
    container_name: gateway-api
    environment:
      USER: root
      PASS: Aa010426.
      HTTPS_PASSWORD: dd666666
      HTTPS_FILE: gateway.pfx
      ENABLE_FLOW_MONITORING: true
    ports:
      - 8000:8000 # 提供给web端调用的管理接口
      - 8200:8080 # Http代理端口
      - 8300:8081 # Https代理端口
    volumes:
      - ./data:/data/
      - ./app/certificates:/app/certificates

  gateway-web:
    image: registry.cn-shenzhen.aliyuncs.com/tokengo/gateway-web
    restart: always
    container_name: gateway-web
    privileged: true
    environment:
      api_url: http://localhost:8000
    ports:
      - 10800:80

第三方下载

Gitee:
https://gitee.com/hejiale010426/FastGateway

Github:
https://github.com/239573049/FastGateway

介绍

索引是一个关键组件,有助于 Hudi 写入端快速更新和删除,并且它在提高查询执行方面也发挥着关键作用。 Hudi提供了多种索引类型,包括全局变化的Bloom索引和Simple索引、利用HBase服务的HBase索引、基于哈希的Bucket索引以及通过元数据表实现的多模态索引。 索引的选择取决于表大小、分区数据分布或流量模式等因素,其中特定索引可能更适合更简单的操作或更好的性能。 用户在为不同表选择索引类型时经常面临权衡,因为还没有一种能够以最小的操作开销促进写入和读取的通用性能索引。

从 Hudi 0.14.0 开始,我们很高兴地宣布 Apache Hudi 的通用索引 - 记录级别索引 (RLI)。 这一创新不仅显着提高了写入效率,还提高了相关查询的读取效率。 RLI 无缝集成在表存储层中,无需任何额外的操作工作即可轻松工作。

在本博客的后续部分中,我们将简要介绍 Hudi 的元数据表,这是讨论 RLI 的先决条件。 接下来我们将深入研究 RLI 的设计和工作流程,然后展示性能分析和索引类型比较。 该博客将以对 RLI 未来工作作为结尾。

元数据表

Hudi
元数据表

.hoodie/metadata/
目录中的读取合并 (MoR) 表。 它包含与记录相关的各种元数据,无缝集成到写入器和读取器路径中,以提高索引效率。 元数据分为四个分区:文件、列统计信息、布隆过滤器和记录级索引。

元数据表与时间轴上的每个提交操作同步更新,换句话说,对元数据表的提交是对Hudi数据表的事务的一部分。 通过包含不同类型元数据的四个分区,此布局可实现多模式索引的目的:

  • files分区跟踪Hudi数据表的分区,以及每个分区的数据文件
  • column stats分区记录了数据表每一列的统计信息
  • bloom filter分区存储基本文件的序列化布隆过滤器
  • record level index分区包含各个记录键和相应文件组ID的映射

用户可以通过设置
hoodie.metadata.enable=true
来启用元数据表。 一旦启用,文件分区将始终启用。 可以单独启用和配置其他分区以利用额外的索引功能。

记录级别索引

从版本 0.14.0 开始,可以通过设置
hoodie.metadata.record.index.enable=true

hoodie.index.type=RECORD_INDEX
来激活记录级别索引 (RLI)。 RLI 背后的核心概念是能够确定记录的位置,从而减少需要扫描以提取所需数据的文件数量。 这个过程通常被称为“索引查找”。 Hudi 采用主键模型,要求每个记录与一个键关联以满足唯一性约束。 因此我们可以在记录键和文件组之间建立一对一的映射,这正是我们打算在记录级索引分区中存储的数据。

对于索引而言,性能至关重要。 包含RLI分区的元数据表选择HFile作为文件格式,HBase的文件格式利用B+树结构进行快速查找。 现实工作负载的基准测试表明,包含 100 万个 RLI 映射的
HFile
可以在 600 毫秒内查找一批 100k 记录。 我们将在后面的部分中介绍性能主题并进行详细分析。

初始化

为现有 Hudi 表初始化 RLI 分区可能是一项费力且耗时的任务,具体取决于记录的数量。 就像典型的数据库一样,构建索引需要时间,但最终会通过加速未来的大量查询而得到回报。

上图显示了 RLI 初始化的步骤。 由于这些作业都是可并行的,因此用户可以相应地扩展集群并配置相关的并行设置(例如
hoodie.metadata.max.init.parallelism
)以满足他们的时间要求。

重点关注最后一步“批量插入到 RLI 分区”,元数据表写入端使用哈希函数对 RLI 记录进行分区,确保生成的文件组的数量与分区的数量一致。 这保证了记录键查找的一致性。

值得注意的是,当前的实现在初始化后修复了 RLI 分区中文件组的数量。 因此用户应该倾向于过度配置文件组并相应地调整这些配置。

hoodie.metadata.record.index.max.filegroup.count
hoodie.metadata.record.index.min.filegroup.count
hoodie.metadata.record.index.max.filegroup.size
hoodie.metadata.record.index.growth.factor

在未来的开发迭代中,RLI 应该能够通过动态重新平衡文件组来克服这一限制,以适应不断增加的记录数量。

在数据表写入时更新 RLI

在常规写入期间,RLI 分区将作为事务的一部分进行更新。 元数据记录将使用传入的记录键及其相应的位置信息生成。 鉴于 RLI 分区包含记录键和位置的精确映射,对数据表的更新插入将导致将相应的键更新插入到 RLI 分区。所采用的哈希函数将保证相同的键被路由到同一文件组。

写入索引

作为写入流程的一部分,RLI 遵循高级索引流程,与任何其他全局索引类似:对于给定的记录集,如果索引发现每个记录存在于任何现有文件组中,它就会使用位置信息标记每个记录。 关键区别在于存在性测试的真实来源——RLI 分区。 下图说明了标记流程的详细步骤。

标记的记录将被传递到 Hudi 写入句柄,并对它们各自的文件组进行写入操作。 索引过程是对表应用更新的关键步骤,因为其效率直接影响写入延迟。 在后面的部分中,我们将使用基准测试结果展示记录索引的性能。

读取流程

记录级别索引也集成在查询端。 在涉及针对记录键列进行相等性检查(例如,EqualTo 或 IN)的查询中,Hudi 的文件索引实现优化了文件裁剪过程。 这种优化是通过利用 RLI 精确定位完成查询所需读取的文件组来实现的。

存储

存储效率是设计的另一个重要方面。 每个RLI映射条目必须包含一些精确定位文件所必需的信息,例如记录键、分区路径、文件组id等。为了优化存储,RLI采用了一些压缩技术,例如对文件组id进行编码(以UUID的形式) ) 转换为 2 个 Long 来表示高位和低位。 使用 Gzip 压缩和 4MB 块大小,单个 RLI 记录的平均大小仅为 48 字节。 为了更实际地说明这一点,假设我们有一个包含 100TB 数据的表,其中包含大约 10 亿条记录(平均记录大小 = 100Kb)。 RLI 分区所需的存储空间约为 48 Gb,不到总数据大小的 0.05%。 由于 RLI 包含与数据表相同数量的条目,因此存储优化对于使 RLI 实用起来至关重要,特别是对于 PB 大小及以上的表。

RLI 利用低成本存储来实现类似于 HBase 索引的快速查找过程,同时避免运行额外服务的开销。 在下一节中我们将回顾一些基准测试结果以展示其性能优势。

性能

我们对记录级别索引进行了全面的基准分析,评估写入延迟、索引查找延迟和数据shuffle等方面,并与 Hudi 中现有的索引机制进行比较。 除了写入操作的基准之外,我们还将展示点查的查询延迟的减少。实验使用Hudi 0.14.0和Spark 3.2.1。

与 Hudi 中的全局简单索引 (GSI) 相比,记录级别索引 (RLI) 的设计具有显着的性能优势,因为大大减少了扫描空间并最大限度地减少了数据shuffle。 GSI 在数据表的所有分区中的传入记录和现有数据之间执行join操作,从而导致大量数据Shuffle和精确定位记录的计算开销。 另一方面 RLI 通过哈希函数有效地提取位置信息,通过仅从元数据表加载感兴趣的文件组,从而显着减少数据shuffle量。

写入延迟

在第一组实验中,我们建立了两个管道:一个使用 GSI 配置,另一个使用 RLI 配置。 每个管道在包含 10 个 m5.4xlarge 核心实例的 EMR 集群上执行,并设置为将批量 200Mb 数据摄取到包含 20 亿条记录的 1TB 数据集中。 RLI 分区配置有 1000 个文件组。 对于 N 批次的摄取,
使用 RLI 的平均写入延迟比 GSI 显着提高了 72%。

注意:在Hudi中的Global Simple Index和Global Bloom Index之间,由于记录键的随机性,前者产生了更好的结果。 因此我们在图表中省略了GSI的呈现。

索引查找延迟

我们还使用 HoodieReadClient 隔离了索引查找步骤,以准确衡量索引效率。 通过在包含 20 亿条记录的 1TB 数据集中查找 400,000 条记录 (0.02%) 的实验,
RLI 比 GSI 提高了 72%,与端到端写入延迟结果一致。

数据Shuffle

在索引查找实验中,我们观察到 GSI 大约有 85Gb 的数据shuffle ,而RLI只有 700Mb 的数据shuffle。
这反映出与 GSI 相比,使用 RLI 时数据shuffle减少了 92%。

查询延迟

记录级别索引将极大地提高在记录键列上使用“EqualTo”和“IN”谓词的 Spark 查询。 我们创建了一个 400GB Hudi 表,包含 20,000 个文件组。 当我们执行基于单个记录键的查询时,我们观察到查询时间有了显着的改进。
启用 RLI 后,查询时间从 977 秒减少到仅 12 秒,延迟减少了 98%。

何时使用

RLI 总体表现出出色的性能,将更新和删除效率提升到一个新的水平,并在执行键匹配查询时快速跟踪读取。 启用 RLI 也很简单,只需设置一些配置标志即可。 下面我们总结了一个表格,突出显示了 RLI 与其他常见 Hudi 索引类型相比的重要特征。

| |
Record Level Index | Global Simple Index | Global Bloom Index | HBase Index | Bucket Index |
| --- | --- | --- | --- | --- | --- |
| Performant look-up in general | Yes | No | No | Yes, with possible throttling issues | Yes |
| Boost both writes and reads | Yes | No, write-only | No, write-only | No, write-only | No, write-only |
| Easy to enable | Yes | Yes | Yes | No, require HBase server | Yes |

许多实际应用程序将受益于 RLI 的使用。 一个常见的例子是满足 GDPR 要求。 通常当用户提出请求时,将提供一组 ID 来标识要删除的记录,这些记录将被更新(列无效)或永久删除。 通过启用 RLI,执行此类更改的离线作业将变得更加高效,从而节省成本。 在读取方面,通过某些跟踪 ID 收集历史事件的分析师或工程师也将体验到来自键匹配查询的极快响应。

虽然 RLI 相对于所有其他指数类型具有上述优势,但在使用它时考虑某些方面也很重要。 与任何其他全局索引类似,RLI 要求表中所有分区的记录键唯一性。 由于 RLI 跟踪所有记录键和位置,因此对于大型表来说,初始化过程可能需要一些时间。 在大型工作负载极度倾斜的场景中,由于当前设计的限制,RLI 可能无法达到所需的性能。

未来的工作

在记录级别索引的初始版本中有某些限制。 正如“初始化”部分中提到的,文件组的数量必须在创建 RLI 分区期间预先确定。 Hudi 确实对现有表使用一些启发式方法和增长因子,但对于新表,建议为 RLI 设置适当的文件组配置。 随着数据量的增加,当需要额外的文件组进行扩展时,RLI 分区需要重新引导。 为了满足重新平衡的需要,可以采用一致的哈希技术。

另一个有价值的增强功能涉及支持辅助列与记录关键字段的索引,从而满足更广泛的查询。 在读取器方面,计划将更多查询引擎(例如 Presto 和 Trino)与记录级别索引集成,以充分利用 Hudi 元数据表提供的性能优势。