Blog

使用sync.Map的特殊场景

Content #

应对特殊场景的 sync.Map #

Go 内建的 map 类型不是线程安全的,所以 Go 1.9 中增加了一个线程安全的 map,也就是 sync.Map。但是,我们一定要记住,这个 sync.Map 并不是用来替换内建的 map 类型的,它只能被应用在一些特殊的场景里。

那这些特殊的场景是啥呢?官方的文档中指出,在以下两个场景中使用 sync.Map,会比使用 map+RWMutex 的方式,性能要好得多:

  1. 只会增长的缓存系统中,一个 key 只写入一次而被读很多次;

  2. 多个 goroutine 为不相交的键集读、写和重写键值对。

这两个场景说得都比较笼统,而且,这些场景中还包含了一些特殊的情况。所以,官方建议你针对自己的场景做性能评测,如果确实能够显著提高性能,再使用 sync.Map。

Viewpoints #

From #

09 | map:如何实现线程安全的map类型?

\345\210\206\347\211\207\345\212\240\351\224\201

Content #

虽然使用读写锁可以提供线程安全的 map,但是在大量并发读写的情况下,锁的 竞争会非常激烈。我在第 4 讲中提到过,锁是性能下降的万恶之源之一。

在并发编程中,我们的一条原则就是尽量减少锁的使用。一些单线程单进程的应 用(比如 Redis 等),基本上不需要使用锁去解决并发线程访问的问题,所以 可以取得很高的性能。但是对于 Go 开发的应用程序来说,并发是常用的一个特 性,在这种情况下,我们能做的就是,尽量减少锁的粒度和锁的持有时间。

你可以优化业务处理的代码,以此来减少锁的持有时间,比如将串行的操作变成 并行的子任务执行。不过,这就是另外的故事了,今天我们还是主要讲对同步原 语的优化,所以这里我重点讲如何减少锁的粒度。

减少锁的粒度常用的方法就是分片(Shard),将一把锁分成几把锁,每个锁控 制一个分片。Go 比较知名的分片并发 map 的实现是orcaman/concurrent-map。

它默认采用 32 个分片,GetShard 是一个关键的方法,能够根据 key 计算出分 片索引。

  var SHARD_COUNT = 32

  // 分成SHARD_COUNT个分片的map
type ConcurrentMap []*ConcurrentMapShared

// 通过RWMutex保护的线程安全的分片,包含一个map
type ConcurrentMapShared struct {
  items        map[string]interface{}
  sync.RWMutex // Read Write mutex, guards access to internal map.
}

// 创建并发map
func New() ConcurrentMap {
  m := make(ConcurrentMap, SHARD_COUNT)
  for i := 0; i < SHARD_COUNT; i++ {
    m[i] = &ConcurrentMapShared{items: make(map[string]interface{})}
  }
  return m
}

// 根据key计算分片索引
func (m ConcurrentMap) GetShard(key string) *ConcurrentMapShared {
  return m[uint(fnv32(key))nt(SHARD_COUNT)]
}

增加或者查询的时候,首先根据分片索引得到分片对象,然后对分片对象加锁进行操作:

...

使用struct类型做为map的key

Content #

如果使用 struct 类型做 key 其实是有坑的,因为如果 struct 的某个字段值修改了,查询 map 时无法获取它 add 进去的值,如下面的例子:

func main() {
    var m = make(map[mapKey]string)
    var key = mapKey{10}

    m[key] = "hello"
    fmt.Printf("m[key]=%s\n", m[key])

    // 修改key的字段的值后再次查询map,无法获取刚才add进去的值
    key.key = 100
    fmt.Printf("再次查询m[key]=%s\n", m[key])
}

那该怎么办呢?如果要使用 struct 作为 key,我们要保证 struct 对象在逻辑上是不可变的,这样才会保证 map 的逻辑没有问题。

Viewpoints #

From #

09 | map:如何实现线程安全的map类型?

NewSQL架构

Content #

NewSQL 架构是原生分布式数据库,架构中的每个层次的设计都是以分布式为目标。NewSQL 是从分布式键值系统演进而来,主要的工作负载由计算节点和存储节点承担,另外由管理节点承担全局时钟和分片信息管理功能。不过,这三类节点是逻辑功能上划分,在设计实现层面是可分可合的。比如,TiDB 是分为独立节点,CockroachDB 则是对等的 P2P 架构。

由于 NewSQL 在架构上的革新性,产品实现的难度比 PGXC 要大,所以产品就相对少一些。Spanner 是 NewSQL 的开山鼻祖,这个不用说了;其他知名度比较高的产品有 CockroachDB、TiDB 和 YugabyteDB,这三款数据库都宣称设计灵感来自 Spanner;另外就是阿里自研的 OceanBase,因为它有一个代理层,有时会被同行质疑,但是从整体架构风格看,我还是愿意把它归为 NewSQL。

NewSQL 在架构上更加领先,而 PGXC 最大程度复用了单体数据库的工程实现,更加稳健。

Viewpoints #

From #

04 | 架构风格:NewSQL和PGXC到底有啥不一样?

单体数据库架构风格的演进

Content #

单体数据库的功能看似已经很完善了,但在面临高并发场景的时候,还是会碰到写入性能不足的问题,很难解决。因此,也就有了向分布式数据库演进的动力。要解决写入性能不足的问题,大家首先想到的,最简单直接的办法就是分库分表。

代理节点和分片信息管理 #

分库分表方案就是在多个单体数据库之前增加代理节点,本质上是增加了 SQL 路由功能。这样,代理节点首先解析客户端请求,再根据数据的分布情况,将请求转发到对应的单体数据库。

代理节点需要实现三个主要功能,它们分别是客户端接入、简单的查询处理器和进程管理中的访问控制。

另外,分库分表方案还有一个重要的功能,那就是分片信息管理,分片信息就是数据分布情况,是区别于编目数据的一种元数据。不过考虑到分片信息也存在多副本的一致性的问题,大多数情况下它会独立出来,更详细的原因我在第 7 讲中展开说明。

协调节点、跨节点查询和分布式事务组件 #

显然,如果把每一次的事务写入都限制在一个单体数据库内,业务场景就会很受局限。因此,跨库事务成为必不可少的功能,但是单体数据库是不感知这个事情的,所以我们就要在代理节点增加分布式事务组件。

同时,简单的分库分表不能满足全局性的查询需求,因为每个数据节点只能看到一部分数据,有些查询运算是无法处理的,比如排序、多表关联等。所以,代理节点要增强查询计算能力,支持跨多个单体数据库的查询。

随着分布式事务和跨节点查询等功能的加入,代理节点已经不再只是简单的路由功能,更多时候会被称为协调节点。

很多分库分表方案会演进到这个阶段,比如 MyCat。这时离分布式数据库还差重要的一步,就是全局时钟。全局时钟是实现数据一致性的必要条件。

全局时钟 #

加上这最后一块拼图,PGXC 区别于单体数据库的功能也就介绍完整了,它们是分片、分布式事务、跨节点查询和全局时钟。

协调节点与数据节点,实现了一定程度上的计算与存储分离,这也是所有分布式数据库的一个架构基调。但是,因为 PGXC 的数据节点本身就是完整的单体数据库,所以也具备很强的计算能力。

PGXC(PostgreSQL-XC)的本意是指一种以 PostgreSQL 为内核的开源分布式数据库。因为 PostgreSQL 的影响力和开放的软件版权协议(类似 BSD),很多厂商在 PGXC 上二次开发,推出自己的产品。不过,这些改动都没有变更主体架构风格,所以我把这类产品统称为 PGXC 风格,其中包括 TBase、GuassDB 300 和 AntDB 等。当然,这里所说的 PGXC 并不限于以 PostgreSQL 为内核,那些以 MySQL 为内核的产品往往也会采用同样的架构,例如 GoldenDB,所以我把它们也归入了 PGXC 风格。

Viewpoints #

From #

04 | 架构风格:NewSQL和PGXC到底有啥不一样?

数据库的基本架构

Content #

我们先通过一张架构图看看数据库的全貌。

这张图从约瑟夫 · 海勒斯坦 (Joseph M. Hellerstein) 等人的论文“Architecture of a Database System”中翻译而来。文中将数据库从逻辑上拆分为 5 个部分:

  1. 客户端通讯管理器 (Client Communications Manager) 这是应用开发者能够直观感受到的模块,通常我们使用 JDBC 或者 ODBC 协议访问数据库时,连接的就是这个部分。
  2. 查询处理器(Relational Query Processor)它包括四个部分,功能上是顺序执行的。首先是解析器,它将接收到的 SQL 解析为内部的语法树。然后是查询重写(Query Rewrite),它也被称为逻辑优化,主要是依据关系代数的等价变换,达到简化和标准化的目的,比如会消除重复条件或去掉一些无意义谓词 ,还有将视图替换为表等操作。再往后就是查询算法优化(Query Optimizer),它也被称为物理优化,主要是根据表连接方式、连接顺序和排序等技术进行优化,我们常说的基于规则优化(RBO)和基于代价优化(CBO)就在这部分。最后就是计划执行器(Plan Executor),最终执行查询计划,访问存储系统。
  3. 事务存储管理器(Transactional Storage Manager)它包括四个部分,其中访问方式(Access Methods)是指数据在磁盘的具体存储形式。锁管理(Lock Manager)是指并发控制。日志管理(Log Manager)是确保数据的持久性。缓存管理(Buffer Manager)则是指 I/O 操作相关的缓存控制。
  4. 进程管理器(Process Manager)连接建好了,数据库会为客户端分配一个进程,客户端后续发送的所有操作都会通过对应的进程来执行。当然,这里的进程只是大致的说法。事实上, Oracle 和 PostgreSQL 是进程的方式,而 MySQL 使用的则是线程。还有,进程与客户也不都是简单的一对一关系。
  5. 共享组件与工具 (Shared Components and Utilities) 在整个过程中还会涉及到的一些辅助操作,当然它们对于数据库的运行也是非常重要的。例如编目数据管理器(Catalog Manager)会记录数据库的表、字段、视图等元数据信息,并根据这些信息来操作具体数据内容。复制机制(Replication)也很重要,它是实现系统高可靠性的基础,在单体数据库中,通过主备节点复制的方式来实现数据的复制。

Viewpoints #

From #

04 | 架构风格:NewSQL和PGXC到底有啥不一样?

Architecture of a Database System

...

同形配子(isogametes)

Content #

什么是同形配子(isogametes)?

在被称为同配生殖(isogamy)的系统中,个体并不能被区分为两种性别,任何个体都能相互交配,不存在两种不同的配子——精子和卵子,所有的性细胞都一样,都称为同形配子(isogametes)。两个同形配子融合在一起产生新的个体,而每一个同形配子是由减数分裂产生的。如果有3个同形配子A、B和C,那么A可以和B 或C融合,B可以同A或C融合。

From #

单词文本矩阵(word-document matrix)

Content #

给定一个含有n个文本(文档)的集合\(D=\{d_1,d_2,\cdots,d_n\}\),以及在所有文本(文档)中都出现的m个单词的集合\(W=\{w_1,w_2,\cdots,w_m\}\),单词文本矩阵(word-document matrix)记作:

\begin{displaymath}X=[x_{ij}]_{m\times n}\end{displaymath}

该矩阵元素 \(x_{ij}\) 表示单词 \(w_{ij}\) 在文本 \(d_j\) 中出现的频数或权值。权值一般会是TF-IDF。

From #

产生式(Production)

Content #

在上下文无关文法中,什么是产生式(Production)?

Java中的if-else语句通常具有如下形式: if (expression) statement else statement

如果我们用变量expr来表示表达式,用变量stmt来表示语句,那么这个构造规则可以表示为: stmt -> if (expr) stmt else stmt

其中箭头可读作“可以具有如下形式(can have the form)”,这样的规则称为产生式(Production)。

From #