背景

线上有个 Python 服务从消息队列获取数据进行处理,在每次处理处理前,会检查有哪些文件变更,针对变更的模块执行 reload 以实现代码的热更,但在一次更新后,发现变更后的代码并未热更生效。

花了半天时间排查,最终发现是代码实现上的一个小坑。

复现场景

基于原始代码逻辑简化逻辑后,复现场景共需要这些文件。

1
2
3
4
5
├── main.py
├── model
│   ├── model1.py
│   └── tools.py
└── P1001.py

各个文件内容如下。

main.py

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
# main.py
import os
import sys
import time
import importlib

class Server:
    def __init__(self):
        self.policy_module = []

    def reload_code(self):
        module_files = set()
        path_list = ["."]
        # 遍历当前目录下的所有py代码
        while len(path_list) > 0:
            path = path_list.pop()
            for file_name in os.listdir(path):
                if os.path.isdir(file_name):
                    new_path = path + "/" + file_name
                    path_list.append(new_path)
                    if new_path not in sys.path:
                        sys.path.append(new_path)
                else:
                    if file_name.endswith(".py") and file_name != "main.py":
                        abs_path_name = path + "/" + file_name
                        module_name = abs_path_name.replace("./", "").replace(".py", "").replace("/", ".")
                        module_files.add(module_name)

        print(module_files)
        
        # 如果已经加载过,那么就reload
        for k in (set(sys.modules.keys()) & module_files):
            print("reload %s" % k)
            importlib.reload(sys.modules[k])

        # 如果没有加载过,那么就加载
        for k in (module_files - set(sys.modules.keys())):
            print("import %s" % k)
            module = importlib.import_module(k)        
            if k.startswith("P"):
                self.policy_module.append(module)
        
    def on_recv(self, msg):
        # 每次处理数据前reload下
        self.reload_code()
        
        # 遍历所有策略模块进行处理
        for module in self.policy_module:
            module.policy(msg)
            
if __name__ == "__main__":
    s = Server()
    # 设置tools的print代码前缀是 >>
    with open("model/tools.py", "w") as f:
        f.write('def m_print(s): print(">>", s)')
    
    # 模拟处理第一个数据
    s.on_recv("test aaa")

    time.sleep(2)
    print()
    
    # 设置tools的print代码前缀是 --
    with open("model/tools.py", "w") as f:
        f.write('def m_print(s): print("--", s)')
        
    # 模拟处理第二个数据
    s.on_recv("test bbb")

P1001.py

1
2
3
4
5
6
# P1001.py

import model.model1 as m

def policy(msg):
    m.predict(msg)

model1.py

1
2
3
4
5
# model1.py
from tools import *

def predict(data):
    m_print(data)

tools.py tools.py 文件是空文件,在 main.py 中测试时,将代码内容写入到 tools.py 中。

上述代码中,只有 main.py 代码的逻辑稍微复杂些,大体来说,

  1. 在框架收到数据时,将会调用 Server 类实例的 on_recv 方法。
  2. 在 on_recv 方法中,首先会遍历当前目录下的所有代码,如果已经加载过,那么就会 reload,如果没有加载过,就会加载。同时存储所有 P 开头的模块。
  3. 遍历所有 P 开头的模块,并调用其 policy 方法。
  4. 在 P10001.py 代码中,调用了 model.model1 模块的 predict 方法,而在 model1 的 predict 方法中,则又调用了 tools 模块的 m_print 方法。
  5. 整个程序调用了两次 on_recv 方法,在这两次调用前,修改 tools 模块中 m_print 方法的打印前缀,第一次是 >>,而第二次是 --。
  6. 理论上来说,第一次打印数据时,输出的前缀应该是 >>,而第二次打印数据时,输出的前缀应该是 --。

但实际程序的执行结果如下,两次打印的前缀均为 >>。线上的问题与此类似,热更了 tools.py 的代码,但实际上并未生效。

Pasted image 20250124161749.png

原因

经过定位,核心原因在 model1.py 中的 from tools import * 这一句。

在程序处理完第一个数据后,查看 Python 进程中的模块,会发现有 2 个 tools 模块,分别是 model.tools、tools。

Pasted image 20250124172339.png

原来在 main.py 中加载模块时是基于路径来的,其加载的模块时 model.tools,热更的也是 model.tools。但在 model.model1 中,却直接导入了 tools(main.py 中在遍历 py 文件时,顺便也将路径都加入到了 sys.path 中,因此可以直接导入 tools),对于 Python 来说,这两个模块处于不同的命名空间下,也即是两个模块。

而 main 的更新逻辑更新的只是 model.tools,并不是 tools,所以这次热更并未生效。

尝试解决

最直接的解决办法是修改 model.model1 模块的导入逻辑,将其改为 from model.tools import *,这样 model.model1 使用的 tools 模块就与框架更新逻辑的一致了。

修改后的 model.model1 模块代码。

1
2
3
4
from model.tools import *

def predict(data):
    m_print(data)

但在实际测试中,发现这个修改时而生效,时而不生效。经过仔细对比,发现生效不生效与 reload 模块时的顺序有关。

Pasted image 20250124183848.png

当先 reload model.model1 模块时,将不生效,而先 reload model.tools 模块时则生效。

询问 kimi 得知 from a.b import * 的方式是将模块中的方法导入到当前命名空间中,必须刷新当前命名空间后才能生效。先 reload model.tools 之后再 reload model.model1 则保证了 model.model1 命名空间的刷新。

Pasted image 20250124184058.png

彻底解决

为了避免 reload 顺序导致热更失效的问题,最好还是不要使用 from aa import * 或 from aa import abc 的方式,而是直接导入整个模块,然后再去使用模块的方法。

这是修改后的 model.model1 的代码。

1
2
3
4
import model.tools as t

def predict(data):
    t.m_print(data)

此时,无论是哪种 reload 顺序,都不影响热更的效果。