2024-08-23 23:54:47
即便已经几乎无人再会把“自搭自写”博客的 Programmer 称为 Geek,但我相信拥有一个 Self-Hosted Blog 依然是众多 Programmer 的普遍追求。虽然 GitHub Pages 和 Jekyll 的组合放到现在也依然不算过时,也不失简便,但考虑到如今各类 SaaS 服务层出不穷,那么在 2024 年的今天,我们能否利用全 SaaS 阵容从零免费搭建一个博客?
关注过我博客的朋友可能会知道在过去的十余载里,Pomash 是我自己拿 Python 写成,也是我自己一直在用的博客程序。不过在使用和维护的过程中我也逐渐发现这个几乎写成于十年前的博客程序似乎有点“过时”了,整理过后,我为造轮子另起炉灶罗列了几点冠冕堂皇的理由:
于是乎作为一次具有某种“致敬”意味的行为艺术,我用 Rust 重写了 Pomash,并用我很喜欢的命名法给它起了一个新名字:
'r{}'.format(''.join(sorted('Pomash'))[::-1]) == 'rsomhaP'
somhaP 是 Pomash 重排后的字符串,加一个字母 r 在开头拼接成 rs 意指用 Rust 重写而成——rsomhaP。
为了全面拥抱 SaaS 服务简化实现与部署,在造轮子的同时“反造轮子”,我在重写的时候带着这么几个原则:
所以最后,这篇文章也孕育而生,让我们看看要跑起来一个 rsomhaP 的博客程序,我们需要哪些步骤。
rsomhaP 抛弃了 SQLite 作为本地数据存储,直接使用现在几乎烂大街的 DB SaaS,目前还只支持 MySQL-compatible 的数据库服务,后续也许会考虑支持 PostgreSQL。
这里我自然选择了利益相关的 TiDB Serverless 作为后端数据库 SaaS,注册并创建好集群,拿到数据库的 Host 等参数即可,没有任何额外的步骤。
MySQL 相关的配置直接写到 rsomhaP 的配置文件里或用环境变量 MYSQL_CONNECTION_URL
均可。
[mysql]
# If `connection_url` is set, other connection-related configs will be ignored.
# connection_url = "mysql://root:[email protected]:4000/rsomhaP"
username = "root"
password = "password"
host = "127.0.0.1"
port = 4000
database = "rsomhaP"
唯一需要注意的点是这里需要提前创建好一个 database:
CREATE DATABASE rsomhaP;
除了天然的享受到了 TiDB 作为一个分布式数据库的优势,我们也可以在 TiDB Cloud 的面板上自动设置自己数据库的备份时间与频率,为自己智慧的结晶多上一份保险。
数据库有了,那么我们至少需要一个 Host 或者说机器来实际部署运行我们的 rsomhaP 程序,除了直接去 Vultr 这类地方买虚拟云主机,我们还有一个更“小而美”的选择:Fly.io。
其实我曾经也写过一篇关于它的博客:Fly.io 初体验之博客搬家。彼时我把 Pomash 迁移了上去,整个体验也非常的丝滑,所以在这里依旧沿用了之前的选择。
我为 rsomhaP 直接写好了一份可以用于 Fly.io 部署的 Dockerfile 和 fly.toml,所以你需要做的仅仅是安装好 flyctl,按自己的配置改好 config.toml 然后在 rsomhaP 的目录下运行:
fly deploy --config fly.toml
这里如果你是第一次用 Fly.io,那么直接运行可能会提示你没有 rsomhap
这个 App。你可以选择去 Fly.io 的网页端 Dashboard 手动创建,也可以直接在命令行里创建:
fly apps create rsomhap
紧接着一路按照提示进行配置即可,这里有两个地方需要注意一下。
首先是为了保证和数据库服务的连通性,我在部署时选择了和 TiDB Serverless 集群位于相同地区的日本 Region,可以在 fly.toml
里这样配置:
app = "rsomhap"
primary_region = "nrt"
对于全部的可用 Region,可以参考这篇官方文档。
部署完成后便可以通过形如 https://app-name.fly.dev 的 Hostname 来访问你刚刚部署好的 rsomhaP 了,Fly.io 当然也支持接入自定义域名和配置免费的 SSL 证书,均可通过网页 Dashboard 或 flyctl 做到,参考。
恭喜你,上面哪些步骤全部完成后,你就已经拥有一个可以访问和写作的博客了🎉
但要想进行图文写作,我们还差这最后一步“图”,由于 rsomhaP 本身并不支持上传和存储图片,所以拥有一个稳定的图床服务是有必要的,这里我们可以用赛博大基建 Cloudflare R2 的对象存储作为我们的图床设施,具体设置方法可以参考这篇文章,写得很好很详细,我就不再次展开了:从零开始搭建你的免费图床系统(Cloudflare R2 + WebP Cloud + PicGo)。
有了图床的“床”,我们还可以更精进一步,用 WebP Cloud Services 这个图片代理 SaaS 来实现更多的功能:
同样推荐大家参考上面那篇文章的作者:
至此,使用 rsomhaP 搭配一众免费 SaaS 的博客部署就完成了,你可以继续浏览本站或者去 rsomhaP 的 GitHub 界面了解更多信息,其实关于 rsomhaP 的开发过程也有很多可以分享的点——例如 axum 库的使用,Markdown 渲染的实现等,待我挖个坑日后再填。
2023-08-07 22:43:16
是的,作为一个从 2017 年起就再也没用过除 Mac 以外笔电的人,时隔 6 年,我购入了这款联想的 ThinkPad X1 Carbon Gen 11。购买的动机其实很简单,那就是反抗 Apple 暴政 macOS 实在用腻了,迫切需要消费折腾带来的新鲜感。
虽然我或多或少对 Ubuntu 和 Debian 这种相对而言更流行的 Linux 发行版比较熟悉,但既然有了“折腾“的初心,所以更希望尝试一些新东西,早有耳闻 Arch Linux 的"简洁主义"和非常 KISS (Keep It Simple and Stupid) 的设计原则,再加上前期采购设备前调研时惊叹于 ArchWiki 的完整和详尽,所以最终决定借此机会直接上手体验一下,看能不能作为自己主力机的主力 OS。
安装的时候我基本上只参考了这两个指南:
前者基于后者进行了一些简略的删改,更适合新手,但也导致里面的有一些描述语焉不详,如果你对 Linux 本身或者 OS 原理不太了解的话,需要谨慎操作,尤其需要注意硬盘分区和引导安装之类的步骤。
目前主流硬件厂商对 Linux 的支持都很完善了,所以安装、配置和使用过程都会比较顺利,但最好还是在开始前提前了解 Arch Linux 对你手上设备的支持情况,例如我这台 ThinkPad X1 Carbon (Gen 11) 的兼容性,可以看到除了前置摄像头外,其他大部分硬件在使用上都没什么问题。
大多数我遇到的安装和配置问题在 Google 后都有比较直接的解决方案(其中大部分最终都指向了 ArchWiki,可见其内容之靠谱和丰富),这里罗列一下我在配置过程中遇到的一些比较独特的问题。
本来想编译 TiKV “烤”一下机,但由于 Arch Linux 自带的 GCC 版本太新了,编译 RocksDB 时各种 Warning 以及 Error,于是需要安装一个旧版本 GCC 来用。考察了一圈 TiKV 和 RocksDB 的相关 Issue 和 PR 后决定装 GCC 9。
yay -S gcc9
殊不知这一行命令敲下去,由于 AUR 的包需要跑 PKGBUILD 来手动编译后再安装,这一编就是好几个小时,最终还没完成就被我遭不住地杀掉了。也是根据 ArchWiki 的建议,我做了这俩操作来加速整个过程:
nproc # 看一下你设备支持的最大进程数,例如我这里是 16
MAKEFLAGS='-j16' BUILDDIR=/tmp/makepkg yay -S gcc9
参数解释:
-j16
用来指定 make 的并行 Job 数量,一般都等于 CPU 最大支持的线程数量BUILDDIR
用来指定编译目录,扔到 tmpfs 里来充分利用内存加速编译方子很好,原本数小时的编译时间不到半个小时就完成了。
由于拿到电脑的时候自带是 Windows 11 系统,所以在开机初始化配置时我跟随着引导开启了 Windows Hello 的面部及 PIN 码识别,但因为随后安装 Arch Linux 时关闭了 Secure Boot,待我在安装结束后再次进入 Windows 11,发现由于之前设置 Windows Hello 有 TPM 的参与,在关闭 Secure Boot 后面部识别以及 PIN 码就都无法使用了。
要解决的话思路也很直接,如果能让 Arch Linux 在开启了 Secure Boot 的情况下也能正确引导启动,那就最好了,经过一番搜寻,我在 reddit 的 archlinux 社区发现一篇教程来使用 sbctl
这个工具对引导文件进行签名认证。跟着其中的操作一番尝试后,再次启动 GRUB 发现并不能正常引导启动,提示内核加载错误。经过一番搜寻,在 ArchWiki 中对 sbctl 进行介绍的部分看到这样一段:
现在签署所有的未被签署的文件。通常是内核与引导加载程序需要被签署。比如:
sbctl sign -s /boot/vmlinuz-linux sbctl sign -s /boot/EFI/BOOT/BOOTX64.EFI
看起来大概率是因为没有对 vmlinuz-linux
内核引导文件签名导致的,重新进行上述的操作再次尝试开启 Secure Boot 启动,成功进入 Arch Linux,完成对 Secure Boot 的配置。
由于 sbctl 这个工具不一定能在所有硬件上都可行,所以如果你在上面的操作里失败了,但又想在不开启 Secure Boot 的前提下使用面孔/指纹解锁,其实也是有办法的。根据 Windows 官方的文档,我们会发现 Windows Hello 并不依赖 TPM 去工作。
所以只需要在关闭了 Secure Boot 的前提下重新设置一遍 Windows Hello 就可以在不使用 TPM 的前提下正常使用面孔/指纹解锁了。
这个其实解决方法很简单,遵循 ArchWiki 中蓝牙这个界面下“从挂起中唤醒”这一节的指引操作即可。但我在第一步就遇到了问题,ThinkPad X1 Carbon 的蓝牙设备并没有那么明显地在 lsusb
后被展示出来:
❯ lsusb
Bus 004 Device 001: ID 1d6b:0003 Linux Foundation 3.0 root hub
Bus 003 Device 006: ID 30c9:0052 Luxvisions Innotech Limited Integrated RGB Camera
Bus 003 Device 005: ID 06cb:00fc Synaptics, Inc.
Bus 003 Device 003: ID 2c7c:0310 Quectel Wireless Solutions Co., Ltd. Quectel EM05-CN
Bus 003 Device 004: ID 0451:82ff Texas Instruments, Inc.
Bus 003 Device 002: ID 0451:8442 Texas Instruments, Inc.
Bus 003 Device 007: ID 8087:0033 Intel Corp.
Bus 003 Device 001: ID 1d6b:0002 Linux Foundation 2.0 root hub
Bus 002 Device 002: ID 0451:8440 Texas Instruments, Inc.
Bus 002 Device 001: ID 1d6b:0003 Linux Foundation 3.0 root hub
Bus 001 Device 001: ID 1d6b:0002 Linux Foundation 2.0 root hub
我在一番搜索后,终于在 lsusb -v
的输出中看到了 Bluetooth 等字样归属于 003 这个 Device,成功拿到了 idVendor
和 idProduct
进行后续的配置。
Bus 003 Device 007: ID 8087:0033 Intel Corp.
...
Interface Descriptor:
bLength 9
bDescriptorType 4
bInterfaceNumber 0
bAlternateSetting 0
bNumEndpoints 3
bInterfaceClass 224 Wireless
bInterfaceSubClass 1 Radio Frequency
bInterfaceProtocol 1 Bluetooth
2023-01-31 21:47:53
之前博客一直部署在 Vultr 每个月 $5 的日本节点上,眼看下个月就又要余额归零了,再加上一直以来整个 VM 上都只跑了 Pomash 这一个程序,算是有点浪费,所以在考虑要不要拥抱一下新时代,找一个做这种小应用部署的 SaaS,把博客程序迁移上去。目前 Pomash 在虚拟机上的搭建方式也十分老手艺:Supervisor 做进程管理,Nginx 做转发。要是能一劳永逸,干掉这些我毕生所学的建站知识,那更是再好不过了。
可能有的朋友看到这里会这样问:为啥不直接找个博客托管平台?为啥不直接用静态博客?答案也很简单,Pomash 算是我初入编程殿堂的启蒙之作,这古老的 Python Tornado Web 框架和前后端不分离的架构,以及谈得上羞耻的代码质量都保留着我那一份青春的回忆。从 14 年的第一个 Commit 算起,到今天(2023-01-31)刚好是 Pomash 的 9 周年,慢慢更新到现在,它绝对不是最好用的博客系统,但一定是我最喜欢的。
Fly.io 其实是跟同事吃饭摆龙门阵的时候了解到的一个容器化部署平台,整个产品都透露出一股小而美的气质,其提供的服务也非常简单:帮助用户用容器化的方式部署应用。人话版本就是每个人都可以讲 5 分钟脱口秀通过写一个 Dockerfile 的工作量(有些情况下甚至连 Dockerfile 都可以不用准备)快速部署可访问的应用。官方文档上所称每个账号的免费额度如下:
对于我这个无人问津的博客来说,使用起来应该是绰绰有余了,故而直接开整。
所有的部署运维操作都可以通过官方提供的命令行工具 flyctl 来完成,整个交互也极为简单,在完成 fly auth login
之后,即可开始部署应用了。 flyctl 的使用极为傻瓜,对于比较简单的项目,例如有 main.go
的 Go 项目,只需要调用 flyctl launch
,它会扫描你的源代码结构,自动帮你生成 Dockerfile(其他语言的项目也类似),如果你只是用 Go 的标准库实现了一个简单跑在 8080 端口上的 HTTP 程序,基本上这一个命令一路 Y 过去就直接部署成功可以在浏览器里访问了。但是对于 Pomash 来说,它还需要一点额外的步骤,所以我选择自己准备一个 Dockerfile。
Pomash 是一个 Python Web 程序,运行起来很简单:python3 run.py
就完事了。不过不知道为什么,当年的我在实现的时候居然决定在博客跑起来前需要先手动生成 SQLite 的数据库文件,所以还得多来一步,再加上 pip 的依赖安装啥的,一点也不复杂的 Dockerfile 最后写出来长这样:
# syntax=docker/dockerfile:1
FROM python:3.8-slim-buster
WORKDIR /pomash_deployment
COPY requirements.txt requirements.txt
RUN pip3 install -r requirements.txt
COPY . .
RUN python3 init_db.py
CMD ["python3", "run.py" , "--port=8080"]
接下来的操作就很简单了,flyctl deploy
然后根据提示输入 Y or N 就可以完成部署,我的应用名设置的是 pomash
,所以最后部署后的地址就是 https://pomash.fly.dev。
最后一步就是域名绑定了。由于 IPv4 枯竭问题,fly.io 官方选择了省着分配 IPv4 地址,只要你的应用部署时使用了默认的 80 和 443 这两个 HTTP 端口,那么就不会分配到独占的 IPv4 地址,但是每部署一个应用 fly.io 都会为你分配一个独占的公网 IPv6 地址。虽然用 CNAME 记录的方式可以把自己的域名跳转到官方给的 URL 上来解决 IPv4 的访问问题,但毕竟相比于 A 记录有一定限制,所以为了让家里还没有 IPv6 的朋友能够打开我的博客,我们可以手动分配一个独占的 IPv4 地址:flyctl ips allocate-v4
。需要注意的是每个账户都只有一个 Dedicated IPv4 的限额,如果你想拥有 2 个及以上的公网独占 IPv4 地址的话,就只能充钱了,价格是 $2 一个月。
完成 DNS 的设置后来到网页的 Dashboard 界面,手动添加对应域名后,fly.io 会通过 Let's Encrypt 自动帮你配置免费的 SSL 证书加密。
当然,一切操作也都可以通过命令行完成,参考官方文档。
整个从注册到最后部署成功的过程是比较丝滑的,几乎没有遇到任何问题,官方文档也写的十分详尽,基本上我遇到的所有问题都可以在内找到详细的解决办法(例如单应用内的多进程部署),可见 fly.io 是很懂面向用户群体痛点所在的。值得一提的是,最初同事给我讲到 fly.io 倒不是因为他们的产品,而是比较有趣的招聘方式。通过他们官网的招聘流程介绍,可以看到他们的“面试”过程很有趣,这里的面试打了引号是因为他们其实并没有面试这一步,而是通过做 2 到 3 个挑战题的方式第一阶段通关后直接加入他们的公司 Slack 和他们的工程师工作一天,一切顺利的话就会给你发 Offer。从这样一个细节来看,除去好用的产品外,这也真的是一家有趣的公司。
2022-05-26 17:00:32
分裂可以说是 Region 生命周期中最为重要的一步,如同细胞一般,分裂是 Region 被创造并持续增多的唯一方式。
本文将介绍以下内容:
我们先来看一个 Region Split 过程的大致流程:
我们可以将 Region 的分裂从动机上分为两类:
因为 Region 是 TiKV 的逻辑存储单元,Region 最基本的分裂方式也是来源于 TiKV 的控制。
TiKV 默认会 10s 进行一次 Region 的分裂检查,此举由 Raft 状态机驱动,定期 Tick 进行触发。函数名称为 PeerFsmDelegate::on_split_region_check_tick
。
因为 Region Split 的行为后续会作为一条 Raft log 在副本间进行同步,所以该函数会首先检查当前 Region peer 是否为 leader,以避免进行无用的检查。
if !self.fsm.peer.is_leader() {
return;
}
if self.fsm.peer.may_skip_split_check
&& self.fsm.peer.compaction_declined_bytes < self.ctx.cfg.region_split_check_diff().0
&& self.fsm.peer.size_diff_hint < self.ctx.cfg.region_split_check_diff().0
{
return;
}
紧接着 Leader check 之后,就是对 Split 必要性的检查,为了避免过多的 Split check,我们设置了以下 3 个条件来进行过滤:
may_skip_split_check flag
是否为 Truecompaction_declined_bytes
是否小于 region-split-check-diff
阈值size_diff_hint
是否小于 region-split-check-diff
阈值may_skip_split_check
的 flag 会在必要时被设置为 False 来确保 Split 检查会尽可能地被执行(例如 TiKV 刚刚启动时)。compaction_declined_bytes
和 size_diff_hint
均是对 Region 大小变化的增量统计(分别统计自 Compaction 数据和 Apply 数据的过程),它们在此隐含了这样一个条件:只有 Region 的大小变化超过 region-split-check-diff
后才需要进行分裂检查(这个配置的默认值是 region-split-size
的 1/16,即 96 / 16 = 6 MB)。
而后就是一些特殊逻辑的检查,在此不进一步展开,他们包括:
需要注意此阶段的检查仅仅是触发了 Region Split 的事件,具体能否分裂以及如何分裂还取决于后续的 Split 触发过程。
TiKV 还有一个会触发 Region Split 的功能来自于 Load Base Split。其核心代码位于 AutoSplitController::flush
。StatsMonitor 会收集读请求的统计信息,包括请求的数目,请求读取的流量以及读取的 Key Range 等。对于 QPS 或 Byte 满足 qps_threshold
和 byte_threshold
的 Region,则会在之前收集的 Key Range 基础上对 Key 进行采样,选择一个切分后左右 Region 上的请求数量最为均衡的 Key 作为切分点进行切分。
PD 也可以进行分裂的触发。此举可以通过以下方式进行:
SplitRegions
/SplitAndScatterRegions
来触发其中,pd-ctl 作为主要面向用户的操作,方式如下:
>> operator add split-region 1 --policy=approximate // 将 Region 1 对半拆分成两个 Region,基于粗略估计值
>> operator add split-region 1 --policy=scan // 将 Region 1 对半拆分成两个 Region,基于精确扫描值
上述操作的本质都是创建一个 Split 的 Operator 并下发给对应 Region。具体的 PD 侧代码可以通过 RegionSplitter::SplitRegions
函数进行自上而下的研究,在此不多做表述。
Operator 通过 Region 心跳下发给 TiKV 后,TiKV 会根据下发的 Split 任务类型去创建对应的事件,具体代码在此。
if resp.has_split_region() {
let mut split_region = resp.take_split_region();
info!("try to split"; "region_id" => region_id, "region_epoch" => ?epoch);
let msg = if split_region.get_policy() == pdpb::CheckPolicy::Usekey {
CasualMessage::SplitRegion {
region_epoch: epoch,
split_keys: split_region.take_keys().into(),
callback: Callback::None,
source: "pd".into(),
}
} else {
CasualMessage::HalfSplitRegion {
region_epoch: epoch,
policy: split_region.get_policy(),
source: "pd",
cb: Callback::None,
}
};
if let Err(e) = router.send(region_id, PeerMsg::CasualMessage(msg)) {
error!("send halfsplit request failed"; "region_id" => region_id, "err" => ?e);
}
}
可以看到根据不同的 Split 方式,所创建的事件也不同——若是给定了分裂点 Key 则会直接下发 CasualMessage::SplitRegion
事件,否则根据不同的分裂策略创建一个 CasualMessage::HalfSplitRegion
事件,期以对 Region 进行对半分。这里的策略主要分为 Scan 和 Approximate 两类,具体的区别会在后文中进行介绍。
在建表或添加分区时,TiDB 会在 DDL 阶段对表的 Region 进行预切分,为每个表或分区创建单独的 Region,用于避免发生大量建表和写入造成的热点问题。此举也是通过调用 PD 的 Split 接口达成的(早期版本是 TiDB 直接下发给 TiKV,现已废弃)。具体的代码入口在 ddl::preSplitAndScatter
接口,你可以通过该方法的调用情况来看不同的 Split Table 发生在何时何处。
除了建表时自动为每个表切分出的一个 Region,如果在单表内部存在写入热点,我们也可以通过 SQL 来手动 Split Region。这个原理其实和上述的 DDL 过程相同,均是调用统一的 SplitRegions
接口来进行 Split 任务的下发。
具体的 SQL 语法可以参考官方文档:Split Region 使用文档。
上面只阐述了 3 大组件的常见 Region Split 触发流程,事实上还有很多其他机制会触发 Region Split,例如 Lightning/BR 这样的工具导入数据前也会对 Region 进行预切分和打散,以求导入后数据的均衡。tikv-ctl 也可以触发 Region 的 Split。
以上述方式触发 Region Split 事件后,具体的 Split 的 Key 可以以多种方式和维度被计算出来。例如通过精确的 Scan 扫描来确定 Region 大小上的中点进行分裂,或通过指定的 Key 直接进行分裂等,不同的方式往往用于不同的场景,具体原理如下。
此 Coprocessor 非 TiKV 中用于下推 SQL 执行的 Coprocessor,而是 raftstore 代码中的一个概念。其主要作用相当于外挂在 TiKV 的 Raft 层上的一个协处理工具集合,用于观测和处理与 Raft 相关的周边事件。SplitChecker
就是其中之一,用于接受,处理和下发与 Region Split 有关的事件。
/// SplitChecker is invoked during a split check scan, and decides to use
/// which keys to split a region.
pub trait SplitChecker<E> {
/// Hook to call for every kv scanned during split.
///
/// Return true to abort scan early.
fn on_kv(&mut self, _: &mut ObserverContext<'_>, _: &KeyEntry) -> bool {
false
}
/// Get the desired split keys.
fn split_keys(&mut self) -> Vec<Vec<u8>>;
/// Get approximate split keys without scan.
fn approximate_split_keys(&mut self, _: &Region, _: &E) -> Result<Vec<Vec<u8>>> {
Ok(vec![])
}
/// Get split policy.
fn policy(&self) -> CheckPolicy;
}
一个 SplitChecker
包含 4 个方法,分别是:
on_kv
,在使用 Scan 方式时,用于在 Iterator 扫描 Key 的过程中接受 Key,并在内部维护对应的状态来实现不同的分裂方式。split_keys
,在完成扫描后通过此方法来拿到最终的 Split Key 结果。approximate_split_keys
,在使用 Approximate 方式时,不进行 Scan 而直接拿到 Split Key 结果policy
,返回当前的 Split 检查策略,有 Scan/Approximate 两种方式。对这 4 个方法不同的实现也就决定了不同的分裂方式,下面我们分别介绍 TiKV 内部支持的所有不同的分裂方式。
HalfCheckObserver
实现了对 Region 的 Sizie 对半切策略,在 Scan 模式下,为了找到一个 Region 内 Size 维度上的中点,把所有的 Key 都记录下来显然是不合理的,这样可能会占用大量的内存。取而代之的方式是根据配置计算出一个最小的 Size 单位 n MB,计算函数名为 half_split_bucket_size
通过将 region_max_size
除以 BUCKET_NUMBER_LIMIT
(常量,值为 1024),计算出一个 Bucket 大小,最小为 1 MB,最大为 512 MB。
fn half_split_bucket_size(region_max_size: u64) -> u64 {
let mut half_split_bucket_size = region_max_size / BUCKET_NUMBER_LIMIT as u64;
let bucket_size_limit = ReadableSize::mb(BUCKET_SIZE_LIMIT_MB).0;
if half_split_bucket_size == 0 {
half_split_bucket_size = 1;
} else if half_split_bucket_size > bucket_size_limit {
half_split_bucket_size = bucket_size_limit;
}
half_split_bucket_size
}
在后续的扫描过程中,仅在每扫描过 n MB 大小后才记录下当前的 Key,这样可以通过牺牲一定的精度换来了较少的内存占用。
fn on_kv(&mut self, _: &mut ObserverContext<'_>, entry: &KeyEntry) -> bool {
if self.buckets.is_empty() || self.cur_bucket_size >= self.each_bucket_size {
self.buckets.push(entry.key().to_vec());
self.cur_bucket_size = 0;
}
self.cur_bucket_size += entry.entry_size() as u64;
false
}
fn split_keys(&mut self) -> Vec<Vec<u8>> {
let mid = self.buckets.len() / 2;
if mid == 0 {
vec![]
} else {
let data_key = self.buckets.swap_remove(mid);
let key = keys::origin_key(&data_key).to_vec();
vec![key]
}
}
在后续计算中点 Key 的过程中,也只需要取我们收集到的 Key 的中间元素,即可获得近似的 Region Size 中点,用于后续的切分。
对于具体 approximate_split_keys
的实现取决于不同的 KV Engine,以默认的 RocksDB 为例,为了避免对整个区间上全 Key-Value 的扫描,我们使用了 RocksDB 的 TableProperties 特性,来在 RocksDB 构建每个 SST 文件的时候就提前收集一些 Key 相关的信息,从而可以在此时避免进行 I/O 操作即可获得近似的 Key Range 上的 Key 信息,再辅之以采样等手段,相较于 Scan 策略会更不精准,但省去了不少资源。对应的代码在 RocksEngine::get_range_approximate_split_keys_cf
方法中。
SizeCheckObserver
实现了根据 Region Size 切分 Region 的策略。其逻辑相对简单,在默认配置下,会对 Region 的 KV 进行 Scan 遍历,每扫描过 96 MB 的数据便会记录下当前的 Key,一次最多记录 10 个。
fn on_kv(&mut self, _: &mut ObserverContext<'_>, entry: &KeyEntry) -> bool {
let size = entry.entry_size() as u64;
self.current_size += size;
let mut over_limit = self.split_keys.len() as u64 >= self.batch_split_limit;
if self.current_size > self.split_size && !over_limit {
self.split_keys.push(keys::origin_key(entry.key()).to_vec());
// if for previous on_kv() self.current_size == self.split_size,
// the split key would be pushed this time, but the entry size for this time should not be ignored.
self.current_size = if self.current_size - size == self.split_size {
size
} else {
0
};
over_limit = self.split_keys.len() as u64 >= self.batch_split_limit;
}
// For a large region, scan over the range maybe cost too much time,
// so limit the number of produced split_key for one batch.
// Also need to scan over self.max_size for last part.
over_limit && self.current_size + self.split_size >= self.max_size
}
fn split_keys(&mut self) -> Vec<Vec<u8>> {
// make sure not to split when less than max_size for last part
if self.current_size + self.split_size < self.max_size {
self.split_keys.pop();
}
if !self.split_keys.is_empty() {
std::mem::take(&mut self.split_keys)
} else {
vec![]
}
}
approximate_split_keys
的实现和 Half 类似,在此不表,依然是基于 RocksDB 的 TableProperties 功能。
KeysCheckObserver
实现了根据 Region Key 数量切分 Region 的策略,其原理和 SizeCheckObserver
相同,只不过把计算方式改成了 Key 数量的统计,在此不过多展开,
TableCheckObserver
实现了根据 Region 范围内 Key 所属的 Table 进行切分的策略。这个 Checker 的实现比较特殊,它在 TiKV 内部引入了 SQL 层的概念。原理也比较简单,在 Scan 时去 Decode 每个 Key,检查其所属的表 ID 和之前 Key 是否相同,若不同则加入 Split Key 进行分裂。
/// Feed keys in order to find the split key.
/// If `current_data_key` does not belong to `status.first_encoded_table_prefix`.
/// it returns the encoded table prefix of `current_data_key`.
fn on_kv(&mut self, _: &mut ObserverContext<'_>, entry: &KeyEntry) -> bool {
if self.split_key.is_some() {
return true;
}
let current_encoded_key = keys::origin_key(entry.key());
let split_key = if self.first_encoded_table_prefix.is_some() {
if !is_same_table(
self.first_encoded_table_prefix.as_ref().unwrap(),
current_encoded_key,
) {
// Different tables.
Some(current_encoded_key)
} else {
None
}
} else if is_table_key(current_encoded_key) {
// Now we meet the very first table key of this region.
Some(current_encoded_key)
} else {
None
};
self.split_key = split_key.and_then(to_encoded_table_prefix);
self.split_key.is_some()
}
由于工作原理决定了它只能基于 Scan 策略进行工作,所以没有提供 approximate_split_keys
方法的实现。
上面一共介绍了 TiKV 支持的 4 种 Split 方式,那么具体工作过程中,实际到底哪一个方式会被触发呢?答案是都有可能。
每个 SplitChecker 都会被加入到一个 SplitCheckerHost 中,并被赋予不同的优先级,每次 Split 都会依次“询问”每个 SplitChecker 的“意见”,如果高优先级的 Checker 不能给出 Split Key 那么就依次向更低优先级的 Checker 轮训,直到得到一个 Split Key 或确认无法 Split。优先级在将 SplitChecker 注册到 Coprocessor 时就被定义好了,代码位于 CoprocessorHost::new
。
pub fn new<C: CasualRouter<E> + Clone + Send + 'static>(
ch: C,
cfg: Config,
) -> CoprocessorHost<E> {
let mut registry = Registry::default();
registry.register_split_check_observer(
200,
BoxSplitCheckObserver::new(SizeCheckObserver::new(ch.clone())),
);
registry.register_split_check_observer(
200,
BoxSplitCheckObserver::new(KeysCheckObserver::new(ch)),
);
registry.register_split_check_observer(100, BoxSplitCheckObserver::new(HalfCheckObserver));
registry.register_split_check_observer(
400,
BoxSplitCheckObserver::new(TableCheckObserver::default()),
);
CoprocessorHost { registry, cfg }
}
可以看到 HalfCheckObserver
有最高优先级,其次是 SizeCheckObserver
和 KeysCheckObserver
,TableCheckObserver
最低。但是我们所见到的大多数 Region 分裂都是基于 Size 的,Half 分裂尽管有最高优先级,为什么不会被频繁触发呢?答案是我们每次基于注册在 Coprocessor 的 Split Checker 创建 SplitCheckerHost
时(代码入口在 CoprocessorHost::new_split_checker_host
),并不会将所有的 Checker 都导入,而是根据不同的配置以及场景进行有选择的添加。例如只有 auto_split
选项设置为关闭时,HalfCheckObserver
才会被添加到 Host 中,这个选项在 TiKV 定时检查触发 Split 时会开启,所以在对应场景下 HalfCheckObserver
不会起作用。
#[derive(Clone)]
pub struct HalfCheckObserver;
impl Coprocessor for HalfCheckObserver {}
impl<E> SplitCheckObserver<E> for HalfCheckObserver
where
E: KvEngine,
{
fn add_checker(
&self,
_: &mut ObserverContext<'_>,
host: &mut Host<'_, E>,
_: &E,
policy: CheckPolicy,
) {
if host.auto_split() {
return;
}
host.add_checker(Box::new(Checker::new(
half_split_bucket_size(host.cfg.region_max_size().0),
policy,
)))
}
}
再例如只有当 split_region_on_table
配置开启时,TableCheckObserver
才会被添加到 Host 中,该配置默认关闭。
#[derive(Default, Clone)]
pub struct TableCheckObserver;
impl Coprocessor for TableCheckObserver {}
impl<E> SplitCheckObserver<E> for TableCheckObserver
where
E: KvEngine,
{
fn add_checker(
&self,
ctx: &mut ObserverContext<'_>,
host: &mut Host<'_, E>,
engine: &E,
policy: CheckPolicy,
) {
if !host.cfg.split_region_on_table {
return;
}
...
}
所以说在大多数情况下,只有 KeysCheckObserver
和 SizeCheckObserver
主导 Region 的分裂方式。
通过 Raftstore 的 Coprocessor 确定好 Region 的 Split Key 后,最后就来到了 Split 的执行阶段。Region 的 Split 任务会被下发到具体的 Region,继而触发 PeerFsmDelegate::on_prepare_split_region
函数,正式开启 Region 的 Split 执行。
首先 TiKV 会再次确认当前 Region 为 leader,并检查 Epoch 等属性是否发生了变化,Epoch 内的 Version 属性只有在完成 Split 或 Merge 的情况下才会增加,因为 Version 一定是严格单调递增的,所以 PD 使用了这个规则去判断范围重叠的不同 Region 的新旧。在检查通过后,便向 PD 发送 AskBatchSplit 请求为即将分裂出来的新 Region 获取 Region ID,并触发 Raft 开始进行 Split log 的 Proposal。
info!(
"try to batch split region";
"region_id" => region.get_id(),
"new_region_ids" => ?resp.get_ids(),
"region" => ?region,
"task" => task,
);
let req = new_batch_split_region_request(
split_keys,
resp.take_ids().into(),
right_derive,
);
let region_id = region.get_id();
let epoch = region.take_region_epoch();
send_admin_request(
&router,
region_id,
epoch,
peer,
req,
callback,
Default::default(),
);
通过 Raft log 将 Split 同步到各个 Peer 之上完成 Commit 之后,ApplyDelegate::exec_batch_split
便开始执行 Region 的分裂。创建新 Region,更改 Region 边界,并将 Region 的新信息写入落盘。
for new_region in ®ions {
if new_region.get_id() == derived.get_id() {
continue;
}
let new_split_peer = new_split_regions.get(&new_region.get_id()).unwrap();
if let Some(ref r) = new_split_peer.result {
warn!(
"new region from splitting already exists";
"new_region_id" => new_region.get_id(),
"new_peer_id" => new_split_peer.peer_id,
"reason" => r,
"region_id" => self.region_id(),
"peer_id" => self.id(),
);
continue;
}
write_peer_state(kv_wb_mut, new_region, PeerState::Normal, None)
.and_then(|_| write_initial_apply_state(kv_wb_mut, new_region.get_id()))
.unwrap_or_else(|e| {
panic!(
"{} fails to save split region {:?}: {:?}",
self.tag, new_region, e
)
});
}
write_peer_state(kv_wb_mut, &derived, PeerState::Normal, None).unwrap_or_else(|e| {
panic!("{} fails to update region {:?}: {:?}", self.tag, derived, e)
});
在默认的分裂方式下,原 Region 要分裂到右侧,举例而言,假设分裂前的 Region 数量一共有 2 个,ID 分别为 1 和 2。2 是即将要分裂的 Region,且 Split Key 为 "b"。
Region 1 ["", "a"), Region 2 ["a", "")
分裂后的新 Region 被分配了 ID 3,那么分裂后的 Region 会形如:
Region 1 ["", "a"), Region 3 ["a", "b"), Region 2 ["b", "")
在 TiKV 完成 Split log 的 Apply 后,会通过 ApplyResult::Res 事件触发 PeerFsmDelegate::on_ready_split_region
来完成 Split 的预后工作。如果当前 Region 是 leader,则会给 PD 发送一个 Report(Batch)Split 的 RPC 请求,仅供 PD 打个日志记录,方便我们在查问题时通过 PD 的日志看到各个 Region 的 Split 记录。由于 Region 的 ID 分配也是严格保证单调递增,所以我们可以说 Region ID 越大的 Region 则越新。
if is_leader {
self.fsm.peer.approximate_size = estimated_size;
self.fsm.peer.approximate_keys = estimated_keys;
self.fsm.peer.heartbeat_pd(self.ctx);
// Notify pd immediately to let it update the region meta.
info!(
"notify pd with split";
"region_id" => self.fsm.region_id(),
"peer_id" => self.fsm.peer_id(),
"split_count" => regions.len(),
);
// Now pd only uses ReportBatchSplit for history operation show,
// so we send it independently here.
let task = PdTask::ReportBatchSplit {
regions: regions.to_vec(),
};
if let Err(e) = self.ctx.pd_scheduler.schedule(task) {
error!(
"failed to notify pd";
"region_id" => self.fsm.region_id(),
"peer_id" => self.fsm.peer_id(),
"err" => %e,
);
}
}
其余则是一些向 PD 上报心跳,统计信息的初始化工作,更新分裂后的 Region epoch 并在 Raft group 中注册 Region 的路由。这些工作完成后,当前 TiKV 上的 Region 可以说是已经完成分裂了。
对于分裂前的原 Region 是 Leader 的 Peer 来说,分裂后的 Region 是可以立马发起选举的,而对于原 Region 非 Leader 的 Peer 来说,它分裂创建出的新 Region 是不能立马发起选举的,而是需要等待一个 Raft 的选举超时时间。这样实现的原因是存在下列的 Case:
如果允许原 Peer 非 Leader 的新 Region 分裂出来后立马开始选举,则会出现同一个数据范围内存在两个 Region leader 对外提供服务,一个是分裂后的新的更小的 Region leader,一个是尚未分裂的原 Region leader(Lease 尚未过期),这样一来就存在破坏线性一致性的可能。由于一次 Raft 的选举超时时间要大于 Leader 的 Lease 时间,所以只要我们保证以下两点:
所以当新分裂的 Region 开始选举时,旧的 Region leader 早些时候一定会因为发现自身的 Epoch 与其余两个 Follower 不同而选举失败完成退选。
在 TiDB 和 TiKV 的语境下,当我们说到 Key 编码时,它可能指的是以下几种情况:
TiDB 在发送请求时使用的是 Raw Key,也即不带任何与 MVCC 相关的信息,也没有 Padding,只包括诸如 TableID,RowID 等基本信息。
TiKV 的 Raftstore 以及 PD 在处理诸如 Region 边界,Split 等 Key 时使用的是 Encoded Key without TS,它在 Raw Key 的基础上进行了 Encode,添加了用于保持字典序的 Padding,但由于此层尚未涉及到具体的事务,所以并没有 TS 参与其中。
TiKV 在实际读写底层 RocksDB 数据时,会将请求的 TS 一并 Encode 到 Key 里来区分 MVCC 信息,所以这一层使用的是 Encoded Key with TS。
Region Split 发生在 Raftstore 这一层,所以其格式均为 Encoded Key without TS,在开发相关功能时,要注意对 Key 进行 Encode,并且剔除 TS 信息,以免出现一些预期外的行为。
2022-02-04 14:23:10
2022 年春节档,要死不活了大半年后,院线终于出现了一些可以让电影院热闹起来的电影,这里面我最想看的是《四海》。
第一次接触导演身份的韩寒,其实已经是他第三部院线作品《飞驰人生》了,但我猜这可能是这四部里面最不韩寒的一部作品。看完《四海》以后我又补看了《后会无期》,两部拥有极其相似内核的作品在某种程度上证实了我的猜测,飞驰人生确实是一部「例外」(尽管我还没看《乘风破浪》)。
以小岛青年的出走作为开端,讲得到和失去的故事。浩汉和仁耀两个角色的路线是相同的,他们都在故事的某个阶段拥有了一些东西,紧接着便开始失去,浩汉失去了他的父亲(某种意义上来说,失去了 2 次)、失去了自己的爱情(这里是「有情人终成兄妹」的恶趣味),最后失去了自己的车子,以及结尾留白处,可能也失去了的生命。
比起浩汉,仁耀的失去没有那么隐晦,他失去了自己的朋友,失去了赛车,失去了爱人。结尾珠江上的一跃让我很震撼,《飞驰人生》结尾的一跃也让我很震撼,但这两者的跃是不同的。后者的跃,是摆脱了重力的跃,它是角色的自我救赎,是向上的;而前者的跃,是一种注定无法离地的跃,它是角色的坠落,是向下的。伴随着死亡,江水和竹蜻蜓的蒙太奇,背景音乐《无法离地的飞行》让我相信至少在那个镜头,我触摸到了韩寒。
《四海》是掺杂了商业包装的韩寒。比起《后会无期》里更纯粹的裸核,角色直言不讳的说出韩式金句和鸡汤,《四海》用很多商业片的元素取代了这些特点:沈腾的喜感、只出现了半段的赛车飙车、小镇青年在大城市的碰撞......这些是大多数观众喜闻乐见的韩寒。而还有很多观众不太能注意到的韩寒。
欢颂说:「我讨厌水,水可怕。」
阿耀说:「水有什么可怕的,火才可怕。」
最后一个人沉入水底,一个人冲进了火焰。
结尾的蒙太奇中,阿耀在隧道里说出过的愿望又重新出现,愿望变成了梦境:在布满乱石的海雾中抓螃蟹,如果不是跌倒了,我们谁也不搀扶谁。这份愿望和广州之行的应照,颇有一种「预言的自我实现」的宿命感。
总有一扇门,你打不开。总有一条河,你越不过。
韩寒的主人公,也总是不自觉地让我想起村上春树。毫无世俗气息的那种气质,离开家乡,离开小岛,前往未知地冒险,从祖国最东边来到最西边,从小渔村来到大城市,这种出世感总是围绕着两个人的作品,个体感情的无常才是主旋律。而《四海》的后半部分,在描述两人在大城市求生存时,有那么一部分「入世」的剧情,出世的韩寒显然不擅长拍这描述「主人公和现代社会的正面冲突」的部分,这也是影片节奏失衡最明显的一个段落。不过这部分拙劣反而让我略微安心,这让我确信他所擅长和想要表达的并不在此。
豆瓣 5.6 分的结果显然是低了,但并不意外,韩寒只是在春节档带来了一部不适合在春节档观看的电影。