有趣的版本号
计算机的世界,版本号(version)无处不在,不管是发布的软件、产品,还是协议、框架。那什么是版本号呢
在这里是这样定义的:
Software versioning is a way to categorize the unique states of computer software as it is developed and released.
软件版本号是对开发、发布中的软件的状态的唯一(unique)概括。简单来说,协议就是对一组状态的手工签名。作为程序员,我们经常用md5来签名,保证数据完整性、可靠性。但是我们很难说,对软件或者协议计算MD5,那么版本号就是手工维护的签名。
为什么需要版本号,是因为软件(如linux内核)、协议(如http)都是在不断的发展完善中,也许是修复上一个版本的bug,也许是引入新的特性。当然,不能说有了新的版本就立马抛弃旧的版本,用户(广义的,程序员也是用户)是不会答应的,新版本也许有更高级的功能,但我用不到;新版本也许性能更好,但是不一定稳定。而且,版本升级是一个复杂的事情,维护老系统的程序员早都离职了,谁敢去升级。还有,开源的、免费的产品一旦放出,就不再属于开发者了。因此,多个版本的软件、协议并存是必然的事情,比如在对于Python语言,不管是官方还是一些开源组织,都呼吁放弃Python2,转向python3,但python2还是活得好好的。只要有多个版本 -- 本质是多组不同状态的软件 -- 存在,我们就需要用版本号予以区分。
软件、协议中的版本号,其最大的作用在于避免鸡同鸭讲。当我们讨论问题的时候,首先得明确大家是在相同的语义环境下,其中,版本号就是一个很重要的context,因为同一个术语在不同的版本可能代表的意思完全不一样,比如Python中的range函数。
版本号的形式
版本号的形式并没有固定的或者约定俗成的格式,完全取决于软件、协议的发布者。
数字形式(numerically)的版本号是最为常见的,比如http1.1,iPhone6, python2.7.3,其中 x.y.z 这种格式又是最为常见的。a代表大版本(major version),不同的a也许是不兼容的;b代表小版本(minor version),同一个大版本中的小版本一般是兼容的,小版本一般新增功能;c一般是修bug(revision)。
在服务化体系之-兼容性与版本号一文中,作者介绍到,在微服务结构中,服务的升级是高频度的事情,但服务升级的时候,一些接口是兼容的,而另外一些接口而是不兼容的。客户端不可能与服务端同步升级,因此多个版本的服务并存也是常态。那么在存在多个版本的服务时,客户端请求如何路由,就依赖于版本号:
服务的版本号,和软件的版本号一样,一般整成三位:
第一位:不兼容的大版本, 如1.0 vs 2.0
第二位:兼容的新功能版本,如1.1 vs 1.2
第三位:兼容的BugFix版本,如1.1.0 vs 1.1.1
果拿着低版本的SDK(如1.0.0) 发起请求,会被服务化框架路由到所有的兼容版本上(如1.1.1,1.2.0),但不会到不兼容的版本上的(如2.0.1)。
当我们使用一个软件、协议的时候,了解其版本号规则也是有好处的,比如Linux内核,也是x.y.z的形式,如2.6.8,但是第二位y却有特殊的意义:偶数表示稳定版本;奇数表示测试版本.
通信协议中的版本号
上面提到了兼容性,兼容性也是一个很广泛的词汇,在本文中,专指不同版本的软件、协议能协同工作,这个在通信协议、网络接口中非常广泛。在《通信协议序列化》一文中,作者循序渐进,从最简单的紧凑模式过渡到类似protobuf这种高级模式,在这个过程中,就提到了兼容性。本节内容都是对原文的引用。
在最简单的版本中,协议架构是这样的:
1 struct userbase 2 { 3 unsigned short cmd;//1-get, 2-set, 定义一个short,为了扩展更多命令(理想那么丰满) 4 unsigned char gender; //1 – man , 2-woman, 3 - ?? 5 char name[8]; //当然这里可以定义为 string name;或len + value 组合,为了叙述方便,就使用简单定长数据 6 }
种编码方式,称之为紧凑模式,意思是除了数据本身外,没有一点额外冗余信息,可以看成是Raw Data。虽然可读性差,但是节省内存和带宽。
但是当需要扩展协议内容的时候,问题就来了。比如,A在基本资料里面加一个生日字段,然后告诉B:
1 struct userbase 2 { 3 unsigned short cmd; 4 unsigned char gender; 5 unsigned int birthday; 6 char name[8]; 7 }
这是B就犯愁了,收到A的数据包,不知道第3个字段到底是旧协议中的name字段,还是新协议中birthday。
这是一个兼容性与可扩展性的问题,而引入版本号,加一个version字段就能解决这个问题
1 struct userbase 2 { 3 unsigned short version; 4 unsigned short cmd; 5 unsigned char gender; 6 unsigned int birthday; 7 char name[8]; 8 }
不管以后协议如何演变,只要version字段不同,接收方就能够正确解析协议。
MVCC
Multi-Version Concurrency Control 多版本并发控制
MVCC是一种并发控制( concurrency control )机制,在RDBMS中有广泛应用。并发控制解决的是数据库事务acid中的I(Isolation,隔离性),比如一个读操作与一个写操作并发执行,如何保证读操作不读取到写操作未提交的数据,即避免脏读(dirty read)。
要实现隔离性,最简单的方法是加锁(Lock-Based Concurrency Control),即一条数据记录同时只允许一个事务操作,比如并发读写的话可以使用读写锁。加锁虽然能解决并发控制的问题,但是在长事务中也会出现锁的争用甚至是死锁的情况。而MVCC通过为每一个数据项保存多分拷贝,每一个事务操作的其实是数据在某一时间点的一份快照,除非事务被最终提交,那么其他事务是无法读取到中间状态的,这就达到了隔离性的要求。
加锁与MVCC经常配合使用,二者在理念上有明确的区别,加锁是悲观的,认为很大概率会冲突,所以使用这一行数据之前先加锁,在解锁之前其他人都不能使用这条记录;而MVCC是乐观的,认为冲突的概率较小,所以使用时先不加锁,如果提交的后面发现冲突了,再自行回滚。
对于一个实现了MVCC的数据存储引擎,以更新一个记录为例,并不是在原来的记录上直接更新,而是拷贝、创建一个更高版本的数据记录,然后在新的版本上更新。这样即使同时有其他事务进行读操作,也是在一个稍微旧一点的版本上读取,互不影响。只有当更新记录的事务提交之后,修改数据库元数据,其他事务才会读取到最新版本的数据记录。
但MVCC对于并发写操作就没有那么好使了,多个并发写在提交的时候很可能会冲突,如果发生冲突,就需要回滚,也可以通过加锁的方式来避免并发写。
网上有很多MVCC在工业界上的实现,比如《轻松理解MYSQL MVCC 实现机制》这篇文章中对innodb mvcc使用详细介绍。
MVCC这种思想在分布式事务中也可以借鉴,在刘杰的《分布式原理介绍》中有相应介绍
缓存中的版本号
咋眼一看,似乎缓存中的版本号与软件、协议的版本号不是一回事,不过一细想,其实都是对一组状态的唯一签名。版本号在缓存中使用非常广泛,其根本作用在于解决缓存过期、不一致的问题。下面给出几个例子
web中的版本号
对于这个,前端开发人员应该都很熟悉,我只是班门弄斧,做个简单介绍。详细的可以参见《前端资源版本控制的那些事儿》
为了优化网页的加载、响应速度,一般会开启浏览器的缓存功能,即浏览器会缓存资源文件(js、css)。比如下面的index.html引用了两个资源文件:
<link rel="stylesheet" href="a.css"></link>
<script src="a.js"></script>
在缓存时间内访问页面时,浏览器不会真正发出请求,而是使用缓存的资源文件。
但这样也会引入新的问题,那就是当服务端修改html文件与资源文件,发布之后,客户端会拉取到最新的index.html,但是读取到的资源文件有可能还是旧的 -- 读取到的是浏览器缓存的资源文件。这就暴露了任何缓存最重要的问题,缓存过期的问题,当缓存系统的数据与原数据不一致的时候,就不应当再使用缓存中的数据,而是拉取最新的原数据,同时缓存最新的元数据。
但是在浏览器缓存这个实例中,浏览器是无法及时感知到缓存的数据已过期。虽然设置了过期时间(expire),但这个过期时间只是单方面的,只能约束客户端(浏览器)的行为,服务端并不保证在这个过期时间内不更新内容。这个不禁让我想到lease机制,lease机制保证了在过期时间内不会修改原数据,因此通过缓存读到的数据一定是最新的。
那么如何避免浏览器读取到过时的缓存资源文件呢,最常用,且一般情况下也够用的方法就是加上版本号。
<link rel="stylesheet" href="a.css?v=0.01"></link>
<script src="a.js?v=0.01"></script>
这样当资源文件变化时,只需修改版本号(上面的v),浏览器就会去服务器拉取最新的资源文件。当然,如果每次修改资源文件的时候都手动修改这个版本号,也是一个费力切容易出错的工作,所以一般都会引入自动化脚本,发布时自动修改版本号。
MongoDB元数据缓存
关于MongoDB,在我之前的文章也有一些介绍。在这里讨论的是MongoDB中元数据(metadata)的缓存,MongoDB中,元数据主要是每一个chunk包含的数据范围(range),以及chunk与shard的映射关系。元数据是整个系统的核心,需要保证高可用性与强一致性。
如上图所示,是MongoDB最常见的Sharded Cluster结构。其中,config server存储系统的元数据;shards真正存储用户数据;而mongos缓存元数据,利用元数据指定最佳的执行计划,也就是路由功能。可以看到,应用(Client app)直接与mongos交互,实际的线上应用,一般也是mongos与应用程序部署在一起,config server 与 shards对用户是透明的。
既然应用程序利用mongos上缓存的元数据进行路由,那么缓存的元数据就必须是准确的,与config server强一致的,否则用户请求就可能被路由到错误的shard上。那么MongoDB是如何解决的呢,答案就在MongoDB Sharded Cluster 路由策略
简而言之,就是增加版本号,元数据的每一次变更(chunk的分裂与迁移)都会增加版本号。这个版本号,在shard本地和元数据中都会维护,自然mongos缓存的元数据中也是有版本号的。当请求被mongos路由到某一个shard时,会携带mongos上的版本号,如果该版本号低于shard上的版本号,那么说明mongos上缓存的数据已经过期,需要重新从config server上拉取。
Python method cache
在《python属性查找》中,介绍了属性查找的顺序,而method属于类属性,如果一个method在类中没有找到,那么会按照mro的顺序在基类查找。那么,对于在一个多层继承的类体系中,属性访问是不是会很慢呢,理论上确实如此,但是实践测试的话并不会很明显。原因就在于在python2.6中,引入了method cache:
Type objects now have a cache of methods that can reduce the work required to find the correct method implementation for a particular class; once cached, the interpreter doesn’t need to traverse base classes to figure out the right method to call.
可见,python解释器会缓存访问过的method,这样就避免了每次访问的时候遍历基类。
但是,Python是动态语言,可以运行时改变代码的行为,也就包括增删method,这个时候缓存就与原始数据不一致了,Python是这么解决的
The cache is cleared if a base class or the class itself is modified, so the cache should remain correct even in the face of Python’s dynamic nature.
在源码(2.7.3)中,每一个缓存的entry都是如下的struct
1 struct method_cache_entry { 2 unsigned int version; 3 PyObject *name; /* reference to exactly a str or None */ 4 PyObject *value; /* borrowed */ 5 };
核心的函数_PyType_Lookup如下:
1 PyObject * 2 _PyType_Lookup(PyTypeObject *type, PyObject *name) 3 { 4 Py_ssize_t i, n; 5 PyObject *mro, *res, *base, *dict; 6 unsigned int h; 7 8 if (MCACHE_CACHEABLE_NAME(name) && 9 PyType_HasFeature(type, Py_TPFLAGS_VALID_VERSION_TAG)) { 10 /* fast path */ 11 h = MCACHE_HASH_METHOD(type, name); 12 if (method_cache[h].version == type->tp_version_tag && 13 method_cache[h].name == name) 14 return method_cache[h].value; 15 } 16 17 /* Look in tp_dict of types in MRO */ 18 mro = type->tp_mro; 19 20 /* If mro is NULL, the type is either not yet initialized 21 by PyType_Ready(), or already cleared by type_clear(). 22 Either way the safest thing to do is to return NULL. */ 23 if (mro == NULL) 24 return NULL; 25 26 res = NULL; 27 assert(PyTuple_Check(mro)); 28 n = PyTuple_GET_SIZE(mro); 29 for (i = 0; i < n; i++) { 30 base = PyTuple_GET_ITEM(mro, i); 31 if (PyClass_Check(base)) 32 dict = ((PyClassObject *)base)->cl_dict; 33 else { 34 assert(PyType_Check(base)); 35 dict = ((PyTypeObject *)base)->tp_dict; 36 } 37 assert(dict && PyDict_Check(dict)); 38 res = PyDict_GetItem(dict, name); 39 if (res != NULL) 40 break; 41 } 42 43 if (MCACHE_CACHEABLE_NAME(name) && assign_version_tag(type)) { 44 h = MCACHE_HASH_METHOD(type, name); 45 method_cache[h].version = type->tp_version_tag; 46 method_cache[h].value = res; /* borrowed */ 47 Py_INCREF(name); 48 Py_DECREF(method_cache[h].name); 49 method_cache[h].name = name; 50 } 51 return res; 52 }
代码逻辑并不复杂,分成三步
step1: 如果该函数有缓存,且缓存版本号与类型当前版本号一致(method_cache[h].version == type->tp_version_tag),那么直接返回缓存的结果;否则
step2:通过mro,找出method name对用的method实例
step3:缓存该method实例,版本号设置为类型当前版本号(method_cache[h].version = type->tp_version_tag)
从上面的几个例子可以看出,缓存中的版本号有时也是dirty flag或者lazy 思想的运用:当缓存内容过期的时候,并不是立即清空或者重新加载新的数据,而是等到重新访问缓存的时候再比较版本号,如果不一致再拉取最新数据。
总结
本文并没有一个明确的主题,都是我平时发现的版本号(version)在各种场景下的使用,比较有趣的是MVCC与缓存中的使用。当然,我相信还有更多更有趣的使用场景,而本人所接触的领域比较狭窄,权当抛砖引玉,欢迎各位朋友们指正与补充。