共计 4235 个字符,预计需要花费 11 分钟才能阅读完成。
Redis 我的项目中,一个名为 “[BUG] Deadlock with streams on redis 7.2” 的 issue 12290 吸引了我的留神。这个 bug 中,redis 服务器在解决特定的客户端申请时陷入了死循环,这个景象在 redis 这样的高性能、高可靠性的数据库系统中是极为常见的。
这个 Issue 不仅仅是一个一般的 bug 报告,它实际上是一次深刻摸索 Redis 外部机制的学习过程。从问题的发现,到复现步骤的详细描述,再到问题的深入分析,最初到解决方案的提出,每一步都充斥了挑战和发现。无论你是 Redis 的使用者,还是对数据库外部机制感兴趣的开发者,我置信你都能从这个 issue 中取得有价值的启发。
在开始钻研这个 bug 之前,咱们先简略理解下这里的背景常识:redis 的流数据类型。
Redis streams 介绍
为了反对更弱小和灵便的流解决能力,Redis 在 5.0 反对了流数据类型,蕴含 XADD, XREAD 和 XREADGROUP。
XADD
命令容许用户向 Redis 流中增加新的音讯。每个音讯都有一个惟一的 ID 和一组字段 - 值对。这种数据结构非常适合示意工夫序列数据,例如日志、传感器读数等。通过 XADD,用户能够将这些数据存储在 Redis 中,而后应用其余命令进行查问和解决。XREAD
命令用于从一个或多个流中读取数据。你能够指定从每个流的哪个地位开始读取,以及最多读取多少条音讯。这个命令适宜于简略的流解决场景,例如,你只须要从流中读取数据,而不须要跟踪读取的进度。XREADGROUP
命令是 Redis 消费者组性能的一部分。消费者组容许多个消费者共享对同一个流的拜访,同时还能跟踪每个消费者的进度。这种性能对于构建可扩大的流解决零碎十分有用。例如,你能够有多个消费者同时读取同一个流,每个消费者解决流中的一部分音讯。通过 XREADGROUP,每个消费者都能够记住它曾经读取到哪里,从而在下次读取时从正确的地位开始。
咱们能够用 XREADGROUP 命令从一个特定的流中读取数据,如果这个流以后没有新的数据,那么收回 XREADGROUP 命令的客户端就会进入一种 阻塞期待
状态,直到流中有新的数据为止。同样的,咱们能够用 XADD 命令向流中增加新的数据,当新的数据被增加到流中后,所有在这个流上 ” 期待 ” 的客户端就会 被唤醒
,而后开始解决新的数据。
留神这里的 ” 期待 ” 并不是咱们通常了解的那种让整个服务器停下来的阻塞。实际上,只有收回 XREADGROUP 命令的那个客户端会进入 ” 期待 ” 状态,而 Redis 服务器还能够持续解决其余客户端的申请。这就意味着,即便有一些客户端在期待新的数据,Redis 服务器也能放弃高效的运行。
更多内容能够参考 Redis 官网文档:Redis Streams tutorial。
Bug 复现
好了,咱们能够来深入研究这个 bug 了,首先咱们来看下复现脚本。一共两个脚本,一个生产订阅者,一个发布者,其中:
- subscriber.py:这个脚本创立了一组订阅者,每个订阅者都尝试创立一个名为 ‘test’ 的工作队列,并继续从该队列中读取新的流。如果没有新的流,订阅者会暂停 5 秒钟,而后持续尝试读取。如果读取到新的流,订阅者会打印出新的流。这个脚本会继续运行,直到所有的订阅者过程都完结。
- feeder.py:这个脚本在同一个工作队列中增加新的工作。它创立了一组发布者,每个发布者都会在工作队列中增加新的工作,并在每次增加工作后暂停 0.1 秒钟。这个脚本会继续运行,直到所有的发布者过程都完结。
subscriber.py
代码如下
import time
from multiprocessing import Process
from redis import Redis
nb_subscribers = 3
def subscriber(user_id):
r = Redis(unix_socket_path='cache.sock')
try:
r.xgroup_create(name='tasks_queue', groupname='test', mkstream=True)
except Exception:
print('group already exists')
while True:
new_stream = r.xreadgroup(groupname='test', consumername=f'testuser-{user_id}', streams={'tasks_queue': '>'},
block=2000, count=1)
if not new_stream:
time.sleep(5)
continue
print(new_stream)
processes = []
for i in range(nb_subscribers):
p = Process(target=subscriber, args=(i,))
p.start()
processes.append(p)
while processes:
new_p = []
for p in processes:
if p.is_alive():
new_p.append(p)
processes = new_p
time.sleep(5)
print('all processes dead')
feeder.py
代码如下:
import time
import uuid
from multiprocessing import Process
from redis import Redis
nb_feeders = 1
def feeder():
r = Redis(unix_socket_path='cache.sock')
while True:
fields = {'task_uuid': str(uuid.uuid4())}
r.xadd(name='tasks_queue', fields=fields, id='*', maxlen=5000)
time.sleep(.1)
processes = []
for _ in range(nb_feeders):
p = Process(target=feeder)
p.start()
processes.append(p)
while processes:
new_p = []
for p in processes:
if p.is_alive():
new_p.append(p)
processes = new_p
time.sleep(5)
print('all processes dead')
留神这里 unix_socket_path
要改为本人 server 配置的 socket path。咱们先启动发布者 feeder.py 往流外面写数据,再用 subscriber.py 来生产流。预期的失常体现 (Redis server v=7.0.8 上就是这个体现) 是 subscriber 会继续取出 feeder 往流外面写入的数据,同时 redis 还能响应其余 client 的申请,server 的 CPU 占用也是在一个正当的程度上。
然而在 7.2.0 版本 (源码是 7.2.0-rc2,编译好的 server 版本是 v=7.1.241) 上,这里就不太失常了。咱们间接从 Github Release 7.2-rc2 下载 Reids 7.2 的源码,而后编译二进制。这里编译指令带上这两个 Flag make REDIS_CFLAGS="-Og -fno-omit-frame-pointer""
,不便后续剖析工具可能拿到堆栈信息。复现步骤很简略,启动 Redis server,接着运行 feeder.py 和 subscriber.py 这两个脚本。咱们会看到订阅者在解决局部流之后会阻塞住,不再有输入。同时 Redis 过程的 CPU 间接飙到了 100%,新的 redis client 也连不下来服务器了,如下图。
杀了两个脚本后,问题仍然存在,除非重启 server 才行。
ebpf 剖析
咱们先不去看 Issue 上对于问题起因的剖析,间接用个别办法来剖析这里 CPU 占用高的起因。剖析 CPU 首选 profile 采样,而后转成火焰图来看。这里强烈推荐 brendangregg 的博客 CPU Flame Graphs,介绍了针对不同语言的服务,如果用工具来剖析 CPU 占用。对于 Redis 来说,官网也给出了文档,咱们这里参考官网的 Redis CPU profiling,用 ebpf 生成 CPU 火焰图。
如何装置 bcc-tools 能够看官网文档,这里不开展了,而后咱们就能够用 profile 工具来做 cpu 采样。
$ profile -F 999 -f --pid $(pgrep redis-server) 60 > redis.folded.stacks
$ ../FlameGraph/flamegraph.pl redis.folded.stacks > redis.svg
profile 是 BCC(BPF Compiler Collection)工具集中的一个工具,用于采集 CPU 的堆栈跟踪信息。这个命令的参数含意如下:
- -F 999,设置采样频率为 999 Hz,即每秒采样 999 次,采样频率抉择奇数 999 是为了防止与其余流动产生同步,从而可能导致误导性的后果。如果采样频率与零碎中的某些周期性流动(如定时器中断、上下文切换等,个别都是偶数周期,比方 100Hz)同步,那么采样后果可能会偏差于这些流动,从而导致剖析后果的偏差。
- -f 折叠堆栈跟踪信息,使其更适宜生成 Flame Graphs。
- –pid $(pgrep redis-server),指定要采集的过程 ID,这里应用 pgrep redis-server 来获取 redis-server 过程的 PID。
- 60,采集的持续时间,单位为秒,redis 官网文档给的 profile 命令可能不实用某些版本。
接着应用了 flamegraph.pl 脚本,它是 FlameGraph 工具集中的一个脚本,用于将堆栈跟踪信息转换为 SVG 格局的 Flame Graphs。最终生成的 CPU 火焰图如下,这里手动过滤了极少局部 unknow 的调用堆栈(不然图片看起来太长了,有点影响浏览)。
通过火焰图,咱们找到了 CPU 跑满的执行堆栈,下一篇文章,咱们持续剖析为啥始终在执行这里的代码了。
本文由 selfboot 发表于集体博客,采纳署名 - 非商业性应用 - 雷同形式共享 3.0 中国大陆许可协定。
非商业转载请注明作者及出处。商业转载请分割作者自己
本文题目为:Redis Issue 剖析:流数据读写导致的“死锁”问题 (1)
本文链接为:https://selfboot.cn/2023/06/14/bug_redis_deadlock_1/