MoreRSS

site iconJmPotato | 耿海直修改

软件工程师@PingCap,前字节。
请复制 RSS 到你的阅读器,或快速订阅到 :

Inoreader Feedly Follow Feedbin Local Reader

JmPotato | 耿海直的 RSS 预览

如何利用全 SaaS 阵容从零免费搭建一个博客?

2024-08-23 23:54:47

即便已经几乎无人再会把“自搭自写”博客的 Programmer 称为 Geek,但我相信拥有一个 Self-Hosted Blog 依然是众多 Programmer 的普遍追求。虽然 GitHub Pages 和 Jekyll 的组合放到现在也依然不算过时,也不失简便,但考虑到如今各类 SaaS 服务层出不穷,那么在 2024 年的今天,我们能否利用全 SaaS 阵容从零免费搭建一个博客?

rsomhaP 作为博客程序

关注过我博客的朋友可能会知道在过去的十余载里,Pomash 是我自己拿 Python 写成,也是我自己一直在用的博客程序。不过在使用和维护的过程中我也逐渐发现这个几乎写成于十年前的博客程序似乎有点“过时”了,整理过后,我为造轮子另起炉灶罗列了几点冠冕堂皇的理由:

  • Python 已经不再是我熟悉的语言,诸多语言特性和最佳实践我已经不甚了解。
  • 虽然 Pomash 是个很简单的博客引擎,但从代码中也不难看出我曾经“笔法”的稚嫩——有很多不忍直视的代码。
  • 用一个 SQLite 文件存储我数十年以来的博客文章听起来一点也不高可用。
  • 用 Rust 重写 XXX 是流量密码。

于是乎作为一次具有某种“致敬”意味的行为艺术,我用 Rust 重写了 Pomash,并用我很喜欢的命名法给它起了一个新名字:

'r{}'.format(''.join(sorted('Pomash'))[::-1]) == 'rsomhaP'

somhaP 是 Pomash 重排后的字符串,加一个字母 r 在开头拼接成 rs 意指用 Rust 重写而成——rsomhaP

为了全面拥抱 SaaS 服务简化实现与部署,在造轮子的同时“反造轮子”,我在重写的时候带着这么几个原则:

  • 依然是 Markdown 友好。
  • 保持单体程序,拒绝前后端分离(其实是我不会写前端)。
  • 简洁且易读的样式。
  • 尽可能使用 SaaS 友好的方式去设计部署方式。

所以最后,这篇文章也孕育而生,让我们看看要跑起来一个 rsomhaP 的博客程序,我们需要哪些步骤。

TiDB Serverless 作为数据库

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 的面板上自动设置自己数据库的备份时间与频率,为自己智慧的结晶多上一份保险。

Fly.io 作为部署机器

数据库有了,那么我们至少需要一个 Host 或者说机器来实际部署运行我们的 rsomhaP 程序,除了直接去 Vultr 这类地方买虚拟云主机,我们还有一个更“小而美”的选择:Fly.io

其实我曾经也写过一篇关于它的博客:Fly.io 初体验之博客搬家。彼时我把 Pomash 迁移了上去,整个体验也非常的丝滑,所以在这里依旧沿用了之前的选择。

我为 rsomhaP 直接写好了一份可以用于 Fly.io 部署的 Dockerfilefly.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 做到,参考

Cloudflare R2 作为图床

恭喜你,上面哪些步骤全部完成后,你就已经拥有一个可以访问和写作的博客了🎉

但要想进行图文写作,我们还差这最后一步“图”,由于 rsomhaP 本身并不支持上传和存储图片,所以拥有一个稳定的图床服务是有必要的,这里我们可以用赛博大基建 Cloudflare R2 的对象存储作为我们的图床设施,具体设置方法可以参考这篇文章,写得很好很详细,我就不再次展开了:从零开始搭建你的免费图床系统(Cloudflare R2 + WebP Cloud + PicGo)

WebP Cloud Services 作为图床代理

有了图床的“床”,我们还可以更精进一步,用 WebP Cloud Services 这个图片代理 SaaS 来实现更多的功能:

  • 不改变画质的情况下进行图片体积压缩,并作为图床缓存,加快博客的加载速度。
  • 隐私擦除,添加水印等自动的图片二次处理。
  • 自定义 Header 来实现更安全的图床访问。

同样推荐大家参考上面那篇文章的作者:

结语

至此,使用 rsomhaP 搭配一众免费 SaaS 的博客部署就完成了,你可以继续浏览本站或者去 rsomhaP 的 GitHub 界面了解更多信息,其实关于 rsomhaP 的开发过程也有很多可以分享的点——例如 axum 库的使用,Markdown 渲染的实现等,待我挖个坑日后再填。

ThinkPad X1 Carbon x Arch Linux

2023-08-07 22:43:16

是的,作为一个从 2017 年起就再也没用过除 Mac 以外笔电的人,时隔 6 年,我购入了这款联想的 ThinkPad X1 Carbon Gen 11。购买的动机其实很简单,那就是反抗 Apple 暴政 macOS 实在用腻了,迫切需要消费折腾带来的新鲜感。

Why Arch Linux

虽然我或多或少对 Ubuntu 和 Debian 这种相对而言更流行的 Linux 发行版比较熟悉,但既然有了“折腾“的初心,所以更希望尝试一些新东西,早有耳闻 Arch Linux 的"简洁主义"和非常 KISS (Keep It Simple and Stupid) 的设计原则,再加上前期采购设备前调研时惊叹于 ArchWiki 的完整和详尽,所以最终决定借此机会直接上手体验一下,看能不能作为自己主力机的主力 OS。

Installing & Configuring

安装的时候我基本上只参考了这两个指南:

前者基于后者进行了一些简略的删改,更适合新手,但也导致里面的有一些描述语焉不详,如果你对 Linux 本身或者 OS 原理不太了解的话,需要谨慎操作,尤其需要注意硬盘分区和引导安装之类的步骤。

目前主流硬件厂商对 Linux 的支持都很完善了,所以安装、配置和使用过程都会比较顺利,但最好还是在开始前提前了解 Arch Linux 对你手上设备的支持情况,例如我这台 ThinkPad X1 Carbon (Gen 11) 的兼容性,可以看到除了前置摄像头外,其他大部分硬件在使用上都没什么问题。

Troubleshooting

大多数我遇到的安装和配置问题在 Google 后都有比较直接的解决方案(其中大部分最终都指向了 ArchWiki,可见其内容之靠谱和丰富),这里罗列一下我在配置过程中遇到的一些比较独特的问题。

AUR 安装旧版本 GCC 速度很慢

本来想编译 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 里来充分利用内存加速编译

方子很好,原本数小时的编译时间不到半个小时就完成了。

开启 Secure Boot

由于拿到电脑的时候自带是 Windows 11 系统,所以在开机初始化配置时我跟随着引导开启了 Windows Hello 的面部及 PIN 码识别,但因为随后安装 Arch Linux 时关闭了 Secure Boot,待我在安装结束后再次进入 Windows 11,发现由于之前设置 Windows Hello 有 TPM 的参与,在关闭 Secure Boot 后面部识别以及 PIN 码就都无法使用了。

使用 sbctl 签名内核与启动项

要解决的话思路也很直接,如果能让 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 的配置。

对 vmlinuz-linux 进行签名

重新设置 Windows Hello

由于 sbctl 这个工具不一定能在所有硬件上都可行,所以如果你在上面的操作里失败了,但又想在不开启 Secure Boot 的前提下使用面孔/指纹解锁,其实也是有办法的。根据 Windows 官方的文档,我们会发现 Windows Hello 并不依赖 TPM 去工作。

Windows 官方文档

所以只需要在关闭了 Secure Boot 的前提下重新设置一遍 Windows Hello 就可以在不使用 TPM 的前提下正常使用面孔/指纹解锁了。

蓝牙设备无法唤醒 Arch Linux

这个其实解决方法很简单,遵循 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,成功拿到了 idVendoridProduct 进行后续的配置。

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

Fly.io 初体验之博客搬家

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 周年,慢慢更新到现在,它绝对不是最好用的博客系统,但一定是我最喜欢的。

The very first commit

Fly.io 是什么

Fly.io 其实是跟同事吃饭摆龙门阵的时候了解到的一个容器化部署平台,整个产品都透露出一股小而美的气质,其提供的服务也非常简单:帮助用户用容器化的方式部署应用。人话版本就是每个人都可以讲 5 分钟脱口秀通过写一个 Dockerfile 的工作量(有些情况下甚至连 Dockerfile 都可以不用准备)快速部署可访问的应用。官方文档上所称每个账号的免费额度如下:

  • Up to 3 shared-cpu-1x 256mb VMs
  • 3GB persistent volume storage (total)
  • 160GB outbound data transfer

对于我这个无人问津的博客来说,使用起来应该是绰绰有余了,故而直接开整。

flyctl

所有的部署运维操作都可以通过官方提供的命令行工具 flyctl 来完成,整个交互也极为简单,在完成 fly auth login 之后,即可开始部署应用了。 flyctl 的使用极为傻瓜,对于比较简单的项目,例如有 main.go 的 Go 项目,只需要调用 flyctl launch,它会扫描你的源代码结构,自动帮你生成 Dockerfile(其他语言的项目也类似),如果你只是用 Go 的标准库实现了一个简单跑在 8080 端口上的 HTTP 程序,基本上这一个命令一路 Y 过去就直接部署成功可以在浏览器里访问了。但是对于 Pomash 来说,它还需要一点额外的步骤,所以我选择自己准备一个 Dockerfile。

准备 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 地址分配

最后一步就是域名绑定了。由于 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 证书加密。

Dashboard

当然,一切操作也都可以通过命令行完成,参考官方文档

写在最后

整个从注册到最后部署成功的过程是比较丝滑的,几乎没有遇到任何问题,官方文档也写的十分详尽,基本上我遇到的所有问题都可以在内找到详细的解决办法(例如单应用内的多进程部署),可见 fly.io 是很懂面向用户群体痛点所在的。值得一提的是,最初同事给我讲到 fly.io 倒不是因为他们的产品,而是比较有趣的招聘方式。通过他们官网的招聘流程介绍,可以看到他们的“面试”过程很有趣,这里的面试打了引号是因为他们其实并没有面试这一步,而是通过做 2 到 3 个挑战题的方式第一阶段通关后直接加入他们的公司 Slack 和他们的工程师工作一天,一切顺利的话就会给你发 Offer。从这样一个细节来看,除去好用的产品外,这也真的是一家有趣的公司。

TiKV Region Split 全流程分析

2022-05-26 17:00:32

分裂可以说是 Region 生命周期中最为重要的一步,如同细胞一般,分裂是 Region 被创造并持续增多的唯一方式。

本文将介绍以下内容:

  • Region Split 是由谁触发的。
  • Region Split 是如何计算 Split Key 的。
  • Region Split 最终是如何执行的。

我们先来看一个 Region Split 过程的大致流程:

  1. TiKV/PD/TiDB 触发 Region Split 事件。
  2. Raftstore 处理 Region Split 事件,计算 Split Key。
  3. Raftstore 执行 Split。

Region Split 的触发方式

我们可以将 Region 的分裂从动机上分为两类:

  • 内部机制导致的 Region 被动分裂(例如 Region 的大小超过阈值,Load Base Split 被触发等)
  • 人工手段对 Region 进行主动分裂(建表或手动 Split Region)

TiKV 触发分裂

因为 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 个条件来进行过滤:

  • Region peer 的 may_skip_split_check flag 是否为 True
  • Region peer 的 compaction_declined_bytes 是否小于 region-split-check-diff 阈值
  • Region peer 的 size_diff_hint 是否小于 region-split-check-diff 阈值

may_skip_split_check 的 flag 会在必要时被设置为 False 来确保 Split 检查会尽可能地被执行(例如 TiKV 刚刚启动时)。compaction_declined_bytessize_diff_hint 均是对 Region 大小变化的增量统计(分别统计自 Compaction 数据和 Apply 数据的过程),它们在此隐含了这样一个条件:只有 Region 的大小变化超过 region-split-check-diff 后才需要进行分裂检查(这个配置的默认值是 region-split-size 的 1/16,即 96 / 16 = 6 MB)。

而后就是一些特殊逻辑的检查,在此不进一步展开,他们包括:

  • 当前是否有堆积未完成的 Split 任务
  • 当前是否处于 Lightning/BR 的导入过程中
  • 当前是否正在生成 Snapshot

需要注意此阶段的检查仅仅是触发了 Region Split 的事件,具体能否分裂以及如何分裂还取决于后续的 Split 触发过程。

Load Base Split

TiKV 还有一个会触发 Region Split 的功能来自于 Load Base Split。其核心代码位于 AutoSplitController::flush。StatsMonitor 会收集读请求的统计信息,包括请求的数目,请求读取的流量以及读取的 Key Range 等。对于 QPS 或 Byte 满足 qps_thresholdbyte_threshold 的 Region,则会在之前收集的 Key Range 基础上对 Key 进行采样,选择一个切分后左右 Region 上的请求数量最为均衡的 Key 作为切分点进行切分。

PD 触发分裂

PD 也可以进行分裂的触发。此举可以通过以下方式进行:

  • 调用 /regions/split 的 HTTP API 触发
  • 通过 pd-ctl 创建 Operator 触发
  • 通过调用 gRPC 接口 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

在建表或添加分区时,TiDB 会在 DDL 阶段对表的 Region 进行预切分,为每个表或分区创建单独的 Region,用于避免发生大量建表和写入造成的热点问题。此举也是通过调用 PD 的 Split 接口达成的(早期版本是 TiDB 直接下发给 TiKV,现已废弃)。具体的代码入口在 ddl::preSplitAndScatter 接口,你可以通过该方法的调用情况来看不同的 Split Table 发生在何时何处。

SQL

除了建表时自动为每个表切分出的一个 Region,如果在单表内部存在写入热点,我们也可以通过 SQL 来手动 Split Region。这个原理其实和上述的 DDL 过程相同,均是调用统一的 SplitRegions 接口来进行 Split 任务的下发。

具体的 SQL 语法可以参考官方文档:Split Region 使用文档

其他

上面只阐述了 3 大组件的常见 Region Split 触发流程,事实上还有很多其他机制会触发 Region Split,例如 Lightning/BR 这样的工具导入数据前也会对 Region 进行预切分和打散,以求导入后数据的均衡。tikv-ctl 也可以触发 Region 的 Split。

Region Split Key 的计算方式

以上述方式触发 Region Split 事件后,具体的 Split 的 Key 可以以多种方式和维度被计算出来。例如通过精确的 Scan 扫描来确定 Region 大小上的中点进行分裂,或通过指定的 Key 直接进行分裂等,不同的方式往往用于不同的场景,具体原理如下。

Coprocessor

此 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 内部支持的所有不同的分裂方式。

Half

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 方法中。

Size

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 功能。

Keys

KeysCheckObserver 实现了根据 Region Key 数量切分 Region 的策略,其原理和 SizeCheckObserver 相同,只不过把计算方式改成了 Key 数量的统计,在此不过多展开,

Tabel

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 有最高优先级,其次是 SizeCheckObserverKeysCheckObserverTableCheckObserver 最低。但是我们所见到的大多数 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;
        }
        ...
}

所以说在大多数情况下,只有 KeysCheckObserverSizeCheckObserver 主导 Region 的分裂方式。

Region Split 的执行过程

通过 Raftstore 的 Coprocessor 确定好 Region 的 Split Key 后,最后就来到了 Split 的执行阶段。Region 的 Split 任务会被下发到具体的 Region,继而触发 PeerFsmDelegate::on_prepare_split_region 函数,正式开启 Region 的 Split 执行。

Pre-check

首先 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 Proposal & Apply

通过 Raft log 将 Split 同步到各个 Peer 之上完成 Commit 之后,ApplyDelegate::exec_batch_split 便开始执行 Region 的分裂。创建新 Region,更改 Region 边界,并将 Region 的新信息写入落盘。

 for new_region in &regions {
    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 可以说是已经完成分裂了。

Raft Election

对于分裂前的原 Region 是 Leader 的 Peer 来说,分裂后的 Region 是可以立马发起选举的,而对于原 Region 非 Leader 的 Peer 来说,它分裂创建出的新 Region 是不能立马发起选举的,而是需要等待一个 Raft 的选举超时时间。这样实现的原因是存在下列的 Case:

  1. 假设有一个 3 副本的 Region
  2. Split 的 Log 已经复制到了所有的 Follower 上
  3. 所有的 Follower 完成了 Region Split Log 的 Apply,完成了分裂
  4. Region 的 Leader 还没有开始或完成分裂

如果允许原 Peer 非 Leader 的新 Region 分裂出来后立马开始选举,则会出现同一个数据范围内存在两个 Region leader 对外提供服务,一个是分裂后的新的更小的 Region leader,一个是尚未分裂的原 Region leader(Lease 尚未过期),这样一来就存在破坏线性一致性的可能。由于一次 Raft 的选举超时时间要大于 Leader 的 Lease 时间,所以只要我们保证以下两点:

  1. 完成分裂的 Region 等待一个 Raft 的选举超时时间再开始选举
  2. 需要 Split 的 Region 不再续约 Lease

所以当新分裂的 Region 开始选举时,旧的 Region leader 早些时候一定会因为发现自身的 Epoch 与其余两个 Follower 不同而选举失败完成退选。

踩坑经验

Split Key 的格式为 Encoded Key without TS

在 TiDB 和 TiKV 的语境下,当我们说到 Key 编码时,它可能指的是以下几种情况:

  • Raw Key
  • Encoded Key without TS
  • Encoded Key with TS

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 分的结果显然是低了,但并不意外,韩寒只是在春节档带来了一部不适合在春节档观看的电影。

浅谈《开端》在剧作上的瑕疵

2022-01-26 11:07:17

在开始之前,我想先念叨几句。

《开端》这部剧其实我很早就关注了,起因是我女朋友是白敬亭的粉丝,所以对他的动向一直有所关注,去年底看到豆瓣条目,彼时的《开端》还是 20 集的长度。后来随着杀青和一系列制作的完成,最终成片为 15 集,单集片长 40 分钟。最初看到这个剧集长度我其实是满意的,毕竟从剧情概要上看也是所谓的“源代码”式国产“无限流”,难免会有场景时空重复,缩短剧集长度其实是一个明智之举,但这也对剧本,对导演来说是一个考验。

对于拍摄剧集,甚至说拍摄电影和拍摄短片来说,叙事节奏其实是比剧情本身更重要,更为考验导演功底的存在,小学语文老师针对写作文常说的一句话就是“详略得当”,如何把控好故事的节奏,在视听语言上做到“详略得当”是影视作品最后的成品能否做到有质感的第一要义。

其次便是人物,事件需要人物的参与和推动。舞台成型,作者退居幕后,角色来到台前。此时故事成品能否做到有质感的第二要义是故事能否仅由角色在舞台框架下的所思所想和所言所行来向前发展,而无需作者从幕后“现形”进行干预。后者在现实中的例子可以是各种剧作上巧合的引入,或是突然抛出的未知剧情,亦或是“机械降神”等强烈出格的手段。

在做好了这两点基础,也即节奏和人物之后,能进一步提升作品质感的,便是冲突的设置。冲突是影视作品中非常核心的一个概念,当一群角色因为不同的理念利益聚集到一起时,冲突的产生,爆发以及解决便是剧情与情感的多个释放点,能否牵动观众,打动观众甚至冒犯观众都会基于此而来。录像带之于《隐秘的角落》,江阳的死之于《沉默的真相》,爆炸案之于《开端》都是剧情的核心冲突点,其他冲突的发展与变化往往都来自于它们。能否处理好,引爆好或是解决这些冲突,也是剧作的功力所在。

那么《开端》在剧作上有些什么瑕疵呢?首先是叙事节奏,前十集把大量的篇幅花费在了男女主通过循环反复尝试和试探凶手上——剧情上这是合理的,但对于上帝视角的观众来说,其中许多情节其实是显而易见的,例如二次元小哥和瓜农大叔,可以很轻易地通过剧作套路和画面信息被观众排除,导演没有选择利用这一点进行一些反套路的设定(例如让前期一些被观众忽略的角色在中后部发挥至关重要的剧情扭转作用,达到出乎意料的效果),而是选择了“顺水推舟”让大量的剧情正中观众下怀,毫不意外的展开很容易让观众失去耐心。其中我比较印象深刻的几集都不是公交车上的动作戏或是警局里的对峙戏,反而是对二次元小哥,西瓜大叔以及见义勇为大叔在戏外人物形象的描写,很能牵动我,更容易让观众完成从一车素不相识的乘客到最后每一位都是鲜活人生写照的认知转变。

说到这就来到了第二部分,人物。如前所言,对公交车上其他乘客进行车外故事描写的部分我很喜欢,但同样的一个问题是,导演没有利用好这些人物的背景故事、动机和观众对他们的感情基础。这里面较为出彩人物设置其实是见义勇为大叔,通过他的背景故事埋下了“消防安全检查提前”的伏笔,以及通过爆炸后警方对其的调查引出了凶手所在地(港务新村),最后在阻止爆炸的过程中也是他发挥了比二次元小哥更直接的作用,而且他朴实的“见义勇为可以拿钱补贴家用”的动机也更纯粹,相比较二次元小哥能被叫出中二名就愿意帮忙来说,更能让观众理解与共情。事实上整部剧结束后我们能看到的,车上乘客在终局中扮演的作用无非只有一个——协助男女主阻止爆炸的工具人。说到这就引出了另外一个问题,如何更好地利用这些人物生平与形象?这里的答案我相信是仁者见仁,智者见智。我的一个想法是也许可以从“爆炸后的人生”这一点入手,首先不能把车上乘客的经历在大结局之前简单的设置成要么被炸死,要么就没有后续。刚出狱的爸爸因身陷爆炸案嫌疑而被影响父子关系,无家可归的父亲身无分文继续在城市中流浪,卢笛因险些“被”献出心脏而被母亲发现秘密基地最终导致猫猫全部被扔……这些更立体的人物困境其实要比死亡更能牵动观众的心,男女主若是能因为目睹到这些而进一步坚定走向 HE 的信念和决心,似乎要比救人一命更有戏剧上的说服力。无奈在当前的循环设定上来看,因为只有从爆炸到睡着的半天时间这些似乎也很难展开。

“循环”这个设定,其实也是《开端》做的好也不好的地方。好在哪里呢?它更像是一种剧情道具,或者说一种情景实验:假设男女主身处于这样一种循环下,面对循环和爆炸他们会怎样反应,怎样行动。这也是这部剧直接的看点来源;不好在哪里?它本应该和爆炸一起是本作中核心的冲突来源,循环因何而起?怎样才能脱离循环?循环的原理是什么?这些问题统统没有解释,也没有看到一丝深入的打算。不过我更愿意相信这是有更深层次的难言之隐,毕竟如果不想突兀地引入科幻元素,考虑神秘主义在审查上会遇到的问题,似乎很难找到一种合适的框架去解释这一切,所以我认为回避这一点其实是制作中的有意为之。对于之前网络上各种双循环的剧情猜测,我觉得也是不错的一个思路,但最终结局选择了更为保守和平稳的女性保护社会议题,中间穿插了一些对网络暴力的反思,这也是一种立意的升华和加分项。

不吐不快,一口气写了这么多,本来还有很多点要吐槽,诸如凶手夫妇的实际作案动机变化过渡,最后一次循环的处理等等……但现在回看 15 集 40 分钟的片长似乎给谁拍都不够用啊,最开始的 20 集好像也挺合适,那就这样吧,毕竟可能被喷“你行你上啊”所以暂且写到这,就目前的成片来看 8.2 分还是过高了,不过作为国产中该类型剧的第一次,以资鼓励还是应该的。只希望以后限制越来越少,创作越来越多,让观众多一些不一样的题材和故事去选择。