背景

线上有个 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.toolstools

Pasted image 20250124172339.png

原来在 main.py 中加载模块时是基于路径来的,其加载的模块时 model.tools,热更的也是 model.tools。但在 model.model1 中,却直接导入了 toolsmain.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 顺序,都不影响热更的效果。