Django migration 原理

Django 自带了一套 ORM,就是将数据库的表映射成 Python 的 Class,这样我们在写程序的时候,就基本不需要写 SQL 和数据库交互了,直接操作 Python 对象就可以了,Django 的 ORM 把我们对类的操作,转换成对数据库的 SQL 操作。

那么 Class 的属性是如何对应到 Table 的 Column 的呢?Class 的属性变了,如何关联到表结构的变化?本文就介绍这种 migration 实现的原理。

 

参与 Class 和 Table 结构一致的,一共有3个角色:

  1. Models:只记录了现在的 Model 是什么样子的;
  2. Migrations:记录了 Model 是如何从最初的样子,变成现在这个样子的——即要变成现在的样子,需要走哪几步;
  3. 数据库中的 django_migrations 表:记录在 Migrations 的所有步骤中,当前的 Table 已经执行了哪几步,这样就可以只执行没有执行过的步骤,来变成最新的状态;

一次 migration 之旅

前面我们说过,Django ORM 基本屏蔽了用户需要做的 SQL 操作,包括 DDL 操作。

从一个用户的角度看,我们做一次 migration,只需要3步:

  1. 修改 models.py,比如添加字段;
  2. 执行 python manage.py makemigrations ;
  3. 执行 python manage.py migrate ;

这时候,数据库中的 Table 和我们 Django 中的 Model 就对上了,我们写业务逻辑就可以了。

但实际上,Django 在背后做了什么呢?如果不了解这些,遇到一些问题就束手无策了(文末会提到一些经典的问题)。

在整个流程中,Django 负责的是自动识别出 Model 进行了哪些变化,将这些变化应用到 Table 中,保证最终 Model 和 Table 还是对应的。本质上,就是将 py 文件的变化,转成数据库的 DDL 变化。

所以首先,Django 要知道你的 Model 做了哪些变化。这是第2步:生成 migrations。Migrations 生成的方式,颇有点现在“声明式”的意思。

它的过程是这样的(这里我们将修改后的 Model 记作 Model-2,修改前的 Model 记作 Model-1):从 migrations/ 文件夹下的 0001_xxx.py 开始 apply,到 0002_xxx.py,一直到最后一个文件,这些文件都记录了 Model 的变化,都 apply 一遍之后,就得到了修改前的的 Model-1 的状态,然后和修改过的 Model-2 来比较,得到了 diff,然后看如何消除这些 diff——即产生最新的 migrations 文件,将 Model-1 的状态转移到 Model-2.这样新的 migrations 就生成了,如果你再跑一遍的话,就发现什么 migrations 也不会出现,因为 migrations 文件记录的变化已经和最新的 Model 一样了(声明式天生的幂等性!)。

这些 migrations 记录了 Model 从 0001_init 开始的所有变化,有了 migrations 文件,你就可以从一个空的数据库,变成现在的结构了。

但是如果数据库不是空的,而是已经有一些结构呢?通常是这种情况:生产环境的数据库,表结构是 v3(执行过migrations 0001,0002,0003),你在开发的时候生成了 migrations 0004,0005。那么怎么知道这个数据库只要执行 0004,0005 就可以了呢?更复杂一点的话,假如你有一个灰度服务器,一个生产服务器,灰度服务器执行过了 0001-0004,生产服务器落后一个版本,执行了0001-0003,怎么知道这些数据库应该执行哪些 migrations 呢?

这里根本的问题是:migrate 的执行(DDL的执行)不是幂等的。所以我们就需要一个地方,记录一下哪些 migrations 已经被执行过了。Django 会自动在你的数据库中建立一张 django_migrations 表,用来记录执行过的 migrations。这样你在执行 python manage.py migrate 的时候,django 会去对比 django_migrations 表,只执行没有执行过的 migrations,无论你有多少表结构版本不同的数据库都没有关系。

下面再用一张图来表示一下整个过程。其实想明白了很简单(我也不知道为啥这张图画出来咋这么复杂,点开可以看大图)。这张图是给一个很简单的 User Model 增加了一个字段。注意一开始蓝色的 Model 和 Table 是 map 的,最终状态红色的 Model 和 Table 也是 map 的。

一些骚操作

有了上面的知识,我们处理起来一些问题就得心应手啦!

1.migrate执行到一半失败了怎么办?

这种情况是很容易发生的,因为 make migrations 的时候,Django 只知道消除 diff,并不知道表结构。这就很有可能导致生成的 migrations 实际上是违反数据库约束的,而不能执行成功。

最糟糕的情况是,一个 migrations 中有很多步,其中部分执行成功了。就造成数据库的结构处于一个“未知”的状态。

不过不要慌,只要从 log 中看下哪些 migrations 步骤已经执行了,去手动 revert,然后从 django_migrations 删除这些记录即可。参考这篇文章

2.migrations应不应该加入到 git 仓库中?

应该!虽然是生成的代码,但是也是要用版本管理,让所有的数据库都执行一套 migrations!看看我的教训吧。

3.我什么 models 都没改,但是每次 make migrations 都会生成新的?

不要慌,我也遇到过。这时候你去看下生成的 migrations 做了啥操作,再想想 Django 为什么会这样做即可。

举个例子吧,我之前的 models 里面一个 default 值用了字典,我们知道字典 key 是没有顺序的(Python 3.7)之前,就导致每次 make migrations 的时候,顺序都有几率不一样,Django 就认为状态不一致,需要新的 migration 文件来消除 diff。

4.migrations 文件冲突了,我和同事开发的时候,我生成了一个 0003_xxx.py 我同事也生成了这个 0003.

不要慌,这是正常现象。按照 Django 的提示,执行生成一个新的 migrations 来 merge 前面的两个就好啦。python manage.py makemigrations --merge 。

5.我的migrations太多了,每次 makemigrations 都要等很久,或者我的 migrations 文件名已经快到 999_xxx.py 了!

我一生中只遇到过一个这样的项目。解决方法也很简单,因为我们只需要一个最终的状态,所以我们可以将之前的 migrations 都删掉。

步骤如下:

  1. 备份整个项目和数据库(非常重要);
  2. 删除除了 __init__.py 之外的所有 migrations 文件夹下的文件:ls */migrations/*.py | grep -v __init__ | xargs rm;
  3. 重新生成 migrations 文件,不出意外的话,所有的 app 都只有一个 migration 文件,就是 init;
  4. 删除数据库 django_migrations 表中所有的记录:delete from django_migrations;
  5. 因为我们的表结构已经是最新的了,所以新生成的 init migrations 并不需要执行。所以我们插入记录到 django_migrations 中,骗 djagno 我们已经执行过了。用这个命令:python manage.py migrate --fake

大功告成,不出意外,makemigrations 又快了起来。

其他的一些骚操作

如何查看一个 migration 文件对应的 SQL 是啥?

如何查看这个数据库执行过哪些migrations了?

最后 n 次执行的 migrations 有问题,数据库炸了,要不要删库跑路?

不用。

 

最后,不要在部署的时候自动执行 migrations。具体推荐大家看下这篇文章:Decoupling database migrations from server startup: why and how (这里面的问题我都遇到过,哭笑)。

其他一些类似 Django ORM 的 migrations 的项目:

 

Linux 进程的生命周期

这篇文章将介绍 Linux 中一个进程是如何诞生的,如何结束的。进程有哪些的不同状态,这些状态如何进行转换。

首先我们从进程的基本概念说起。

Linux 中,进程是由父进程创建的,每一个进程又可以作为父进程再创建子进程。每一个进程都有一个ID,叫做 pid,作为进程的唯一标志。Pid 在系统的同一时间不会重复,当分配 pid 的时候,kernel 会取当前系统中最大的 pid + 1,直到这个值超过 /proc/sys/kernel/pid_max ,所以 kernel 不保证 pid 在不同时间不会复用。在进程的结构体里面保存了 ppid,就是父进程的 id。根据 pid 和 ppid 我们可以找到每一个进程的父进程,这样,系统中所有的进程就像一个树一样可以串起来。

通过 pstree 这个工具,我们用一个树展示出所有的进程。

可以看到,最上层的进程的 pid 是 1,其他的进程要么是 pid 1 进程的子进程,要么是 pid 1 子进程的子进程,pid 1 的进程是所有进程的“祖先”。Pid 为 1 的进程,是在 kernel 启动的时候就创建的,所以实际 init 进程没有通常意义上的“父进程”,这里显示出它的父进程是进程0,只是一个标志。

Pid 为 1 的进程是可以指定的,我这个系统中是 systemd。如果不指定的话,Kernel 将尝试以下 4 个可执行文件:

  1. /sbin/init: The preferred and most likely location for the init process.
  2. /etc/init: Another likely location for the init process.
  3. /bin/init: A possible location for the init process.
  4. /bin/sh: The location of the Bourne shell, which the kernel tries to run if it fails to find an init process.

除了 pid 为 1 的进程外,其实还有一个 pid 为 2 的进程,父进程也是 0. 这个进程叫 kthreadd。

同理,这个进程也是在 Kernel 启动的时候就创建的。Linux 中有一些进程是跑在 Kernel 中的,但是为了统一调度,这些进程也同用户进程一样放在一起管理。kthreadd 就是这些 kernel 进程的父进程,等同于 init 进程(或者 systemd 进程)之于我们的用户进程。

Pid 0可以认为就是 kernel 本身。这个进程也是 idel process,当没有可以运行的进程的时候,kernel 就运行进程 0.

那么一个进程是如何产生自己的子进程的呢?

进程创建

在 Unix 中,创建进程和载入要执行的程序是分开的。创建一个进程一共2步,涉及2个 syscall。

第一个是 fork(),负责创建出来一个新的进程。那么新的进程也需要有可执行的代码、地址空间啥的呀,默认是啥呢?总不能是空的吧,如果是空的,那 kernel 执行到这个进程怎么办?

答案是 fork() 出来的进程,和父进程是一模一样的,除了:

  • 子进程的 pid 被重新分配;
  • 子进程的父进程被设置为原来的进程;
  • 子进程资源的统计被初始化成0;
  • 子进程中,未处理的 signal 全部清除;
  • 父进程的 file lock 不会继承到子进程;

那两个进程是一模一样的,我执行了 fork() 之后,怎么让这两个进程做不一样的事情呢?答案是 fork() 的返回值不同,fork() 在父进程中返回子进程的 pid,在子进程中返回 0.这样我们就可以在调用 fork() 之后,通过一个 if 判断,让子进程和父进程做不一样的事情。一般是下面这种代码模板:

 

新的进程创建好了,我们想让新的进程去执行另一部分任务,但是无法像上面这样都写在代码中,怎么办呢?举个很好理解的场景,我在终端的 bash 中执行了一个 top 命令,那么 bash 的代码肯定是和 top 没有关系的,怎么让 bash 新创建的子进程去执行 top 的代码吗?

这就是载入部分了。载入的 syscall 是一个函数族,这里用调用最简单的 execl() 举例:

有关 execl 具体的行为,可以参考下 manual.  exec 是一个 syscall family,除了 execl() 这个之外,还有:execl, execlp, execle, execv, execvp, execvpe。本质上他们都一样,只是传入参数的格式、path、环境变量有些许不同。这些函数都是 glibc 提供的库函数(其他的 C 库提供的会有所不同,比如 libc 提供的函数族就是 execl, execle, execlp, execv, execvp, execvP ),底层他们调用的都是 execve(感谢 Nitroethane 指正)。

通常,打开的文件可以通过 exec 继承。比如父进程打开了一个文件,那么父进程的子进程是有 full access 的。如果不想这么做的话,可以在 exec 之前关闭这些文件。

 

Copy-on-write

在早期的 Unix 系统中,fork() 之后要复制很多东西到子进程,最耗时的是要一个 page 一个 page 地拷贝内存,这个操作非常耗时。

现代的 Unix 系统中,有一种叫做 copy-on-write 的机制。本质上是对这种资源复制的优化策略:如果多个消费者都需要一段内存的副本的话,先不去真正的做拷贝,而是让这些消费者都认为自己持有这段内存。如果进行读操作,那其实大家读到的还是同一段内存;如果要进行写操作,那这段内存(以 page 为单位)就会透明地被 kernel 拷贝出来。所以是一种懒加载的策略,只有用到的时候才去拷贝。

fork() 可以说是这个机制的最大受益者,因为 fork() 之后经常跟的是 exec ,也就是说子进程被 fork 出来之后都去干别的了,你复制一遍父进程的内存过来也没有意义。

这里我还想提一下,Redis 的 rdb 机制也是 copy-on-write 的一大受益者。Redis 的 rdb 就是定期将 Redis 内存中存储的数据转存到硬盘上,这个功能是基于 fork 实现的:当 Reids 要要 dump rdb 的时候,先 fork 出一个子进程,然后子进程开始从内存读出来数据,写到硬盘上,而原来的进程(父进程)继续提供服务。得益于 copy-on-write,子进程不需要完全复制一份父进程的内存来做写硬盘操作,即节省了时间和CPU,也节省了内存——它和父进程读的是同一段内存。

但是这里有个问题就是,假如 Reids 用在了一个写操作非常频繁的场景的话,那 copy-on-write 的意义就不大了。子进程 fork 出来之后,父进程因为写入很频繁,大部分内存都脏了,都需要被 kernel copy 出一份进行写操作。就可能导致在 dump rdb 的时候,内存占用提升了一倍(子进程和父进程分别有一份内存)。

进程销毁

一个进程要结束自己,非常简单。调用 exit() 这个 syscall,kernel 就会结束这个函数的调用者进程。我们也可以通过 atexit() 或 on_exit() 函数注册一些进程退出时的 hook。

除了显示的调用 exit() 之外,一个经典的退出方式是执行完整个程序。比如 main() 函数的代码最后,编译器会插入一个 _exit()

进程收到 SIGTERM 或 SIGKILL 之后也会退出。具体的 signal 下面会介绍。

最后一种结束进程的方式是,惹的 kernel 不高兴了。比如出现 OOM 了(out-of-memory),或者 segment fault。kernel 会结束这个进程。

子进程退出之后,谁关心子进程是如何退出的呢?比如 daemon 进程退出了,systemd 如何实现“restart on failure”呢?所以这里还需要某种机制,来关心子进程的退出状态。

当子进程的状态改变之后,父进程会发生2件事情:

  • 父进程会收到 SIGCHLD 信号;
  • 父进程 block 调用 waitpid() 会返回(即这个系统调用会拿到子进程具体的事件);

也就是说父进程如果关心子进程的状态的话,一般是会设置 SIGCHLD 信号的 handler(进程如何处理信号,本文暂不涉及),收到这个信号之后去调用 wait() 系统调用,得到具体的事件。

这里有一个 Python 写的例子,展示了父进程如何得到子进程的状态。

执行这段程序,输出如下:

这段代码执行的流程如下:

子进程在退出之后,保存进程状态的一些信息并不会立即销毁,因为这样的话,父进程就无法获得这些信息了。它们等待父进程调用 wait() 来读出来后,才会真正销毁。

这种已经退出,但是 state change 并没有被父进程读到的进程,叫做讲僵尸进程(Zombies)。父进程创建了子进程,就需要对子进程负责,在子进程退出之后,去调用 wait() 来 clear 这些子进程状态,即使父进程并不关心这些状态是什么。不然的话,这些子进程的状态将一直存在,占用资源(虽然很少),成为 ghosts,这些 ghosts 的 parent 也成为了不负责任的父母。

通常,我们可以安装一个 signal handler 来 wait() 这些子进程。需要注意的是,发给父进程的 SIGCHLD 可能被合并,比如有 3 个子进程退出了,但是父进程实际上只会收到一次 SIGCHLD。所以我们在 wait() 的时候要注意使用一个循环。

 

如果一个进程创建了子进程,但是在子进程之前就退出了呢?这样子进程就没有父进程来调用 wait() 了。

当一个进程结束的时候,kernel 会遍历它的子进程,将这些父进程已经死掉的进程,分配给 init 进程(即 pid 是1的进程)作为它们的父进程。这就保证了系统中每一个进程,都有一个直接的父进程。init 进程会定期去 wait() 它所有的子进程。

进程的状态

上面我们已经提到了一些进程的状态:Zombies,Runing,Stopped。

这里重点解释一下几种状态:

  • R – runnable,处于这个状态的进程是可以执行的,会被 kernel 放到一个待执行的 queue 中,分配时间片去执行。我们无法控制进程的运行,runnable 的进程由 kernel 的 scheduler 来决定什么时候运行,运行多长时间;
  • D – 不可中断的 sleep(一般是IO);
  • S – 可中断的 sleep;
  • Z – 进程已死,或者失去响应,死透了;
  • T – 进程以暂停(是可以恢复的,还没死透);

这里面其他状态都比较好理解,D 和 S 有点模糊。

代码在执行的时候,会在 user space 和 kernel space 之间切换。当执行 syscall 的时候,代码的执行从用户空间切换到内核空间。当在等待 system call 的时候,进程处于 S 状态或者 D 状态。S 状态比如 read() ,这时收到像 SIGTERM 这样的信号,进程就会提前结束 syscall,返回用户空间,执行 signal hanlder 的代码;如果是处于 D 状态,进程就是不可 kill 的

T 状态是可以恢复的暂停。比如我们给进程发送 SIGSTOP 或者按 CTRL+Z,就可以将进程置为暂停状态,可以通过 bg/fg 命令,或者发送 SIGCONT 信号恢复。

下面开启了一个 sleep 命令,然后用 CTRL+Z 暂停它,再用 fg 重新开启。展示了 S -> T -> S 的状态转换:

将 sleep 命令替换成 yes > /dev/null ,上面实验的状态 S 会变成 R,其他一样。

综上,状态流转如下(再盗图一张):

所有的进程状态,可以看下这个文档,介绍了源代码中定义的进程状态。

信号

上面介绍了一些信号,这里再列一些常用的:

  • SIGCHLD:子进程状态改变;
  • SIGCONT: 进程继续执行;
  • SIGSTOP: 进程暂停,和上面的 SIGCONT 是一对;
  • SIGTERM: 指示进程结束,这个信号可以被捕捉或者忽略,允许进程收到这个信号之后 gracefully exit;
  • SIGKILL: 立即结束进程,收到这个信号的进程无法忽略这个信号,必须退出。但是 init 除外,init 不接受 SIGKILL;
  • SIGINT: 从终端(用 CTRL+C)发出的终端信号。在一些 REPL 中会介绍当前的命令,也有些进程的表现是直接退出;

信号本质上是一中进程间通讯方式(IPC),Signal wiki

 

以上就是基本的进程知识了,本文所有的参考资料已经在文中链接。这是我最近读 Linux System Programming 的笔记,如有理解错误,请指出。接下来还会分享一些有关 Linux 的文章。

 

如何正确实现分布式锁

分布式锁几乎是分布式系统中必备的一个组件,通常用在两个场景:

  1. 保证耗时的操作同一时间只有一个执行者,减少重复执行的时间;
  2. 保证一个共享资源同时只有一个修改者,防止竞争条件;

我在某些公众号看过好多讨论分布式锁的文章(在公众号里面好像是一个很热门的话题),但遗憾的是,我看过的这些实现里,或多或少都有问题。

一个分布式锁要求满足两点基本的条件:

  1. 同一时刻只有一个锁的持有者,不能出现两个进程同时持有锁的情况(safety);
  2. 不能出现一个进程永远持有锁的情况(死锁)(liveness);

在使用场景上,第一种场景(减少重复执行)对锁是弱依赖的,假如锁失去 safety,那么影响就是某个操作被多执行了几次;而第二种情况对锁的要求是强依赖的,如果锁没有 safety,就会面临数据不一致的严重后果。

最基本的实现

实现分布式锁,最基本的是有一个唯一的资源,支持 CAS 操作,最简单的实现如下面这样:

这也是本地环境中我们保护一个共享资源的方法。但是在分布式系统中,我们会遇到很多复杂的问题,比如获得锁之后,实例宕机了,这样它永远不会把锁还回来,造成了死锁。本地环境基本不需要考虑这种情况,因为线程退出一般会进行清理操作,在 finally 中释放锁;如果是整个进程 crash 了,那么连通锁、共享变量等就一起没了。

于是对于分布式的系统中,这些锁一般都需要带有一个“有效时间”,超过了这个时间,锁自动又变成了可获得的了(注意这一步是锁的提供者将锁过期的)。这就是常见的租期机制。

租期机制

租期机制解决了 liveness 的问题,但是带来了潜在的 safety 的问题。

因为在没有租期机制的情况下,如果原来锁的持有者不释放,那么锁是永远不会被再次 acquire 的。但是现在有了租期的机制,即使原来的进程还持有锁,但租期已经到了,那么锁就可以重新被 acquire。这样就有多个实例同时获得锁的可能性存在。

这就要求锁的持有者必须在锁的租期内完成事务,将锁释放。如果一个租期处理不完,那必须自己延长租期。

延长租期这里有一个细节,非常重要。就是延长租期的时候必须确认自己依然是锁的持有者。举个例子:现在有 A 和 B 两个实例,A 获得了锁,然后出现了网络问题掉线了,A 的租期超时,这时候 B 又成功获得了锁。A 的网络问题解决,重新上线,那么这时候我们就有了两个锁的持有者,A 和 B 同时续期。

这个问题其实很有意思,我在实际就遇到过这种情况:我用的一个定时调度器(Redbeat)为了保证高可用,采用了多个实例的同时运行的方法,然后通过一个分布式锁来保证只有一个获得锁的实例真正工作,然后此实例不断续期。如果这个实例没有续期成功,那其他实例就会获得锁开始工作。

Redbeat 的续期操作没有考虑检查锁的持有者问题,就导致可能出现多个持有者的情况,造成一个任务被执行两次。我在这个 issue 里面详细描述了这种情况和复现方法。

这个问题的解决方法很简单,就是我们在锁上面标志出目前锁的持有者,每次续期操作的时候都检查是否依然是持有者,如果不是,就失败掉续期,并且认为自己不再是锁的持有者(具体的一个实现参考下面)。在 Redbeat 里我提了一个 PR,先检查是否是锁的持有者再进行续期操作。

这样,锁的 liveness 还是有保证的,不会出现锁的持有者已经挂了,但是锁没有释放的问题;续期的机制也阻止了竞争者重复获得锁的问题。

以上都是分布式锁的经典实现,这些实现问题最多的其实是在锁的释放上。

锁的释放

对于提供 CAS 的系统,比如 Redis,我们一般用它的 SETNX 语义(如果 key 不存在则设置)来实现。释放锁的过程是删除这个 key,这样其他进程就可以获得锁了。

这种实现存在的典型问题是在释放的时候,没有检查自己是否仍是锁的持有者(本质上,和上面延长租期的时候要检查自己依然是锁的持有者,是一样的)。

考虑这样一种情况,存在进程A、B,A 获得了锁,然后出现网络问题,锁的租期过了之后 B 获得了锁。之后 A 重新上线,去释放锁,这时候 A 实际上释放了 B 的锁,造成可能有 C 重新获得锁,那么 B 和 C 就同时成为了锁的持有者。

锁的持有者标志方法

解决以上两个问题,就是标志出谁持有了锁,只有锁的当前持有者才能对锁做续期、释放等操作。

一种标致锁的持有者的方式是,设置的锁的时候将 value 设置为持有者的名字,比如 实例、进程、线程ID,不过考虑到 value 要唯一,实现起来可能有些复杂。

另一种方法是将锁的 value 设置成唯一的 token,获得锁的时候记住这个 token,释放、续期的时候检查 token 是否和自己的 token 一样。Redis-py 中的 lock 就是这样实现的。

但是 redis-py 中的 extend 操作(对我来说)很奇怪,这个 extend 10s 实际的语义是:将锁原来的租期还剩下的时间,加上 10 s。考虑上面 Redbeat 的场景,肯定是要在锁过期之前续期的,比如说我在使用锁第 9s 的时候续期,那么续期 10s 效果是锁的过期时间变成 11s。然后再运行 9s 的时候续期,锁的时间变成 12s。就导致运行的时间越长,锁的过期时间越长,假如进程挂了,要经过很长的时间(不可预期),其他进程才能 take over。

实际上在 Redbeat 中我们实现的 extend 是“Extend to”的语义。将锁的过期时间重置到 10s。这样就不会使得锁的过期时间越来越长。

Redis 的 SETNX 文档中描述了一种锁的实现模型。这个文档没有使用过期机制,而是将 timestamp 设置为锁的 value,然后用检查过期时间的方式来判断锁有没有释放。对于锁的 timestamp 过期,多个实例试图获得锁的时候,是通过 GETSET 保证只有一个实例拿到了锁的。其他实例需要拿到锁,发现自己不是第一个的话,要认为自己获得锁失败。

 

同一租期内的问题

以上,看起来不可能出现竞争条件了,但实际上这个实现依然是有问题的。DDIA 的作者在跟 antirez 有关 redlock 的争论中,举了这么一种情况:A获得了锁,在租期内发生了 GC stop the world,然后时间超过了租期,但是 A 并不知道。这时候 B 获得了锁去更新了资源,然后 A 过了暂停之后也去更新了资源。

Client 1 不可能做每一步操作都检查锁是否过期,这样太繁琐了,并且即使这样,依然可能在检查锁ok之后发生进程暂停。然后这时候过期(因为检查操作和写操作不是原子的)。

这个问题的解决方法是 fencing。获得锁的时候,锁服务为其分配一个只递增的值。然后进程在访问共享资源的时候会带上这个递增 token,当存储资源接受写操作的时候,会只接受更大的 token 的写,低于存储资源当前最大 token 的写请求会被拒绝。

注意:这里要求写操作是感知和记录下现在的最大的 token 的,实现的复杂度上也有所增加。

 

以上,我们讨论的锁都是基于一个安全可靠的、提供 CAS 机制的资源来实现我们的分布式锁的,假如要实现这样一种资源(比如 Redlock 的尝试),就要面对进程暂停、时钟偏移、网络延迟问题,要复杂的多。

推荐阅读:

  1. Leases: An Efficient Fault-Tolerant Mechanism for Distributed File Cache Consistency 
  2. Database · 理论基础 · 关于一致性协议和分布式锁
  3. Is Redlock safe?
  4. How to do distributed locking
 

Django 项目使用 CircleCI

自从认识了 CircleCI 之后,基本上都在用这个了。相比于之前用的 travis-ci,CircleCI 丑是丑了点,但是相比与 travis 有几点好处:

  1. CircleCI 基于 docker image 的,怎么做隔离的不太清楚,有可能是在虚拟机上面执行 docker 来做隔离的,而 travis 还是基于 VM。这样好的好处是,CircleCI 提供了很多 image,你可以组合出来很多服务。比如 Django 项目用到了 redis 和 MySQL,你只要在 yaml 里面加上这两个 image 就好了,而 travis 要在 VM 里面处理好服务依赖。不仅不方便,每次执行速度也慢很多。
  2. CircleCI 支持 private repo,travis 只支持开源 repo。

但要说缺点的话,CircleCI 用户体验实在不如 travis,配置比较复杂。每次用都会多少踩一些坑。这篇文章介绍一下一个 Django 项目接入的过程,和其中一些要注意的坑。

1. 设定好 Django 项目的测试和依赖

以前 Django 测试用的是 Django 自带的 manage.py 里面的 test. 后来发现还是 pytest 比较好:插件多、模板代码少些很多,fixture 的设计比较合理,测试中使用到 db 需要明确声明,否则无法 access db,这样更加 explicit,测试执行的速度也更快。

除了 pytest,其他的还有一些依赖,test-requirements.txt 文件的内容如下:

其中 pytest-django 是 pytest 继承到 Django 中去了,pytest-cov 是追踪测试覆盖率的,factory_boy 是可以根据 Django 的 ORM 自动生产测试需要的 Model (这个强烈推荐,如果不用这个的话,需要写一推 json 来事先定义好测试用的 Model,后续维护也很费劲,如果改了一个不需要测试的 Model 的 Field,这些 json 也需要维护)。

然后运行 Django 测试,使用 pytest 命令就好了:

这个命令太长了,我们可以将环境变量和命令参数写到 pytest.ini 文件中去:

这样每次测试,使用 pytest 这个命令就可以了,参数和环境变量会自动设置。

解释一下每个参数是干嘛用的:

  • 第一行是 Django 环境变量,用来区分测试使用的 django.conf.settings 和开发、生产用的;
  • –resuse-db :pytest 测试复用DB,不必每次都创建然后执行 migrations,对测试执行速度的提升非常明显。但是在 CircleCI 上运行测试,由于每次 MySQL 都是一个新的镜像实例,所以还是要每次新建数据库,执行 migrations 的。这个参数只是在本地执行的时候有用;
  • –cov-config / –cov :这个是追踪测试覆盖率的 coverage.py 使用的配置文件,和要追踪测试覆盖率的文件夹;
  • –cov-report :生成测试覆盖率的格式,每次运行完测试之后,生成覆盖率测试的文件。在 CircleCI 上我们可以设置测试运行完之后将这些文件上传至 artifacts 上去,可以在浏览器看这些文件;
  • –junitxml : 测试的 Summary,也可以在 CircleCI 上展示;

以上就是项目中测试的配置,现在运行 pytest 可以自动发现项目中的测试用例执行了,并且测试完成后会生成测试报告。

接下来介绍如何在 CircleCI 上配置,实现每次 git push 之后自动执行代码。

2. 在 CircleCI 开启 CI 和设置运行环境

接入的方式分两步,根据 CircleCI 的指引就可以:

  1. 用 Github 账户登陆 CircleCI,然后在 CircleCI 上导入 Github 的项目;
  2. 在项目中添加 .circleci/config.yml 配置文件,git push,就会自动触发 CircleCI 的 build 了。

其中配置文件以我的这个项目为例,配置文件如下(基本上是拿 CircleCI 的配置模板修改了一下,保留了注释):

前面说过 CircleCI 是基于 Docker 的,它的一个好处就是:如果你需要 MySQL、Redis 之类的服务,只要在 docker 这里声明就好了,CircleCI 在启动测试的时候会帮你启动这些容器。

build:docker 这里的配置就是用到的服务,用到哪个配置就写上 CircleCI 的 image 就好了,常用的都有,比较丰富。后面的 steps 来定义 CI 的步骤,一些事先定义好的 steps 可以参考下文档, 比如 clone 代码之类的就不需要自己实现了。

但是这里有一些挺坑的地方,需要注意。

使用 CircleCI 官方 MySQL 这个 image 需要设置验证方式,不然的话会遇到以下这个错误:

MySQL,redis 等都不能通过 .sock 文件访问,访问的时候不要使用 localhost,使用 IP。因为本质上这不是在同一个 image 启动的,测试所在的容器并不会有这些服务的 sock 文件,实际上是启动了不同的 image 然后通过 docker 的 network 放在一组,实现了访问。

还有一个巨坑的地方是,有时候你依赖的服务还没准备好,测试就开始执行了。我用的时候发现有的时候访问 MySQL 端口不通,有的时候却是通的。解决的方式也很挫,就是 31-38 行,使用 dockerize block 住这个 step,不断检查端口是否接受连接了。端口通了才继续执行后面的步骤。

这里为了加快测试的执行速度,可以将创建的 venv 缓存起来,参考上面的 restore cache 和 save cache 那一步。需要注意的是 key 加上了 checksum,这样依赖文件更改的时候可以自动重新安装。有个小坑的地方是 CircleCI 竟然没提供删除 cache 的功能,所以我的 key 加上了 v3 ,如果想弃用之前的 cache 的话,只要升级到 v4 就好了,cache 找不到自动安装新的。

最后两步是上传测试 Summary 和覆盖率文件。效果如下:

test summary 展示

测试覆盖率文件

注意 venv 不要建立在 working_directory 下面,不然你的 venv 里面的库也会被追踪测试覆盖率。

最后再吐槽一下 Artifacts 没有自动打开 index.html 的按钮,每次都需要自己找到这个文件点开,有点反人类。

 

Redis客户端缓存设计(In-Process caching)

今天看 antirez 写了一篇有关客户端缓存设计的想法:《Client side caching in Redis 6》(文章比较难懂,如果先看 Ben 演讲,理解起来 antirez 这篇博客会轻松一些)。antirez 认为,redis 接下来的一个重点是配合客户端,因为客户端缓存显而易见的可以减轻 redis 的压力,速度也快很多。大公司或多或少都有实现这种应用端缓存的机制,antirez 想通过 server 端的一些设计来减少客户端缓存实现的复杂度和成本,甚至不惜在 redis 协议上做修改。

antirez 的博客对细节介绍的比较清楚,我这篇文章算是拾人牙慧了,一来理清一下自己的思路,二来我认为除了缓存,这个机制还可以用在实时控制应用程序的某些配置上面。跟大家分享一下。

一、缓存的数据一致性问题

通常我们谈论缓存一致性的时候,一般在谈的都是这种架构:应用有一个数据库和缓存,数据库中的常用数据会被放到缓存中,在这种场景下如何保证数据库的数据和缓存中的数据是一致的?这种其实比较好解决,Cache-aside 模式已经是比较成熟和通用的了,实现上也比较简单和可靠。

Remember kids: you either have a single source of truth, or multiple sources of lies.

但 Redis 这种缓存从某种意义上还是一种远程的缓存,每次缓存读取会增加一次 TCP RTT(这个影响个人认为随着技术发展会逐渐减少),数据的序列化和反序列化也需要资源。如果对效率有更高的要求,就要考虑进程内缓存了。

进程内缓存的数据一致性比分布式的缓存面临更大的挑战。一个进程更新的时候,如何通知其他进程也更新自己的缓存呢?如果按照分布式缓存的思路,我们可以缩短缓存过期时间,进程内缓存如果过期了就去分布式缓存获取数据。这样不必实现复杂的通知机制,但是不同进程内的数据依然会面临不一致的问题,并且不同进程缓存过期时间不统一,用户体验也不好,同一个请求到了不同的进程,可能出现反复幻读的情况。另外也会对分布式进行大量不必要的更新,浪费网络资源。

进程内缓存面临2个主要的问题是:

  1. 保证数据的一致性,包括各个进程缓存的数据要是一致的,进程缓存和 Redis 缓存要是一致的;
  2. 尽可能减小网络压力;

为了实现所有进程的缓存的一致性,显而易见的实现是,当一个 key value 被修改了,广播被修改的键值对。所有客户端收到广播的时候更新自己的 kv。这种实现的缺点是1)有可能一个进程收到两个冲突的广播,无法解决。2)广播键值对,这样修改缓存的代价太大了。基于这两点,可以想到优化方案是我们只广播更新的 key,Redis 的缓存最为 Source of Truth,客户端收到了 key 更新的消息,就去 Redis 获得最新的键值对。这样就解决了冲突的问题,对资源的消耗也少了。

这里还可能存在的问题是:业务为了避免 key 冲突,通常会把 key 的名字写的很长:project:app:username:lastlogin 。这样 debug 起来也很简单。Redis 的 Key 长度限制是 512M,“如果一个问题有可能发生,那么它就一定会发生”。另一个问题是即使其他进程缓存了 key 但是用不到,也要立即去 Redis 获取最新的 value,也浪费了资源,当然这里可以用类似 Cache-aside 的策略,收到 key 即删除,下次用到的时候再取,这个问题不大。

所以 Ben 视频中提出了一个很有创意的实现。这里是借鉴了 Redis Cluster,所以我们先跑个题:Redis Cluster 是将 key Hash 到不同的 slots,每个 Node 都保存一个范围的 Slots(Shared nothing),当读请求过来的时候,对这个请求的 key Hash 计算 Slot,然后根据 Slot 去对应的 Node 寻找这个 Key。

基于这个 idea,为了节省空间(同时节省广播 Key 的网络资源和处理 Key 更新的资源),每一个 key 都属于一个 slot,在 key 更新的时候,我们只广播更新了这个 key 所在的 slot:“嘿,这个 slot 更新了,你不用管 slot 里面哪一个 key 更新了,只要你的 key 在这个 slot 里面,那就去读 Redis 里面的数据吧,进程内缓存的数据已经脏了!” Slot 是 2bytes,比 key 小多了,可以节省很多网络资源。

空间是节省了,随之而来的问题是更新逻辑变得复杂。如果广播 key,那客户端收到 key 即删除,下次从 Redis 取就好了。收到的是一个 slot 呢?Slot 是单向的(因为 hash 是单向的),有 key 可以计算出 slot,有了 slot 可不知道这个 slot 能包含哪些 key。

计算遍历所有的 key 吗?命中脏 slots 的话,就删除这个key?但是这样的话相当于对每一个缓存更新操作,客户端都要遍历计算一遍自己所有 key 的 slot,显然是不可接受的。

这里也是采用惰性计算的思想:客户端收到了 slot 更新的广播,只把 slot 存起来,当真正用到在此 slot 中的 key 的时候才去 Redis 更新。那么就会有这样一种情况,slot 中部分 key 更新了,部分 key 没有更新,如何区分开哪些 key 已经在 slot 更新之后更新过了呢?这里只要记一下 slot 更新的 timestamp 就可以,每一个 key-value 也带有一个 timestamp 属性。如果 key 的 timestamp 早于 slot 的 timestamp,那 key 就是需要更新的;更新之后 key 的 timestamp 就晚于 slot 的 timestamp 了。下次可以直接用。

客户端内缓存需要保存的内容,增加了 slot 和租后修改时间,kv 也增加了 timestamp 保存从 Redis 获得缓存的时间,用来判断这个 key 是否脏了。

读缓存的流程:先将 kv 的 timestamp 和 slot 的 timestamp 对比,看 key 是否已脏。

写操作比较直观,先写到 Redis,再写到 in-process cache,两个都洗完之后返回客户端缓存更新成功。

这里还有一些 tricky 的情况:我更新了一个 key,刷新了这个 key 的 timestamp,同时又收到了这个 key 所在的 slot 更新的消息。这时客户端如何处理这种情况呢?以自己更新的为准还是收到的 slot 信息为准?这个问题可以用 Master clock 时钟同步或 RedLock 来解决,但是从并发性和系统复杂度方面讲,两个都不是最好的办法。

这里有一种无锁的实现,通过操作的顺序来保证数据一致,非常巧妙,实现的几个要点如下:

  1. 所有的 timestamp 都是 server local timestamp:获得了 slot 更新的消息,将 slot 的更新时间设定为当前 local timestamp;进程更新 key 了,进程将 key 的更新时间设定为 local timestamp。实际上,timestamp 已经当成一个相对于本地 server 的偏移量来用了,无论是不同进程之间的时间如何偏移不准,都没有影响;
  2. 必须先更新 Redis,Redis 更新完成之后再发布更新消息(再次强调,Redis 作为 Source of the Truth);
  3. 在更新 Redis、in-process cache 之前就获取 timestamp,这一步很关键。这里解释一下为什么要先获取 current timestamp 再进行更新:其根本目的是 slot、key 的 timestamp 就尽量提前。如果在 Get current timestamp 之前收到了 slot update message,那么我们的更新操作一定发生在其他进程的更新操作之后,没有毛病;如果在 Get current timestamp 之后收到了 slot update message,那么不管如何,我们的 key timestamp 会落后收到的 slot timestamp,会去 redis 获取,也没有毛病。假设这里先更新完再获取 timestamp,会有这么一种情况:我们更新好了 in-process cache,这时候来了一条 slot update message,我们更新了这个 slot 的 timestamp,然后我们自己的更新操作到了获取 timestamp 这一步,我们记录了自己的 key timestamp 和 slot timestamp。就造成了我们的 key 更新时间实际上晚于真正的 key 更新时间,我们保存了一个过时的 key 却不知道。

以上说的有点啰嗦,总结一下:我们总是更新完 redis 缓存在发送同步消息,这样其他进程收到消息的时候,Redis 总是保存了最新的缓存;通过提前获取时间来保证了本地更新缓存和收到缓存更新时的冲突(倾向于认为自己的数据过时),并且没有锁、没有时间同步要求、不会保存了一个过时的 key 却认为是最新的。

最后其实还有一种非常极端的情况,但是有出现的可能,我觉得作者这个解决方法很牛,所以也说一下。

这个情况就是在一个 timestamp 分辨率下,我更新缓存,但同时收到了 slot 更新的消息。即我的 key 的 timestamp 是 a,但是在 a 这个 timestamp 的同时其他进程更新了缓存,这个时候 timestamp a 依然是正确的,但其实缓存住的是一个过时的 key。其实这个发生的概率太小了,timestamp 的精度是 300ns 的话,必须在 300ns 内更新完 redis 缓存和 in-process 缓存,收到 sync 消息,才有可能发生——但是依然有概率发生的。

解决的方法很聪明:GetTimestamp() 之后总是 -1(其实不必-1,减一个 timestamp 精度就可以).这样就保证收到 sync message 我总是倾向于缓存脏了,去 redis 获取。如果发生一个 timestamp 进度内出现缓存更新和收到 sync 消息,那么我实际缓存更新的时间肯定晚于其他进程更新缓存的时间,因为我把我的表调快了嘛,所以我保存的 key 是新的。

最后的方案还有一些小问题:如果 pub/sub 出现了网络问题,那么情况就退化成等待缓存过期的情况了。Pub/Sub 本身在 redis 中就不是可靠的,可以通过 Stream 或其他可靠的机制来替代。

Well well,以上就是在现有的 Redis 实现客户端缓存的一些设计,这个设计源自 Redis Conf 的演讲。Antirez 认为这个方法非常好,所以想在 Redis Server 端做一些支持。

二、Redis 6 客户端缓存加强

注意上面讲的都是 Client 端所做的实现,对 Redis Server 并没有任何改动,这意味着,使用当前的 Redis 完全是可以实现的。这个实现存在的问题有:

  1. pub/sub 不可靠,如果 sub 掉线,pub 端和 Server 并不做任何承诺,这个上面说过的;
  2. 广播的是 slot 信息,slot 中如果有一个 key 更新了,那么整个 slot 都认为脏了。一共有 16384 个 slot,这意味着如果我们有百万级别的 key 数量的话,将平均几百个 key 共享 slot,这样的话变脏的几率是很高的。这是一种 Trade off 吧,一个 slot 占用 16bit,16384 个 slots 一共占用 64Kib 的数据。毕竟客户端的的目的是最大程度的利用缓存;
  3. 一个 slot 更新的消息,Server 要广播给所有的客户端,及时那些客户端并没有缓存这个 slot。这是因为服务端无法知道哪些客户端缓存了哪些 slots;

以上的问题,只有在 Server 端参与的情况下才可以更好地解决。所以 Redis 6 会实现 “Server-assisted client side caching”。

关于问题2和问题3:Redis 的 Command Table 里面会标记出来 Read Only 的命令,当客户端发送了 Read Only 的命令,比如 MGET,Server 除了返回响应之外,还会记住这个客户端读取了这个数据(数据所在的 slots)。这样 Server 会保持追踪哪个客户端缓存了哪些 slots,在 slot 变脏之后,只通知缓存这个 slot 的客户端。

实现的方式是保持一个很大的指针数组,每一个指针指向一个 Linked List,代表缓存了一个 Slot 的客户端列表。

每一个 client 都有一个 unique ID,第四行就表示,client 9,1,2 读取了 slot4 中的数据。client假设 slot4 中的 key 更新了,那么 Server 将会把 Slot 更新的信息发送给这三个客户端。这样就解决了问题3,无意义的消息推送问题。

这个指针数据有多大呢?首先这里的 slots 数量比 Ben 的方法多很多,这里使用的是 CRC64 的 24bit 输出,那就是 2^24 个 slots。指针是 64bit 指针,即 64bit ** 2^^24 = ~130Megabyte。Antirez 认为服务端这些内容是是值的,这解决了很多 key 共享 slots 的问题,就算有千万级别的 key,也只有两三个 key 共享 slots,散列度很高。

有2点需要注意:客户端可以重新 match 这些 slots,服务端是 16million 个,客户端可以使用更少,收到 slots 信息自己做 mapping;这个 feature 可以选择开关,通过 CLIENT TRACKING on 来打开。

关于问题1:Antirez 建议客户端感知连接的断开,处理好重连的情况;建议有一个 Thead 来 ping connection 确保连接存活。Redis Server 为了尽量减少影响,以及方便客户端的实现,还会通知其他客户端:请转发此消息(slot invalidation)给掉线客户端。

 

另外这样的实现在 Redis 协议里面有些冲突,客户端调用 block 命令收到的回复和收到的 slot push message 回复如何区分?Antirez 直接没有提复杂的实现(比如从 Response 中添加额外的信息标志这个是正常回复,还是 Invalidation message?),而是建议另开一个 Connection,专门处理 Invalidation messages,不和接收正常回复的 Connection 共享一个 Connection Pool。客户端可以通过发送命令 CLIENT TRACKING on REDIRECT 1234 来告诉服务器有一个专门的客户端是接收 Invalidation messages 的。

以上已经合并到 unstable。其实我倒觉得,现在同 datacenter 内的网速已经很快了(),增加这样的复杂度来换取客户端缓存的优势好像并不值得,实际我也没有接触过客户端缓存,也不知道会带来多少的性能提升。但是我看到这个实现我马上想到了一个非常实用的使用场景。

三、客户端缓存的应用场景:动态配置数据

复杂系统中我们肯定有动态修改配置的需求:比如如果 condition-1 就这样处理,如果 condition-2 就那样处理。每一个请求都需要判断这个 condition,这个 condition 又经常变。

如果放到数据库里面,通过数据库来修改,显然对数据库的压力就太大了。一般我们会实现某种形式的通知系统。类似下面的这种结构。

对支付宝中间件熟悉的同学,可以发现这就是 DRM(动态配置中心,Dynamic Resource Management)。现在有了 Redis 这种机制的话,我们可以很轻松的实现一个可靠的动态配置系统。

下面这张图很直白的表示了什么样的数据,适合什么样的缓存。

Source: https://www.youtube.com/watch?v=kliQLwSikO4

在这张图中,配置类型的数据几乎是属于最右下角的了,可能每一个请求都需要读取配置数据,访问频率非常高,但是改动很小。除此之外,像是对于每次请求都要通过 cookie 判断用户登陆状态的情况,对 session 的读取压力也是非常大的,这种场景其实做到客户端缓存中也比较合适。

综上,Client-caching 我认为意义是很大的。如果你有 Redis,那么你不需要引入其他依赖,不需要 Message Queue,不需要同步时钟,不需要锁,就可以得到一个近乎强一致的客户端缓存机制。

 

最后,在这篇博客中有这么一句话让我感动:

Yet I was among the streets of New York thinking about this idea.

像 Antirez 这种程序员是一种英雄主义,也是我的榜样。

参考资料:

  1. RedisConf18: Techniques for Synchronizing In Memory Caches with Redis – Redis Labs (Slide
  2. /RedisMultilevelCache
  3. Client side caching: initial design ideas
  4. Client side caching in Redis 6
  5. 进程内缓存,究竟怎么玩?
  6. 进程内缓存与分布式缓存的比较
  7. 缓存更新的套路