本文记录了作者尝试解决这个[CRASH] Does Valkey over RDMA not support I/O threads? #3112 的问题.
https://github.com/valkey-io/valkey/issues/3112
总结一次实验的workflow,这是拉的战线最长的一次,前前后后整了一个多月还没merge进去.
server端
# 解开当前进程的内存锁
sudo prlimit --memlock=unlimited --pid $$
# 创建在内存中工作的虚拟网卡
sudo modprobe dummy
sudo ip link add dummy0 type dummy
sudo ip link set dummy0 up
# 配置本地的IP
sudo ip addr add 10.0.0.1/24 dev dummy0
# 做RXE的绑定
sudo rdma link add rxe_dummy type rxe netdev dummy0
# 停止本来运行的 valkey
sudo systemctl stop valkey
# 编译一遍
make BUILD_RDMA=yes USE_FAST_FLOAT=yes
# 跑server
sudo ./src/valkey-server valkey.conf --rdma-bind 10.0.0.1 --rdma-port 6379
client端
# 解开当前进程的内存锁
sudo prlimit --memlock=unlimited --pid $$
# 直接做暴力压测,还可以尝试比这个参数更高的
./src/valkey-benchmark -h 10.0.0.1 -p 6379 -d 256 --threads 16 -c 100 -P 32 -n 20000000 -t get --rdma
因为这是多线程,注意此时valkey的配置问题:
io-threads 4
save ""
protected-mode no
bind 0.0.0.0
抓取日志分析的方法
# 拿一下server当前的PID:
./src/valkey-cli -h 10.0.0.1 -p 6379 INFO server | grep process_id
# 拿到这个PID之后 抓线程栈
sudo gdb -p $(PID) -ex 'thread apply all bt' -batch > /tmp/valkey_bt.txt
# 看一下文件的前多少行
head -n 60 /tmp/valkey_bt.txt
# 抓取当前I/O队列的状态
./src/valkey-cli -h 10.0.0.1 -p 6379 INFO stats | grep -E 'clients_pending_io_read|clients_pending_io_write|io_threaded_reads_processed|io_threaded_writes_processed|instantaneous_ops_per_sec'
# 看一下客户端的情况
./src/valkey-cli -h 10.0.0.1 -p 6379 CLIENT LIST | head -n 10
# 查看系统积压的延迟
./src/valkey-cli -h 10.0.0.1 -p 6379 LATENCY LATEST
# 看队列长度和事件计数,就是查事件的状态机
./src/valkey-cli -h 10.0.0.1 -p 6379 INFO stats | egrep 'clients_pending_io_read|clients_pending_io_write|io_threaded_reads_processed|io_threaded_writes_processed|instantaneous_ops_per_sec'
同时还要注意格式的整理
clang-format -style=file -i src/networking.c
尝试复现多线程RDMA崩溃问题 #
首先做带有RDMA的编译:
make BUILD_RDMA=yes USE_FAST_FLOAT=yes
1.创建一个dummy虚拟网卡(纯粹在内存中模拟,wlan0环境可能不稳定?)
sudo modprobe dummy
sudo ip link add dummy0 type dummy
sudo ip link set dummy0 up
2.给虚拟网卡配置一个本地的IP
sudo ip addr add 10.0.0.1/24 dev dummy0 # 这个地址是否是可行的?
3.SoftRoCE绑定到这个dummy0上面去
# 如果之前的 rxe0 还在,可以先删掉: sudo rdma link del rxe0
sudo rdma link add rxe_dummy type rxe netdev dummy0
4.运行server端:
./src/valkey-server valkey.conf --rdma-bind 10.0.0.1 --rdma-port 6379
如果存在端口的冲突,那么:
sudo systemctl stop valkey
5.然后我们的客户端针对虚拟IP进行压测:(这个压测的量是比较大的,注意内存锁的问题)
./src/valkey-benchmark -h 10.0.0.1 -p 6379 -d 256 --threads 5 -r 100000 -n 12000000 -c 30 -t get --rdma
瞬间报错,证明最新的主线分支尚存在此问题.
但是客户端报错是:
~/Project/valkey unstable ⇡ ❯ ./src/valkey-benchmark -h 10.0.0.1 -p 6379 -d 256 --threads 5 -r 100000 -n 12000000 -c 30 -t get --rdma
Could not connect to server at 10.0.0.1:6379: RDMA: reg send mr failed
这是很激进的压测,那么RDMA在访问内存的原理和DMA直接访问用户态内存的原理是类似的,我们都需要锁定一部分内存,防止Linux给我们交换到了swap分区.
在 Arch Linux 下,普通用户的“最大锁定内存限制(memlock)”通常是有限的。当内存锁定达到上限时,
rdma_reg_mr()这个底层 API 就会失败,导致客户端发送出不完整的报文或直接断开。客户端的异常断开或畸形报文,瞬间触发了 Server 端的那个清理内存的 Bug -> 就是这也有可能是原因之一?看一下是否开了内存锁:
# 在linux内部,内存是流动的,就是会和我们的swap分区进行不断的交换 # 那么这意味着一个进程最多锁定 8MB的内存来使用,这样对DMA肯定不友好,因为DMA要锁定一部分内存专门用来做交换 ~ ❯ ulimit -l 8192 # 所以我们尝试修改内存限制, 注意,这个也只是针对当前的进程的 ~ ❯ sudo prlimit --memlock=unlimited --pid $$ # 我们需要验证当前进程确实没有了限制,那么这样确实就没有了限制,都是unlimited ~ ❯ cat /proc/self/limits | grep "Max locked memory" Max locked memory unlimited unlimited bytes # 那么这样的应用应该放在client和server上面去客户端(发送方):需要分配一块内存存放要发送的 GET/SET 命令,并把它锁定。网卡会直接从这块内存把数据搬走发到网络上。
服务端(接收方):也必须提前分配好一块内存,并把它锁定。网卡收到数据后,会直接越过 CPU,把数据写进这块锁定的内存里。
到底是客户端发送了错误的报文还是多线程导致的? #
先跑一个单线程的测试:
~/Project/valkey unstable ⇡ ❯ ./src/valkey-benchmark -h 10.0.0.1 -p 6379 -c 1 -n 1000 -t get --rdma
====== GET ======
1000 requests completed in 0.07 seconds
1 parallel clients
3 bytes payload
keep alive: 1
host configuration "save": 3600 1 300 100 60 10000
host configuration "appendonly": no
multi-thread: no
Latency by percentile distribution:
0.000% <= 0.039 milliseconds (cumulative count 113)
50.000% <= 0.055 milliseconds (cumulative count 726)
75.000% <= 0.063 milliseconds (cumulative count 931)
93.750% <= 0.071 milliseconds (cumulative count 949)
96.875% <= 0.119 milliseconds (cumulative count 971)
98.438% <= 0.151 milliseconds (cumulative count 988)
99.219% <= 0.175 milliseconds (cumulative count 994)
99.609% <= 0.215 milliseconds (cumulative count 997)
99.805% <= 0.247 milliseconds (cumulative count 999)
99.902% <= 1.559 milliseconds (cumulative count 1000)
100.000% <= 1.559 milliseconds (cumulative count 1000)
Cumulative distribution of latencies:
96.500% <= 0.103 milliseconds (cumulative count 965)
99.600% <= 0.207 milliseconds (cumulative count 996)
99.900% <= 0.303 milliseconds (cumulative count 999)
100.000% <= 1.607 milliseconds (cumulative count 1000)
Summary:
throughput summary: 14084.51 requests per second
latency summary (msec):
avg min p50 p95 p99 max
0.056 0.032 0.055 0.079 0.159 1.559
单线程下肯定是没什么问题,实际上在有内存锁的情况下,这个最多只能放到3,之后就会报错,这虽然不是重点,但是Client就算内存不够,通信出现问题,server也不应该这样退出,这是有问题的!
放开内存锁之后,发现在最新的版本上好像确实没有这个问题了.
但是实际上,只是这个错误不能 100%复现,只要使用比如这个配置文件:
io-threads 8 # 使用8个线程
save ""
protected-mode no
bind 0.0.0.0
我们使用这样的模式来进行压力测试,我们开启 -P 参数,原来的benchmark采取的是ping-pong的模式,也就是每当一个命令执行完,client收到结果之后,才会去发送下一个.
但是 -P 流水线的方式,把32个GET请求放在一起给server,server进行解析,解析完成之后,才会返回.
./src/valkey-benchmark -h 10.0.0.1 -p 6379 -d 256 --threads 16 -c 100 -P 32 -n 20000000 -t get --rdma
1.实际上,生产环境都会强制使用pipelining来提高生产力,所以这样的测试是有价值的!
在高性能网络编程中,最大的性能杀手是 RTT(Round Trip Time,网络往返延迟)。 如果采用你所说的“发一个命令,等一个结果”的 Ping-Pong 模式,即使服务器处理命令只需要 1 微秒,但网络传输一来一回可能需要 100 微秒。大部分时间都在空等。 为了压榨出极限的吞吐量(比如达到百万级 QPS),真实的业务系统(如大规模缓存预热、批量写入)都会强制开启 Pipelining,一次性把几十上百个命令塞进一个 Socket 发过去。
因此,Valkey 必须能够完美、稳定地处理 Pipelining 请求,这是它的核心能力基石。 如果一个服务端程序一遇到 Pipelining 就崩溃,那它绝对是一个高危的 P0 级 Bug。
2.
**cmd_queue**到底是怎么工作的?你可能会问,既然 Pipelining 会让多个命令同时到达,那 Server 是怎么处理的?这就引出了引发崩溃的那个主角:
cmd_queue。在 Valkey 9.0 引入新的 I/O 多线程模型后,架构是这样的:
网卡收包:RDMA 网卡把包含 32 个
GET命令的巨大数据块直接 DMA 到内存中。I/O 线程解析:后台的 I/O 线程被唤醒,开始读取这块内存。它通过词法分析,把这个字节流切分成了 32 个独立的命令对象,然后依次
push到这个客户端的c->cmd_queue中。此时,**c->cmd_queue.len**就是 32。主线程执行:主线程接管,发现队列里有 32 个命令,于是依次执行它们,并将结果写回输出缓冲区。执行完毕后,清空队列(
len重新归 0)。**也就是说在这里,I/O线程和主线程是交替工作的?**这是轮转决定的?
现在,这个错误就是100%可以复现的了,我们可以着手进行解决.
git使用 #
比如有这样一种情景:
我们不小心
git add .
git commit -m "......"
但是突然发现,有一个文件是我们实验是才会使用的,比如valkey.conf,那么你就可以
git checkout HEAD^ -- valkey.conf
来恢复这个文件,然后我们继续修正上一次的commit
git commit --amend --no-edit
这样就OK了.
踩坑 RDB文件 #
在valkey.conf中的配置
save "" # 那么此时就是纯缓存系统,我们不会保存RDB文件.
假如是:
save 3600 1 # 假如3600s之内,有一个key发生了变化,我们就fork来触发一个快照的任务.
但是在做RDMA性能测试的时候就会踩坑,如果你在一个分支中进行了测试,但是保存了RDB文件,切分支的时候,因为dump.rdb在.gitignore中,那么这个文件也会带过来,然后你再次使用benchmark做测试的时候,server启动的时候会把这个rdb文件加载进来,那就会造成段错误(我的疑惑是正常的情况下是否会造成影响,就是即使加载随便一个rdb文件进来,我在做benchmark测试的时候比如./src/valkey-benchmark -h 10.0.0.1 -p 6379 -d 256 --threads 16 -c 100 -P 32 -n 20000000 -t get --rdma 是不是也应该正常进行?).
这也是我发现的一个问题,之后再进行处理,可以提一个issue来做.
解决CI中的两个crash的问题 #
现在我们已经解决了多线程崩溃的问题,但是不知道CI中还是会出现两个crash,我还是怀疑pizhenwei给出的方案是存在问题的,确实是存在问题的,就是状态机还是存在漏洞.
我们valkey的测试,使用TCL(tool command language 很多E2E的测试都是这样)
test-ubuntu-io-threads
#
报错,日志里最后正好是 Starting test WAITAOF when replica switches between masters, fsync: no,然后测试客户端读回复报 I/O error,说明是“等待回复阶段连接被断了/状态错乱了”。
多跑几遍来进行复现:
for i in {1..100}; do
echo "run $i"
./runtest --io-threads --accurate --verbose --dump-logs --single unit/wait || break
done
./runtest \
--io-threads \
--accurate \
--verbose --verbose \
--dump-logs \
--dont-clean \
--stop \
--tags network \
--single unit/wait \
--only "WAITAOF when replica switches between masters, fsync: no" \
--loops 500
看起来是主从复制的时候出了问题?
本地完全复现不出来这个错误我靠,难道和linux版本还有关系?
首先你要理解CI在做什么,首先会merge,就是把你的修改merge到当前的最新分支编译,然后进行测试,就是去看你的代码合并进来之后会不会产生问题.
这里的问题就是本地应该怎么去做CI的复现对吧,其实可以看到https://github.com/valkey-io/valkey/blob/unstable/.github/workflows/daily.yml 去观察一次workflow的情况究竟是怎么样的.
比如这个:
test-ubuntu-io-threads:
runs-on: ubuntu-latest
if: |
(github.event_name == 'workflow_call' || github.event_name == 'workflow_dispatch' ||
(github.event_name == 'schedule' && github.repository == 'valkey-io/valkey') ||
(github.event_name == 'pull_request' &&
(github.event.pull_request.base.ref != 'unstable' ||
contains(github.event.pull_request.labels.*.name, 'run-extra-tests')) &&
(github.event.action != 'labeled' ||
(github.event.pull_request.base.ref == 'unstable' && github.event.label.name == 'run-extra-tests'))
)) &&
!contains(github.event.inputs.skipjobs, 'iothreads')
timeout-minutes: 1440
steps:
- name: prep
if: github.event_name == 'workflow_dispatch' || github.event_name == 'workflow_call'
run: |
echo "GITHUB_REPOSITORY=${{inputs.use_repo || github.event.inputs.use_repo}}" >> $GITHUB_ENV
echo "GITHUB_HEAD_REF=${{inputs.use_git_ref || github.event.inputs.use_git_ref}}" >> $GITHUB_ENV
echo "skipjobs: ${{github.event.inputs.skipjobs}}"
echo "skiptests: ${{github.event.inputs.skiptests}}"
echo "test_args: ${{github.event.inputs.test_args}}"
echo "cluster_test_args: ${{github.event.inputs.cluster_test_args}}"
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
repository: ${{ inputs.use_repo || github.event.inputs.use_repo || github.repository }}
ref: ${{ inputs.use_git_ref || github.event.inputs.use_git_ref || github.ref }}
- name: Install libbacktrace
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
repository: ianlancetaylor/libbacktrace
ref: b9e40069c0b47a722286b94eb5231f7f05c08713
path: libbacktrace
- run: cd libbacktrace && ./configure && make && sudo make install
- name: make
run: make SERVER_CFLAGS='-Werror' USE_LIBBACKTRACE=yes
- name: testprep
run: sudo apt-get install tcl8.6 tclx
- name: test
if: true && !contains(github.event.inputs.skiptests, 'valkey')
run: ./runtest --io-threads --accurate --verbose --tags network --dump-logs ${{github.event.inputs.test_args}}
- name: cluster tests
if: true && !contains(github.event.inputs.skiptests, 'cluster')
run: ./runtest-cluster --io-threads ${{github.event.inputs.cluster_test_args}}
这是ubuntu版本中运行的对吧,那么最好的方法就是起一个ubuntu的docker,配置对应的环境从而进行编译和测试.
用 Ubuntu Docker 容器 + 一份临时 worktree + 按
daily.yml的方式编译和跑测试。
开一个ubuntu的容器来做测试 #
# 在宿主机上准备worktree,防止产生影响
cd /home/ada/Project/valkey
git fetch upstream
git worktree add ../valkey-ci-repro fix-rdma-io-threads
cd ../valkey-ci-repro
git merge upstream/unstable
然后我们起一个ubuntu的容器:
# 容器内部操作
docker run --rm -it \
--name valkey-ci-repro \
-v "$PWD":/work \
-w /work \
ubuntu:24.04 \
bash
# 安装基础环境
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y \
build-essential \
pkg-config \
git \
wget \
ca-certificates \
tcl8.6 \
tclx \
libssl-dev
# 之后模拟workflow的操作
cd /tmp
git clone https://github.com/ianlancetaylor/libbacktrace.git
cd libbacktrace
git checkout b9e40069c0b47a722286b94eb5231f7f05c08713
./configure
make -j"$(nproc)"
make install
ldconfig
# 然后回仓库,按照CI的参数重新编译
cd /work
make distclean
make SERVER_CFLAGS='-Werror' USE_LIBBACKTRACE=yes -j"$(nproc)"
# 之后再使用容器的话可以这样
docker start -ai valkey-ci-repro
# 如果已经在后台运行的话
docker exec -it valkey-ci-repro bash
本地根本跑不出来,都模拟成这样了?
后面可以建立一个DockerFile:
FROM ubuntu:24.04
RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y \
build-essential \
pkg-config \
git \
wget \
ca-certificates \
tcl8.6 \
tclx \
libssl-dev \
&& rm -rf /var/lib/apt/lists/*
RUN cd /tmp \
&& git clone https://github.com/ianlancetaylor/libbacktrace.git \
&& cd libbacktrace \
&& git checkout b9e40069c0b47a722286b94eb5231f7f05c08713 \
&& ./configure \
&& make -j"$(nproc)" \
&& make install \
&& ldconfig
WORKDIR /work
这里查不出来,先放着.
其实还是在找状态机的时序漏洞.
crash2 TLS卡死 #
~/Project/valkey fix-rdma-io-threads* 2m 17s ❯ ./runtest --io-threads --tls --accurate --verbose --dump-logs --single unit/wait
发现这个测试是存在问题的.
卡住了,你怎么进行排错:
ps aux | grep valkey
# aux: a 表示显示所有用户的进程,u 表示以面向用户的详细格式输出(包含 CPU、内存占用等),x 表示显示没有控制终端的后台进程。
这里要讲一下TLS的编译了:
# 编译条件,注意写一些CI
make BUILD_TLS=yes SERVER_CFLAGS='-Werror' USE_LIBBACKTRACE=yes
# 生成测试证书
./utils/gen-test-certs.sh
# 检查TLS
tclsh <<< 'package require tls; puts OK'
这里比较有意思,我们来做了一套对比的实验:
1.基线:
./runtest --accurate --verbose --verbose --dump-logs --single unit/protocol
2.只有 TLS:
./runtest --tls --accurate --verbose --verbose --dump-logs --single unit/protocol
3.只有 io-threads:
./runtest --io-threads --accurate --verbose --verbose --dump-logs --single unit/protocol
4.TLS + io-threads:
./runtest --io-threads --tls --accurate --verbose --verbose --dump-logs --single unit/protocol
只有第四组会挂掉,就是这里的状态机接力的时候出现了问题.
这里的手法就是首先理解TLS状态机和普通TCP状态机时序上的差异.
CI报了24个错误? #
随便挑一个CI看一下https://github.com/valkey-io/valkey/actions/runs/23701289010/job/69045307291?pr=3335
分析一下,报的都是一类错误,分析一下测试为什么会挂住,这是TCL测试:
test_slave_buffers {slave buffer are counted correctly} 1000000 10 0 1
参数含义:cmd_count=1000000, payload_len=10, limit_memory=0, pipeline=1
完整流程:
-
启动 master + replica 两个实例
-
创建 100 个 key,每个 100KB (共约 10MB 数据)
-
replica 连接 master 并完成全量同步
-
用 SIGSTOP 暂停 replica 进程 (
pause_process $slave_pid) —— replica 完全"冻结",不读不写 -
创建一个
valkey_deferring_client连接到 master -
在紧密循环中发 1,000,000 条
SETRANGE key:0 0 AAAAAAAAAA命令(pipeline 模式,只发不收) -
发完后再循环读 1,000,000 条回复
-
检查 master 的
mem_clients_slaves是否正确反映了积压的 replica 输出缓冲区
TCP流控dead lock #
这是两个独立的 TCP 数据流:
数据流 A:Client → Server(命令)
Tcl 进程 → write() → Client 内核发送缓冲区 → [TCP] → Server 内核接收缓冲区 → read() → Valkey Server
数据流 B:Server → Client(回复)
Valkey Server → write() → Server 内核发送缓冲区 → [TCP] → Client 内核接收缓冲区 → [无人读取!]
关键机制:TCP 有接收窗口 (receive window)。当接收端的接收缓冲区满了,发送端就不能再发了。
死锁的形成过程 #
阶段 1:一切正常
-
Client 发命令,Server 读取并处理
-
Server 生成回复(每个 SETRANGE 回复约 5 字节
:10\r\n),写入内核发送缓冲区 -
TCP 将回复传输到 Client 的内核接收缓冲区
阶段 2:Client 接收缓冲区开始填满
-
Client 在紧密的 for 循环里,从不调用 read()
-
Client 的 TCP 接收缓冲区持续积累回复数据
-
Linux 的 TCP 接收缓冲区初始大小约
**128KB**(由**net.ipv4.tcp_rmem**的 default 值决定) -
TCP 自动调优 (auto-tuning) 可以扩大到
~6MB,但前提是应用层在活跃地读取 -
因为 Client 不读,TCP 自动调优不会显著扩大接收缓冲区
阶段 3:死锁形成
当 Client 的接收缓冲区满了 (~128KB, 约 26,000 条回复):
Server → Client 方向:
Client 内核接收缓冲区满 → TCP 接收窗口变为 0 → Server write() 返回 EAGAIN
Server 的回复积压在用户态的 client->reply 链表中
Client → Server 方向:
理论上这个方向是独立的,但问题出在内核层面…
这里有一个关键的 TCP 内核行为:虽然 TCP 是全双工的,但在 Linux 内核的实现中,TCP 内存管理是共享的。当一个连接在一个方向上积压了大量未消费的数据,内核的 TCP 内存压力检测 (**tcp_mem**, **tcp_moderate_rcvbuf**) 可能会限制这个连接在另一个方向上的缓冲区大小。
这里要注意的一点就是linux的接收和发送缓冲区是共享的,所以会有压力检测(虽然是全双工的).
更直接的问题是:当 Server 端积压了大量发不出去的回复数据,Server 的 TCP 发送缓冲区满了。这时 TCP 的拥塞控制和内存管理可能会间接影响同一连接上的接收窗口通告。
最终效果:
-
Client 的
flush $fd(阻塞 write)被挂起 —— Client 内核的发送缓冲区满了 -
Server 也写不出回复 —— Client 内核的接收缓冲区满了
-
双方都在等对方先让步 → 死锁
为什么是"有时挂、有时不挂"(flaky:古怪的) #
死锁是否发生取决于精确的时序:
-
Client 发送速度:每条命令约 54 字节,
flush是阻塞 write() 系统调用。在快的 CI runner 上,1M 条命令可能在 10-20 秒内全部发完。 -
Server 处理速度:Server 在事件循环中读取 → 处理命令 → 生成回复。如果 Server 能在 Client 发完所有命令之前保持足够的接收缓冲区空间,就不会死锁。
-
TCP 缓冲区大小:受系统参数
tcp_rmem,tcp_wmem,tcp_mem影响,不同 CI runner 可能不同。 -
32-bit 的额外问题:32-bit 模式下内存分配开销更大,Tcl 解释器本身也更慢,更容易触发死锁窗口。
这解释了:
-
upstream
#8453的test-ubuntu-32bit通过了(47分44秒)—— 那次 CI runner 可能更快,Client 在死锁窗口关闭前完成了所有发送 -
你的 PR 的
test-ubuntu-32bit失败了(22分后超时)—— CI runner 更慢,命中了死锁
说白了就是如果处理得太慢,就有可能会发生这种死锁.
还是有CI的错误需要处理 #
但是我不知道是偶现的还是经常性的.
docker run --rm -it \
-v /home/ada/Project/valkey:/valkey \
-w /valkey \
alpine:latest sh -euxc '
apk add --no-cache build-base git tcl procps tclx
git clone --depth 1 https://github.com/ianlancetaylor/libbacktrace.git /tmp/libbacktrace
cd /tmp/libbacktrace && git fetch --depth 1 origin b9e40069c0b47a722286b94eb5231f7f05c08713 && git checkout b9e40069c0b47a722286b94eb5231f7f05c08713
cd /tmp/libbacktrace && ./configure && make -j"$(nproc)" && make install
apk add --no-cache openssl-dev pkgconf # 加上openssl,装上开发依赖
cd /valkey && make SERVER_CFLAGS="-Werror" USE_JEMALLOC=no CFLAGS=-DUSE_MALLOC_USABLE_SIZE USE_LIBBACKTRACE=yes -j"$(nproc)"
cd /valkey && ./runtest --verbose --dump-logs
'
我们之后怎么在不同版本的linux上进行CI crash的复现总结 #
docker会自己拉没有的容器.
$ docker ps # 列出容器
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
$ docker ps -a # 列出所有容器,包括已经停止的
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
c3146944eaa9 debian:bookworm-slim "/bin/bash" 3 months ago Exited (130) 3 months ago modest_allen
如果你想关闭容器之后能够保留:
docker run -it --name valkey-alpine \
-v /home/ada/Project/valkey:/valkey \
-w /valkey \
alpine:latest sh
以后(容器若在跑):
docker start -ai valkey-alpine
若容器是 Exited 状态:
docker start valkey-alpine
docker exec -it valkey-alpine sh
测试都过了,可能只是远端的问题没有解决.
注意还会出现挂载权限的问题 #
-
之前在 Docker(Alpine)里用
v /home/ada/Project/valkey:/valkey编过,容器里一般是 root,会在挂载目录里生成 属主为 root 的文件,例如deps/libvalkey/obj/*、deps/libvalkey/lib/libvalkey.a等。 -
你现在在 宿主机上用普通用户 跑
./runtest --tls,会触发make,里面会对deps/libvalkey做clean。日志里已有:rm: cannot remove 'obj/sockcompat.d': Permission denied ... make[3]: *** [Makefile:312: clean] Error 1说明 删不掉 root 拥有的产物,清理不完整。
-
接着编 TLS 版 libvalkey 时出现:
fatal error: opening dependency file obj/tls.d: Permission denied典型就是
deps/libvalkey/obj目录或里面文件仍是 root 只写,当前用户不能在里面写.d文件。 -
后面的
je_mallctl/je_malloc_usable_size隐式声明,多半是在 依赖树没干净重编、.make-settings和实际已编译的 jemalloc 状态不一致时的 连带现象;先把权限和deps清理修好,再全量make,这类错误通常会消失。
怎么处理(在宿主机上)
任选一种可靠做法:
做法 A:只修属主(推荐,不动 git 跟踪文件)
sudo chown -R "$(id -un):$(id -gn)" /home/ada/Project/valkey/deps/libvalkey/obj \
/home/ada/Project/valkey/deps/libvalkey/lib
若还有别的 Permission denied,可对整棵仓库做一次(注意会改所有构建产物的属主):
sudo chown -R "$(id -un):$(id -gn)" /home/ada/Project/valkey
然后:
cd /home/ada/Project/valkey && make distclean && make BUILD_TLS=yes -j"$(nproc)"
做法 B:以后在 Docker 里用当前用户映射(避免再出现)
docker run 时加 --user "$(id -u):$(id -g)"
(有时还需处理 $HOME),这样挂载目录里新文件属主是你的 UID,宿主机编译不会踩权限。
还是存在一个UAF问题? #
I tried adding a test to test for use-after-free (took AI’s help) where some clients tried to send ping, and in the same batch there was a kill command issued.
Test:
[a1b19c5](https://github.com/valkey-io/valkey/commit/a1b19c52a5946568343760f7df10d0179c36264d)This test failed with ASAN - https://github.com/sarthakaggarwal97/valkey/actions/runs/24042292630/job/70116805349#step:7:530
Can we check if this is something we should investigate. The tests seems legitimate.
还是先尝试本地的复现 #
我们起了一个新的tcl测试,但是会发现出现了问题,先把这个测试加到我们的tcl中去.
proc stress_same_batch_client_kill_on_handled_clients {} {
set server_pid [s process_id]
set victim_count 16
set iterations 100
for {set iter 0} {$iter < $iterations} {incr iter} {
for {set i 0} {$i < $victim_count} {incr i} {
set victim($i) [valkey_deferring_client]
$victim($i) client id
}
for {set i 0} {$i < $victim_count} {incr i} {
set victim_id($i) [$victim($i) read]
}
set killer [valkey_deferring_client]
# Build one late CLIENT KILL command that can synchronously free many
# already-handled clients in the same batch. If the handled_clients
# post-pass still dereferences those raw client pointers, ASAN should
# catch it.
set kill_args [list kill id]
for {set i 0} {$i < $victim_count} {incr i} {
lappend kill_args $victim_id($i)
}
# Queue all victim reads first, then queue the killer command while the
# server is stopped so they are eligible for the same IO-thread batch.
pause_process $server_pid
for {set i 0} {$i < $victim_count} {incr i} {
$victim($i) ping
$victim($i) flush
}
$killer client {*}$kill_args
$killer flush
resume_process $server_pid
assert_equal $victim_count [$killer read]
for {set i 0} {$i < $victim_count} {incr i} {
catch {$victim($i) read}
catch {$victim($i) close}
unset victim($i)
unset victim_id($i)
}
$killer close
# Keep the loop making forward progress so sanitizer failures point at
# the batching window instead of a later idle teardown.
assert_equal {PONG} [r ping]
}
}
start_server {config "minimal.conf" tags {"external:skip" "valgrind:skip"} overrides {enable-debug-command {yes} io-threads 5}} {
# Skip if non io-threads mode - as it is relevant only for io-threads mode
assert_equal {io-threads 5} [r config get io-threads]
@@ -102,3 +153,9 @@ start_server {config "minimal.conf" tags {"external:skip" "valgrind:skip"} overr
}
}
}
start_server {config "minimal.conf" tags {"external:skip" "valgrind:skip"} overrides {io-threads 2 events-per-io-thread 0 use-exit-on-panic yes crash-memcheck-enabled no}} {
test {ASAN canary for same-batch CLIENT KILL vs handled_clients post-pass} {
stress_same_batch_client_kill_on_handled_clients
}
}
Root Cause分析:
1. CLIENT KILL 在批处理中执行: 当在同一个批次中,有些客户端发送 PING,同时有一个 CLIENT KILL 命令杀死这些客户端时
2. 客户端被添加到 handled_clients: 在 processClientsCommandsBatch 中 (memory_prefetch.c:250-252):
if (beforeNextClient(c) == C_OK) {
listAddNodeTail(handled_clients, c);
}
3. 客户端在批处理中被 CLIENT KILL 释放: CLIENT KILL 命令会调用 freeClient(),直接释放那些 victim 客户端
4. UAF 发生: 在 processIOThreadsReadDone 最后的循环中 (networking.c:6456-6460):
while ((handled_ln = listNext(&handled_li))) {
client *c = listNodeValue(handled_ln); // c 已经被释放!
if (!c->conn) continue; // <-- ASAN 在这里触发 heap-use-after-free
connUpdateState(c->conn);
}
然后进行编译:
# 先清理之前的编译缓存,防止残留对象文件干扰
make distclean
# 使用 ASAN 选项编译,强制使用 libc 内存分配器
make SANITIZER=address -j$(nproc)
# 还要防止OOM杀死进程
sudo sysctl vm.overcommit_memory=1
-
MALLOC=libc:如果不加这个,Valkey 会链接 jemalloc,ASAN 的拦截机制会失效或者直接导致编译/运行崩溃。 -
fno-omit-frame-pointer:告诉编译器不要优化掉栈帧指针(Frame Pointer)。这能让 ASAN 报错时,打印出非常清晰、深度的函数调用栈(Call Stack),就像你贴的日志里那样,方便你精准定位是哪一行触发的。
首先dlopen我们要保证能找到对应的动态库,配置对应的路径.
export LD_LIBRARY_PATH=/home/ada/Project/valkey/src/modules/lua
然后运行单个的测试用例:
./runtest --single unit/io-threads
~/Project/valkey fix-rdma-io-threads* 4m 22s ❯ ./runtest --single unit/io-threads
Cleanup: may take some time... OK
Starting test server at port 21079
[ready]: 276318
Testing unit/io-threads
[ok]: Force the use of IO threads and assert active IO thread usage (1194 ms)
[err]: Sanitizer error: =================================================================
==276399==ERROR: AddressSanitizer: heap-use-after-free on address 0x7ce588be6388 at pc 0x55bc4c205b5f bp 0x7ffcdada6970 sp 0x7ffcdada6960
READ of size 8 at 0x7ce588be6388 thread T0
#0 0x55bc4c205b5e in processIOThreadsReadDone /home/ada/Project/valkey/src/networking.c:6458
#1 0x55bc4c31c9f1 in beforeSleep /home/ada/Project/valkey/src/server.c:1945
#2 0x55bc4bf6a5ef in aeProcessEvents /home/ada/Project/valkey/src/ae.c:426
#3 0x55bc4bf6a5ef in aeMain /home/ada/Project/valkey/src/ae.c:543
#4 0x55bc4bf39efd in main /home/ada/Project/valkey/src/server.c:7687
#5 0x7f8589dee6c0 (/usr/lib/libc.so.6+0x276c0) (BuildId: 7a8d41a2df4fde040b4c6ac2832311ab645a1e41)
#6 0x7f8589dee7f8 in __libc_start_main (/usr/lib/libc.so.6+0x277f8) (BuildId: 7a8d41a2df4fde040b4c6ac2832311ab645a1e41)
#7 0x55bc4bf3c494 in _start (/home/ada/Project/valkey/src/valkey-server+0x158494) (BuildId: 4805b42524cc62566a136ec6bcf2de4751ee59df)
0x7ce588be6388 is located 8 bytes inside of 576-byte region [0x7ce588be6380,0x7ce588be65c0)
freed by thread T0 here:
#0 0x7f858a31f79d (/usr/lib/libasan.so.8+0x11f79d) (BuildId: 0b96d08695bbce2da9d4770c29ad2e72fb536f47)
#1 0x55bc4c207064 in freeClient /home/ada/Project/valkey/src/networking.c:2168
#2 0x55bc4c210ea8 in freeClient /home/ada/Project/valkey/src/networking.c:2055
#3 0x55bc4c210ea8 in clientKillCommand /home/ada/Project/valkey/src/networking.c:5262
#4 0x55bc4c3335cf in call /home/ada/Project/valkey/src/server.c:3882
#5 0x55bc4c33becb in processCommand /home/ada/Project/valkey/src/server.c:4568
#6 0x55bc4c1eda61 in processCommandAndResetClient /home/ada/Project/valkey/src/networking.c:3808
#7 0x55bc4c1eda61 in processPendingCommandAndInputBuffer /home/ada/Project/valkey/src/networking.c:3841
#8 0x55bc4c130e12 in processClientsCommandsBatch /home/ada/Project/valkey/src/memory_prefetch.c:248
#9 0x55bc4c130e12 in processClientsCommandsBatch /home/ada/Project/valkey/src/memory_prefetch.c:231
#10 0x55bc4c2057c1 in processIOThreadsReadDone /home/ada/Project/valkey/src/networking.c:6451
#11 0x55bc4c31c9f1 in beforeSleep /home/ada/Project/valkey/src/server.c:1945
#12 0x55bc4bf6a5ef in aeProcessEvents /home/ada/Project/valkey/src/ae.c:426
#13 0x55bc4bf6a5ef in aeMain /home/ada/Project/valkey/src/ae.c:543
#14 0x55bc4bf39efd in main /home/ada/Project/valkey/src/server.c:7687
#15 0x7f8589dee6c0 (/usr/lib/libc.so.6+0x276c0) (BuildId: 7a8d41a2df4fde040b4c6ac2832311ab645a1e41)
#16 0x7f8589dee7f8 in __libc_start_main (/usr/lib/libc.so.6+0x277f8) (BuildId: 7a8d41a2df4fde040b4c6ac2832311ab645a1e41)
#17 0x55bc4bf3c494 in _start (/home/ada/Project/valkey/src/valkey-server+0x158494) (BuildId: 4805b42524cc62566a136ec6bcf2de4751ee59df)
previously allocated by thread T0 here:
#0 0x7f858a320cb5 in malloc (/usr/lib/libasan.so.8+0x120cb5) (BuildId: 0b96d08695bbce2da9d4770c29ad2e72fb536f47)
#1 0x55bc4c479589 in ztrymalloc_usable_internal /home/ada/Project/valkey/src/zmalloc.c:156
#2 0x55bc4c479589 in valkey_malloc /home/ada/Project/valkey/src/zmalloc.c:185
#3 0x55bc4c1cfd99 in createClient /home/ada/Project/valkey/src/networking.c:282
#4 0x55bc4c1d5347 in acceptCommonHandler /home/ada/Project/valkey/src/networking.c:1835
#5 0x55bc4c36312c in connSocketAcceptHandler /home/ada/Project/valkey/src/socket.c:333
#6 0x55bc4bf6a6d4 in aeProcessEvents /home/ada/Project/valkey/src/ae.c:486
#7 0x55bc4bf6a6d4 in aeMain /home/ada/Project/valkey/src/ae.c:543
#8 0x55bc4bf39efd in main /home/ada/Project/valkey/src/server.c:7687
#9 0x7f8589dee6c0 (/usr/lib/libc.so.6+0x276c0) (BuildId: 7a8d41a2df4fde040b4c6ac2832311ab645a1e41)
#10 0x7f8589dee7f8 in __libc_start_main (/usr/lib/libc.so.6+0x277f8) (BuildId: 7a8d41a2df4fde040b4c6ac2832311ab645a1e41)
#11 0x55bc4bf3c494 in _start (/home/ada/Project/valkey/src/valkey-server+0x158494) (BuildId: 4805b42524cc62566a136ec6bcf2de4751ee59df)
SUMMARY: AddressSanitizer: heap-use-after-free /home/ada/Project/valkey/src/networking.c:6458 in processIOThreadsReadDone
Shadow bytes around the buggy address:
0x7ce588be6100: fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd
0x7ce588be6180: fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd
0x7ce588be6200: fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd
0x7ce588be6280: fd fd fd fd fd fd fd fd fa fa fa fa fa fa fa fa
0x7ce588be6300: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
=>0x7ce588be6380: fd[fd]fd fd fd fd fd fd fd fd fd fd fd fd fd fd
0x7ce588be6400: fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd
0x7ce588be6480: fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd
0x7ce588be6500: fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd
0x7ce588be6580: fd fd fd fd fd fd fd fd fa fa fa fa fa fa fa fa
0x7ce588be6600: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
Shadow byte legend (one shadow byte represents 8 application bytes):
Addressable: 00
Partially addressable: 01 02 03 04 05 06 07
Heap left redzone: fa
Freed heap region: fd
Stack left redzone: f1
Stack mid redzone: f2
Stack right redzone: f3
Stack after return: f5
Stack use after scope: f8
Global redzone: f9
Global init order: f6
Poisoned by user: f7
Container overflow: fc
Array cookie: ac
Intra object redzone: bb
ASan internal: fe
Left alloca redzone: ca
Right alloca redzone: cb
==276399==ABORTING
Logged sanitizer errors (pid 276399):
=================================================================
==276399==ERROR: AddressSanitizer: heap-use-after-free on address 0x7ce588be6388 at pc 0x55bc4c205b5f bp 0x7ffcdada6970 sp 0x7ffcdada6960
READ of size 8 at 0x7ce588be6388 thread T0
#0 0x55bc4c205b5e in processIOThreadsReadDone /home/ada/Project/valkey/src/networking.c:6458
#1 0x55bc4c31c9f1 in beforeSleep /home/ada/Project/valkey/src/server.c:1945
#2 0x55bc4bf6a5ef in aeProcessEvents /home/ada/Project/valkey/src/ae.c:426
#3 0x55bc4bf6a5ef in aeMain /home/ada/Project/valkey/src/ae.c:543
#4 0x55bc4bf39efd in main /home/ada/Project/valkey/src/server.c:7687
#5 0x7f8589dee6c0 (/usr/lib/libc.so.6+0x276c0) (BuildId: 7a8d41a2df4fde040b4c6ac2832311ab645a1e41)
#6 0x7f8589dee7f8 in __libc_start_main (/usr/lib/libc.so.6+0x277f8) (BuildId: 7a8d41a2df4fde040b4c6ac2832311ab645a1e41)
#7 0x55bc4bf3c494 in _start (/home/ada/Project/valkey/src/valkey-server+0x158494) (BuildId: 4805b42524cc62566a136ec6bcf2de4751ee59df)
0x7ce588be6388 is located 8 bytes inside of 576-byte region [0x7ce588be6380,0x7ce588be65c0)
freed by thread T0 here:
#0 0x7f858a31f79d (/usr/lib/libasan.so.8+0x11f79d) (BuildId: 0b96d08695bbce2da9d4770c29ad2e72fb536f47)
#1 0x55bc4c207064 in freeClient /home/ada/Project/valkey/src/networking.c:2168
#2 0x55bc4c210ea8 in freeClient /home/ada/Project/valkey/src/networking.c:2055
#3 0x55bc4c210ea8 in clientKillCommand /home/ada/Project/valkey/src/networking.c:5262
#4 0x55bc4c3335cf in call /home/ada/Project/valkey/src/server.c:3882
#5 0x55bc4c33becb in processCommand /home/ada/Project/valkey/src/server.c:4568
#6 0x55bc4c1eda61 in processCommandAndResetClient /home/ada/Project/valkey/src/networking.c:3808
#7 0x55bc4c1eda61 in processPendingCommandAndInputBuffer /home/ada/Project/valkey/src/networking.c:3841
#8 0x55bc4c130e12 in processClientsCommandsBatch /home/ada/Project/valkey/src/memory_prefetch.c:248
#9 0x55bc4c130e12 in processClientsCommandsBatch /home/ada/Project/valkey/src/memory_prefetch.c:231
#10 0x55bc4c2057c1 in processIOThreadsReadDone /home/ada/Project/valkey/src/networking.c:6451
#11 0x55bc4c31c9f1 in beforeSleep /home/ada/Project/valkey/src/server.c:1945
#12 0x55bc4bf6a5ef in aeProcessEvents /home/ada/Project/valkey/src/ae.c:426
#13 0x55bc4bf6a5ef in aeMain /home/ada/Project/valkey/src/ae.c:543
#14 0x55bc4bf39efd in main /home/ada/Project/valkey/src/server.c:7687
#15 0x7f8589dee6c0 (/usr/lib/libc.so.6+0x276c0) (BuildId: 7a8d41a2df4fde040b4c6ac2832311ab645a1e41)
#16 0x7f8589dee7f8 in __libc_start_main (/usr/lib/libc.so.6+0x277f8) (BuildId: 7a8d41a2df4fde040b4c6ac2832311ab645a1e41)
#17 0x55bc4bf3c494 in _start (/home/ada/Project/valkey/src/valkey-server+0x158494) (BuildId: 4805b42524cc62566a136ec6bcf2de4751ee59df)
previously allocated by thread T0 here:
#0 0x7f858a320cb5 in malloc (/usr/lib/libasan.so.8+0x120cb5) (BuildId: 0b96d08695bbce2da9d4770c29ad2e72fb536f47)
#1 0x55bc4c479589 in ztrymalloc_usable_internal /home/ada/Project/valkey/src/zmalloc.c:156
#2 0x55bc4c479589 in valkey_malloc /home/ada/Project/valkey/src/zmalloc.c:185
#3 0x55bc4c1cfd99 in createClient /home/ada/Project/valkey/src/networking.c:282
#4 0x55bc4c1d5347 in acceptCommonHandler /home/ada/Project/valkey/src/networking.c:1835
#5 0x55bc4c36312c in connSocketAcceptHandler /home/ada/Project/valkey/src/socket.c:333
#6 0x55bc4bf6a6d4 in aeProcessEvents /home/ada/Project/valkey/src/ae.c:486
#7 0x55bc4bf6a6d4 in aeMain /home/ada/Project/valkey/src/ae.c:543
#8 0x55bc4bf39efd in main /home/ada/Project/valkey/src/server.c:7687
#9 0x7f8589dee6c0 (/usr/lib/libc.so.6+0x276c0) (BuildId: 7a8d41a2df4fde040b4c6ac2832311ab645a1e41)
#10 0x7f8589dee7f8 in __libc_start_main (/usr/lib/libc.so.6+0x277f8) (BuildId: 7a8d41a2df4fde040b4c6ac2832311ab645a1e41)
#11 0x55bc4bf3c494 in _start (/home/ada/Project/valkey/src/valkey-server+0x158494) (BuildId: 4805b42524cc62566a136ec6bcf2de4751ee59df)
SUMMARY: AddressSanitizer: heap-use-after-free /home/ada/Project/valkey/src/networking.c:6458 in processIOThreadsReadDone
Shadow bytes around the buggy address:
0x7ce588be6100: fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd
0x7ce588be6180: fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd
0x7ce588be6200: fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd
0x7ce588be6280: fd fd fd fd fd fd fd fd fa fa fa fa fa fa fa fa
0x7ce588be6300: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
=>0x7ce588be6380: fd[fd]fd fd fd fd fd fd fd fd fd fd fd fd fd fd
0x7ce588be6400: fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd
0x7ce588be6480: fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd
0x7ce588be6500: fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd fd
0x7ce588be6580: fd fd fd fd fd fd fd fd fa fa fa fa fa fa fa fa
0x7ce588be6600: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
Shadow byte legend (one shadow byte represents 8 application bytes):
Addressable: 00
Partially addressable: 01 02 03 04 05 06 07
Heap left redzone: fa
Freed heap region: fd
Stack left redzone: f1
Stack mid redzone: f2
Stack right redzone: f3
Stack after return: f5
Stack use after scope: f8
Global redzone: f9
Global init order: f6
Poisoned by user: f7
Container overflow: fc
Array cookie: ac
Intra object redzone: bb
ASan internal: fe
Left alloca redzone: ca
Right alloca redzone: cb
==276399==ABORTING
[exception]: Executing test client: I/O error reading reply.
I/O error reading reply
while executing
"[srv $level "client"] {*}$args"
(procedure "r" line 7)
invoked from within
"r ping"
(procedure "stress_same_batch_client_kill_on_handled_clients" line 48)
invoked from within
"stress_same_batch_client_kill_on_handled_clients"
("uplevel" body line 2)
invoked from within
"uplevel 1 $code"
(procedure "test" line 63)
invoked from within
"test {ASAN canary for same-batch CLIENT KILL vs handled_clients post-pass} {
stress_same_batch_client_kill_on_handled_clients
}"
("uplevel" body line 2)
invoked from within
"uplevel 1 $code "
(procedure "start_server" line 2)
invoked from within
"start_server {config "minimal.conf" tags {"external:skip" "valgrind:skip"} overrides {io-threads 2 events-per-io-thread 0 use-exit-on-panic yes crash-..."
(file "tests/unit/io-threads.tcl" line 157)
invoked from within
"source $path"
(procedure "execute_test_file" line 6)
invoked from within
"execute_test_file $data"
(procedure "test_client_main" line 10)
invoked from within
"test_client_main $::test_server_port "
看上面的日志,这下就是复现成功了.
目前解决的手法就是:
git流程 backport操作 #
就是因为现在的代码逻辑已经和新的IO的逻辑不兼容了,所以我们要进行新的操作.就是把这部分代码合并进入旧版本就是9.0的版本中来处理.(就是cherry pick这样的操作.)
比如你需要知道自己当前的分支历史究竟做了什么修改:
$ git log --author="Ada-Church-Closure" --oneline origin/fix-rdma-io-threads
直接重建分支,然后做cherry pick:
cd /home/ada/Project/valkey
git fetch upstream
# 在「纯净 9.0」上重建分支(名字仍用 PR 分支名,方便直接更新 PR)
git checkout -B fix-rdma-io-threads upstream/9.0
git cherry-pick 3fc639514 d8f8b3991 0dabb3dc9 df67b5d6d 0eb377393 ecf0dd743 \
36dbf89a9 3a43bd4f5 7f139ef5c 454b41518 48611b619 a3e370894 685ed1ec4 95b637d29
claude最后复盘 #
📋 问题总结
你在解决 Issue #3112: 当 RDMA 与 I/O 线程配合使用时,在高并发管道场景下(如 -P 32)会出现两个严重问题:
- 崩溃问题 (Crash)
-
现象: 断言失败 c->cmd_queue.len == 0
-
根本原因: 在 processIOThreadsReadDone 中,connUpdateState(c->conn) 在命令执行之前被调用。对于 RDMA,这会同步触发 CQ (Completion Queue),导致在cmd_queue 仍然有大量命令时重新进入解析逻辑,违反了断言条件。
- 挂起问题 (Lost Wakeup)
-
现象: 服务器完全卡死
-
根本原因: I/O 线程由于批处理限制可能在 querybuf 中留下未解析的数据。由于 RDMA 的 CQ
是边缘触发的,如果不主动排空缓冲区就返回事件循环,系统不会再次唤醒,导致死锁。
🔧 解决方案演进
初版方案 (commit 3fc6395)
在 src/networking.c:6378 的 processIOThreadsReadDone 函数中:
// 1. 创建一个列表跟踪已处理的客户端
list *handled_clients = listCreate();
// 2. 延迟 connUpdateState 调用
connUpdateState(c->conn); // ❌ 移除过早的调用
// 3. 收集处理过的客户端
listAddNodeTail(handled_clients, c);
// 4. 先批量执行命令队列
processClientsCommandsBatch();
// 5. 同步排空残留的 querybuf
listRewind(handled_clients, &handled_li);
while ((handled_ln = listNext(&handled_li))) {
client *c = listNodeValue(handled_ln);
if (c->querybuf && c->qb_pos < sdslen(c->querybuf)) {
processPendingCommandAndInputBuffer(c); // 🔑 关键:同步排空
}
connUpdateState(c->conn); // ✅ 安全时机调用
}
UAF 问题修复 (commit 0dabb3d → df67b5d)
-
发现问题: @sarthakaggarwal97 指出直接存储客户端指针可能导致 UAF(Use-After-Free),因为 processPendingCommandAndInputBuffer 可能释放客户端
-
尝试方案1: 使用客户端 ID 列表 + lookupClientByID(性能开销)
-
最终方案: 修改 processClientsCommandsBatch 和 addCommandToBatchAndProcessIfFull,只在客户端存活时才加入 handled_clients 列表
TLS 兼容性修复 (commits 0eb3773 + ecf0dd7)
-
问题: TLS 在 CONN_STATE_ACCEPTING 状态时也需要更新状态
-
解决: 在 accept 状态下,如果是 TLS 连接,仍然调用 connUpdateState
代码简化 (commit 7f139ef)
- 让 beforeNextClient 直接返回 int 而不是包装函数,简化逻辑
清理改进 (commits 48611b6 + a3e3708)
-
移除不必要的 expire.h 包含
-
其他小的代码格式修正
🎯 关键设计要点
-
边缘触发感知: RDMA 的 CQ 是边缘触发的,必须在返回事件循环前主动排空所有缓冲数据
-
重入安全: connUpdateState 对 RDMA 会同步触发 CQ,必须在命令队列为空时才能安全调用
-
TCP/TLS 兼容: 这个改动对 TCP 是无害的(甚至略微减少 epoll 轮询),对 TLS 也正确处理了 accept 状态
-
UAF 防护: 通过在正确的时机加入列表,避免操作已释放的客户端
✅ 验证方法
你的压力测试脚本非常棒:
-
同时运行 4 个 TCP + 4 个 RDMA benchmark 进程
-
使用高并发参数(-c 200 -P 64)
-
随机杀死部分进程模拟网络中断
-
服务器应该稳定运行,无崩溃、无挂起
📊 当前状态
-
✅ 核心测试通过(test-ubuntu-io-threads, test-ubuntu-tls-io-threads)
-
✅ RDMA 专项测试通过
-
⚠️ 两个 CI 失败是 flaky tests(不稳定测试),与你的改动无关
-
👍 获得 @pizhenwei 和 @zuiderkwast 的 LGTM(Looks Good To Me)
-
🎯 已加入 Valkey 9.0 和 9.1 的待移植列表
💡 我的评价
这是一个非常高质量的修复:
-
问题分析透彻: 清楚识别了边缘触发和重入的本质问题
-
逐步优化: 从初版到最终版,通过 code review 不断改进
-
向后兼容: 对 TCP/TLS 零影响
-
测试完善: 提供了复现步骤和压力测试脚本
唯一还需要做的是等待 maintainer 处理那两个 flaky tests,然后就可以合并了!