基于Go与PostgreSQL的分布式锁的设计
2022-09-09齐洋原变青刘颖杨婷
齐洋 原变青 刘颖 杨婷
(北京经济管理职业学院 北京市 100102)
1 引言
互联网的兴起特别是大数据与云计算技术的发展,对传统的软件应用也产生了极大的影响。在互联网环境下,海量的数据和极大的并发访问量若仍然以传统的单机应用的存储处理模式,则其处理速度、访问速度将变得极其缓慢,造成用户体验极差。为了提高性能,有两条路线,一种是提高单个服务器的性能,增加CPU、内存等资源,另一种就是利用廉价的普通机器构建分布式系统。第一种方式操作简单,但是配置高的服务器价格昂贵,并且当数据量和并发量到达一定程度,即使现有的最强劲单个服务器也难以承载这些压力。所以,分布式系统成为了绝大多数互联网公司的首选。由多个分布式应用组成的分布式系统能够承载互联网级别的数据量和并发访问,但是也带来了资源管理上复杂性。如何让多个分布式应用能够并发正确的访问共享资源就是分布式系统中的一个问题。而解决这个问题就需要用到分布式锁。
2 分布式锁的概念和应用场景
2.1 分布式锁的概念与场景
分布式锁,就是分布式系统中的锁。在一般的单体应用本地部署的情况下,为解决共享资源被并发访问的问题,引入了本地锁。主流的编程语言都会支持本地锁或同步,如Java 中的synchronized 关键字和ReentrantLock,Go 中的sync.Mutex 等。当代码块或者变量由本地锁控制时,同时只能由一个线程访问更改被控制的代码块或者变量。在分布式系统中,各个应用是分布在不同的进程中的并且部署在不同的机器上,此时再采用本地锁来做并发访问控制将无法满足需求。而在分布式锁是为了解决分布式系统中的共享资源被并发访问的问题,所以它必然有分布式的特点,而为了协调多个进程能够正确并发访问资源,协调部分又需要是集中的。在本地锁的应用场景中,争夺锁的最小实体是线程,而分布式锁的最小争夺实体是进程。
在分布式系统的场景中,云计算平台是一个当前流行的应用场景。前文提到,分布式系统是运行在廉价的普通机器上,长时间的高负载的运行,这些普通服务器将不可避免的出现一部分机器宕机的情况,同时,应用也会因为长时间的运行而因为系统缺陷和内存泄漏等原因出现崩溃然后重启的问题。在出现以上问题期间,为提高用户体验,构建云计算平台的各个组件的功能和云平台上运行的各个应用的访问都不应该出现问题,即这一类的错误对于用户来看应该是不可见的。在用户来看,系统是一直可用的,这就是系统的高可用性。系统的高可用性主要可以分为两种模式,即activeactive 和active-standby。active-active 即一个应用的多个实例都是处于运行状态,多个实例共同处理用户对于此应用的所有请求。active-standby 则是一个应用的多个实例只有一个处于真正的运行状态并由其处理所有的用户请求,其他的实例则一直作为备选,当运行实例出现问题时,备选实例中将有一个成为新的运行实例。在active-standby 模式中,一般便是使用分布式锁来实现一个运行多个备选的状态。多个实例通过争夺分布式锁,争到的便是实际运行实例,其他的为备选实例,以此实现高可用性。
2.2 现有的分布式锁的实现方式
基于Zookeeper 的分布式锁:
Zookeeper 是一个开源的分布式应用程序协调组件,是Google 的Chubby 的开源实现,在著名的大数据软件Hadoop 中为集群提供一致性服务。基于Zookeeper 的临时有序节点可以实现分布式锁。当有客户端进程尝试加锁时,Zookeeper 集群中与该锁对应的节点目录下,将会生成一个唯一的瞬时有序节点。判断进程是否获取到锁的方式就是判断当前进程是否是这些有序节点序号最小的那个。释放锁的时候,将这个瞬时节点删除即可。Zookeeper 分布式锁可以避免服务宕机而产生的锁无法释放,从而产生的死锁问题。
基于Redis 的分布式锁:
Redis 是Remote Dictionary Server 的简写,即远程字典服务器,是一个以C 语言开发的开源的支持网络,可以基于内存也可持久化的日志型的Key-Value 数据库,其提供了多种语言的开发接口。在很多的大型系统中作为缓存数据库使用,其轻量、快速的特点深受开发者的欢迎。
Redis 的分布式锁主要使用其setnx、get 和getset 三个函数来实现。setnx(key)即Set if not exists,此函数具备原子性,如果key 不存在则可以设置value 并返回1;如果key 存在则设置失败返回0。get(key)获取对应value 的过期时间;getset(key,newValue)也具备原子性,设置newValue 成功后会返回key 对应的旧值。获取锁时,调用setnx(key),返回1 表示成功获取锁,流程结束。返回0 表示没有获取锁,则调用get(key)获取值得过期时间oldExpireTime,与当前时间比较,如果小于当前时间表示锁已超时,可以获取。然后算出新的过期时间newExpireTime,执行getset(key, newExpireTime),会返回key 值当前的过期时间currentExipreTime。比较oldExipreTime 和currentExpireTime,如果相等,表示getset设置成功,成功获取到锁。如果不相等,表示锁被别的进程获取到。获取锁流程失败,过一段时间重试。当锁持有进程释放锁时,执行delete(key)即可。
3 相关实现技术
3.1 PostgreSQL
PostgreSQL 是一个强大的功能齐全的开源关系型数据库系统。他诞生于1986年的美国加州大学计算机学院,初始是作为POSTGRES 项目的一部分。经过三十多年的开发和应用,它支持标准的SQL 语言并加入了很多其他的功能以确保数据能够安全存储,根据数据负载能够灵活扩展。它兼容所有的主流操作系统,除SQL 的基本类型外还支持JSON、Key-value 等数据类型,在数据一致性、高并发、高可用、数据恢复、数据安全等方面都有极为出色的表现,并且还有很多类似PostGIS 这样的强大插件。PostgreSQL 的上述强大特性为其在世界范围内赢得了很高的赞誉,也成为了很多开发者和机构首选的开源关系数据库系统。
3.2 PostgreSQL显式锁(Explicit Locking)
PostgreSQL 显式锁提供了一系列锁定模式来控制应用访问数据表中的数据,这在一些应用需要细粒度的锁定而使用标准的SQL 语句并不能达到目的的场景十分有用。其实,执行标准的SQL语句进行操作也是调用了一系列的显示锁,只是调用的细节由PostgreSQL 隐藏,用户不能进行控制。本系统中主要使用了表级别的锁(Table-level Locks)。这里只简单介绍ACCESS EXCLUSIVE 模式。此模式将确保操作当前数据库事务的进程是当前唯一能访问目标表的进程,其他所有的事务对目标表的一切操作都将被阻塞。这样,在分布式进程获取锁的时候,多个进程将不会因为并发访问得到不一致的结果。并且,当一个事务获取到显示锁后,随着事务的结束,此显示锁也会自动释放,因此应用层面只需获取锁,而不必显示地释放锁。
3.3 Golang
Golang(Go)是由谷歌公司与2009年开发的静态编译型编程语言。它兼容所有的主流操作系统,语法简单,只有25 个关键字,能直接编译成可执行文件。由于其在设计之初就考虑了高效的并发机制,不像很多其他的编程语言还需要开发者自己实现或者引入第三方的库来支持并发,Go语言在很多高并发、多线程的应用场景都得到了广泛的应用,从一般的Web 开发到分布式系统、云计算、容器等。当今开源软件界炙手可热的容器与容器编排软件docker、kubernetes、knative 等都是由Go 语言开发的。而随着容器技术目前已成为当今各业界公司的标配技术,Go 语言也变得越来越流行,在编程语言的排行榜上也不断上升。
4 功能分析
一个合格的分布式锁应具有以下功能与性能要求:
(1)在分布式系统环境下,多个分布式进程同时尝试获取锁,最终只能有一个进程成功地获取锁,成为锁的持有进程。
(2)分布式锁必须具备锁失效机制。即锁的持有进程必须定时的刷新自己的持有记录,以防止锁持有进程崩溃带来的死锁后果。当一个锁持有进程超过规定时间仍未刷新持有记录,则其他的尝试进程将有一个进程成功获取锁,成为新的持有进程。当旧的持有进程从崩溃状态恢复之后,其将加入到尝试获取锁的进程中,以待当前持有进程释放锁或者超时未刷新持有记录,尝试获取的各个进程将有一个成功获取,以此循环往复。
(3)锁的获取过程是非阻塞的。即所有尝试获取锁的进程在调用获取方法时,将直接返回结果获取到或者未获取到,这些尝试进程不能被长时间阻塞在获取过程。
(4)当锁持有者正常退出时,必须保证其成功释放锁。
(5)获取锁和释放锁的过程要具有较高的性能,不能耗费过多的资源和时间。
5 系统设计与实现
5.1 数据库设计
本文分布式锁所用的仅一张数据库表lock,lock 表的设计如下:
锁的持有者名称(owner),持有者持有锁的时间戳(lock_timestamp),持有锁的最长时间(ttl),其中持有者为表主键。
owner 需要唯一标识尝试获取锁的应用实例,这里一般应用名称加UUID 的方式来组成持有者名称。UUID(universal unique Identifier)即通用唯一标识符被定义为一个128 位的二级制数,分为五段,一般用十六进制标识,段与段之间用减号进行连接。UUID 是一个无规律的符号,每次调用生成方法均能生成一个与之前完全不重复的值,由此,在分布式系统中,其很适合用来作为标识。
5.2 详细设计与实现
分布式锁系统需要一些配置项来定义数据库连接,锁获取、持有和释放的选项。
yaml 形式的配置项示例如下所示:
配置项中,TTL 为锁的最大持有时间,RetryInterval 为尝试获取锁的间隔时间。
数据库连接配置中,URL 为数据库连接地址,MaxOpen Connections 为数据库最大连接接数量;MaxIdleConnections为数据库最大闲置连接数量;ConnectionMaxLifetime 为每个数据库连接最长使用时间;ConnectionMaxIdleTime 为每个数据库连接最大空闲时间。
获取锁的详细流程:
(1)开启PostgreSQL 数据库事务。
(2)获取当前锁的状态:调用PostgreSQL 显示锁并使用Access Exclusive 模 式,LOCK TABLE lock IN ACCESS EXCLUSIVE MODE。查询锁状态,SELECT owner,lock_timestamp,ttl FROM lock LIMIT 1 FOR UPDATE NOWAIT。查询的时候使用FOR UPDATE NOWAIT 锁定查到的数据以防止其他进程更改。使用NOWAIT 能确保当有其他进程锁定数据集的时候此查询不会阻塞等待锁释放,而是直接返回错误标识加锁失败。查询锁状态需要注意的是如果lock 表中没有数据,PostgreSQL 的Go 库会返回空结果错误sql.ErrNoRows,所以此处应判断返回的错误是否是sql.ErrNoRows。如果是此错误,则查询函数返回nil 值的锁结果和nil 的错误结果。如果是其他错误,则返回nil 锁结果和对应的错误。如果查询成功,返回查到的锁内容和nil 错误。关键代码如下:
(3)检查查询结果:如果错误和锁返回值都是nil,说明当前没有进程持有锁,表示此次获取的是全新的锁。如果错误返回值不为nil,则表示此次获取过程失败,退出此次获取过程,等待下一次获取周期的到来。
(4)正式获取或刷新锁:如果返回锁的持有进程owner和当前进程的owner 值不同,进一步检查返回的锁是否已经超时,即检查lock_timestamp 加上ttl 是否已经超过现在时间,如果已经超过,说明当前持有进程已经超时,此尝试进程将成为下一任锁持有进程,此过程将删除当前返货锁的记录,然后确定尝试进程为下一任owner。这里检查时间时需要注意一点,就是PostgreSQL 数据库服务器和Go 程序运行的服务器很有可能不是同一台,因此不同的服务器的时间可能出现差异,所以所有的时间均PostgreSQL 的时间为准,获取时间使用PostgreSQL 的SELECT NOW() AT TIME ZONE‘utc’来实现。然后构建新锁的内容,owner 为当前尝试进程owner,获取时间戳为PostgreSQL 当前时间,ttl 为配置的TTL。将新锁插入到lock 表中,插入成功之后表示获取锁成功,结束此次事务,同时Access Exclusive 显示锁自动释放。
如果返回锁的持有进程owner 和当前进程的owner 值相同,表示尝试进程本身就是锁持有进程,直接进入锁刷新流程,更新锁的lock_timestamp 字段为当前时间,刷新流程结束,结束此次事务,同时Access Exclusive 显示锁自动释放。
步骤(3)、(4)关键代码如下:
检查锁的状态:
获取锁代码:
释放锁的流程:
删除lock 表中owner 为当前进程的记录。
应用进程引入锁的方式:
尝试获取锁的各个进程无论是否成功获取到锁,都要每隔配置项中的间隔时间RetryInterval 重复获取/刷新锁流程,以确保锁的有效性。并且每个进程在正常退出时,都要尝试释放锁。
在获取锁的所有进程中,获取的过程须在其他所有功能模块之前启动,如果获取锁成功,则后续功能模块依次启动,此进程开始正常工作。如果获取失败,此进程将一直重复尝试获取锁流程,后续模块在获取成功之前将不启动。这样,就实现了云计算平台上多个应用实例都是running 状态,但是只有一个实例真正工作,即active 状态;并且当工作实例出现问题,如崩溃,假死等问题之后,其将失去锁,然后会有新的应用实例得到锁,然后启动后续模块开始工作,令整个分布式系统始终有工作的实例,实现了分布式应用的高可用性。
6 结束语
本文介绍了分布式锁的概念与应用场景,提出了分布式锁的需求并基于Go 语言和PostgreSQL 数据库设计并实现了分布式锁。在当前最流行的云计算容器编排平台kubernetes中,因kubernetes 是基于Go 实现并提供了基于Go 的clientgo 开发库,因此本分布式锁的实现很适合运行在kubernetes平台上的Go 语言开发的应用。开发完成后,笔者在kubernetes 平台上开发应用测试此锁实现,经历了kubernetes节点失效,某应用实例内存泄漏等多种错误,此分布式锁在上述情况下都能正常的工作,始终有应用实例抢占到锁并开始工作,有效的保证了应用的高可用性。