Python功能点实现:数据热更新

关键词:热更新 | 热重载 | 定时更新 | 即时更新 | 缓存 | functools | cachetools | LRU | TTL

假设应用需要加载一个配置文件config.txt,一般的做法类似于:

with open('config.txt') as f:
    parameters = f.read()

接下来parameters中存储的数据就可以被其他代码使用,但是这样写的话程序每次启动后,数据是固定死的,无法动态地自我更新,每次要修改配置/模型只能重启整个应用。

本文中热更新的意思是在应用运行时内,从外部(如文件、数据库、REST API)中获得数据并更新应用内的Python对象。应用场景一般是应用作为服务(有对外的API),需要在不重启的前提下更新自己的配置参数或者算法模型。

热更新可以分为两种:定时更新(periodic update)即时更新(on-call update),前者周期性地、主动地执行更新,后者则是被动地等待,直到接收到某种来自应用外部的信号才会执行更新。本文将通过内存缓存(Memory Cache)和装饰器(Decorator)技术来实现两种热更新。

内存缓存

先来说明一下内存缓存。缓存中的数据一般以键值对(key-value pair)的形式存在,value中放数据本身,key中放数据的某种描述名。缓存的容量决定了其最大可容纳的数据条数,当容量已满时再向缓存中存入新的数据,缓存就会采取开始清理行为:清除掉已存的部分数据,从而为新数据腾地方。清理的策略(判定何时需要清理、具体如何清理数据)有很多种,决定了缓存的不同类型。

Python中缓存常被写成装饰器的形式,缓存数据的key里放的是被装饰原函数的参数值组合(key的生成方法可以不同,后面还会提到),value里放的则是原函数的返回值。这样当函数被调用时,程序会先去缓存数据里找是不是已经有相同的参数值,如果有就直接返回已缓存的返回值,不重复进行原函数内的计算。

注意缓存的使用有一个隐含前提:函数本身是无状态的。假如函数内引用了全局变量,或者存在闭包,那同样的参数值不一定必然计算出相同的返回值。这样缓存的返回值和实际期望的返回值就不一定一致了。

定时热更新

定时更新的实现使用了来源于第三方库cachetoolsTTLCache,TTL(Time-to-Live)指存在时长策略。这种缓存为每一条存入的数据记录其存在的时长。每次调用都会检查是否存在超过某个设定时长阈值的数据,如果有就会开始清理行为:所有超时数据都会被清除掉;如果没有超时数据,缓存将会换用LRU策略,使缓存不超出容量大小(下一部分会提到LRU的具体策略)。

当我们将TTL缓存的容量设为1时、且用于加载数据的原函数参数不变的情况下,逻辑就变成了定时更新:

  • 未超过时长:缓存保留,每次调用都使用缓存数据
  • 超过时长,缓存清空(只有一条数据),程序重新计算(在这里即重新加载数据)

示例代码如下(运行需要安装cachetools,并在文首链接里下载完整的项目):

import time
import cachetools

from utils import change_conf_file

ROTATE = 5

@cachetools.cached(cachetools.TTLCache(1, ROTATE))
def reload():
    print('Cache cleared, reloading config...')
    with open('config.txt') as f:
        parameters = f.read()
    return parameters

class Model():
    def log(self):
        self.model = reload()
        print(self.model)

if __name__ == '__main__':
    # Reload automatically every [ROTATE] seconds
    model = Model()
    while True:
        time.sleep(2)
        change_conf_file() # change data
        model.log()

即时热更新

即时更新的实现使用了来源于Python内置库functoolslru_cache,LRU(Least Recently Used)指最少使用策略。这种缓存为每一条存入的数据记录其被使用的次数,每次调用都会检查缓存大小是否超出容量,如超出就会开始清理行为:会从使用次数最少的数据开始清理,直到缓存大小处于容量以内。

当我们将LRU缓存的容量设为1、且用于加载数据的原函数参数不变的情况下,原函数只有在第一次被调用时才会发生计算,之后调用都会直接返回缓存中的数据,到这里与一般的读取效果上并无区别。当我们需要热更新数据的时候,只需要主动清空缓存。如下例中Getter.getModel.cache_clear()。其中Getter.getModel是装饰后的函数,其中带有用于清理缓存的函数cache_clear()。有了这个扳机,我们只需要额外开发一个API(比如REST下的GET)来触发它,这样通过外部即时call API就可以进行热更新了。

示例代码如下(运行需要在文首链接里下载完整的项目):

import time
from functools import lru_cache

from utils import change_conf_file

class Getter:
    @staticmethod
    @lru_cache(1)
    def getModel():
        with open('config.txt') as f:
            model = f.read()
        return model

class Model():
    def log(self):
        self.model = Getter.getModel()
        print(self.model)

if __name__ == '__main__':
    # Reload only when cache_clear() is called
    model = Model()
    while True:
        model.log()
        time.sleep(2)
        change_conf_file() # change data
        Getter.getModel.cache_clear()
        print('Cache cleared, reloading config...')
        model.log()

这里补充一个细节,上面的示例中被缓存装饰器装饰的原函数getModel是一个无参数的函数,这种情况下lru_cache是如何运作的呢?lru_cache的实现中使用函数functools._make_key来生成缓存的key。在Python中,当原函数无参数时,默认参数args可认为是空元组(),可选参数可认为是空字典{},在这种情况下生成的key将会是空列表[](注意列表是不可hash的,不可直接作为字典的key,functools里的实际数据结构较为复杂,这里没有深入)。上一部分提到的第三方库cachetools实现了类似的方法keys.typedkey,两者生成的key存在区别,但是结合其他方法,行为在大部分情况是一样的,包括本文中的无参数函数情况。

import time
from functools import _make_key
from cachetools.keys import typedkey

if __name__ == '__main__':
    print(_make_key((), {}, False)) # []
    print(typedkey((), {}, False)) # ((), {}, <class 'tuple'>, <class 'dict'>)

扩展问题

  • 多线程情况:本文的热更新方法均基于缓存,而由于缓存涉及到读写操作,在多线程环境下我们需要考虑其正确性。functools.lru_cachecachetools.TTLCache里均有使用到锁的机制,再考虑到Python的GIL锁,本文所述的热更新在线程安全上应该算是有保障的,但目前未经试验无法完全下断言。更正:functools.lru_cache的文档中提到多线程环境下hit和miss的计数只是近似值,而Cachetool的文档中则明确提到cachetools.TTLCache这样的类是非线程安全的需要额外提供锁以实现同步。最开始写这篇文章的时候我对GIL的理解有误,按我现在的理解,GIL宽泛地讲只会阻止多线程调用多个CPU,但同一个CPU下的多线程仍然是有效的(不然还要多线程干嘛),所以同步的问题还是要考虑清除。
  • import:假如我们在一个模块(module)里更新model,而另一个模块import这个model,那么当原模块的model热更新后,import得到的model并不会更新,这种行为可能与Python自身的module cache有关。要实现所有module的热更新,能考虑的一个办法是让数据自己成为一个模块,使其变得可以在模块之间共享

吐槽:这篇写得我浑身无力啊,本来觉得很简单,就平时用的小东西拿来拎拎清,没想到越拎越深...很多时候我们很happy是因为我们站在冰山的最上面,不用面对水下的魔鬼细节...作为搞技术的,我们还是不能光看脸,也要多盯裆(≖_≖)✧

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,189评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,577评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,857评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,703评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,705评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,620评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,995评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,656评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,898评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,639评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,720评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,395评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,982评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,953评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,195评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,907评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,472评论 2 342