多页打印视图 点击此处打印.

返回常规视图.

Pigsty 博客

新闻

Pigsty现已加入阿里云PgSQL七天训练营课程体系

阿里云PgSQL7日训练营,现已提供Pigsty教程。

监控系统是智能化管理与自动化运维的基石,可以为资源规划, 故障排查,性能优化提供至关重要的数据支持。 本课程将以开源PostgreSQL监控解决方案Pigsty为基础,介绍生产级监控系统的部署实施落地详情, 以及如何在实际场景中利用监控数据进行系统水位评估,定位系统故障,优化查询性能,做到数据驱动。

PDF下载:《PostgreSQL监控实战——使用Pigsty解决实际问题》

PG Conf China 2020

《生产级PostgreSQL监控系统——Pigsty》@ 2020 PostgreSQL中国技术大会(2021/01/16 广州)

在2020 PostgreSQL中国技术大会(2021/01/16 广州),专场七:数据库内核及新特性(下),Pigsty作者冯若航将进行题为《生产级PostgreSQL监控系统—pigsty》的分享。欢迎各位朋友光临

分享内容

PDF下载:《生产级PostgreSQL监控系统——Pigsty》

大会议程

紧急通知

各位赞助商、各位嘉宾、各位伙伴、亲爱的PGer: 前天接到南京政府方面的通知,南京《2020 PostgreSQL中国技术大会》活动将会被取消(因为疫情防控)。此次变故肯定会对您的计划产生影响!我们也非常难过和紧张。在这里,深表歉意!!!

经过昨天1天的场地寻找,大会会务组正式决定,大会将继续,只是把大会搬到广州,时间不变(还是1月15号,16号),新的地址是:广州万富希尔顿酒店(广州市白云区云城东路515、517号),由此给您带来的损失,我们愿意协商解决,承担责任。更多详细信息,请联络我们的工作人员。真心希望大家能够继续支持本次大会。

2020数据库嘉年华分享

在2020年数据库嘉年华上,Pigsty作者冯若航分享了如何基于开源组件构建针对大规模生产数据库集群的监控系统。

监控系统是管理运维改进的基础,这次分享将介绍自研开源PostgreSQL监控全方位解决方案 —— pigsty。千类指标,几十种面板,覆盖PostgreSQL及其附属组件的方方面面;在不同层次上提供性能洞察:关系、数据库、实例、服务、集群、分片、全局汇总等等。以及如何在实践中使用这套系统。

分享内容

PDF下载:《PostgreSQL监控系统——Pigsty》 PDF下载

PG Conf China 2019

《FDW原理与应用》 2019 PostgreSQL中国技术大会

在2019 PostgreSQL中国技术大会(2019/11/16 北京) 进行了关于题为《FDW原理与应用》的分享

分享内容

PDF下载:《FDW原理与应用》

版本

v0.6.0 发布注记

v0.6 对数据库供给方案进行了大量改进

v0.6 对数据库供给方案进行了修改与调整,根据用户的反馈添加了一系列实用功能与修正。针对监控系统的移植性进行优化,便于与其他外部数据库供给方案对接,例如阿里云MyBase。

BUG修复

  • 修复了新版本Patroni重启后会重置PG HBA的问题
  • 修复了PG Overview Dashboard标题中的别字
  • 修复了沙箱集群pg-test的默认主库,原来为pg-test-2,应当为pg-test-1
  • 修复了过时代码注释

功能改进

  • 改造Prometheus与监控供给方式
    • 允许在无基础设施的情况下对已有PG集群进行监控部署,便于监控系统与其他供给方案集成。#11
    • 基于Inventory渲染所有监控对象的静态列表,用于静态服务发现。#11
    • Prometheus添加了静态对象模式,用于替代动态服务发现,集中进行身份管理#11
    • 监控Exporter现在添加了service_registry选项,Consul服务注册变为可选项 #13
    • Exporter现在可以通过拷贝二进制的方式直接安装:exporter_binary_install#14
    • Exporter现在具有xxx_enabled选项,控制是否启用该组件。
  • Haproxy供给重构与改进 #8
    • 新增了全局HAProxy管理界面导航,默认域名h.pigsty
    • 允许将主库加入只读服务集中,当集群中所有从库宕机时自动承接读流量。 #8
    • 允许位Haproxy实例管理界面启用认证 haproxy_admin_auth_enabled
    • 允许通过配置项调整每个服务对应后端的流量权重. #10
  • 访问控制模型改进。#7
    • 添加了默认角色dbrole_offline,用于慢查询,ETL,交互式查询场景。
    • 修改默认HBA规则,允许dbrole_offline分组的用户访问pg_role == 'offline'pg_offline_query == true的实例。
  • 软件更新 Release v0.6
    • PostgreSQL 13.2
    • Prometheus 2.25
    • PG Exporter 0.3.2
    • Node Exporter 1.1
    • Consul 1.9.3
    • 更新默认PG源:PostgreSQL现在默认使用浙江大学的镜像,加速下载安装

接口变更

新增选项

service_registry: consul                      # 服务注册机制:none | consul | etcd | both
prometheus_options: '--storage.tsdb.retention=30d'  # prometheus命令行选项
prometheus_sd_method: consul                  # Prometheus使用的服务发现机制:static|consul
prometheus_sd_interval: 2s                    # Prometheus服务发现刷新间隔
pg_offline_query: false                       # 设置后将允许dbrole_offline角色连接与查询该实例
node_exporter_enabled: true                   # 设置后将安装配置Node Exporter
pg_exporter_enabled: true                     # 设置后将安装配置PG Exporter
pgbouncer_exporter_enabled: true              # 设置后将安装配置Pgbouncer Exporter
dcs_disable_purge: false                      # 双保险,强制 dcs_exists_action = abort 避免误删除DCS实例
pg_disable_purge: false                       # 双保险,强制 pg_exists_action = abort 避免误删除数据库实例
haproxy_weight: 100                           # 配置实例的相对负载均衡权重
haproxy_weight_fallback: 1                    # 配置集群主库在只读服务中的相对权重

移除选项

prometheus_metrics_path                       # 与 exporter_metrics_path 重复
prometheus_retention                          # 功能被 prometheus_options 覆盖

v0.5.0 发布注记

v0.5.0对数据库内部的定制模板进行了大幅改进。

大纲

  • Pigsty官方文档站正式上线!
  • 添加了数据库模板的定制支持,用户可以通过配置文件定制所需的数据库内部对象。
  • 对默认访问控制模型进行了改进
  • 重构了HBA管理的逻辑,现在将由Pigsty替代Patroni直接负责生成HBA
  • 将Grafana监控系统的供给方案从sqlite改为JSON文件静态Provision
  • pg-cluster-replication面板加入Pigsty开源免费套餐。
  • 最新的经过测试的离线安装包:pkg.tgz (v0.5)

定制数据库

您是否烦恼过单实例多租户的问题?比如总有研发拿着PostgreSQL当MySQL使,明明是一个Schema就能解决的问题,非要创建一个新的数据库出来,在一个实例中创建出几十个不同的DB。 不要忧伤,不要心急。Pigsty已经提供数据库内部对象的Provision方案,您可以轻松地在配置文件中指定所需的数据库内对象,包括:

  • 角色
    • 用户/角色名
    • 密码
    • 用户属性
    • 用户备注
    • 用户所属的权限组
  • 数据库
    • 属主
    • 额外的模式
    • 额外的扩展插件
    • 数据库级的自定义配置参数
  • 数据库
    • 属主
    • 额外的模式
    • 额外的扩展插件
    • 数据库级的自定义配置参数
  • 默认权限
    • 默认情况下这里配置的权限会应用至所有由 超级用户 和 管理员用户创建的对象上。
  • 默认扩展
    • 所有新创建的业务数据库都会安装有这些默认扩展
  • 默认模式
    • 所有新创建的业务数据库都会创建有这些默认的模式

配置样例

# 通常是每个DB集群配置的变量
pg_users:
  - username: test
    password: test
    comment: default test user
    groups: [ dbrole_readwrite ]    # dborole_admin|dbrole_readwrite|dbrole_readonly
pg_databases:                       # create a business database 'test'
  - name: test
    extensions: [{name: postgis}]   # create extra extension postgis
    parameters:                     # overwrite database meta's default search_path
      search_path: public,monitor

# 通常是整个环境统一配置的全局变量
# - system roles - #
pg_replication_username: replicator           # system replication user
pg_replication_password: DBUser.Replicator    # system replication password
pg_monitor_username: dbuser_monitor           # system monitor user
pg_monitor_password: DBUser.Monitor           # system monitor password
pg_admin_username: dbuser_admin               # system admin user
pg_admin_password: DBUser.Admin               # system admin password

# - default roles - #
pg_default_roles:
  - username: dbrole_readonly                 # sample user:
    options: NOLOGIN                          # role can not login
    comment: role for readonly access         # comment string

  - username: dbrole_readwrite                # sample user: one object for each user
    options: NOLOGIN
    comment: role for read-write access
    groups: [ dbrole_readonly ]               # read-write includes read-only access

  - username: dbrole_admin                    # sample user: one object for each user
    options: NOLOGIN BYPASSRLS                # admin can bypass row level security
    comment: role for object creation
    groups: [dbrole_readwrite,pg_monitor,pg_signal_backend]

  # NOTE: replicator, monitor, admin password are overwritten by separated config entry
  - username: postgres                        # reset dbsu password to NULL (if dbsu is not postgres)
    options: SUPERUSER LOGIN
    comment: system superuser

  - username: replicator
    options: REPLICATION LOGIN
    groups: [pg_monitor, dbrole_readonly]
    comment: system replicator

  - username: dbuser_monitor
    options: LOGIN CONNECTION LIMIT 10
    comment: system monitor user
    groups: [pg_monitor, dbrole_readonly]

  - username: dbuser_admin
    options: LOGIN BYPASSRLS
    comment: system admin user
    groups: [dbrole_admin]

  - username: dbuser_stats
    password: DBUser.Stats
    options: LOGIN
    comment: business read-only user for statistics
    groups: [dbrole_readonly]


# object created by dbsu and admin will have their privileges properly set
pg_default_privilegs:
  - GRANT USAGE                         ON SCHEMAS   TO dbrole_readonly
  - GRANT SELECT                        ON TABLES    TO dbrole_readonly
  - GRANT SELECT                        ON SEQUENCES TO dbrole_readonly
  - GRANT EXECUTE                       ON FUNCTIONS TO dbrole_readonly
  - GRANT INSERT, UPDATE, DELETE        ON TABLES    TO dbrole_readwrite
  - GRANT USAGE,  UPDATE                ON SEQUENCES TO dbrole_readwrite
  - GRANT TRUNCATE, REFERENCES, TRIGGER ON TABLES    TO dbrole_admin
  - GRANT CREATE                        ON SCHEMAS   TO dbrole_admin
  - GRANT USAGE                         ON TYPES     TO dbrole_admin

# schemas
pg_default_schemas: [monitor]

# extension
pg_default_extensions:
  - { name: 'pg_stat_statements',  schema: 'monitor' }
  - { name: 'pgstattuple',         schema: 'monitor' }
  - { name: 'pg_qualstats',        schema: 'monitor' }
  - { name: 'pg_buffercache',      schema: 'monitor' }
  - { name: 'pageinspect',         schema: 'monitor' }
  - { name: 'pg_prewarm',          schema: 'monitor' }
  - { name: 'pg_visibility',       schema: 'monitor' }
  - { name: 'pg_freespacemap',     schema: 'monitor' }
  - { name: 'pg_repack',           schema: 'monitor' }
  - name: postgres_fdw
  - name: file_fdw
  - name: btree_gist
  - name: btree_gin
  - name: pg_trgm
  - name: intagg
  - name: intarray

# postgres host-based authentication rules
pg_hba_rules:
  - title: allow meta node password access
    role: common
    rules:
      - host    all     all                         10.10.10.10/32      md5

  - title: allow intranet admin password access
    role: common
    rules:
      - host    all     +dbrole_admin               10.0.0.0/8          md5
      - host    all     +dbrole_admin               172.16.0.0/12       md5
      - host    all     +dbrole_admin               192.168.0.0/16      md5

  - title: allow intranet password access
    role: common
    rules:
      - host    all             all                 10.0.0.0/8          md5
      - host    all             all                 172.16.0.0/12       md5
      - host    all             all                 192.168.0.0/16      md5

  - title: allow local read-write access (local production user via pgbouncer)
    role: common
    rules:
      - local   all     +dbrole_readwrite                               md5
      - host    all     +dbrole_readwrite           127.0.0.1/32        md5

  - title: allow read-only user (stats, personal) password directly access
    role: replica
    rules:
      - local   all     +dbrole_readonly                               md5
      - host    all     +dbrole_readonly           127.0.0.1/32        md5
pg_hba_rules_extra: []

# pgbouncer host-based authentication rules
pgbouncer_hba_rules:
  - title: local password access
    role: common
    rules:
      - local  all          all                                     md5
      - host   all          all                     127.0.0.1/32    md5

  - title: intranet password access
    role: common
    rules:
      - host   all          all                     10.0.0.0/8      md5
      - host   all          all                     172.16.0.0/12   md5
      - host   all          all                     192.168.0.0/16  md5
pgbouncer_hba_rules_extra: []

数据库模板

权限模型

v0.5 改善了默认的权限模型,主要是针对单实例多租户的场景进行优化,并收紧权限控制。

  • 撤回了普通业务用户对非所属数据库的默认CONNECT权限
  • 撤回了非管理员用户对所属数据库的默认CREATE权限
  • 撤回了所有用户在public模式下的默认创建权限。

供给方式

原先Pigsty采用直接拷贝Grafana自带的grafana.db的方式完成监控系统的初始化。 这种方式虽然简单粗暴管用,但不适合进行精细化的版本控制管理。在v0.5中,Pigsty采用了Grafana API完成了监控系统面板供给的工作。 您所需的就是在grafana_url中填入带有用户名密码的Grafana URL。 因此,监控系统可以背方便地添加至已有的Grafana中。

v0.4.0 发布注记

第二个公开测试版v0.4现已正式发行

第二个公开测试版v0.4现已正式发行

Pigsty v0.4对监控系统进行了整体升级改造,精心挑选了10个面板作为标准的Pigsty开源内容。同时,针对Grafana 7.3的不兼容升级进行了大量适配改造工作。使用升级的pg_exporter v0.3.1作为默认指标导出器,调整了监控报警规则的监控面板连接。

Pigsty开源版

Pigsty开源版选定了以下10个Dashboard作为开源内容。其他Dashboard作为可选的商业支持内容提供。

  • PG Overview
  • PG Cluster
  • PG Service
  • PG Instance
  • PG Database
  • PG Query
  • PG Table
  • PG Table Catalog
  • PG Table Detail
  • Node

尽管进行了少量阉割,这10个监控面板所涵盖的内容仍然可以吊打所有同类软件。

软件升级

Pigsty v0.4进行了大量软件适配工作,包括:

  • Upgrade to PostgreSQL 13.1, Patroni 2.0.1-4, add citus to repo.
  • Upgrade to pg_exporter 0.3.1
  • Upgrade to Grafana 7.3, Ton’s of compatibility work
  • Upgrade to prometheus 2.23, with new UI as default
  • Upgrade to consul 1.9

其他改进

  • Update prometheus alert rules
  • Fix alertmanager info links
  • Fix bugs and typos.
  • add a simple backup script

离线安装包

  • v0.4的离线安装包(CentOS 7.8)已经可以从Github下载:pkg.tgz

v0.3.0 发布注记

v0.3.0 第一个公开的试用版本现已释出!

首个Pigsty公开测试版本现在已经释出!

监控系统

Pigsty v0.3 包含以下8个监控面板作为开源内容:

  • PG Overview
  • PG Cluster
  • PG Service
  • PG Instance
  • PG Database
  • PG Table Overview
  • PG Table Catalog
  • Node

离线安装包

  • v0.3 离线安装包(CentOS 7.8)已经可以从Github下载:pkg.tgz

文章

关于PostgreSQL的文章

容器化数据库是个好主意吗?

把数据库放入Docker是一个好主意吗?当然是个馊主意!

对于无状态的应用服务而言,容器是一个相当完美的开发运维解决方案。然而对于带持久状态的服务 —— 数据库来说,事情就没有那么简单了。生产环境的数据库是否应当放入容器中,仍然是一个充满争议的问题。

站在开发者的角度上,我非常喜欢Docker,并始终相信Docker是未来软件开发部署运维的标准方式,而Kubernetes则是事实上的下一代“操作系统”。但站在DBA的立场上,我认为就目前而言,将生产环境数据库放入Docker中仍然是一个馊主意。

Docker解决什么问题?

让我们先来看一看Docker对自己的描述。

docker-dev

docker-ops

Docker用于形容自己的词汇包括:轻量,标准化,可移植,节约成本,提高效率,自动,集成,高效运维。这些说法并没有问题,Docker在整体意义上确实让开发和运维都变得更容易了。因而可以看到很多公司都热切地希望将自己的软件与服务容器化。但有时候这种热情会走向另一个极端:将一切软件服务都容器化,甚至是生产环境的数据库

容器最初是针对无状态的应用而设计的,在逻辑上,容器内应用产生的临时数据也属于该容器的一部分。用容器创建起一个服务,用完之后销毁它。这些应用本身没有状态,状态通常保存在容器外部的数据库里,这是经典的架构与用法,也是容器的设计哲学。

但当用户想把数据库本身也放到容器中时,事情就变得不一样了:数据库是有状态的,为了维持这个状态不随容器停止而销毁,数据库容器需要在容器上打一个洞,与底层操作系统上的数据卷相联通。这样的容器,不再是一个能够随意创建,销毁,搬运,转移的对象,而是与底层环境相绑定的对象。因此,传统应用使用容器的诸多优势,对于数据库容器来说都不复存在。

可靠性

让软件跑起来,和让软件可靠地运行是两回事。数据库是信息系统的核心,在绝大多数场景下属于**关键(Critical)**应用,Critical Application可按字面解释,就是出了问题会要命的应用。这与我们的日常经验相符:Word/Excel/PPT这些办公软件如果崩了强制重启即可,没什么大不了的;但正在编辑的文档如果丢了、脏了、乱了,那才是真的灾难。数据库亦然,对于不少公司,特别是互联网公司来说,如果数据库被删了又没有可用备份,基本上可以宣告关门大吉了。

可靠性(Reliability)是数据库最重要的属性。可靠性是系统在困境(adversity)(硬件故障、软件故障、人为错误)中仍可正常工作(正确完成功能,并能达到期望的性能水准)的能力。可靠性意味着容错(fault-tolerant)与韧性(resilient),它是一种安全属性,并不像性能与可维护性那样的活性属性直观可衡量。它只能通过长时间的正常运行来证明,或者某一次故障来否证。很多人往往会在平时忽视安全属性,而在生病后,车祸后,被抢劫后才追悔莫及。安全生产重于泰山,数据库被删,被搅乱,被脱库后再捶胸顿足是没有意义的。

回头再看一看Docker对自己的特性描述中,并没有包含“可靠”这个对于数据库至关重要的属性。

可靠性证明与社区知识

如前所述,可靠性并没有一个很好的衡量方式。只有通过长时间的正确运行,我们才能对一个系统的可靠性逐渐建立信心。在裸机上部署数据库可谓自古以来的实践,通过几十年的持续工作,它很好的证明了自己的可靠性。Docker虽为DevOps带来一场革命,但仅仅五年的历史对于可靠性证明而言仍然是图样图森破。对关乎身家性命的生产数据库而言还远远不够:因为还没有足够的小白鼠去趟雷

想要提高可靠性,最重要的就是从故障中吸取经验。故障是宝贵的经验财富:它将未知问题变为已知问题,是运维知识的表现形式。社区的故障经验绝大多都基于裸机部署的假设,各式各样的故障在几十年里都已经被人们踩了个遍。如果你遇到一些问题,大概率是别人已经踩过的坑,可以比较方便地处理与解决。同样的故障如果加上一个“Docker”关键字,能找到的有用信息就要少的多。这也意味着当疑难杂症出现时,成功抢救恢复数据的概率要更低,处理紧急故障所需的时间会更长。

微妙的现实是,如果没有特殊理由,企业与个人通常并不愿意分享故障方面的经验。故障有损企业的声誉:可能暴露一些敏感信息,或者是企业与团队的垃圾程度。另一方面,故障经验几乎都是真金白银的损失与学费换来的,是运维人员的核心价值所在,因此有关故障方面的公开资料并不多。

额外失效点

开发关心Feature,而运维关注Bug。相比裸机部署而言,将数据库放入Docker中并不能降低硬件故障,软件错误,人为失误的发生概率。用裸机会有的硬件故障,用Docker一个也不会少。软件缺陷主要是应用Bug,也不会因为采用容器与否而降低,人为失误同理。相反,引入Docker会因为引入了额外的组件,额外的复杂度,额外的失效点,导致系统整体可靠性下降

举个最简单的例子,dockerd守护进程崩了怎么办,数据库进程就直接歇菜了。尽管这种事情发生的概率并不高,但它们在裸机上压根不会发生

此外,一个额外组件引入的失效点可能并不止一个:Docker产生的问题并不仅仅是Docker本身的问题。当故障发生时,可能是单纯Docker的问题,或者是Docker与数据库相互作用产生的问题,还可能是Docker与操作系统,编排系统,虚拟机,网络,磁盘相互作用产生的问题。可以参见官方PostgreSQL Docker镜像的Issue列表:https://github.com/docker-library/postgres/issues?q=。

此外,彼之蜜糖,吾之砒霜。某些Docker的Feature,在特定的环境下也可能会变为Bug。

隔离性

Docker提供了进程级别的隔离性,通常来说隔离性对应用来说是个好属性。应用看不见别的进程,自然也不会有很多相互作用导致的问题,进而提高了系统的可靠性。但隔离性对于数据库而言不一定完全是好事。

一个微妙的真实案例在同一个数据目录上启动两个PostgreSQL实例,或者在宿主机和容器内同时启动了两个数据库实例。在裸机上第二次启动尝试会失败,因为PostgreSQL能意识到另一个实例的存在而拒绝启动;但在使用Docker的情况下因其隔离性,第二个实例无法意识到宿主机或其他数据库容器中的另一个实例。如果没有配置合理的Fencing机制(例如通过宿主机端口互斥,pid文件互斥),两个运行在同一数据目录上的数据库进程能把数据文件搅成一团浆糊。

数据库需不需要隔离性?当然需要, 但不是这种隔离性。数据库的性能很重要,因此往往是独占物理机部署。除了数据库进程和必要的工具,不会有其他应用。即使放在容器中,也往往采用独占绑定物理机的模式运行。因此Docker提供的隔离性对于这种数据库部署方案而言并没有什么意义;不过对云数据库厂商来说,这倒真是一个实用的Feature,用来搞多租户超卖妙用无穷。

工具

数据库需要工具来维护,包括各式各样的运维脚本,部署,备份,归档,故障切换,大小版本升级,插件安装,连接池,性能分析,监控,调优,巡检,修复。这些工具,也大多针对裸机部署而设计。这些工具与数据库一样,都需要精心而充分的测试。让一个东西跑起来,与确信这个东西能持久稳定正确的运行,是完全不同的可靠性水准。

一个简单的例子是插件,PostgreSQL提供了很多实用的插件,譬如PostGIS。假如想为数据库安装该插件,在裸机上只要yum install然后create extension postgis两条命令就可以。但如果是在Docker里,按照Docker的实践原则,用户需要在镜像层次进行这个变更,否则下次容器重启时这个扩展就没了。因而需要修改Dockerfile,重新构建新镜像并推送到服务器上,最后重启数据库容器,毫无疑问,要麻烦的多。

再比如说监控,在传统的裸机部署模式下,机器的各项指标是数据库指标的重要组成部分。容器中的监控与裸机上的监控有很多微妙的区别。不注意可能会掉到坑里。例如,CPU各种模式的时长之和,在裸机上始终会是100%,但这样的假设在容器中就不一定总是成立了。再比方说依赖/proc文件系统的监控程序可能在容器中获得与裸机上涵义完全不同的指标。虽然这类问题最终都是可解的(例如把Proc文件系统挂载到容器内),但相比简洁明了的方案,没人喜欢复杂丑陋的work around。

类似的问题包括一些故障检测工具与系统常用命令,虽然理论上可以直接在宿主机上执行,但谁能保证容器里的结果和裸机上的结果有着相同的涵义?更为棘手的是紧急故障处理时,一些需要临时安装使用的工具在容器里没有,外网不通,如果再走Dockerfile→Image→重启这种路径毫无疑问会让人抓狂。

把Docker当成虚拟机来用的话,很多工具大抵上还是可以正常工作的,不过这样就丧失了使用的Docker的大部分意义,不过是把它当成了另一个包管理器用而已。有人觉得Docker通过标准化的部署方式增加了系统的可靠性,因为环境更为标准化更为可控。这一点不能否认。私以为,标准化的部署方式虽然很不错,但如果运维管理数据库的人本身了解如何配置数据库环境,将环境初始化命令写在Shell脚本里和写在Dockerfile里并没有本质上的区别。

可维护性

软件的大部分开销并不在最初的开发阶段,而是在持续的维护阶段,包括修复漏洞、保持系统正常运行、处理故障、版本升级,偿还技术债、添加新的功能等等。可维护性对于运维人员的工作生活质量非常重要。应该说可维护性是Docker最讨喜的地方:Infrastructure as code。可以认为Docker的最大价值就在于它能够把软件的运维经验沉淀成可复用的代码,以一种简便的方式积累起来,而不再是散落在各个角落的install/setup文档。在这一点上Docker做的相当出色,尤其是对于逻辑经常变化的无状态应用而言。Docker和K8s能让用户轻松部署,完成扩容,缩容,发布,滚动升级等工作,让Dev也能干Ops的活,让Ops也能干DBA的活(迫真)。

环境配置

如果说Docker最大的优点是什么,那也许就是环境配置的标准化了。标准化的环境有助于交付变更,交流问题,复现Bug。使用二进制镜像(本质是物化了的Dockerfile安装脚本)相比执行安装脚本而言更为快捷,管理更方便。一些编译复杂,依赖如山的扩展也不用每次都重新构建了,这些都是很爽的特性。

不幸的是,数据库并不像通常的业务应用一样来来去去更新频繁,创建新实例或者交付环境本身是一个极低频的操作。同时DBA们通常都会积累下各种安装配置维护脚本,一键配置环境也并不会比Docker慢多少。因此在环境配置上Docker的优势就没有那么显著了,只能说是Nice to have。当然,在没有专职DBA时,使用Docker镜像可能还是要比自己瞎折腾要好一些,因为起码镜像中多少沉淀了一些运维经验。

通常来说,数据库初始化之后连续运行几个月几年也并不稀奇。占据数据库管理工作主要内容的并不是创建新实例与交付环境,主要还是日常运维的部分。不幸的是在这一点上Docker并没有什么优势,反而会产生一些麻烦。

日常运维

Docker确实能极大地简化来无状态应用的日常维护工作,诸如创建销毁,版本升级,扩容等,但同样的结论能延伸到数据库上吗?

数据库容器不可能像应用容器一样随意销毁创建,重启迁移。因而Docker并不能对数据库的日常运维的体验有什么提升,真正有帮助的倒是诸如ansible之类的工具。而对于日常运维而言,很多操作都需要通过docker exec的方式将脚本透传至容器内执行。底下跑的还是一样的脚本,只不过用docker-exec来执行又额外多了一层包装,这就有点脱裤子放屁的意味了。

此外,很多命令行工具在和Docker配合使用时都相当尴尬。譬如docker exec会将stderrstdout混在一起,让很多依赖管道的命令无法正常工作。以PostgreSQL为例,在裸机部署模式下,某些日常ETL任务可以用一行bash轻松搞定:

psql <src-url> -c 'COPY tbl TO STDOUT' |\
psql <dst-url> -c 'COPY tdb FROM STDIN'

但如果宿主机上没有合适的客户端二进制程序,那就只能这样用Docker容器中的二进制:

docker exec -it srcpg gosu postgres bash -c "psql -c \"COPY tbl TO STDOUT\" 2>/dev/null" |\ docker exec -i dstpg gosu postgres psql -c 'COPY tbl FROM STDIN;'

当用户想为容器里的数据库做一个物理备份时,原本很简单的一条命令现在需要很多额外的包装:dockergosubashpg_basebackup

docker exec -i postgres_pg_1 gosu postgres bash -c 'pg_basebackup -Xf -Ft -c fast -D - 2>/dev/null' | tar -xC /tmp/backup/basebackup

如果说客户端应用psql|pg_basebackup|pg_dump还可以通过在宿主机上安装对应版本的客户端工具来绕开这个问题,那么服务端的应用就真的无解了。总不能在不断升级容器内数据库软件的版本时每次都一并把宿主机上的服务器端二进制版本升级了吧?

另一个Docker喜欢讲的例子是软件版本升级:例如用Docker升级数据库小版本,只要简单地修改Dockerfile里的版本号,重新构建镜像然后重启数据库容器就可以了。没错,至少对于无状态的应用来说这是成立的。但当需要进行数据库原地大版本升级时问题就来了,用户还需要同时修改数据库状态。在裸机上一行bash命令就可以解决的问题,在Docker下可能就会变成这样的东西:https://github.com/tianon/docker-postgres-upgrade。

如果数据库容器不能像AppServer一样随意地调度,快速地扩展,也无法在初始配置,日常运维,以及紧急故障处理时相比普通脚本的方式带来更多便利性,我们又为什么要把生产环境的数据库塞进容器里呢?

Docker和K8s一个很讨喜的地方是很容易进行扩容,至少对于无状态的应用而言是这样:一键拉起起几个新容器,随意调度到哪个节点都无所谓。但数据库不一样,作为一个有状态的应用,数据库并不能像普通AppServer一样随意创建,销毁,水平扩展。譬如,用户创建一个新从库,即使使用容器,也得从主库上重新拉取基础备份。生产环境中动辄几TB的数据库,用万兆网卡也需要个把钟头才能完成,也很可能还是需要人工介入与检查。相比之下,在同样的操作系统初始环境下,运行现成的拉从库脚本与跑docker run在本质上又能有什么区别?毕竟时间都花在拖从库上了。

使用Docker承放生产数据库的一个尴尬之处就在于,数据库是有状态的,而且为了建立这个状态需要额外的工序。通常来说设置一个新PostgreSQL从库的流程是,先通过pg_baseback建立本地的数据目录副本,然后再在本地数据目录上启动postmaster进程。然而容器是和进程绑定的,一旦进程退出容器也随之停止。因此为了在Docker中扩容一个新从库:要么需要先后启动pg_baseback容器拉取数据目录,再在同一个数据卷上启动postgres两个容器;要么需要在创建容器的过程中就指定好复制目标并等待几个小时的复制完成;要么在postgres容器中再使用pg_basebackup偷天换日替换数据目录。无论哪一种方案都是既不优雅也不简洁。因为容器的这种进程隔离抽象,对于数据库这种充满状态的多进程,多任务,多实例协作的应用存在抽象泄漏,它很难优雅地覆盖这些场景。当然有很多折衷的办法可以打补丁来解决这类问题,然而其代价就是大量非本征复杂度,最终受伤的还是系统的可维护性。

总的来说,不可否认Docker对于提高系统整体的可维护性是有帮助的,只不过针对数据库来说这种优势并不显著:容器化的数据库能简化并加速创建新实例或扩容的速度,但也会在日常运维中引入一些麻烦和问题。不过,我相信随着Docker与K8s的进步,这些问题最终都是可以解决克服的。

性能

性能也是人们经常关注的一个维度。从性能的角度来看,数据库的基本部署原则当然是离硬件越近越好,额外的隔离与抽象不利于数据库的性能:越多的隔离意味着越多的开销,即使只是内核栈中的额外拷贝。对于追求性能的场景,一些数据库选择绕开操作系统的页面管理机制直接操作磁盘,而一些数据库甚至会使用FPGA甚至GPU加速查询处理。

实事求是地讲,Docker作为一种轻量化的容器,性能上的折损并不大,这也是Docker相比虚拟机的优势所在。但毫无疑问的是,将数据库放入Docker只会让性能变得更差而不是更好。

总结

容器技术与编排技术对于运维而言是非常有价值的东西,它实际上弥补了从软件到服务之间的空白,其愿景是将运维的经验与能力代码化模块化。容器技术将成为未来的包管理方式,而编排技术将进一步发展为“数据中心分布式集群操作系统”,成为一切软件的底层基础设施Runtime。当越来越多的坑被踩完后,人们可以放心大胆的把一切应用,有状态的还是无状态的都放到容器中去运行。但现在,起码对于数据库而言,还只是一个美好的愿景。

最后需要再次强调的是,以上讨论仅限于生产环境数据库。换句话说,对于开发环境而言,我其实是很支持将数据库放入Docker中的,毕竟不是所有的开发人员都知道怎么配置本地测试数据库环境,使用Docker交付环境显然要比一堆手册简单明了的多。对于生产环境的无状态应用,甚至一些带有衍生状态的不甚重要衍生数据系统(譬如Redis缓存),Docker也是一个不错的选择。但对于生产环境的核心关系型数据库而言,如果里面的数据真的很重要,使用Docker前还望三思:我愿意当小白鼠吗?出了疑难杂症我能Hold住吗?真搞砸了这锅我背的动吗?

任何技术决策都是一个利弊权衡的过程,譬如这里使用Docker的核心权衡可能就是牺牲可靠性换取可维护性。确实有一些场景,数据可靠性并不是那么重要,或者说有其他的考量:譬如对于云计算厂商来说,把数据库放到容器里混部超卖就是一件很好的事情:容器的隔离性,高资源利用率,以及管理上的便利性都与该场景十分契合。这种情况下将数据库放入Docker中也许就是利大于弊的。但对于多数的场景而言,可靠性往往都是优先级最高的的属性,牺牲可靠性换取可维护性通常并不是一个可取的选择。更何况实际很难说运维管理数据库的工作会因为用了Docker而轻松多少:为了安装部署一次性的便利而牺牲长久的日常运维可维护性,并不是一个很好的生意。

综上所述,我认为就目前对于普通用户而言,将生产环境的数据库放入容器中恐怕并不是一个明智的选择。

理解时间:时间时区那些事

理解时间:时间时区那些事

时间是个很玄妙的东西,看不见也摸不着。我们都能意识到时间的存在,但要给它下个定义,很多人也说不上来。本文当然不是为了探讨哲学问题,但对时间的正确理解,对正确处理工作生活中的时间问题很有帮助(例如,计算机中的时间表示与时间处理,数据库,编程语言中对于时间的处理)。

0x01 秒与计时

时间的单位是秒,但秒的定义并不是一成不变的。它有一个天文学定义,也有一个物理学定义。

世界时(UT1)

在最开始,秒的定义来源于日。秒被定义为平均太阳日的1/86400。而太阳日,则是由天文学现象定义的:两次连续正午时分的间隔被定义为一个太阳日;一天有86400秒,一秒等于86400分之一天,Perfect!以这一标准形成的时间标准,就称为世界时(Univeral Time, UT1),或不严谨的说,格林威治标准时(Greenwich Mean Time, GMT),下面就用GMT来指代它了。 ​ 这个定义很直观,但有一个问题:它是基于天文学现象的,即地球与太阳的周期性运动。不论是用地球的公转还是自转来定义秒,都有一个很尴尬的地方:虽然地球自转与公转的变化速度很慢,但并不是恒常的,譬如:地球的自转越来越慢,而地月位置也导致了每天的时长其实都不完全相同。这意味着作为物理基本单位的秒,其时长竟然是变化的。在衡量时间段的长短上就比较尴尬,几十年的一秒可能和今天的一秒长度已经不是一回事了。

原子时(TAI)

为了解决这个问题,在1967年之后,秒的定义变成了:铯133原子基态的两个超精细能级间跃迁对应辐射的9,192,631,770个周期的持续时间。秒的定义从天文学定义升级成为了物理学定义,其描述由相对易变的天文现象升级到了更稳定的宇宙中的基本物理事实。现在我们有了真正精准的秒啦:一亿年的偏差也不超过一秒。 ​ 当然,这么精确的秒除了用来衡量时间间隔,也可以用来计时。从1958-01-01 00:00:00开始作为公共时间原点,国际原子钟开始了计数,每计数9,192,631,770这么多个原子能级跃迁周期就+1s,这个钟走的非常准,每一秒都很均匀。使用这定义的时间称为国际原子时(International Atomic Time, TAI),下文简称TAI。

冲突

在最开始,这两种秒是等价的:一天是86400天文秒,也等于86400物理秒,毕竟物理学这个定义就是特意去凑天文学的定义嘛。所以相应的,GMT也与国际原子时TAI也保持着同步。然而正如前面所说,天文学现象影响因素太多了,并不是真正的“天行有常”。随着地球自转公转速度变化,天文定义的秒要比物理定义的秒稍微长了那么一点点,这也就意味着GMT要比TAI稍微落后一点点。 ​ 那么哪种定义说了算,世界时还是原子时?如果理论与生活实践经验相违背,绝大多数人都不会选择反直觉的方案:假设一种极端场景,两个钟之间的差异日积月累,到最后出现了几分钟甚至几小时的差值:明明日当午,按GMT应当是12:00:00,但GMT走慢了,TAI显示的时间已经是晚上六点了,这就违背了直觉。在表示时刻这一点上,还是由天文定义说了算,即以GMT为准。 ​ 当然,就算是天文定义说了算,也要尊重物理规律,毕竟原子钟走的这么准不是?实际上世界时与原子时之间的差值也就在几秒的量级。那么我们会自然而然地想到,使用国际原子时TAI作为基准,但加上一些闰秒(leap second)修正到GMT不就行了?既有高精度,又符合常识。于是就有了新的协调世界时(Coordinated Universal Time, UTC)

协调世界时(UTC)

UTC是调和GMT与TAI的产物:

  • UTC使用精确的国际原子时TAI作为计时基础

  • UTC使用国际时GMT作为修正目标

  • UTC使用闰秒作为修正手段,

我们通常所说的时间,通常就是指世界协调时间UTC,它与世界时GMT的差值在0.9秒内,在要求不严格的实践中,可以近似认为UTC时间与GMT时间是相同的,很多人也把它与GMT混为一谈。

但问题紧接着就来了,按照传统,一天24小时,一小时60分钟,一分钟60秒,日和秒之间有86400的换算关系。以前用日来定义秒,现在秒成了基本单位,就要用秒去定义日。但现在一天不等于86400秒了。无论用哪头定义哪头,都会顾此失彼。唯一的办法,就是打破这种传统:一分钟不一定只有60秒了,它在需要的时候可以有61秒! ​ 这就是闰秒机制,UTC以TAI为基准,因此走的也比GMT快。假设UTC和GMT的差异不断变大,在即将超过一秒时,让UTC中的某一分钟变为61秒,续的这一秒就像UTC在等GMT一样,然后误差就追回来了。每次续一秒时,UTC时间都会落后TAI多一秒,截止至今,UTC已经落后TAI三十多秒了。最近的一次闰秒调整是在2016年跨年:

国际标准时间UTC将在格林尼治时间2016年12月31日23时59分59秒(北京时间2017年1月1日7时59分59秒)之后,在原子时钟实施一个正闰秒,即增加1秒,然后才会跨入新的一年。

所以说,GMT和UTC还是有区别的,UTC里你能看到2016-12-31 23:59:60的时间,但GMT里就不会。

0x02 本地时间与时区

刚才讨论的时间都默认了一个前提:位于本初子午线(0度经线)上的时间。我们还需要考虑地球上的其他地方:毕竟美帝艳阳高照时,中国还在午夜呢。 ​ 本地时间,顾名思义就是以当地的太阳来计算的时间:正午就是12:00。太阳东升西落,东经120度上的本地时间比起本初子午线上就早了120° / (360°/24) = 8个小时。这意味着在北京当地时间12点整时,UTC时间其实是12-8=4,早晨4:00。 ​ 大家统一用UTC时间好不好呢?可以当然可以,毕竟中国横跨三个时区,也只用了一个北京时间。只要大家习惯就行。但大家都已经习惯了本地正午算12点了,强迫全世界人民用统一的时间其实违背了历史习惯。时区的设置使得长途旅行者能够简单地知道当地人的作息时间:反正差不多都是朝九晚五上班。这就降低了沟通成本。于是就有了时区的概念。当然像新疆这种硬要用北京时间的结果就是,游客乍一看当地人11点12点才上班可能会有些懵。

但在大一统的国家内部,使用统一的时间也有助于降低沟通成本。假如一个新疆人和一个黑龙江人打电话,一个用的乌鲁木齐时间,一个用的北京时间,那就会鸡同鸭讲。都约着12点,结果实际差了两个小时。时区的选用并不完全是按照地理经度而来的,也有很多的其他因素考量(例如行政区划)。 ​ 这就引出了时区的概念:时区是地球上使用同一个本地时间定义的区域时区实际上可以视作从地理区域到时间偏移量的单射。 ​ 但其实有没有那个地理区域都不重要,关键在于时间偏移量的概念。UTC/GMT时间本身的偏移量为0,时区的偏移量都是相对于UTC时间而言的。这里,本地时间,UTC时间与时区的关系是:

本地时间 = UTC时间 + 本地时区偏移量。

比如UTC、GMT的时区都是+0,意味着没有偏移量。中国所处的东八区偏移量就是+8。意味着计算当地时间时,要在UTC时间的基础上增加8个小时。

夏令时(Daylight Saving Time, DST),可以视为一种特殊的时区偏移修正。指的是在夏天天亮的较早的时候把时间调快一个小时(实际上不一定是一个小时),从而节省能源(灯火)。我国在86年到92年之间曾短暂使用过夏令时。欧盟从1996年开始使用夏令时,不过欧盟最近的民调显示,84%的民众希望取消夏令时。对程序员而言,夏令时也是一个额外的麻烦事,希望它能尽快被扫入历史的垃圾桶。

0x03 时间的表示

那么,时间又如何表示呢?使用TAI的秒数来表示时间当然不会有歧义,但使用不便。习惯上我们将时间分为三个部分:日期,时间,时区,而每个部分都有多种表示方法。对于时间的表示,世界诸国人民各有各的习惯,例如,2006年1月2日,美国人就可能喜欢使用诸如January 2, 19991/2/1999这样的日期表示形式,而中国人也许会用诸如“2006年1月2日”,“2006/01/02”这样的表示形式。发送邮件时,首部中的时间则采用RFC2822中规定的Sat, 24 Nov 2035 11:45:15 −0500格式。此外,还有一系列的RFC与标准,用于指定日期与时间的表示格式。

ANSIC       = "Mon Jan _2 15:04:05 2006"
UnixDate    = "Mon Jan _2 15:04:05 MST 2006"
RubyDate    = "Mon Jan 02 15:04:05 -0700 2006"
RFC822      = "02 Jan 06 15:04 MST"
RFC822Z     = "02 Jan 06 15:04 -0700" // RFC822 with numeric zone
RFC850      = "Monday, 02-Jan-06 15:04:05 MST"
RFC1123     = "Mon, 02 Jan 2006 15:04:05 MST"
RFC1123Z    = "Mon, 02 Jan 2006 15:04:05 -0700" // RFC1123 with numeric zone
RFC3339     = "2006-01-02T15:04:05Z07:00"
RFC3339Nano = "2006-01-02T15:04:05.999999999Z07:00"

不过在这里,我们只关注计算机中的日期表示形式与存储方式。而计算机中,时间最经典的表示形式,就是Unix时间戳。

Unix时间戳

比起UTC/GMT,对于程序员来说,更为熟悉的可能是另一种时间:Unix时间戳。UNIX时间戳是从1970年1月1日(UTC/GMT的午夜,在1972年之前没有闰秒)开始所经过的秒数,注意这里的秒其实是GMT中的秒,也就是不计闰秒,毕竟一天等于86400秒已经写死到无数程序的逻辑里去了,想改是不可能改的。 ​ 使用GMT秒数的好处是,计算日期的时候根本不用考虑闰秒的问题。毕竟闰年已经很讨厌了,再来一个没有规律的闰秒,绝对会让程序员抓狂。当然这不代表就不需要考虑闰秒的问题了,诸如ntp等时间服务还是需要考虑闰秒的问题的,应用程序有可能会受到影响:比如遇到‘时光倒流’拿到两次59秒,或者获取到秒数为60的时间值,一些实现简陋的程序可能就直接崩了。当然,也有一种将闰秒均摊到某一天全天的顺滑手段。 ​ Unix时间戳背后的思想很简单,建立一条时间轴,以某一个纪元点(Epoch)作为原点,将时间表示为距离原点的秒数。Unix时间戳的纪元为GMT时间的1970-01-01 00:00:00,32位系统上的时间戳实际上是一个有符号四字节整型,以秒为单位。这意味它能表示的时间范围为:2^32 / 86400 / 365 = 68年,差不多从1901年到2038年。 ​ 当然,时间戳并不是只有这一种表示方法,但通常这是最为传统稳妥可靠的做法。毕竟不是所有的程序员都能处理好许多和时区、闰秒相关的微妙错误。使用Unix时间戳的好处就是时区已经固定死了是GMT了,存储空间与某些计算处理(比如排序)也相对容易。 ​ 在*nix命令行中使用date +%s可以获取Unix时间戳。而date -r @1500000000则可以反向将Unix时间戳转换为其他时间格式,例如转换为2017-07-14 10:40:00可以使用:

date -d @1500000000 '+%Y-%m-%d %H:%M:%S'	# Linux
date -r 1500000000 '+%Y-%m-%d %H:%M:%S'		# MacOS, BSD

在很久以前,当主板上的电池没电之后,系统的时钟就会自动重置成0;还有很多软件的Bug也会导致导致时间戳为0,也就是1970-01-01;以至于这个纪元时间很多非程序员都知道了。 ​

数据库中的时间存储

通常情况下,Unix时间戳是传递/存储时间的最佳方式,它通常在计算机内部以整型的形式存在,内容为距离某个特定纪元的秒数。它极为简单,无歧义,存储占用更紧实,便于比较大小,且在程序员之间存在广泛共识。不过,Epoch+整数偏移量的方式适合在机器上进行存储与交换,但它并不是一种人类可读的格式(也许有些程序员可读)。

PostgreSQL提供了丰富的日期时间数据类型与相关函数,它能以高度灵活的方式自动适配各种格式的时间输入输出,并在内部以高效的整型表示进行存储与计算。在PostgreSQL中,变量CURRENT_TIMESTAMP或函数now()会返回当前事务开始时的本地时间戳,返回的类型是TIMESTAMP WITH TIME ZONE,这是一个PostgreSQL扩展,会在时间戳上带有额外的时区信息。SQL标准所规定的类型为TIMESTAMP,在PostgreSQL中使用8字节的长整型实现。可以使用SQL语法AT TIME ZONE zone或内置函数timezone(zone,ts)将带有时区的TIMESTAMP转换为不带时区的标准版本。

通常(我认为的)最佳实践是,只要应用稍具规模或涉及到任何国际化的功能,存储时间就应当使用TIMESTAMP类型并存储GMT时间,当然,PostgreSQL Wiki中推荐的方式是使用PostgreSQL自己的TimestampTZ扩展类型,带时区的时间戳是12字节,而不带时区的则为8字节,在固定使用GMT时区的情况下,个人还是更倾向于使用不带时区的TIMESTAMP类型。

-- 获取本地事务开始时的时间戳
vonng=# SELECT now(), CURRENT_TIMESTAMP;
              now              |       current_timestamp
-------------------------------+-------------------------------
 2018-12-11 21:50:15.317141+08 | 2018-12-11 21:50:15.317141+08

-- now()/CURRENT_TIMESTAMP返回的是带有时区信息的时间戳
 vonng=# SELECT pg_typeof(now()),pg_typeof(CURRENT_TIMESTAMP);
        pg_typeof         |        pg_typeof
--------------------------+--------------------------
 timestamp with time zone | timestamp with time zone
 

-- 将本地时区+8时间转换为UTC时间,转化得到的是TIMESTAMP
-- 注意不要使用从TIMESTAMPTZ到TIMESTAMP的强制类型转换,会直接截断时区信息。
 vonng=# SELECT now() AT TIME ZONE 'UTC';
          timezone
----------------------------
 2018-12-11 13:50:25.790108

-- 再将UTC时间转换为太平洋时间
vonng=# SELECT (now() AT TIME ZONE 'UTC') AT TIME ZONE 'PST';
           timezone
-------------------------------
 2018-12-12 05:50:37.770066+08
 
 -- 查看PG自带的时区数据表
 vonng=# TABLE pg_timezone_names LIMIT 4;
       name       | abbrev | utc_offset | is_dst
------------------+--------+------------+--------
 Indian/Mauritius | +04    | 04:00:00   | f
 Indian/Chagos    | +06    | 06:00:00   | f
 Indian/Mayotte   | EAT    | 03:00:00   | f
 Indian/Christmas | +07    | 07:00:00   | f
...

-- 查看PG自带的时区缩写
vonng=# TABLE pg_timezone_abbrevs  LIMIT 4;
 abbrev | utc_offset | is_dst
--------+------------+--------
 ACDT   | 10:30:00   | t
 ACSST  | 10:30:00   | t
 ACST   | 09:30:00   | f
 ACT    | -05:00:00  | f
 ...

一个经常让人困惑的问题就是TIMESTAMPTIMESTAMPTZ之间的相互转化问题。

-- 使用::TIMESTAMP将TIMESTAMPTZ强制转换为TIMESTAMP,直接截断时区部分内容
-- 时间的其余"内容"保持不变
vonng=# SELECT now(), now()::TIMESTAMP;
             now               |           now
-------------------------------+--------------------------
 2018-12-12 05:50:37.770066+08 |  2018-12-12 05:50:37.770066+08

-- 对有时区版TIMESTAMPTZ使用AT TIME ZONE语法
-- 会将其转换为无时区版的TIMESTAMP,返回给定时区下的时间
vonng=# SELECT now(), now() AT TIME ZONE 'UTC';
              now              |          timezone
-------------------------------+----------------------------
 2019-05-23 16:58:47.071135+08 | 2019-05-23 08:58:47.071135
 
 
 -- 对无时区版TIMESTAMP使用AT TIME ZONE语法
-- 会将其转换为带时区版的TIMESTAMPTZ,即在给定时区下解释该无时区时间戳。
vonng=# SELECT now()::TIMESTAMP, now()::TIMESTAMP AT TIME ZONE 'UTC';
            now             |           timezone
----------------------------+-------------------------------
 2019-05-23 17:03:00.872533 | 2019-05-24 01:03:00.872533+08
 
 -- 这里的意思是,UTC时间的 2019-05-23 17:03:00

微信公众号原文

PostgreSQL开发规约

没有规矩,不成方圆。

0x00背景

没有规矩,不成方圆。

PostgreSQL的功能非常强大,但是要把PostgreSQL用好,需要后端、运维、DBA的协力配合。

本文针对PostgreSQL数据库原理与特性,整理了一份开发规范,希望可以减少大家在使用PostgreSQL数据库过程中遇到的困惑。 你好我也好,大家都好。

0x01 命名规范

无名,万物之始,有名,万物之母。

【强制】 通用命名规则

  • 本规则适用于所有对象名,包括:库名、表名、表名、列名、函数名、视图名、序列号名、别名等。
  • 对象名务必只使用小写字母,下划线,数字,但首字母必须为小写字母,常规表禁止以_打头。
  • 对象名长度不超过63个字符,命名统一采用snake_case
  • 禁止使用SQL保留字,使用select pg_get_keywords(); 获取保留关键字列表。
  • 禁止出现美元符号,禁止使用中文,不要以pg开头。
  • 提高用词品味,做到信达雅;不要使用拼音,不要使用生僻冷词,不要使用小众缩写。

【强制】 库命名规则

  • 库名最好与应用或服务保持一致,必须为具有高区分度的英文单词。
  • 命名必须以<biz>-开头,<biz>为具体业务线名称,如果是分片库必须以-shard结尾。
  • 多个部分使用-连接。例如:<biz>-chat-shard<biz>-payment等,总共不超过三段。

【强制】 角色命名规范

  • 数据库su有且仅有一个:postgres,用于流复制的用户命名为replication
  • 生产用户命名使用<biz>-作为前缀,具体功能作为后缀。
  • 所有数据库默认有三个基础角色: <biz>-read<biz>-write<biz>-usage,分别拥有所有表的只读,只写,函数的执行权限。
  • 生产用户,ETL用户,个人用户通过继承相应的基础角色获取权限。
  • 更为精细的权限控制使用独立的角色与用户,依业务而异。

【强制】 模式命名规则

  • 业务统一使用<*>作为模式名,<*>为业务定义的名称,必须设置为search_path首位元素。
  • dbamonitortrash为保留模式名。
  • 分片模式命名规则采用:rel_<partition_total_num>_<partition_index>
  • 无特殊理由不应在其他模式中创建对象。

【推荐】 关系命名规则

  • 关系命名以表意清晰为第一要义,不要使用含混的缩写,也不应过分冗长,遵循通用命名规则。
  • 表名应当使用复数名词,与历史惯例保持一致,但应尽量避免带有不规则复数形式的单词。
  • 视图以v_作为命名前缀,物化视图使用mv_作为命名前缀,临时表以tmp_作为命名前缀。
  • 继承或分区表应当以父表表名作为前缀,并以子表特性(规则,分片范围等)作为后缀。

【推荐】 索引命名规则

  • 创建索引时如有条件应当指定索引名称,并与PostgreSQL默认命名规则保持一致,避免重复执行时建立重复索引。
  • 用于主键的索引以_pkey结尾,唯一索引以_key结尾,用于EXCLUDED约束的索引以_excl结尾,普通索引以_idx结尾。

【推荐】 函数命名规则

  • select,insert,delete,update,upsert打头,表示动作类型。
  • 重要参数可以通过_by_ids, _by_user_ids的后缀在函数名中体现。
  • 避免函数重载,同名函数尽量只保留一个。
  • 禁止通过BIGINT/INTEGER/SMALLINT等整型进行重载,调用时可能产生歧义。

【推荐】 字段命名规则

  • 不得使用系统列保留字段名:oid, xmin, xmax,cmin, cmax, ctid等。
  • 主键列通常命名为id,或以id作为后缀。
  • 创建时间通常命名为created_time,修改时间通常命名为updated_time
  • 布尔型字段建议使用is_has_等作为前缀。
  • 其余各字段名需与已有表命名惯例保持一致。

【推荐】 变量命名规则

  • 存储过程与函数中的变量使用命名参数,而非位置参数。
  • 如果参数名与对象名出现冲突,在参数后添加_,例如user_id_

【推荐】 注释规范

  • 尽量为对象提供注释(COMMENT),注释使用英文,言简意赅,一行为宜。
  • 对象的模式或内容语义发生变更时,务必一并更新注释,与实际情况保持同步。

0x02 设计规范

Suum cuique

【强制】 字符编码必须为UTF8

  • 禁止使用其他任何字符编码。

【强制】 容量规划

  • 单表记录过亿,或超过10GB的量级,可以考虑开始进行分表。
  • 单表容量超过1T,单库容量超过2T。需要考虑分片。

【强制】 不要滥用存储过程

  • 存储过程适用于封装事务,减少并发冲突,减少网络往返,减少返回数据量,执行少量自定义逻辑。
  • 存储过程不适合进行复杂计算,不适合进行平凡/频繁的类型转换与包装。

【强制】 存储计算分离

  • 移除数据库中不必要的计算密集型逻辑,例如在数据库中使用SQL进行WGS84到其他坐标系的换算。
  • 例外:与数据获取、筛选密切关联的计算逻辑允许在数据库中进行,如PostGIS中的几何关系判断。

【强制】 主键与身份列

  • 每个表都必须有身份列,原则上必须有主键,最低要求为拥有非空唯一约束
  • 身份列用于唯一标识表中的任一元组,逻辑复制与诸多三方工具有赖于此。

【强制】 外键

  • 不建议使用外键,建议在应用层解决。使用外键时,引用必须设置相应的动作:SET NULL, SET DEFAULT, CASCADE,慎用级联操作。

【强制】 慎用宽表

  • 字段数目超过15个的表视作宽表,宽表应当考虑进行纵向拆分,通过相同的主键与主表相互引用。
  • 因为MVCC机制,宽表的写放大现象比较明显,尽量减少对宽表的频繁更新。

【强制】 配置合适的默认值

  • 有默认值的列必须添加DEFAULT子句指定默认值。
  • 可以在默认值中使用函数,动态生成默认值(例如主键发号器)。

【强制】 合理应对空值

  • 字段语义上没有零值与空值区分的,不允许空值存在,须为列配置NOT NULL约束。

【强制】 唯一约束通过数据库强制

  • 唯一约束须由数据库保证,任何唯一列须有唯一约束。
  • EXCLUDE约束是泛化的唯一约束,可以在低频更新场景下用于保证数据完整性。

【强制】 注意整数溢出风险

  • 注意SQL标准不提供无符号整型,超过INTMAX但没超过UINTMAX的值需要升格存储。
  • 不要存储超过INT64MAX的值到BIGINT列中,会溢出为负数。

【强制】 统一时区

  • 使用TIMESTAMP存储时间,采用utc时区。
  • 统一使用ISO-8601格式输入输出时间类型:2006-01-02 15:04:05,避免DMY与MDY问题。
  • 使用TIMESTAMPTZ时,采用GMT/UTC时间,0时区标准时。

【强制】 及时清理过时函数

  • 不再使用的,被替换的函数应当及时下线,避免与未来的函数发生冲突。

【推荐】 主键类型

  • 主键通常使用整型,建议使用BIGINT,允许使用不超过64字节的字符串。
  • 主键允许使用Serial自动生成,建议使用Default next_id()发号器函数。

【推荐】 选择合适的类型

  • 能使用专有类型的,不使用字符串。(数值,枚举,网络地址,货币,JSON,UUID等)
  • 使用正确的数据类型,能显著提高数据存储,查询,索引,计算的效率,并提高可维护性。

【推荐】 使用枚举类型

  • 较稳定的,取值空间较小(十几个内)的字段应当使用枚举类型,不要使用整型与字符串表示。
  • 使用枚举类型有性能、存储、可维护性上的优势。

【推荐】 选择合适的文本类型

  • PostgreSQL的文本类型包括 char(n), varchar(n), text
  • 通常建议使用varchartext,带有(n)修饰符的类型会检查字符串长度,会导致微小的额外开销,对字符串长度有限制时应当使用varchar(n),避免插入过长的脏数据。
  • 避免使用char(n),为了与SQL标准兼容,该类型存在不合直觉的行为表现(补齐空格与截断),且并没有存储和性能优势。

【推荐】 选择合适的数值类型

  • 常规数值字段使用INTEGER。主键、容量拿不准的数值列使用BIGINT
  • 无特殊理由不要用SMALLINT,性能与存储提升很小,会有很多额外的问题。
  • REAL表示4字节浮点数,FLOAT表示8字节浮点数
  • 浮点数仅可用于末尾精度无所谓的场景,例如地理坐标,不要对浮点数使用等值判断。
  • 精确数值类型使用NUMERIC,注意精度和小数位数设置。
  • 货币数值类型使用MONEY

【推荐】 使用统一的函数创建语法

  • 签名单独占用一行(函数名与参数),返回值单启一行,语言为第一个标签。
  • 一定要标注函数易变性等级:IMMUTABLE, STABLE, VOLATILE
  • 添加确定的属性标签,如:RETURNS NULL ON NULL INPUT,PARALLEL SAFE,ROWS 1,注意版本兼容性。
CREATE OR REPLACE FUNCTION
  nspname.myfunc(arg1_ TEXT, arg2_ INTEGER)
  RETURNS VOID
LANGUAGE SQL
STABLE
PARALLEL SAFE
ROWS 1
RETURNS NULL ON NULL INPUT
AS $function$
SELECT 1;
$function$;

【推荐】 针对可演化性而设计

  • 在设计表时,应当充分考虑未来的扩展需求,可以在建表时适当添加1~3个保留字段。
  • 对于多变的非关键字段可以使用JSON类型。

【推荐】 选择合理的规范化等级

  • 允许适当降低规范化等级,减少多表连接以提高性能。

【推荐】 使用新版本

  • 新版本有无成本的性能提升,稳定性提升,有更多新功能。
  • 充分利用新特性,降低设计复杂度。

【推荐】 慎用触发器

  • 触发器会提高系统的复杂度与维护成本,不鼓励使用。

0x03 索引规范

Wer Ordnung hält, ist nur zu faul zum Suchen.

【强制】 在线查询必须有配套索引

  • 所有在线查询必须针对其访问模式设计相应索引,除极个别小表外不允许全表扫描。
  • 索引有代价,不允许创建不使用的索引。

【强制】 禁止在大字段上建立索引

  • 被索引字段大小无法超过2KB(1/3的页容量),原则上禁止超过64个字符。
  • 如有大字段索引需求,可以考虑对大字段取哈希,并建立函数索引。或使用其他类型的索引(GIN)。

【强制】 明确空值排序规则

  • 如在可空列上有排序需求,需要在查询与索引中明确指定NULLS FIRST还是NULLS LAST
  • 注意,DESC排序的默认规则是NULLS FIRST,即空值会出现在排序的最前面,通常这不是期望行为。
  • 索引的排序条件必须与查询匹配,如:create index on tbl (id desc nulls last);

【强制】 利用GiST索引应对近邻查询问题

  • 传统B树索引无法提供对KNN问题的良好支持,应当使用GiST索引。

【推荐】 利用函数索引

  • 任何可以由同一行其他字段推断得出的冗余字段,可以使用函数索引替代。
  • 对于经常使用表达式作为查询条件的语句,可以使用表达式或函数索引加速查询。
  • 典型场景:建立大字段上的哈希函数索引,为需要左模糊查询的文本列建立reverse函数索引。

【推荐】 利用部分索引

  • 查询中查询条件固定的部分,可以使用部分索引,减小索引大小并提升查询效率。
  • 查询中某待索引字段若只有有限几种取值,也可以建立几个相应的部分索引。

【推荐】 利用范围索引

  • 对于值与堆表的存储顺序线性相关的数据,如果通常的查询为范围查询,建议使用BRIN索引。
  • 最典型场景如仅追加写入的时序数据,BRIN索引更为高效。

【推荐】 关注联合索引的区分度

  • 区分度高的列放在前面

0x04 查询规范

The limits of my language mean the limits of my world.

—Ludwig Wittgenstein

【强制】 读写分离

  • 原则上写请求走主库,读请求走从库。
  • 例外:需要读己之写的一致性保证,且检测到显著的复制延迟。

【强制】 快慢分离

  • 生产中1毫秒以内的查询称为快查询,生产中超过1秒的查询称为慢查询。
  • 慢查询必须走离线从库,必须设置相应的超时。
  • 生产中的在线普通查询执行时长,原则上应当控制在1ms内。
  • 生产中的在线普通查询执行时长,超过10ms需修改技术方案,优化达标后再上线。
  • 在线查询应当配置10ms数量级或更快的超时,避免堆积造成雪崩。
  • Master与Slave角色不允许大批量拉取数据,数仓ETL程序应当从Offline从库拉取数据

【强制】 主动超时

  • 为所有的语句配置主动超时,超时后主动取消请求,避免雪崩。
  • 周期性执行的语句,必须配置小于执行周期的超时。

【强制】 关注复制延迟

  • 应用必须意识到主从之间的同步延迟,并妥善处理好复制延迟超出合理范围的情况
  • 平时在0.1ms的延迟,在极端情况下可能达到十几分钟甚至小时量级。应用可以选择从主库读取,稍后再度,或报错。

【强制】 使用连接池

  • 应用必须通过连接池访问数据库,连接6432端口的pgbouncer而不是5432的postgres。
  • 注意使用连接池与直连数据库的区别,一些功能可能无法使用(比如Notify/Listen),也可能存在连接污染的问题。

【强制】 禁止修改连接状态

  • 使用公共连接池时禁止修改连接状态,包括修改连接参数,修改搜索路径,更换角色,更换数据库。
  • 万不得已修改后必须彻底销毁连接,将状态变更后的连接放回连接池会导致污染扩散。

【强制】 重试失败的事务

  • 查询可能因为并发争用,管理员命令等原因被杀死,应用需要意识到这一点并在必要时重试。
  • 应用在数据库大量报错时可以触发断路器熔断,避免雪崩。但要注意区分错误的类型与性质。

【强制】 掉线重连

  • 连接可能因为各种原因被中止,应用必须有掉线重连机制。
  • 可以使用SELECT 1作为心跳包查询,检测连接的有消息,并定期保活。

【强制】 在线服务应用代码禁止执行DDL

  • 不要在应用代码里搞大新闻。

【强制】 显式指定列名

  • 避免使用SELECT *,或在RETURNING子句中使用*。请使用具体的字段列表,不要返回用不到的字段。当表结构发生变动时(例如,新值列),使用列通配符的查询很可能会发生列数不匹配的错误。
  • 例外:当存储过程返回具体的表行类型时,允许使用通配符。

【强制】 禁止在线查询全表扫描

  • 例外情况:常量极小表,极低频操作,表/返回结果集很小(百条记录/百KB内)。
  • 在首层过滤条件上使用诸如!=, <>的否定式操作符会导致全表扫描,必须避免。

【强制】 禁止在事务中长时间等待

  • 开启事务后必须尽快提交或回滚,超过10分钟的IDEL IN Transaction将被强制杀死。
  • 应用应当开启AutoCommit,避免BEGIN之后没有配对的ROLLBACKCOMMIT
  • 尽量使用标准库提供的事务基础设施,不到万不得已不要手动控制事务。

【强制】 使用游标后必须及时关闭

【强制】 科学计数

  • count(*)统计行数的标准语法,与空值无关。
  • count(col)统计的是col列中的非空记录数。该列中的NULL值不会被计入。
  • count(distinct col)col列除重计数,同样忽视空值,即只统计非空不同值的个数。
  • count((col1, col2))对多列计数,即使待计数的列全为空也会被计数,(NULL,NULL)有效。
  • a(distinct (col1, col2))对多列除重计数,即使待计数列全为空也会被计数,(NULL,NULL)有效。

【强制】 注意聚合函数的空值问题

  • 除了count之外的所有聚合函数都会忽略空值输入,因此当输入值全部为空时,结果是NULL。但count(col)在这种情况下会返回0,是一个例外。
  • 如果聚集函数返回空并不是期望的结果,使用coalesce来设置缺省值。

【强制】谨慎处理空值

  • 明确区分零值与空值,空值使用IS NULL进行等值判断,零值使用常规的=运算符进行等值判断。
  • 空值作为函数输入参数时应当带有类型修饰符,否则对于有重载的函数将无法识别使用何者。
  • 注意空值比较逻辑:任何涉及到空值比较运算结果都是unknown,需要注意unknown参与布尔运算的逻辑:
    • andTRUE or UNKNOWN会因为逻辑短路返回TRUE
    • orFALSE and UNKNOWN会因为逻辑短路返回FALSE
    • 其他情况只要运算对象出现UNKNOWN,结果都是UNKNOWN
  • 空值与任何值的逻辑判断,其结果都为空值,例如NULL=NULL返回结果是NULL而不是TRUE/FALSE
  • 涉及空值与非空值的等值比较,请使用``IS DISTINCT FROM 进行比较,保证比较结果非空。
  • 空值与聚合函数:聚合函数当输入值全部为NULL时,返回结果为NULL。

【强制】 注意序列号空缺

  • 当使用Serial类型时,INSERTUPSERT等操作都会消耗序列号,该消耗不会随事务失败而回滚。
  • 当使用整型作为主键,且表存在频繁插入冲突时,需要关注整型溢出的问题。

【推荐】 重复查询使用准备语句

  • 重复的查询应当使用准备语句(Prepared Statement),消除数据库硬解析的CPU开销。
  • 准备语句会修改连接状态,请注意连接池对于准备语句的影响。

【推荐】 选择合适的事务隔离等级

  • 默认隔离等级为读已提交,适合大多数简单读写事务,普通事务选择满足需求的最低隔离等级。
  • 需要事务级一致性快照的写事务,请使用可重复读隔离等级。
  • 对正确性有严格要求的写入事务请使用可序列化隔离等级。
  • 在RR与SR隔离等级出现并发冲突时,应当视错误类型进行积极的重试。

【推荐】 判断结果存在性不要使用count

  • 使用SELECT 1 FROM tbl WHERE xxx LIMIT 1判断是否存满足条件的列,要比Count快。
  • 可以使用select exists(select * FROM app.sjqq where xxx limit 1)将存在性结果转换为布尔值。

【推荐】 使用RETURNING子句

  • 如果用户需要在插入数据和,删除数据前,或者修改数据后马上拿到插入或被删除或修改后的数据,建议使用RETURNING子句,减少数据库交互次数。

【推荐】 使用UPSERT简化逻辑

  • 当业务出现插入-失败-更新的操作序列时,考虑使用UPSERT替代。

【推荐】 利用咨询锁应对热点并发

  • 针对单行记录的极高频并发写入(秒杀),应当使用咨询锁对记录ID进行锁定。
  • 如果能在应用层次解决高并发争用,就不要放在数据库层面进行。

【推荐】优化IN操作符

  • 使用EXISTS子句代替IN操作符,效果更佳。
  • 使用=ANY(ARRAY[1,2,3,4])代替IN (1,2,3,4),效果更佳。

【推荐】 不建议使用左模糊搜索

  • 左模糊搜索WHERE col LIKE '%xxx'无法充分利用B树索引,如有需要,可用reverse表达式函数索引。

【推荐】 使用数组代替临时表

  • 考虑使用数组替代临时表,例如在获取一系列ID的对应记录时。=ANY(ARRAY[1,2,3])要比临时表JOIN好。

0x05 发布规范

【强制】 发布形式

  • 目前以邮件形式提交发布,发送邮件至dba@p1.com 归档并安排提交。
  • 标题清晰:xx项目需在xx库执行xx动作。
  • 目标明确:每个步骤需要在哪些实例上执行哪些操作,结果如何校验。
  • 回滚方案:任何变更都需要提供回滚方案,新建也需要提供清理脚本。

【强制】发布评估

  • 线上数据库发布需要经过研发自测,主管审核,(可选QA审核),DBA审核几个评估阶段。
  • 自测阶段应当确保变更在开发、预发环境执行正确无误。
    • 如果是新建表,应当给出记录数量级,数据日增量预估值,读写量级预估。
    • 如果是新建函数,应当给出压测报告,至少需要给出平均执行时间。
    • 如果是模式迁移,必须梳理清楚所有上下游依赖。
  • Team Leader需要对变更进行评估与审核,对变更内容负责。
  • DBA对发布的形式与影响进行评估与审核。

【强制】 发布窗口

  • 19:00 后不允许数据库发布,紧急发布请TL做特殊说明,抄送CTO。
  • 16:00点后确认的需求将顺延至第二天执行。(以TL确认时间为准)

0x06 管理规范

【强制】 关注备份

  • 每日全量备份,段文件持续归档

【强制】 关注年龄

  • 关注数据库与表的年龄,避免事物ID回卷。

【强制】 关注老化与膨胀

  • 关注表与索引的膨胀率,避免性能劣化。

【强制】 关注复制延迟

  • 监控复制延迟,使用复制槽时更必须十分留意。

【强制】 遵循最小权限原则

【强制】并发地创建与删除索引

  • 对于生产表,必须使用CREATE INDEX CONCURRENTLY并发创建索引。

【强制】 新从库数据预热

  • 使用pg_prewarm,或逐渐接入流量。

【强制】 审慎地进行模式变更

  • 添加新列时必须使用不带默认值的语法,避免全表重写
  • 变更类型时,必要时应当重建所有依赖该类型的函数。

【推荐】 切分大批量操作

  • 大批量写入操作应当切分为小批量进行,避免一次产生大量WAL。

【推荐】 加速数据加载

  • 关闭autovacuum,使用COPY加载数据。
  • 事后建立约束与索引。
  • 调大maintenance_work_mem,增大max_wal_size
  • 完成后执行vacuum verbose analyze table

微信公众号原文

PG好处都有啥

PG好处都有啥,我要给它夸一夸,为什么PG是世界上最先进的开源关系型数据库

PostgreSQL的Slogan是“世界上最先进的开源关系型数据库”,但我觉得这口号不够响亮,而且一看就是在怼MySQL那个“世界上最流行的开源关系型数据库”的口号,有碰瓷之嫌。要我说最能生动体现PG特色的口号应该是:一专多长的全栈数据库,一招鲜吃遍天嘛。

pggood

全栈数据库

​ 成熟的应用可能会用到许许多多的数据组件(功能):缓存,OLTP,OLAP/批处理/数据仓库,流处理/消息队列,搜索索引,NoSQL/文档数据库,地理数据库,空间数据库,时序数据库,图数据库。传统的架构选型呢,可能会组合使用多种组件,典型的如:Redis + MySQL + Greenplum/Hadoop + Kafuka/Flink + ElasticSearch,一套组合拳基本能应付大多数需求了。不过比较令人头大的就是异构系统集成了:大量的代码都是重复繁琐的胶水代码,干着把数据从A组件搬运到B组件的事情。

在这里,MySQL就只能扮演OLTP关系型数据库的角色,但如果是PostgreSQL,就可以身兼多职,One handle them all,比如:

  • OLTP:事务处理是PostgreSQL的本行

  • OLAP:citus分布式插件,ANSI SQL兼容,窗口函数,CTE,CUBE等高级分析功能,任意语言写UDF

  • 流处理:PipelineDB扩展,Notify-Listen,物化视图,规则系统,灵活的存储过程与函数编写

  • 时序数据:timescaledb时序数据库插件,分区表,BRIN索引

  • 空间数据:PostGIS扩展(杀手锏),内建的几何类型支持,GiST索引。

  • 搜索索引:全文搜索索引足以应对简单场景;丰富的索引类型,支持函数索引,条件索引

  • NoSQL:JSON,JSONB,XML,HStore原生支持,至NoSQL数据库的外部数据包装器

  • 数据仓库:能平滑迁移至同属Pg生态的GreenPlum,DeepGreen,HAWK等,使用FDW进行ETL

  • 图数据:递归查询

  • 缓存:物化视图

ext

以Extension作六器,礼天地四方。

以Greenplum礼天,

以Postgres-XL礼地,

以Citus礼东方,

以TimescaleDB礼南方,

以PipelineDB礼西方,

以PostGIS礼北方。

—— 《周礼.PG》

​ 在探探的旧版架构中,整个系统就是围绕PostgreSQL设计的。几百万日活,几百万全局DB-TPS,几百TB数据的规模下,数据组件只用了PostgreSQL。独立的数仓,消息队列和缓存都是后来才引入的。而且这只是验证过的规模量级,进一步压榨PG是完全可行的。

​ 因此,在一个很可观的规模内,PostgreSQL都可以扮演多面手的角色,一个组件当多种组件使。虽然在某些领域它可能比不上专用组件,至少都做的都还不赖。而单一数据组件选型可以极大地削减项目额外复杂度,这意味着能节省很多成本。它让十个人才能搞定的事,变成一个人就能搞定的事。

​ 为了不需要的规模而设计是白费功夫,实际上这属于过早优化的一种形式。只有当没有单个软件能满足你的所有需求时,才会存在分拆集成的利弊权衡。集成多种异构技术是相当棘手的工作,如果真有那么一样技术可以满足你所有的需求,那么使用该技术就是最佳选择,而不是试图用多个组件来重新实现它。

​ 当业务规模增长到一定量级时,可能不得不使用基于微服务/总线的架构,将数据库的功能分拆为多个组件。但PostgreSQL的存在极大地推后了这个权衡到来的阈值,而且分拆之后依然能继续发挥重要作用。

运维友好

当然除了功能强大之外,Pg的另外一个重要的优势就是运维友好。有很多非常实用的特性:

  • DDL能放入事务中,删表,TRUNCATE,创建函数,索引,都可以放在事务里原子生效,或者回滚。

    这就能进行很多骚操作,比如在一个事务里通过RENAME,完成两张表的王车易位。

  • 能够并发地创建、删除索引,添加非空字段,重整索引与表(不锁表)。

    这意味着可以随时在线上不停机进行重大的模式变更,按需对索引进行优化。

  • 复制方式多样:段复制,流复制,触发器复制,逻辑复制,插件复制等等。

    这使得不停服务迁移数据变得相当容易:复制,改读,改写三步走,线上迁移稳如狗。

  • 提交方式多样:异步提交,同步提交,法定人数同步提交。

    这意味着Pg允许在C和A之间做出权衡与选择,例如交易库使用同步提交,普通库使用异步提交。

  • 系统视图非常完备,做监控系统相当简单。

  • FDW的存在让ETL变得无比简单,一行SQL就能解决。

    FDW可以方便地让一个实例访问其他实例的数据或元数据。在跨分区操作,数据库监控指标收集,数据迁移等场景中妙用无穷。同时还可以对接很多异构数据系统。

生态健康

​ PostgreSQL的生态也很健康,社区相当活跃。

​ 相比MySQL,PostgreSQL的一个巨大的优势就是协议友好。PG采用类似BSD/MIT的PostgreSQL协议,差不多理解为只要别打着Pg的旗号出去招摇撞骗,随便你怎么搞,换皮出去卖都行。君不见多少国产数据库,或者不少“自研数据库”实际都是Pg的换皮或二次开发产品。

​ 当然,也有很多衍生产品会回馈主干,比如timescaledb, pipelinedb, citus 这些基于PG的“数据库”,最后都变成了原生PG的插件。很多时候你想实现个什么功能,一搜就能找到对应的插件或实现。开源嘛,还是要讲一些情怀的。

​ Pg的代码质量相当之高,注释写的非常清晰。C的代码读起来有种Go的感觉,代码都可以当文档看了。能从中学到很多东西。相比之下,其他数据库,比如MongoDB,看一眼我就放弃了读下去的兴趣。

​ 而MySQL呢,社区版采用的是GPL协议,这其实挺蛋疼的。要不是GPL传染,怎么会有这么多基于MySQL改的数据库开源出来呢?而且MySQL还在乌龟壳的手里,让自己的蛋蛋攥在别人手中可不是什么明智的选择,更何况是业界毒瘤呢?Facebook修改React协议的风波就算是一个前车之鉴了。

问题

当然,要说有什么缺点或者遗憾,那还是有几个的:

  • 因为使用了MVCC,数据库需要定期VACUUM,需要定期维护表和索引避免性能下降。
  • 没有很好的开源集群监控方案(或者太丑!),需要自己做。
  • 慢查询日志和普通日志是混在一起的,需要自己解析处理。
  • 官方Pg没有很好用的列存储,对数据分析而言算一个小遗憾。

当然都是些无关痛痒的小毛小病,不过真正的问题可能和技术无关……

​ 说到底,MySQL确实是最流行的开源关系型数据库,没办法,写Java的,写PHP的,很多人最开始用的都是MySQL…,所以Pg招人相对困难是一个事实,很多时候只能自己培养。不过看DB Engines上的流行度趋势,未来还是很光明的。

dbrank

其他

​ 学PostgreSQL是一件很有趣的事,它让我意识到数据库的功能远远不止增删改查。我学着SQL Server与MySQL迈进数据库的大门。但却是PostgreSQL真正向我展示了数据库的奇妙世界。

​ 之所以写本文,是因为在知乎上的老坟又被挖了出来,让笔者回想起当年邂逅PostgreSQL时的青葱岁月。(https://www.zhihu.com/question/20010554/answer/94999834 )当然,现在我干了专职的PG DBA,忍不住再给这老坟补几铲。“王婆卖瓜,自卖自夸”,夸一夸PG也是应该的。嘿嘿嘿……

全栈工程师就该用全栈数据库嘛。

​ 我自己比较选型过MySQL和PostgreSQL,难得地在阿里这种MySQL的世界中有过选择的自由。我认为单从技术因素上来讲,PG是完爆MySQL的。尽管阻力很大,最后还是把PostgreSQL用了起来,推了起来。我用它做过很多项目,解决了很多需求(小到算统计报表,大到给公司创收个小目标)。大多数需求PG单挑就搞定了,少部分也会再用些MQ和NoSQL(Redis,MongoDB,Cassandra/HBase)。Pg实在是让人爱不释手。

最后实在是对Pg爱不释手,以至于专职去研究PG了。

在我的第一份工作中就深刻尝到了甜头,使用PostgreSQL,一个人的开发效率能顶一个小团队:

  • 后端懒得写怎么办,PostGraphQL直接从数据库模式定义生成GraphQL API,自动监听DDL变更,生成相应的CRUD方法与存储过程包装,对于后台开发再方便不过,类似的工具还有PostgREST与pgrest。对于中小数据量的应用都还堪用,省了一大半后端开发的活。

  • 需要用到Redis的功能,直接上Pg,模拟普通功能不在话下,缓存也省了。Pub/Sub使用Notify/Listen/Trigger实现,用来广播配置变更,做一些控制非常方便。

  • 需要做分析,窗口函数,复杂JOIN,CUBE,GROUPING,自定义聚合,自定义语言,爽到飞起。如果觉得规模大了想scale out可以上citus扩展(或者换greenplum);比起数仓可能少个列存比较遗憾,但其他该有的都有了。

  • 用到地理相关的功能,PostGIS堪称神器,千行代码才能实现的复杂地理需求,一行SQL轻松高效解决

  • 存储时序数据,timescaledb扩展虽然比不上专用时序数据库,但百万记录每秒的入库速率还是有的。用它解决过硬件传感器日志存储,监控系统Metrics存储的需求。

  • 一些流计算的相关功能,可以用PipelineDB直接定义流式视图实现:UV,PV,用户画像实时呈现。

  • PostgreSQL的FDW是一种强大的机制,允许接入各种各样的数据源,以统一的SQL接口访问。它妙用无穷:

    • file_fdw这种自带的扩展,可以将任意程序的输出接入数据表。最简单的应用就是监控系统信息
    • 管理多个PostgreSQL实例时,可以在一个元数据库中用自带的postgres_fdw导入所有远程数据库的数据字典。统一访问所有数据库实例的元数据,一行SQL拉取所有数据库的实时指标,监控系统做起来不要太爽。
    • 之前做过的一件事就是用hbase_fdw和MongoFDW,将HBase中的历史批量数据,MongoDB中的当日实时数据包装为PostgreSQL数据表,一个视图就简简单单地实现了融合批处理与流处理的Lambda架构。
    • 使用redis_fdw进行缓存更新推送;使用mongo_fdw完成从mongo到pg的数据迁移;使用mysql_fdw读取MySQL数据并存入数仓;实现跨数据库,甚至跨数据组件的JOIN;使用一行SQL就能完成原本多少行代码才能实现的复杂ETL,这是一件多么美妙的事情。
  • 各种丰富的类型与方法支持:例如JSON,从数据库直接生成前端所需的JSON响应,轻松而惬意。范围类型,优雅地解决很多原本需要程序处理的边角情况。其他的例如数组,多维数组,自定义类型,枚举,网络地址,UUID,ISBN。很多开箱即用的数据结构让程序员省去了多少造轮子的功夫。

  • 丰富的索引类型:通用的Btree索引;大幅优化顺序访问的Brin索引;等值查询的Hash索引;GIN倒排索引;GIST通用搜索树,高效支持地理查询,KNN查询;Bitmap同时利用多个独立索引;Bloom高效过滤索引;能大幅减小索引大小的条件索引;能优雅替代冗余字段的函数索引。而MySQL就只有那么可怜的几种索引。

  • 稳定可靠,正确高效。MVCC轻松实现快照隔离,MySQL的RR隔离等级实现不完善,无法避免PMP与G-single异常。而且基于锁与回滚段的实现会有各种坑;PostgreSQL通过SSI能实现高性能的可序列化。

  • 复制强大:WAL段复制,流复制(v9出现,同步、半同步、异步),逻辑复制(v10出现:订阅/发布),触发器复制,第三方复制,各种复制一应俱全。

  • 运维友好:可以将DDL放在事务中执行(可回滚),创建索引不锁表,添加新列(不带默认值)不锁表,清理/备份不锁表。各种系统视图,监控功能都很完善。

  • 扩展众多、功能丰富、可定制程度极强。在PostgreSQL中可以使用任意的语言编写函数:Python,Go,Javascript,Java,Shell等等。与其说Pg是数据库,不如说它是一个开发平台。我就试过很多没什么卵用但很好玩的东西:**数据库里(in-db)**的爬虫/ 推荐系统 / 神经网络 / Web服务器等等。有着各种功能强悍或脑洞清奇的第三方插件:https://pgxn.org

  • PostgreSQL的License友好,BSD随便玩,君不见多少数据库都是PG的换皮产品。MySQL有GPL传染,还要被Oracle捏着蛋蛋。

微信公众号原文

区块链与分布式数据库

区块链的技术本质、提供的功能、及演化方向就是分布式数据库

区块链的本质,想提供的功能,及其演化方向,就是分布式数据库。

确切的讲,是拜占庭容错(抗恶意节点攻击)的分布式(无领导者复制)数据库

如果这种分布式数据库用来存储各种币的交易记录,这个系统就叫做所谓的“XX币”。例如以太坊就是这样一个分布式数据库,上面除了记载着各种山寨币的交易记录,还可以记载各种奇奇怪怪的内容。花一点以太币,就可以在这个分布式数据库里留下一条记录(一封信)。而所谓智能合约就是这个分布式数据库上的存储过程

从形式上看,区块链与**预写式日志(Write-Ahead-Log, WAL, Binlog, Redolog)**在设计原理上是高度一致的。

WAL是数据库的核心数据结构,记录了从数据库创建之初到当前时刻的所有变更,用于实现主从复制、备份回滚、故障恢复等功能。如果保留了全量的WAL日志,就可以从起点回放WAL,时间旅行到任意时刻的状态,如PostgreSQL的PITR。

区块链其实就是这样一份日志,它记录了从创世以来的每笔Transaction。回放日志就可以还原数据库任意时刻的状态(反之则不成立)。所以区块链当然可以算作某种意义上的数据库。

区块链的两大特性:去中心化与防篡改,用数据库的概念也很好理解:

  • 去中心化的实质就是无领导者复制(leaderless replication),核心在于分布式共识
  • 防篡改的实质就是拜占庭容错,即,使得篡改WAL的计算代价在概率上不可行

正如WAL分为日志段,区块链也被划分为一个一个**区块,**且每一段带有先前日志段的哈希指纹。

所谓挖矿就是一个公开的猜数字比快游戏(满足条件的数字才会被共识承认),先猜中者能获取下一个日志段的初夜权:向日志段里写一笔向自己转账的记录(就是挖矿的奖励),并广播出去(如果别人也猜中了,以先广播至多数为准)。所有节点通过共识算法,保证当前最长的链为权威日志版本。区块链通过共识算法实现日志段的无主复制

而如果想要修改某个WAL日志段中的一比交易记录,比如,转给自己一万个比特币,需要把这个区块以及其后所有区块的指纹给凑出来(连猜几次数字),并让多数节点相信这个伪造版本才行(拼一个更长的伪造版本,意味着猜更多次数字)。比特币中六个区块确认一个交易就是这个意思,篡改六个日志段之前的记录的算例代价,通常在概率上是不可行的。区块链通过这种机制(如Merkle树)实现拜占庭容错

区块链涉及到的相关技术中,除了分布式共识外都很简单,但这种应用方式机制设计确实是相当惊艳的。区块链可以算是一次数据库的演化尝试,长期来看前景广阔。但搞链能立竿见影起作用的领域,好像都是老大哥的地盘。而且不管怎么吹嘘,现在的区块链离真正意义上的分布式数据库还差的太远,所以现在入场搞应用的大概率都是先烈。

原文知乎链接

一致性:过载的术语

一致性这个词重载的很厉害,在不同的语境和上下文中,它其实代表着不同的东西:

一致性这个词重载的很厉害,在不同的语境和上下文中,它其实代表着不同的东西:

  • 在事务的上下文中,比如ACID里的C,指的就是通常的一致性(Consistency)
  • 在分布式系统的上下文中,例如CAP里的C,实际指的是线性一致性(Linearizability)
  • 此外,“一致性哈希”,“最终一致性”这些名词里的“一致性”也有不同的涵义。

这些一致性彼此不同却又有着千丝万缕的联系,所以经常会把人绕晕。

​ 在事务的上下文中,一致性(Consistency) 的概念是:对数据的一组特定陈述必须始终成立。即不变量(invariants)。具体到分布式事务的上下文中这个不变量是:所有参与事务的节点状态保持一致:要么全部成功提交,要么全部失败回滚,不会出现一些节点成功一些节点失败的情况。

​ 在分布式系统的上下文中,线性一致性(Linearizability) 的概念是:多副本的系统能够对外表现地像只有单个副本一样(系统保证从任何副本读取到的值都是最新的),且所有操作都以原子的方式生效(一旦某个新值被任一客户端读取到,后续任意读取不会再返回旧值)。

​ 线性一致性这个词可能有些陌生,但说起它的另一个名字大家就清楚了:强一致性(strong consistency) ,当然还有一些诨名:原子一致性(atomic consistency),立即一致性(immediate consistency)外部一致性(external consistency ) 说的都是它。

这两个“一致性”完全不是一回事儿,但之间其实有着微妙的联系,它们之间的桥梁就是共识(Consensus)

简单来说:

  • 分布式事务一致性会因为协调者单点引入可用性问题
  • 为了解决可用性问题,分布式事务的节点需要在协调者故障时就新协调者选取达成共识
  • 解决共识问题等价于实现一个线性一致的存储
  • 解决共识问题等价于实现全序广播(total order boardcast)
  • Paxos/Raft 实现了全序广播

具体来讲

为了保证分布式事务的一致性,分布式事务通常需要一个协调者(Coordinator)/事务管理器(Transaction Manager)来决定事务的最终提交状态。但无论2PC还是3PC,都无法应对协调者失效的问题,而且具有扩大故障的趋势。这就牺牲了可靠性、可维护性与可扩展性。为了让分布式事务真正可用,就需要在协调者挂点的时候能赶快选举出一个新的协调者来解决分歧,这就需要所有节点对谁是Boss达成共识(Consensus)

共识意味着让几个节点就某事达成一致,可以用来确定一些互不相容的操作中,哪一个才是赢家。共识问题通常形式化如下:一个或多个节点可以提议(propose)某些值,而共识算法决定采用其中的某个值。在保证分布式事务一致性的场景中,每个节点可以投票提议,并对谁是新的协调者达成共识。

​ 共识问题与许多问题等价,两个最典型的问题就是:

  • 实现一个具有线性一致性的存储系统
  • 实现全序广播(保证消息不丢失,且消息以相同的顺序传递给每个节点。)

Raft算法解决了全序广播问题。维护多副本日志间的一致性,其实就是让所有节点对同全局操作顺序达成一致,也其实就是让日志系统具有线性一致性。 因而解决了共识问题。(当然正因为共识问题与实现强一致存储问题等价,Raft的具体实现etcd 其实就是一个线性一致的分布式数据库。)

总结一下:

线性一致性是一个精确定义的术语,线性一致性是一种 一致性模型 ,对分布式系统的行为作出了很强的保证。

分布式事务中的一致性则与事务ACID中的C一脉相承,并不是一个严格的术语。(因为什么叫一致,什么叫不一致其实是应用说了算。在分布式事务的场景下可以认为是:所有节点的事务状态始终保持相同

分布式事务本身的一致性是通过协调者内部的原子操作与多阶段提交协议保证的,不需要共识;但解决分布式事务一致性带来的可用性问题需要用到共识。

推荐阅读:

为什么要学习数据库原理

计算机系为什么要学数据库原理和设计?

问题

计算机系为什么要学数据库原理和设计?

我们学校开了数据库系统原理课程。但是我还是很迷茫,这几节课老师一上来就讲一堆令人头大的名词概念,我以为我们知道“如何设计构建表”,“如何mysql增删改查”就行了……那为什么还要了解关系模式的表示方法,计算,规范化……概念模型……各种模型的相互转换,为什么还要了解什么关系代数,什么笛卡尔积……这些的理论知识。我十分困惑,通过这些理论概念,该课的目的或者说该书的目的究竟是想让学生学会什么呢?

回答

​ 只会写代码的是码农;学好数据库,基本能混口饭吃;在此基础上再学好操作系统和计算机网络,就能当一个不错的程序员。如果能再把离散数学、数字电路、体系结构、数据结构/算法、编译原理学通透,再加上丰富的实践经验与领域特定知识,就能算是一个优秀的工程师了。(前端算IO密集型应用就别抬杠了)

计算机其实就是存储/IO/CPU三大件; 而计算说穿了就是两个东西:数据与算法(状态与转移函数)。常见的软件应用,除了各种模拟仿真、模型训练、视频游戏这些属于计算密集型应用外,绝大多数都属于数据密集型应用。从最抽象的意义上讲,这些应用干的事儿就是把数据拿进来,存进数据库,需要的时候再拿出来。

​ 抽象是应对复杂度的最强武器。操作系统提供了对存储的基本抽象:内存寻址空间与磁盘逻辑块号。文件系统在此基础上提供了文件名到地址空间的KV存储抽象。而数据库则在其基础上提供了对应用通用存储需求的高级抽象

​ 在真实世界中,除非准备从基础组件的轮子造起,不然根本没那么多机会去摆弄花哨的数据结构和算法(对数据密集型应用而言)。甚至写代码的本事可能也没那么重要:可能只会有那么一两个Ad Hoc算法需要在应用层实现,大部分需求都有现成的轮子可以使用,主要的创造性工作往往是在数据模型设计上。实际生产中,数据表就是数据结构,索引与查询就是算法。而应用代码往往扮演的是胶水的角色,处理IO与业务逻辑,其他大部分的工作都是在数据系统之间搬运数据

​ 在最宽泛的意义上,有状态的地方就有数据库。它无所不在,网站的背后、应用的内部,单机软件,区块链里,甚至在离数据库最远的Web浏览器中,也逐渐出现了其雏形:各类状态管理框架与本地存储。“数据库”可以简单地只是内存中的哈希表/磁盘上的日志,也可以复杂到由多种数据系统集成而来。关系型数据库只是数据系统的冰山一角(或者说冰山之巅),实际上存在着各种各样的数据系统组件:

  • 数据库:存储数据,以便自己或其他应用程序之后能再次找到(PostgreSQL,MySQL,Oracle)
  • 缓存:记住开销昂贵操作的结果,加快读取速度(Redis,Memcached)
  • 搜索索引:允许用户按关键字搜索数据,或以各种方式对数据进行过滤(ElasticSearch)
  • 流处理:向其他进程发送消息,进行异步处理(Kafka,Flink)
  • 批处理:定期处理累积的大批量数据(Hadoop)

​ **架构师最重要的能力之一,就是了解这些组件的性能特点与应用场景,能够灵活地权衡取舍、集成拼接这些数据系统。**绝大多数工程师都不会去从零开始编写存储引擎,因为在开发应用时,数据库已经是足够完美的工具了。关系型数据库则是目前所有数据系统中使用最广泛的组件,可以说是程序员吃饭的主要家伙,重要性不言而喻。

了解意义(WHY)比了解方法(HOW)更重要。但一个很遗憾的现实是,以大多数学生,甚至相当一部分公司能够接触到的现实问题而言,拿几个文件甚至在内存里放着估计都能应付大多数场景了(需求简单到低级抽象就可以Handle)。没什么机会接触到数据库真正要解决的问题,也就难有真正使用与学习数据库的驱动力,更别提数据库原理了。当软硬件故障把数据搞成一团浆糊(可靠性);当单表超出了内存大小,并发访问的用户增多(可扩展性),当代码的复杂度发生爆炸,开发陷入泥潭(可维护性),人们才会真正意识到数据库的重要性。所以我也理解当前这种填鸭教学现状的苦衷:工作之后很难有这么大把的完整时间来学习原理了,所以老师只好先使劲灌输,多少让学生对这些知识有个印象。等学生参加工作后真正遇到这些问题,也许会想起大学好像还学了个叫数据库的东西,这些知识就会开始反刍。


​ 数据库,尤其是关系型数据库,非常重要。那为什么要学习其原理呢?

​ 对优秀的工程师来说,只会数据库是远远不够的。学习原理对于当CRUD BOY搬砖收益并不大,但当通用组件真的无解需要自己撸起袖子上时,没有金坷垃怎么种庄稼?设计系统时,理解原理能让你以最少的复杂度代价写出更可靠高效的代码;遇到疑难杂症需要排查时,理解原理能带来精准的直觉与深刻的洞察。

​ 数据库是一个博大精深的领域,存储I/O计算无所不包。其主要原理也可以粗略分为几个部分:数据模型设计原理(应用)、存储引擎原理(基础)、索引与查询优化器的原理(性能)、事务与并发控制的原理(正确性)、故障恢复与复制系统的原理(可靠性)。 所有的原理都有其存在意义:为了解决实际问题。

​ 例如数据模型设计中范式理论,就是为了解决数据冗余这一问题而提出的,它是为了把事情做漂亮(可维护)。它是模型设计中一个很重要的设计权衡:通常而言,冗余少则复杂度小/可维护性强,冗余高则性能好。比如用了冗余字段,那更新时原本一条SQL就搞定的事情,现在现在就要用两条SQL更新两个地方,需要考虑多对象事务,以及并发执行时可能的竞态条件。这就需要仔细权衡利弊,选择合适的规范化等级。数据模型设计,就是生产中的数据结构设计不了解这些原理,就难以提取良好的抽象,其他工作也就无从谈起。

​ 而关系代数与索引的原理,则在查询优化中扮演重要的角色,它是为了把事情做得快(性能,可扩展)。当数据量越来越大,SQL写的越来越复杂时,它的意义就会体现出来:**怎样写出等价但是更高效的查询?**当查询优化器没那么智能时,就需要人来干这件事。这种优化往往成本极小而收益巨大,比如一个需要几秒的KNN查询,如果知道R树索引的原理,就可以通过改写查询,创建GIST索引优化到1毫秒内,千倍的性能提升。**不了解索引与查询设计原理,就难以充分发挥数据库的性能。**​

​ 事务与并发控制的原理,是为了把事情做正确(可靠性)。事务是数据处理领域最伟大的抽象之一,它提供了很多有用的保证(ACID),但这些保证到底意味着什么?事务的原子性让你在提交前能随时中止事务并丢弃所有写入,相应地,事务的持久性则承诺一旦事务成功提交,即使发生硬件故障或数据库崩溃,写入的任何数据也不会丢失。这让错误处理变得无比简单:要么成功完事,要么失败重试。有了后悔药,程序员不用再担心半路翻车会留下惨不忍睹的车祸现场了。

​ 另一方面,事务的隔离性则保证同时执行的事务无法相互影响(Serializable), 数据库提供了不同的隔离等级保证,以供程序员在性能与正确性之间进行权衡。编写并发程序并不容易,在几万TPS的负载下,各种极低概率,匪夷所思的问题都会出现:事务之间相互踩踏,丢失更新,幻读与写入偏差,慢查询拖慢快查询导致连接堆积,单表数据库并发增大后的性能急剧恶化,甚至快慢查询都减少但因比例变化导致的灵异抽风。这些问题,在低负载的情况下会潜伏着,随着规模量级增长突然跳出来,给你一个大大的惊喜。现实中真正可能出现的各类异常,也绝非SQL标准中简单的几种异常能说清的。 不理解事务的原理,意味着应用的可靠性可能遭受不必要的损失。

​ 故障恢复与复制的原理,可能对于程序员没有那么重要,但架构师与DBA必须清楚。高可用是很多应用的追求目标,但什么是高可用,高可用怎么保证?读写分离?快慢分离?异地多活?x地x中心?说穿了底下的核心技术其实就是复制(Replication)(或再加上自动故障切换(Failover))。这里有无穷无尽的坑:复制延迟带来的各种灵异现象,网络分区与脑裂,存疑事务blahblah。不理解复制的原理,高可用就无从谈起。

​ 对于一些程序员而言,可能数据库就是“增删改查”,包一包接口,原理似乎属于“屠龙之技”。如果止步于此,那原理确实没什么好学的,但有志者应当打破砂锅问到底的精神。私认为只了解自己本领域知识是不够的,只有把当前领域赖以建立的上层领域摸清楚,才能称为专家。在数据库面前,后端也是前端;对于程序员知识而言,数据库是一个合适的栈底。


​ 上面讲了WHY,下面就说一下 HOW

​ 数据库教学的一个矛盾是:如果连数据库都不会用,那学数据库原理有个卵用呢?

​ 学数据库的原则是学以致用只有实践,才能带来对问题的深刻理解;只有先知其然,才有条件去知其所以然。教材可以先草草的过一遍,然后直接去看数据库文档,上手去把数据库用起来,做个东西出来。通过实践掌握数据库的使用,再去学习原理就会事半功倍(以及充满动力)。对于学习而言,有条件去实习当然最好,没有条件那最好的办法就是自己创造场景,自己挖掘需求。

​ 比如,从解决个人需求开始:管理个人密码,体重跟踪,记账,做个小网站、在线聊天小程序。当它演化的越来越复杂,开始有多个用户,出现各种蛋疼问题之后,你就会开始意识到事务的意义。

​ 再比如,结合爬虫,抓一些房价、股价、地理、社交网络的数据存在数据库里,做一些挖掘与分析。当你积累的数据越来越多,分析查询越来越复杂;SQL长得没法读,跑起来慢出猪叫,这时候关系代数的理论就能指导你进一步进行优化。

​ 当你意识到这些设计都是为了解决现实生产中的问题,并亲自遇到过这些问题之后,再去学习原理,才能相互印证,并知其所以然。当你发现查询时间随数据增长而指数增长时;当你遇到成千上万的用户同时读写为并发控制焦头烂额时;当你碰上软硬件故障把数据搅得稀巴烂时;当你发现数据冗余让代码复杂度快速爆炸时;你就会发现这些设计存在的意义。

​ 教材、书籍、文档、视频、邮件组、博客都是很好的学习资源。教材的话华章的黑皮系列教材都还不错,《数据库系统概念》这本就挺好的。但我推荐先看看这本书:《设计数据密集型应用》 ,写的非常好,我觉得不错就义务翻译了一下。纸上得来终觉浅,绝知此事要躬行。实践方能出真知,新手上路选哪家?个人推荐世界上最先进的开源关系型数据库PostgreSQL,设计优雅,功能强大。传教就有请德哥出场了:https://github.com/digoal/blog 。有时间的话可以再看看Redis,源码简单易读,实践中也很常用,非关系型数据库也应当多了解一下。

​ 最后,关系型数据库虽然强大,却绝非数据处理的终章,尽可能多地去尝试各种各样的数据库吧。

知乎原文链接

微信公众号原文

开发

关于使用PostgreSQL进行开发的经验

PgSQL JsonPath

有了JSONPATH,PostgreSQL用户就能以一种简洁而高效的方式操作JSON数据。

PostgreSQL 12 JSON

PostgreSQL 12 已经正式放出了Beta1测试版本。PostgreSQL12带来了很多给力的新功能,其中最有吸引力的特性之一莫过于新的JSONPATH支持。在以前的版本中,虽说PostgreSQL在JSON功能的支持上已经很不错了,但要实现一些特定的功能,还是需要写复杂难懂的SQL或者存储过程才能实现。

有了JSONPATH,PostgreSQL用户就能以一种简洁而高效的方式操作JSON数据。

8.14.6 jsonpath类型

jsonpath类型实现了对PostgreSQL中 SQL / JSON路径语言的支持,以有效地查询JSON数据。它提供了已解析的SQL / JSON路径表达式的二进制表示,该表达式指定路径引擎从JSON数据中检索的项目,以便使用SQL / JSON查询函数进行进一步处理。

SQL / JSON路径语言完全集成到SQL引擎中:其谓词和运算符的语义通常遵循SQL。同时,为了提供一种最自然的JSON数据处理方式,SQL / JSON路径语法使用了一些JavaScript约定:

  • Dot .用于成员访问。
  • 方括号[]用于数组访问。
  • SQL / JSON数组是0相对的,不像从1开始的常规SQL数组。

SQL / JSON路径表达式是SQL字符串文字,因此在传递给SQL / JSON查询函数时必须用单引号括起来。遵循JavaScript约定,路径表达式中的字符串文字必须用双引号括起来。此字符串文字中的任何单引号必须使用SQL约定的单引号进行转义。

路径表达式由一系列路径元素组成,可以是以下内容:

  • JSON基元类型的路径文字:Unicode文本,数字,true,false或null。
  • 路径变量列于表8.24
  • 表8.25中列出了访问者运算符。
  • jsonpath第9.15.1.2节中列出的运算符和方法
  • 括号,可用于提供过滤器表达式或定义路径评估的顺序。

有关在jsonpathSQL / JSON查询函数中使用表达式的详细信息,请参见第9.15.1节

表8.24。 jsonpath变量

变量 描述
$ 表示要查询的JSON文本的变量(上下文项)。
$varname 一个命名变量。其值必须在PASSINGSQL / JSON查询函数的子句中设置。详情。
@ 表示过滤器表达式中路径评估结果的变量。

表8.25。 jsonpath访问器

访问者操作员 描述
.*key*``."$*varname*" 成员访问器,返回具有指定键的对象成员。如果键名是以符号$或不符合标识符的JavaScript规则的命名变量,则必须将其括在双引号中作为字符串文字。
.* 通配符成员访问器,返回位于当前对象顶级的所有成员的值。
.** 递归通配符成员访问器,它处理当前对象的所有级别的JSON层次结构,并返回所有成员值,而不管其嵌套级别如何。这是SQL / JSON标准的PostgreSQL扩展。
.**{*level*}``.**{*lower_level* to*upper_level*}``.**{*lower_level* to last} .**,但是使用JSON层次结构的嵌套级别进行过滤。级别指定为整数。零级别对应于当前对象。这是SQL / JSON标准的PostgreSQL扩展。
[*subscript*, ...] 数组元素访问器。*subscript*可能有两种形式:*expr*或。第一种形式通过索引指定单个数组元素。第二种形式通过索引范围指定数组切片。零索引对应于第一个数组元素。*lower_expr* to *upper_expr*下标中的表达式可以包括整数,数值表达式或jsonpath返回单个数值的任何其他表达式。的last关键字可以在表达式表示在阵列中的最后一个下标来使用。这对处理未知长度的数组很有帮助。
[*] 返回所有数组元素的通配符数组元素访问器。

[6]为此,术语 “ 值 ”包括数组元素,尽管JSON术语有时会认为数组元素与对象内的值不同。

PgSQL事务隔离等级

PostgreSQL实际上只有两种事务隔离等级:读已提交(Read Commited)可序列化(Serializable)

PostgreSQL 事务隔离等级

基础

SQL标准定义了四种隔离级别,但PostgreSQL实际上只有两种事务隔离等级:读已提交(Read Commited)可序列化(Serializable)

SQL标准定义了四种隔离级别,但实际上这也是很粗鄙的一种划分。详情请参考并发异常那些事

查看/设置事务隔离等级

通过执行:SELECT current_setting('transaction_isolation'); 可以查看当前事务隔离等级。

通过在事务块顶部执行 SET TRANSACTION ISOLATION LEVEL { SERIALIZABLE | REPEATABLE READ | READ COMMITTED | READ UNCOMMITTED } 来设定事务的隔离等级。

或者为当前会话生命周期设置事务隔离等级:

SET SESSION CHARACTERISTICS AS TRANSACTION transaction_mode

Actual isolation level P4 G-single G2-item G2
RC(monotonic atomic views) - - - -
RR(snapshot isolation) - -
Serializable

隔离等级与并发问题

创建测试表 t ,并插入两行测试数据。

CREATE TABLE t (k INTEGER PRIMARY KEY, v int);
TRUNCATE t; INSERT INTO t VALUES (1,10), (2,20);

更新丢失(P4)

PostgreSQL的 读已提交RC 隔离等级无法阻止丢失更新的问题,但可重复读隔离等级则可以。

丢失更新,顾名思义,就是一个事务的写入覆盖了另一个事务的写入结果。

在读已提交隔离等级下,无法阻止丢失更新的问题,考虑一个计数器并发更新的例子,两个事务同时从计数器中读取出值,加1后写回原表。

T1 T2 Comment
begin;
begin;
SELECT v FROM t WHERE k = 1 T1读
SELECT v FROM t WHERE k = 1 T2读
update t set v = 11 where k = 1; T1写
update t set v = 11 where k = 1; T2因T1阻塞
COMMIT T2恢复,写入
COMMIT T2写入覆盖T1

解决这个问题有两种方式,使用原子操作,或者在可重复读的隔离等级执行事务。

使用原子操作的方式为:

T1 T2 Comment
begin;
begin;
update t set v = v+1 where k = 1; T1写
update t set v = v + 1 where k = 1; T2因T1阻塞
COMMIT T2恢复,写入
COMMIT T2写入覆盖T1

解决这个问题有两种方式,使用原子操作,或者在可重复读的隔离等级执行事务。

在可重复读的隔离等级

读已提交(RC)

begin; set transaction isolation level read committed; -- T1
begin; set transaction isolation level read committed; -- T2

update t set v = 11 where k = 1; -- T1
update t set v = 12 where k = 1; -- T2, BLOCKS
update t set v = 21 where k = 2; -- T1

commit; -- T1. This unblocks T2
select * from t; -- T1. Shows 1 => 11, 2 => 21
update t set v = 22 where k = 2; -- T2


commit; -- T2
select * from test; -- either. Shows 1 => 12, 2 => 22
T1 T2 Comment
begin; set transaction isolation level read committed;
begin; set transaction isolation level read committed;
update t set v = 11 where k = 1;
update t set v = 12 where k = 1; T2会等待T1持有的锁
SELECT * FROM t 2:20, 1:11
update pair set v = 21 where k = 2;
commit; T2解锁
select * from pair; T2看见T1的结果和自己的修改
update t set v = 22 where k = 2
commit

提交后的结果

1

 relname | locktype | virtualtransaction |  pid  |       mode       | granted | fastpath
---------+----------+--------------------+-------+------------------+---------+----------
 t_pkey  | relation | 4/578              | 37670 | RowExclusiveLock | t       | t
 t       | relation | 4/578              | 37670 | RowExclusiveLock | t       | t
 relname | locktype | virtualtransaction |  pid  |       mode       | granted | fastpath
---------+----------+--------------------+-------+------------------+---------+----------
 t_pkey  | relation | 4/578              | 37670 | RowExclusiveLock | t       | t
 t       | relation | 4/578              | 37670 | RowExclusiveLock | t       | t
 t_pkey  | relation | 6/494              | 37672 | RowExclusiveLock | t       | t
 t       | relation | 6/494              | 37672 | RowExclusiveLock | t       | t
 t       | tuple    | 6/494              | 37672 | ExclusiveLock    | t       | f
 relname | locktype | virtualtransaction |  pid  |       mode       | granted | fastpath
---------+----------+--------------------+-------+------------------+---------+----------
 t_pkey  | relation | 4/578              | 37670 | RowExclusiveLock | t       | t
 t       | relation | 4/578              | 37670 | RowExclusiveLock | t       | t
 t_pkey  | relation | 6/494              | 37672 | RowExclusiveLock | t       | t
 t       | relation | 6/494              | 37672 | RowExclusiveLock | t       | t
 t       | tuple    | 6/494              | 37672 | ExclusiveLock    | t       | f

Testing PostgreSQL transaction isolation levels

These tests were run with Postgres 9.3.5.

Setup (before every test case):

create table test (id int primary key, value int);
insert into test (id, value) values (1, 10), (2, 20);

To see the current isolation level:

select current_setting('transaction_isolation');

Read Committed basic requirements (G0, G1a, G1b, G1c)

Postgres “read committed” prevents Write Cycles (G0) by locking updated rows:

begin; set transaction isolation level read committed; -- T1
begin; set transaction isolation level read committed; -- T2
update test set value = 11 where id = 1; -- T1
update test set value = 12 where id = 1; -- T2, BLOCKS
update test set value = 21 where id = 2; -- T1
commit; -- T1. This unblocks T2
select * from test; -- T1. Shows 1 => 11, 2 => 21
update test set value = 22 where id = 2; -- T2
commit; -- T2
select * from test; -- either. Shows 1 => 12, 2 => 22

Postgres “read committed” prevents Aborted Reads (G1a):

begin; set transaction isolation level read committed; -- T1
begin; set transaction isolation level read committed; -- T2
update test set value = 101 where id = 1; -- T1
select * from test; -- T2. Still shows 1 => 10
abort;  -- T1
select * from test; -- T2. Still shows 1 => 10
commit; -- T2

Postgres “read committed” prevents Intermediate Reads (G1b):

begin; set transaction isolation level read committed; -- T1
begin; set transaction isolation level read committed; -- T2
update test set value = 101 where id = 1; -- T1
select * from test; -- T2. Still shows 1 => 10
update test set value = 11 where id = 1; -- T1
commit; -- T1
select * from test; -- T2. Now shows 1 => 11
commit; -- T2

Postgres “read committed” prevents Circular Information Flow (G1c):

begin; set transaction isolation level read committed; -- T1
begin; set transaction isolation level read committed; -- T2
update test set value = 11 where id = 1; -- T1
update test set value = 22 where id = 2; -- T2
select * from test where id = 2; -- T1. Still shows 2 => 20
select * from test where id = 1; -- T2. Still shows 1 => 10
commit; -- T1
commit; -- T2

Observed Transaction Vanishes (OTV)

Postgres “read committed” prevents Observed Transaction Vanishes (OTV):

begin; set transaction isolation level read committed; -- T1
begin; set transaction isolation level read committed; -- T2
begin; set transaction isolation level read committed; -- T3
update test set value = 11 where id = 1; -- T1
update test set value = 19 where id = 2; -- T1
update test set value = 12 where id = 1; -- T2. BLOCKS
commit; -- T1. This unblocks T2
select * from test where id = 1; -- T3. Shows 1 => 11
update test set value = 18 where id = 2; -- T2
select * from test where id = 2; -- T3. Shows 2 => 19
commit; -- T2
select * from test where id = 2; -- T3. Shows 2 => 18
select * from test where id = 1; -- T3. Shows 1 => 12
commit; -- T3

Predicate-Many-Preceders (PMP)

Postgres “read committed” does not prevent Predicate-Many-Preceders (PMP):

begin; set transaction isolation level read committed; -- T1
begin; set transaction isolation level read committed; -- T2
select * from test where value = 30; -- T1. Returns nothing
insert into test (id, value) values(3, 30); -- T2
commit; -- T2
select * from test where value % 3 = 0; -- T1. Returns the newly inserted row
commit; -- T1

Postgres “repeatable read” prevents Predicate-Many-Preceders (PMP):

begin; set transaction isolation level repeatable read; -- T1
begin; set transaction isolation level repeatable read; -- T2
select * from test where value = 30; -- T1. Returns nothing
insert into test (id, value) values(3, 30); -- T2
commit; -- T2
select * from test where value % 3 = 0; -- T1. Still returns nothing
commit; -- T1

Postgres “read committed” does not prevent Predicate-Many-Preceders (PMP) for write predicates – example from Postgres documentation:

begin; set transaction isolation level read committed; -- T1
begin; set transaction isolation level read committed; -- T2
update test set value = value + 10; -- T1
delete from test where value = 20;  -- T2, BLOCKS
commit; -- T1. This unblocks T2
select * from test where value = 20; -- T2, returns 1 => 20 (despite ostensibly having been deleted)
commit; -- T2

Postgres “repeatable read” prevents Predicate-Many-Preceders (PMP) for write predicates – example from Postgres documentation:

begin; set transaction isolation level repeatable read; -- T1
begin; set transaction isolation level repeatable read; -- T2
update test set value = value + 10; -- T1
delete from test where value = 20;  -- T2, BLOCKS
commit; -- T1. T2 now prints out "ERROR: could not serialize access due to concurrent update"
abort;  -- T2. There's nothing else we can do, this transaction has failed

Lost Update (P4)

Postgres “read committed” does not prevent Lost Update (P4):

begin; set transaction isolation level read committed; -- T1
begin; set transaction isolation level read committed; -- T2
select * from test where id = 1; -- T1
select * from test where id = 1; -- T2
update test set value = 11 where id = 1; -- T1
update test set value = 11 where id = 1; -- T2, BLOCKS
commit; -- T1. This unblocks T2, so T1's update is overwritten
commit; -- T2

Postgres “repeatable read” prevents Lost Update (P4):

begin; set transaction isolation level repeatable read; -- T1
begin; set transaction isolation level repeatable read; -- T2
select * from test where id = 1; -- T1
select * from test where id = 1; -- T2
update test set value = 11 where id = 1; -- T1
update test set value = 11 where id = 1; -- T2, BLOCKS
commit; -- T1. T2 now prints out "ERROR: could not serialize access due to concurrent update"
abort;  -- T2. There's nothing else we can do, this transaction has failed

Read Skew (G-single)

Postgres “read committed” does not prevent Read Skew (G-single):

begin; set transaction isolation level read committed; -- T1
begin; set transaction isolation level read committed; -- T2
select * from test where id = 1; -- T1. Shows 1 => 10
select * from test where id = 1; -- T2
select * from test where id = 2; -- T2
update test set value = 12 where id = 1; -- T2
update test set value = 18 where id = 2; -- T2
commit; -- T2
select * from test where id = 2; -- T1. Shows 2 => 18
commit; -- T1

Postgres “repeatable read” prevents Read Skew (G-single):

begin; set transaction isolation level repeatable read; -- T1
begin; set transaction isolation level repeatable read; -- T2
select * from test where id = 1; -- T1. Shows 1 => 10
select * from test where id = 1; -- T2
select * from test where id = 2; -- T2
update test set value = 12 where id = 1; -- T2
update test set value = 18 where id = 2; -- T2
commit; -- T2
select * from test where id = 2; -- T1. Shows 2 => 20
commit; -- T1

Postgres “repeatable read” prevents Read Skew (G-single) – test using predicate dependencies:

begin; set transaction isolation level repeatable read; -- T1
begin; set transaction isolation level repeatable read; -- T2
select * from test where value % 5 = 0; -- T1
update test set value = 12 where value = 10; -- T2
commit; -- T2
select * from test where value % 3 = 0; -- T1. Returns nothing
commit; -- T1

Postgres “repeatable read” prevents Read Skew (G-single) – test using write predicate:

begin; set transaction isolation level repeatable read; -- T1
begin; set transaction isolation level repeatable read; -- T2
select * from test where id = 1; -- T1. Shows 1 => 10
select * from test; -- T2
update test set value = 12 where id = 1; -- T2
update test set value = 18 where id = 2; -- T2
commit; -- T2
delete from test where value = 20; -- T1. Prints "ERROR: could not serialize access due to concurrent update"
abort; -- T1. There's nothing else we can do, this transaction has failed

Write Skew (G2-item)

Postgres “repeatable read” does not prevent Write Skew (G2-item):

begin; set transaction isolation level repeatable read; -- T1
begin; set transaction isolation level repeatable read; -- T2
select * from test where id in (1,2); -- T1
select * from test where id in (1,2); -- T2
update test set value = 11 where id = 1; -- T1
update test set value = 21 where id = 2; -- T2
commit; -- T1
commit; -- T2

Postgres “serializable” prevents Write Skew (G2-item):

begin; set transaction isolation level serializable; -- T1
begin; set transaction isolation level serializable; -- T2
select * from test where id in (1,2); -- T1
select * from test where id in (1,2); -- T2
update test set value = 11 where id = 1; -- T1
update test set value = 21 where id = 2; -- T2
commit; -- T1
commit; -- T2. Prints out "ERROR: could not serialize access due to read/write dependencies among transactions"

Anti-Dependency Cycles (G2)

Postgres “repeatable read” does not prevent Anti-Dependency Cycles (G2):

begin; set transaction isolation level repeatable read; -- T1
begin; set transaction isolation level repeatable read; -- T2
select * from test where value % 3 = 0; -- T1
select * from test where value % 3 = 0; -- T2
insert into test (id, value) values(3, 30); -- T1
insert into test (id, value) values(4, 42); -- T2
commit; -- T1
commit; -- T2
select * from test where value % 3 = 0; -- Either. Returns 3 => 30, 4 => 42

Postgres “serializable” prevents Anti-Dependency Cycles (G2):

begin; set transaction isolation level serializable; -- T1
begin; set transaction isolation level serializable; -- T2
select * from test where value % 3 = 0; -- T1
select * from test where value % 3 = 0; -- T2
insert into test (id, value) values(3, 30); -- T1
insert into test (id, value) values(4, 42); -- T2
commit; -- T1
commit; -- T2. Prints out "ERROR: could not serialize access due to read/write dependencies among transactions"

Postgres “serializable” prevents Anti-Dependency Cycles (G2) – Fekete et al’s example with two anti-dependency edges:

begin; set transaction isolation level serializable; -- T1
select * from test; -- T1. Shows 1 => 10, 2 => 20
begin; set transaction isolation level serializable; -- T2
update test set value = value + 5 where id = 2; -- T2
commit; -- T2
begin; set transaction isolation level serializable; -- T3
select * from test; -- T3. Shows 1 => 10, 2 => 25
commit; -- T3
update test set value = 0 where id = 1; -- T1. Prints out "ERROR: could not serialize access due to read/write dependencies among transactions"
abort; -- T1. There's nothing else we can do, this transaction has failed

PgSQL前后端通信协议

了解PostgreSQL服务器与客户端通信使用的TCP协议

了解PostgreSQL服务器与客户端通信使用的TCP协议

启动阶段

基本流程

  • 客户端发送一条StartupMessage (F)向服务端发起连接请求

    载荷包括0x30000的Int32版本号魔数,以及一系列kv结构的运行时参数(NULL0分割,必须参数为user),

  • 客户端等待服务端响应,主要是等待服务端发送的ReadyForQuery (Z)事件,该事件代表服务端已经准备好接收请求。

上面是连接建立过程中最主要的两个事件,其他事件包括包括认证消息 AuthenticationXXX (R) ,后端密钥消息 BackendKeyData (K),错误消息ErrorResponse (E),一系列上下文无关消息(NoticeResponse (N)NotificationResponse (A)ParameterStatus(S)

模拟客户端

编写一个go程序模拟这一过程

package main

import (
	"fmt"
	"net"
	"time"

	"github.com/jackc/pgx/pgproto3"
)

func GetFrontend(address string) *pgproto3.Frontend {
	conn, _ := (&net.Dialer{KeepAlive: 5 * time.Minute}).Dial("tcp4", address)
	frontend, _ := pgproto3.NewFrontend(conn, conn)
	return frontend
}

func main() {
	frontend := GetFrontend("127.0.0.1:5432")

	// 建立连接
	startupMsg := &pgproto3.StartupMessage{
		ProtocolVersion: pgproto3.ProtocolVersionNumber,
		Parameters:      map[string]string{"user": "vonng"},
	}
	frontend.Send(startupMsg)

	// 启动过程,收到ReadyForQuery消息代表启动过程结束
	for {
		msg, _ := frontend.Receive()
		fmt.Printf("%T %v\n", msg, msg)
		if _, ok := msg.(*pgproto3.ReadyForQuery); ok {
			fmt.Println("[STARTUP] connection established")
			break
		}
	}

	// 简单查询协议
	simpleQueryMsg := &pgproto3.Query{String: `SELECT 1 as a;`}
	frontend.Send(simpleQueryMsg)
	// 收到CommandComplete消息代表查询结束
	for {
		msg, _ := frontend.Receive()
		fmt.Printf("%T %v\n", msg, msg)
		if _, ok := msg.(*pgproto3.CommandComplete); ok {
			fmt.Println("[QUERY] query complete")
			break
		}
	}
}

输出结果为:

*pgproto3.Authentication &{0 [0 0 0 0] [] []}
*pgproto3.ParameterStatus &{application_name }
*pgproto3.ParameterStatus &{client_encoding UTF8}
*pgproto3.ParameterStatus &{DateStyle ISO, MDY}
*pgproto3.ParameterStatus &{integer_datetimes on}
*pgproto3.ParameterStatus &{IntervalStyle postgres}
*pgproto3.ParameterStatus &{is_superuser on}
*pgproto3.ParameterStatus &{server_encoding UTF8}
*pgproto3.ParameterStatus &{server_version 11.3}
*pgproto3.ParameterStatus &{session_authorization vonng}
*pgproto3.ParameterStatus &{standard_conforming_strings on}
*pgproto3.ParameterStatus &{TimeZone PRC}
*pgproto3.BackendKeyData &{35703 345830596}
*pgproto3.ReadyForQuery &{73}
[STARTUP] connection established
*pgproto3.RowDescription &{[{a 0 0 23 4 -1 0}]}
*pgproto3.DataRow &{[[49]]}
*pgproto3.CommandComplete &{SELECT 1}
[QUERY] query complete

连接代理

可以在jackc/pgx/pgproto3的基础上,很轻松地编写一些中间件。例如下面的代码就是一个非常简单的“连接代理”:

package main

import (
	"io"
	"net"
	"strings"
	"time"

	"github.com/jackc/pgx/pgproto3"
)

type ProxyServer struct {
	UpstreamAddr string
	ListenAddr   string
	Listener     net.Listener
	Dialer       net.Dialer
}

func NewProxyServer(listenAddr, upstreamAddr string) *ProxyServer {
	ln, _ := net.Listen(`tcp4`, listenAddr)
	return &ProxyServer{
		ListenAddr:   listenAddr,
		UpstreamAddr: upstreamAddr,
		Listener:     ln,
		Dialer:       net.Dialer{KeepAlive: 1 * time.Minute},
	}
}

func (ps *ProxyServer) Serve() error {
	for {
		conn, err := ps.Listener.Accept()
		if err != nil {
			panic(err)
		}
		go ps.ServeOne(conn)
	}
}

func (ps *ProxyServer) ServeOne(clientConn net.Conn) error {
	backend, _ := pgproto3.NewBackend(clientConn, clientConn)
	startupMsg, err := backend.ReceiveStartupMessage()
	if err != nil && strings.Contains(err.Error(), "ssl") {
		if _, err := clientConn.Write([]byte(`N`)); err != nil {
			panic(err)
		}
		// ssl is not welcome, now receive real startup msg
		startupMsg, err = backend.ReceiveStartupMessage()
		if err != nil {
			panic(err)
		}
	}

	serverConn, _ := ps.Dialer.Dial(`tcp4`, ps.UpstreamAddr)
	frontend, _ := pgproto3.NewFrontend(serverConn, serverConn)
	frontend.Send(startupMsg)

	errChan := make(chan error, 2)
	go func() {
		_, err := io.Copy(clientConn, serverConn)
		errChan <- err
	}()
	go func() {
		_, err := io.Copy(serverConn, clientConn)
		errChan <- err
	}()

	return <-errChan
}

func main() {
	proxy := NewProxyServer("127.0.0.1:5433", "127.0.0.1:5432")
	proxy.Serve()
}

这里代理监听5433端口,并将消息解析并转发至在5432端口的真实的数据库服务器。在另一个Session中执行以下命令:

$ psql postgres://127.0.0.1:5433/data?sslmode=disable -c 'SELECT * FROM pg_stat_activity LIMIT 1;'

可以观察到这一过程中的消息往来:

[B2F] *pgproto3.ParameterStatus &{application_name psql}
[B2F] *pgproto3.ParameterStatus &{client_encoding UTF8}
[B2F] *pgproto3.ParameterStatus &{DateStyle ISO, MDY}
[B2F] *pgproto3.ParameterStatus &{integer_datetimes on}
[B2F] *pgproto3.ParameterStatus &{IntervalStyle postgres}
[B2F] *pgproto3.ParameterStatus &{is_superuser on}
[B2F] *pgproto3.ParameterStatus &{server_encoding UTF8}
[B2F] *pgproto3.ParameterStatus &{server_version 11.3}
[B2F] *pgproto3.ParameterStatus &{session_authorization vonng}
[B2F] *pgproto3.ParameterStatus &{standard_conforming_strings on}
[B2F] *pgproto3.ParameterStatus &{TimeZone PRC}
[B2F] *pgproto3.BackendKeyData &{41588 1354047533}
[B2F] *pgproto3.ReadyForQuery &{73}
[F2B] *pgproto3.Query &{SELECT * FROM pg_stat_activity LIMIT 1;}
[B2F] *pgproto3.RowDescription &{[{datid 11750 1 26 4 -1 0} {datname 11750 2 19 64 -1 0} {pid 11750 3 23 4 -1 0} {usesysid 11750 4 26 4 -1 0} {usename 11750 5 19 64 -1 0} {application_name 11750 6 25 -1 -1 0} {client_addr 11750 7 869 -1 -1 0} {client_hostname 11750 8 25 -1 -1 0} {client_port 11750 9 23 4 -1 0} {backend_start 11750 10 1184 8 -1 0} {xact_start 11750 11 1184 8 -1 0} {query_start 11750 12 1184 8 -1 0} {state_change 11750 13 1184 8 -1 0} {wait_event_type 11750 14 25 -1 -1 0} {wait_event 11750 15 25 -1 -1 0} {state 11750 16 25 -1 -1 0} {backend_xid 11750 17 28 4 -1 0} {backend_xmin 11750 18 28 4 -1 0} {query 11750 19 25 -1 -1 0} {backend_type 11750 20 25 -1 -1 0}]}
[B2F] *pgproto3.DataRow &{[[] [] [52 56 55 52] [] [] [] [] [] [] [50 48 49 57 45 48 53 45 49 56 32 50 48 58 52 56 58 49 57 46 51 50 55 50 54 55 43 48 56] [] [] [] [65 99 116 105 118 105 116 121] [65 117 116 111 86 97 99 117 117 109 77 97 105 110] [] [] [] [] [97 117 116 111 118 97 99 117 117 109 32 108 97 117 110 99 104 101 114]]}
[B2F] *pgproto3.CommandComplete &{SELECT 1}
[B2F] *pgproto3.ReadyForQuery &{73}
[F2B] *pgproto3.Terminate &{}

PgSQL变更数据捕获

数据变更捕获是一种很有趣的ETL替代方案。

在实际生产中,我们经常需要把数据库的状态同步到其他地方去,例如同步到数据仓库进行分析,同步到消息队列供下游消费,同步到缓存以加速查询。总的来说,搬运状态有两大类方法:ETL与CDC。

前驱知识

CDC与ETL

数据库在本质上是一个状态集合,任何对数据库的变更(增删改)本质上都是对状态的修改。

在实际生产中,我们经常需要把数据库的状态同步到其他地方去,例如同步到数据仓库进行分析,同步到消息队列供下游消费,同步到缓存以加速查询。总的来说,搬运状态有两大类方法:ETL与CDC。

  • ETL(ExtractTransformLoad)着眼于状态本身,用定时批量轮询的方式拉取状态本身。

  • CDC(ChangeDataCapture)则着眼于变更,以流式的方式持续收集状态变化事件(变更)。

ETL大家都耳熟能详,每天批量跑ETL任务,从生产OLTP数据库 拉取(E)转换(T) 格式, 导入(L) 数仓,在此不赘述。相比ETL而言,CDC算是个新鲜玩意,随着流计算的崛起也越来越多地进入人们的视线。

变更数据捕获(change data capture, CDC)是一种观察写入数据库的所有数据变更,并将其提取并转换为可以复制到其他系统中的形式的过程。 CDC很有意思,特别是当变更能在被写入数据库后立刻用于后续的流处理时。

例如用户可以捕获数据库中的变更,并不断将相同的变更应用至搜索索引(e.g elasticsearch)。如果变更日志以相同的顺序应用,则可以预期的是,搜索索引中的数据与数据库中的数据是匹配的。同理,这些变更也可以应用于后台刷新缓存(redis),送往消息队列(Kafka),导入数据仓库(EventSourcing,存储不可变的事实事件记录而不是每天取快照),收集统计数据与监控(Prometheus),等等等等。在这种意义下,外部索引,缓存,数仓都成为了PostgreSQL在逻辑上的从库,这些衍生数据系统都成为了变更流的消费者,而PostgreSQL成为了整个数据系统的主库。在这种架构下,应用只需要操心怎样把数据写入数据库,剩下的事情交给CDC即可。系统设计可以得到极大地简化:所有的数据组件都能够自动与主库在逻辑上保证(最终)一致。用户不用再为如何保证多个异构数据系统之间数据同步而焦头烂额了。

实际上PostgreSQL自10.0版本以来提供的逻辑复制(logical replication)功能,实质上就是一个CDC应用:从主库上提取变更事件流:INSERT, UPDATE, DELETE, TRUNCATE,并在另一个PostgreSQL主库实例上重放。如果这些增删改事件能够被解析出来,它们就可以用于任何感兴趣的消费者,而不仅仅局限于另一个PostgreSQL实例。

逻辑复制

想在传统关系型数据库上实施CDC并不容易,关系型数据库本身的预写式日志WAL 实际上就是数据库中变更事件的记录。因此从数据库中捕获变更,基本上可以认为等价于消费数据库产生的WAL日志/复制日志。(当然也有其他的变更捕获方式,例如在表上建立触发器,当变更发生时将变更记录写入另一张变更日志表,客户端不断tail这张日志表,当然也有一定的局限性)。

大多数数据库的复制日志的问题在于,它们一直被当做数据库的内部实现细节,而不是公开的API。客户端应该通过其数据模型和查询语言来查询数据库,而不是解析复制日志并尝试从中提取数据。许多数据库根本没有记录在案的获取变更日志的方式。因此捕获数据库中所有的变更然后将其复制到其他状态存储(搜索索引,缓存,数据仓库)中是相当困难的。

此外,仅有 数据库变更日志仍然是不够的。如果你拥有 全量 变更日志,当然可以通过重放日志来重建数据库的完整状态。但是在许多情况下保留全量历史WAL日志并不是可行的选择(例如磁盘空间与重放耗时的限制)。 例如,构建新的全文索引需要整个数据库的完整副本 —— 仅仅应用最新的变更日志是不够的,因为这样会丢失最近没有更新过的项目。因此如果你不能保留完整的历史日志,那么你至少需要包留一个一致的数据库快照,并保留从该快照开始的变更日志。

因此实施CDC,数据库至少需要提供以下功能:

  1. 获取数据库的变更日志(WAL),并解码成逻辑上的事件(对表的增删改而不是数据库的内部表示)

  2. 获取数据库的"一致性快照",从而订阅者可以从任意一个一致性状态开始订阅而不是数据库创建伊始。

  3. 保存消费者偏移量,以便跟踪订阅者的消费进度,及时清理回收不用的变更日志以免撑爆磁盘。

我们会发现,PostgreSQL在实现逻辑复制的同时,已经提供了一切CDC所需要的基础设施。

  • 逻辑解码(Logical Decoding),用于从WAL日志中解析逻辑变更事件
  • 复制协议(Replication Protocol):提供了消费者实时订阅(甚至同步订阅)数据库变更的机制
  • 快照导出(export snapshot):允许导出数据库的一致性快照(pg_export_snapshot
  • 复制槽(Replication Slot),用于保存消费者偏移量,跟踪订阅者进度。

因此,在PostgreSQL上实施CDC最为直观优雅的方式,就是按照PostgreSQL的复制协议编写一个"逻辑从库" ,从数据库中实时地,流式地接受逻辑解码后的变更事件,完成自己定义的处理逻辑,并及时向数据库汇报自己的消息消费进度。就像使用Kafka一样。在这里CDC客户端可以将自己伪装成一个PostgreSQL的从库,从而不断地实时从PostgreSQL主库中接收逻辑解码后的变更内容。同时CDC客户端还可以通过PostgreSQL提供的复制槽(Replication Slot)机制来保存自己的消费者偏移量,即消费进度,实现类似消息队列一至少次的保证,保证不错过变更数据。(客户端自己记录消费者偏移量跳过重复记录,即可实现"恰好一次 “的保证 )

逻辑解码

在开始进一步的讨论之前,让我们先来看一看期待的输出结果到底是什么样子。

PostgreSQL的变更事件以二进制内部表示形式保存在预写式日志(WAL)中,使用其自带的pg_waldump工具可以解析出来一些人类可读的信息:

rmgr: Btree       len (rec/tot):     64/    64, tx:       1342, lsn: 2D/AAFFC9F0, prev 2D/AAFFC810, desc: INSERT_LEAF off 126, blkref #0: rel 1663/3101882/3105398 blk 4
rmgr: Heap        len (rec/tot):    485/   485, tx:       1342, lsn: 2D/AAFFCA30, prev 2D/AAFFC9F0, desc: INSERT off 10, blkref #0: rel 1663/3101882/3105391 blk 139

WAL日志里包含了完整权威的变更事件记录,但这种记录格式过于底层。用户并不会对磁盘上某个数据页里的二进制变更(文件A页面B偏移量C追加写入二进制数据D)感兴趣,他们感兴趣的是某张表中增删改了哪些行哪些字段。逻辑解码就是将物理变更记录翻译为用户期望的逻辑变更事件的机制(例如表A上的增删改事件)。

例如用户可能期望的是,能够解码出等价的SQL语句

INSERT INTO public.test (id, data) VALUES (14, 'hoho');

或者最为通用的JSON结构(这里以JSON格式记录了一条UPDATE事件)

{
  "change": [
    {
      "kind": "update",
      "schema": "public",
      "table": "test",
      "columnnames": ["id", "data" ],
      "columntypes": [ "integer", "text" ],
      "columnvalues": [ 1, "hoho"],
      "oldkeys": { "keynames": [ "id"],
        "keytypes": ["integer" ],
        "keyvalues": [1]
      }
    }
  ]
}

当然也可以是更为紧凑高效严格的Protobuf格式,更为灵活的Avro格式,抑或是任何用户感兴趣的格式。

逻辑解码 所要解决的问题,就是将数据库内部二进制表示的变更事件,解码(Decoding)成为用户感兴趣的格式。之所以需要这样一个过程,是因为数据库内部表示是非常紧凑的,想要解读原始的二进制WAL日志,不仅仅需要WAL结构相关的知识,还需要系统目录(System Catalog),即元数据。没有元数据就无从得知用户可能感兴趣的模式名,表名,列名,只能解析出来的一系列数据库自己才能看懂的oid。

关于流复制协议,复制槽,事务快照等概念与功能,这里就不展开了,让我们进入动手环节。

快速开始

假设我们有一张用户表,我们希望捕获任何发生在它上面的变更,假设数据库发生了如下变更操作

下面会重复用到这几条命令

DROP TABLE IF EXISTS users;
CREATE TABLE users(id SERIAL PRIMARY KEY, name TEXT);

INSERT INTO users VALUES (100, 'Vonng');
INSERT INTO users VALUES (101, 'Xiao Wang');
DELETE FROM users WHERE id = 100;
UPDATE users SET name = 'Lao Wang' WHERE id = 101;

最终数据库的状态是:只有一条(101, 'Lao Wang')的记录。无论是曾经有一个名为Vonng的用户存在过的痕迹,抑或是隔壁老王也曾年轻过的事实,都随着对数据库的删改而烟消云散。我们希望这些事实不应随风而逝,需要被记录下来。

操作流程

通常来说,订阅变更需要以下几步操作:

  • 选择一个一致性的数据库快照,作为订阅变更的起点。(创建一个复制槽)
  • (数据库发生了一些变更)
  • 读取这些变更,更新自己的的消费进度。

那么, 让我们先从最简单的办法开始,从PostgreSQL自带的的SQL接口开始

SQL接口

逻辑复制槽的增删查API:

TABLE pg_replication_slots; -- 查
pg_create_logical_replication_slot(slot_name name, plugin name) -- 增
pg_drop_replication_slot(slot_name name) -- 删

从逻辑复制槽中获取最新的变更数据:

pg_logical_slot_get_changes(slot_name name, ...)  -- 消费掉
pg_logical_slot_peek_changes(slot_name name, ...) -- 只查看不消费

在正式开始前,还需要对数据库参数做一些修改,修改wal_level = logical,这样在WAL日志中的信息才能足够用于逻辑解码。

-- 创建一个复制槽test_slot,使用系统自带的测试解码插件test_decoding,解码插件会在后面介绍
SELECT * FROM pg_create_logical_replication_slot('test_slot', 'test_decoding');

-- 重放上面的建表与增删改操作
-- DROP TABLE | CREATE TABLE | INSERT 1 | INSERT 1 | DELETE 1 | UPDATE 1

-- 读取复制槽test_slot中未消费的最新的变更事件流
SELECT * FROM  pg_logical_slot_get_changes('test_slot', NULL, NULL);
    lsn    | xid |                                data
-----------+-----+--------------------------------------------------------------------
 0/167C7E8 | 569 | BEGIN 569
 0/169F6F8 | 569 | COMMIT 569
 0/169F6F8 | 570 | BEGIN 570
 0/169F6F8 | 570 | table public.users: INSERT: id[integer]:100 name[text]:'Vonng'
 0/169F810 | 570 | COMMIT 570
 0/169F810 | 571 | BEGIN 571
 0/169F810 | 571 | table public.users: INSERT: id[integer]:101 name[text]:'Xiao Wang'
 0/169F8C8 | 571 | COMMIT 571
 0/169F8C8 | 572 | BEGIN 572
 0/169F8C8 | 572 | table public.users: DELETE: id[integer]:100
 0/169F938 | 572 | COMMIT 572
 0/169F970 | 573 | BEGIN 573
 0/169F970 | 573 | table public.users: UPDATE: id[integer]:101 name[text]:'Lao Wang'
 0/169F9F0 | 573 | COMMIT 573

-- 清理掉创建的复制槽
SELECT pg_drop_replication_slot('test_slot');

这里,我们可以看到一系列被触发的事件,其中每个事务的开始与提交都会触发一个事件。因为目前逻辑解码机制不支持DDL变更,因此CREATE TABLEDROP TABLE并没有出现在事件流中,只能看到空荡荡的BEGIN+COMMIT。另一点需要注意的是,只有成功提交的事务才会产生逻辑解码变更事件。也就是说用户不用担心收到并处理了很多行变更消息之后,最后发现事务回滚了,还需要担心怎么通知消费者去会跟变更。

通过SQL接口,用户已经能够拉取最新的变更了。这也就意味着任何有着PostgreSQL驱动的语言都可以通过这种方式从数据库中捕获最新的变更。当然这种方式实话说还是略过于土鳖。更好的方式是利用PostgreSQL的复制协议直接从数据库中订阅变更数据流。当然相比使用SQL接口,这也需要更多的工作。

使用客户端接收变更

在编写自己的CDC客户端之前,让我们先来试用一下官方自带的CDC客户端样例——pg_recvlogical。与pg_receivewal类似,不过它接收的是逻辑解码后的变更,下面是一个具体的例子:

# 启动一个CDC客户端,连接数据库postgres,创建名为test_slot的槽,使用test_decoding解码插件,标准输出
pg_recvlogical \
	-d postgres \
	--create-slot --if-not-exists --slot=test_slot \
	--plugin=test_decoding \
	--start -f -

# 开启另一个会话,重放上面的建表与增删改操作
# DROP TABLE | CREATE TABLE | INSERT 1 | INSERT 1 | DELETE 1 | UPDATE 1

# pg_recvlogical输出结果
BEGIN 585
COMMIT 585
BEGIN 586
table public.users: INSERT: id[integer]:100 name[text]:'Vonng'
COMMIT 586
BEGIN 587
table public.users: INSERT: id[integer]:101 name[text]:'Xiao Wang'
COMMIT 587
BEGIN 588
table public.users: DELETE: id[integer]:100
COMMIT 588
BEGIN 589
table public.users: UPDATE: id[integer]:101 name[text]:'Lao Wang'
COMMIT 589

# 清理:删除创建的复制槽
pg_recvlogical -d postgres --drop-slot --slot=test_slot

上面的例子中,主要的变更事件包括事务的开始结束,以及数据行的增删改。这里默认的test_decoding插件的输出格式为:

BEGIN {事务标识}
table {模式名}.{表名} {命令INSERT|UPDATE|DELETE}  {列名}[{类型}]:{取值} ...
COMMIT {事务标识}

实际上,PostgreSQL的逻辑解码是这样工作的,每当特定的事件发生(表的Truncate,行级别的增删改,事务开始与提交),PostgreSQL都会调用一系列的钩子函数。所谓的逻辑解码输出插件(Logical Decoding Output Plugin),就是这样一组回调函数的集合。它们接受二进制内部表示的变更事件作为输入,查阅一些系统目录,将二进制数据翻译成为用户感兴趣的结果。

逻辑解码输出插件

除了PostgreSQL自带的"用于测试"的逻辑解码插件:test_decoding 之外,还有很多现成的输出插件,例如:

当然还有PostgreSQL自带逻辑复制所使用的解码插件:pgoutput,其消息格式文档地址

安装这些插件非常简单,有一些插件(例如wal2json)可以直接从官方二进制源轻松安装。

yum install wal2json11
apt install postgresql-11-wal2json

或者如果没有二进制包,也可以自己下载编译。只需要确保pg_config已经在你的PATH中,然后执行make & sudo make install两板斧即可。以输出SQL格式的decoder_raw插件为例:

git clone https://github.com/michaelpq/pg_plugins && cd pg_plugins/decoder_raw
make && sudo make install

使用wal2json接收同样的变更

pg_recvlogical -d postgres --drop-slot --slot=test_slot
pg_recvlogical -d postgres --create-slot --if-not-exists --slot=test_slot \
	--plugin=wal2json --start -f -

结果为:

{"change":[]}
{"change":[{"kind":"insert","schema":"public","table":"users","columnnames":["id","name"],"columntypes":["integer","text"],"columnvalues":[100,"Vonng"]}]}
{"change":[{"kind":"insert","schema":"public","table":"users","columnnames":["id","name"],"columntypes":["integer","text"],"columnvalues":[101,"Xiao Wang"]}]}
{"change":[{"kind":"delete","schema":"public","table":"users","oldkeys":{"keynames":["id"],"keytypes":["integer"],"keyvalues":[100]}}]}
{"change":[{"kind":"update","schema":"public","table":"users","columnnames":["id","name"],"columntypes":["integer","text"],"columnvalues":[101,"Lao Wang"],"oldkeys":{"keynames":["id"],"keytypes":["integer"],"keyvalues":[101]}}]}

而使用decoder_raw获取SQL格式的输出

pg_recvlogical -d postgres --drop-slot --slot=test_slot
pg_recvlogical -d postgres --create-slot --if-not-exists --slot=test_slot \
	--plugin=decoder_raw --start -f -

结果为:

INSERT INTO public.users (id, name) VALUES (100, 'Vonng');
INSERT INTO public.users (id, name) VALUES (101, 'Xiao Wang');
DELETE FROM public.users WHERE id = 100;
UPDATE public.users SET id = 101, name = 'Lao Wang' WHERE id = 101;

decoder_raw可以用于抽取SQL形式表示的状态变更,将这些抽取得到的SQL语句在同样的基础状态上重放,即可得到相同的结果。PostgreSQL就是使用这样的机制实现逻辑复制的。

一个典型的应用场景就是数据库不停机迁移。在传统不停机迁移模式(双写,改读,改写)中,第三步改写完成后是无法快速回滚的,因为写入流量在切换至新主库后如果发现有问题想立刻回滚,老主库上会丢失一些数据。这时候就可以使用decoder_raw提取主库上的最新变更,并通过一行简单的Bash命令,将新主库上的变更实时同步到旧主库。保证迁移过程中任何时刻都可以快速回滚至老主库。

pg_recvlogical -d <new_master_url> --slot=test_slot --plugin=decoder_raw --start -f - |
psql <old_master_url>

另一个有趣的场景是UNDO LOG。PostgreSQL的故障恢复是基于REDO LOG的,通过重放WAL会到历史上的任意时间点。在数据库模式不发生变化的情况下,如果只是单纯的表内容增删改出现了失误,完全可以利用类似decoder_raw的方式反向生成UNDO日志。提高此类故障恢复的速度。

最后,输出插件可以将变更事件格式化为各种各样的形式。解码输出为Redis的kv操作,或者仅仅抽取一些关键字段用于更新统计数据或者构建外部索引,有着很大的想象空间。

编写自定义的逻辑解码输出插件并不复杂,可以参阅这篇官方文档。毕竟逻辑解码输出插件本质上只是一个拼字符串的回调函数集合。在官方样例的基础上稍作修改,即可轻松实现一个你自己的逻辑解码输出插件。

CDC客户端

PostgreSQL自带了一个名为pg_recvlogical的客户端应用,可以将逻辑变更的事件流写至标准输出。但并不是所有的消费者都可以或者愿意使用Unix Pipe来完成所有工作的。此外,根据端到端原则,使用pg_recvlogical将变更数据流落盘并不意味着消费者已经拿到并确认了该消息,只有消费者自己亲自向数据库确认才可以做到这一点。

编写PostgreSQL的CDC客户端程序,本质上是实现了一个"猴版”数据库从库。客户端向数据库建立一条复制连接(Replication Connection) ,将自己伪装成一个从库:从主库获取解码后的变更消息流,并周期性地向主库汇报自己的消费进度(落盘进度,刷盘进度,应用进度)。

复制连接

复制连接,顾名思义就是用于复制(Replication) 的特殊连接。当与PostgreSQL服务器建立连接时,如果连接参数中提供了replication=database|on|yes|1,就会建立一条复制连接,而不是普通连接。复制连接可以执行一些特殊的命令,例如IDENTIFY_SYSTEM, TIMELINE_HISTORY, CREATE_REPLICATION_SLOT, START_REPLICATION, BASE_BACKUP, 在逻辑复制的情况下,还可以执行一些简单的SQL查询。具体细节可以参考PostgreSQL官方文档中前后端协议一章:https://www.postgresql.org/docs/current/protocol-replication.html

譬如,下面这条命令就会建立一条复制连接:

$ psql 'postgres://localhost:5432/postgres?replication=on&application_name=mocker'

从系统视图pg_stat_replication可以看到主库识别到了一个新的"从库”

vonng=# table pg_stat_replication ;
-[ RECORD 1 ]----+-----------------------------
pid              | 7218
usesysid         | 10
usename          | vonng
application_name | mocker
client_addr      | ::1
client_hostname  |
client_port      | 53420

编写自定义逻辑

无论是JDBC还是Go语言的PostgreSQL驱动,都提供了相应的基础设施,用于处理复制连接。

这里让我们用Go语言编写一个简单的CDC客户端,样例使用了jackc/pgx,一个很不错的Go语言编写的PostgreSQL驱动。这里的代码只是作为概念演示,因此忽略掉了错误处理,非常Naive。将下面的代码保存为main.go,执行go run main.go即可执行。

默认的三个参数分别为数据库连接串,逻辑解码输出插件的名称,以及复制槽的名称。默认值为:

dsn := "postgres://localhost:5432/postgres?application_name=cdc"
plugin := "test_decoding"
slot := "test_slot"
go run main.go postgres:///postgres?application_name=cdc test_decoding test_slot

代码如下所示:

package main

import (
	"log"
	"os"
	"time"

	"context"
	"github.com/jackc/pgx"
)

type Subscriber struct {
	URL    string
	Slot   string
	Plugin string
	Conn   *pgx.ReplicationConn
	LSN    uint64
}

// Connect 会建立到服务器的复制连接,区别在于自动添加了replication=on|1|yes|dbname参数
func (s *Subscriber) Connect() {
	connConfig, _ := pgx.ParseURI(s.URL)
	s.Conn, _ = pgx.ReplicationConnect(connConfig)
}

// ReportProgress 会向主库汇报写盘,刷盘,应用的进度坐标(消费者偏移量)
func (s *Subscriber) ReportProgress() {
	status, _ := pgx.NewStandbyStatus(s.LSN)
	s.Conn.SendStandbyStatus(status)
}

// CreateReplicationSlot 会创建逻辑复制槽,并使用给定的解码插件
func (s *Subscriber) CreateReplicationSlot() {
	if consistPoint, snapshotName, err := s.Conn.CreateReplicationSlotEx(s.Slot, s.Plugin); err != nil {
		log.Fatalf("fail to create replication slot: %s", err.Error())
	} else {
		log.Printf("create replication slot %s with plugin %s : consist snapshot: %s, snapshot name: %s",
			s.Slot, s.Plugin, consistPoint, snapshotName)
		s.LSN, _ = pgx.ParseLSN(consistPoint)
	}
}

// StartReplication 会启动逻辑复制(服务器会开始发送事件消息)
func (s *Subscriber) StartReplication() {
	if err := s.Conn.StartReplication(s.Slot, 0, -1); err != nil {
		log.Fatalf("fail to start replication on slot %s : %s", s.Slot, err.Error())
	}
}

// DropReplicationSlot 会使用临时普通连接删除复制槽(如果存在),注意如果复制连接正在使用这个槽是没法删的。
func (s *Subscriber) DropReplicationSlot() {
	connConfig, _ := pgx.ParseURI(s.URL)
	conn, _ := pgx.Connect(connConfig)
	var slotExists bool
	conn.QueryRow(`SELECT EXISTS(SELECT 1 FROM pg_replication_slots WHERE slot_name = $1)`, s.Slot).Scan(&slotExists)
	if slotExists {
		if s.Conn != nil {
			s.Conn.Close()
		}
		conn.Exec("SELECT pg_drop_replication_slot($1)", s.Slot)
		log.Printf("drop replication slot %s", s.Slot)
	}
}

// Subscribe 开始订阅变更事件,主消息循环
func (s *Subscriber) Subscribe() {
	var message *pgx.ReplicationMessage
	for {
		// 等待一条消息, 消息有可能是真的消息,也可能只是心跳包
		message, _ = s.Conn.WaitForReplicationMessage(context.Background())
		if message.WalMessage != nil {
			DoSomething(message.WalMessage) // 如果是真的消息就消费它
			if message.WalMessage.WalStart > s.LSN { // 消费完后更新消费进度,并向主库汇报
				s.LSN = message.WalMessage.WalStart + uint64(len(message.WalMessage.WalData))
				s.ReportProgress()
			}
		}
		// 如果是心跳包消息,按照协议,需要检查服务器是否要求回送进度。
		if message.ServerHeartbeat != nil && message.ServerHeartbeat.ReplyRequested == 1 {
			s.ReportProgress() // 如果服务器心跳包要求回送进度,则汇报进度
		}
	}
}

// 实际消费消息的函数,这里只是把消息打印出来,也可以写入Redis,写入Kafka,更新统计信息,发送邮件等
func DoSomething(message *pgx.WalMessage) {
	log.Printf("[LSN] %s [Payload] %s", 
             pgx.FormatLSN(message.WalStart), string(message.WalData))
}

// 如果使用JSON解码插件,这里是用于Decode的Schema
type Payload struct {
	Change []struct {
		Kind         string        `json:"kind"`
		Schema       string        `json:"schema"`
		Table        string        `json:"table"`
		ColumnNames  []string      `json:"columnnames"`
		ColumnTypes  []string      `json:"columntypes"`
		ColumnValues []interface{} `json:"columnvalues"`
		OldKeys      struct {
			KeyNames  []string      `json:"keynames"`
			KeyTypes  []string      `json:"keytypes"`
			KeyValues []interface{} `json:"keyvalues"`
		} `json:"oldkeys"`
	} `json:"change"`
}

func main() {
	dsn := "postgres://localhost:5432/postgres?application_name=cdc"
	plugin := "test_decoding"
	slot := "test_slot"
	if len(os.Args) > 1 {
		dsn = os.Args[1]
	}
	if len(os.Args) > 2 {
		plugin = os.Args[2]
	}
	if len(os.Args) > 3 {
		slot = os.Args[3]
	}

	subscriber := &Subscriber{
		URL:    dsn,
		Slot:   slot,
		Plugin: plugin,
	}                                // 创建新的CDC客户端
	subscriber.DropReplicationSlot() // 如果存在,清理掉遗留的Slot

	subscriber.Connect()                   // 建立复制连接
	defer subscriber.DropReplicationSlot() // 程序中止前清理掉复制槽
	subscriber.CreateReplicationSlot()     // 创建复制槽
	subscriber.StartReplication()          // 开始接收变更流
	go func() {
		for {
			time.Sleep(5 * time.Second)
			subscriber.ReportProgress()
		}
	}()                                    // 协程2每5秒地向主库汇报进度
	subscriber.Subscribe()                 // 主消息循环
}

在另一个数据库会话中再次执行上面的变更,可以看到客户端及时地接收到了变更的内容。这里客户端只是简单地将其打印了出来,实际生产中,客户端可以完成任何工作,比如写入Kafka,写入Redis,写入磁盘日志,或者只是更新内存中的统计数据并暴露给监控系统。甚至,还可以通过配置同步提交,确保所有系统中的变更能够时刻保证严格同步(当然相比默认的异步模式比较影响性能就是了)。

对于PostgreSQL主库而言,这看起来就像是另一个从库。

postgres=# table pg_stat_replication; -- 查看当前从库
-[ RECORD 1 ]----+------------------------------
pid              | 14082
usesysid         | 10
usename          | vonng
application_name | cdc
client_addr      | 10.1.1.95
client_hostname  |
client_port      | 56609
backend_start    | 2019-05-19 13:14:34.606014+08
backend_xmin     |
state            | streaming
sent_lsn         | 2D/AB269AB8     -- 服务端已经发送的消息坐标
write_lsn        | 2D/AB269AB8     -- 客户端已经执行完写入的消息坐标
flush_lsn        | 2D/AB269AB8     -- 客户端已经刷盘的消息坐标(不会丢失)
replay_lsn       | 2D/AB269AB8     -- 客户端已经应用的消息坐标(已经生效)
write_lag        |
flush_lag        |
replay_lag       |
sync_priority    | 0
sync_state       | async

postgres=# table pg_replication_slots;  -- 查看当前复制槽
-[ RECORD 1 ]-------+------------
slot_name           | test
plugin              | decoder_raw
slot_type           | logical
datoid              | 13382
database            | postgres
temporary           | f
active              | t
active_pid          | 14082
xmin                |
catalog_xmin        | 1371
restart_lsn         | 2D/AB269A80       -- 下次客户端重连时将从这里开始重放
confirmed_flush_lsn | 2D/AB269AB8       -- 客户端确认完成的消息进度

局限性

想要在生产环境中使用CDC,还需要考虑一些其他的问题。略有遗憾的是,在PostgreSQL CDC的天空上,还飘着两朵小乌云。

完备性

就目前而言,PostgreSQL的逻辑解码只提供了以下几个钩子:

LogicalDecodeStartupCB startup_cb;
LogicalDecodeBeginCB begin_cb;
LogicalDecodeChangeCB change_cb;
LogicalDecodeTruncateCB truncate_cb;
LogicalDecodeCommitCB commit_cb;
LogicalDecodeMessageCB message_cb;
LogicalDecodeFilterByOriginCB filter_by_origin_cb;
LogicalDecodeShutdownCB shutdown_cb;

其中比较重要,也是必须提供的是三个回调函数:begin:事务开始,change:行级别增删改事件,commit:事务提交 。遗憾的是,并不是所有的事件都有相应的钩子,例如数据库的模式变更,Sequence的取值变化,以及特殊的大对象操作。

通常来说,这并不是一个大问题,因为用户感兴趣的往往只是表记录而不是表结构的增删改。而且,如果使用诸如JSON,Avro等灵活格式作为解码目标格式,即使表结构发生变化,也不会有什么大问题。

但是尝试从目前的变更事件流生成完备的UNDO Log是不可能的,因为目前模式的变更DDL并不会记录在逻辑解码的输出中。好消息是未来会有越来越多的钩子与支持,因此这个问题是可解的。

同步提交

需要注意的一点是,有一些输出插件会无视BeginCommit消息。这两条消息本身也是数据库变更日志的一部分,如果输出插件忽略了这些消息,那么CDC客户端在汇报消费进度时就可能会出现偏差(落后一条消息的偏移量)。在一些边界条件下可能会触发一些问题:例如写入极少的数据库启用同步提交时,主库迟迟等不到从库确认最后的Commit消息而卡住)

故障切换

理想很美好,现实很骨感。当一切正常时,CDC工作流工作的很好。但当数据库出现故障,或者出现故障转移时,事情就变得比较棘手了。

恰好一次保证

另外一个使用PostgreSQL CDC的问题是消息队列中经典的恰好一次问题。

PostgreSQL的逻辑复制实际上提供的是至少一次保证,因为消费者偏移量的值会在检查点的时候保存。如果PostgreSQL主库宕机,那么重新发送变更事件的起点,不一定恰好等于上次订阅者已经消费的位置。因此有可能会发送重复的消息。

解决方法是:逻辑复制的消费者也需要记录自己的消费者偏移量,以便跳过重复的消息,实现真正的恰好一次 消息传达保证。这并不是一个真正的问题,只是任何试图自行实现CDC客户端的人都应当注意这一点。

Failover Slot

对目前PostgreSQL的CDC来说,Failover Slot是最大的难点与痛点。逻辑复制依赖复制槽,因为复制槽持有着消费者的状态,记录着消费者的消费进度,因而数据库不会将消费者还没处理的消息清理掉。

但以目前的实现而言,复制槽只能用在主库上,且复制槽本身并不会被复制到从库上。因此当主库进行Failover时,消费者偏移量就会丢失。如果在新的主库承接任何写入之前没有重新建好逻辑复制槽,就有可能会丢失一些数据。对于非常严格的场景,使用这个功能时仍然需要谨慎。

这个问题计划将于下一个大版本(13)解决,Failover Slot的Patch计划于版本13(2020)年合入主线版本。

在那之前,如果希望在生产中使用CDC,那么务必要针对故障切换进行充分地测试。例如使用CDC的情况下,Failover的操作就需要有所变更:核心思想是运维与DBA必须手工完成复制槽的复制工作。在Failover前可以在原主库上启用同步提交,暂停写入流量并在新主库上使用脚本复制复制原主库的槽,并在新主库上创建同样的复制槽,从而手工完成复制槽的Failover。对于紧急故障切换,即原主库无法访问,需要立即切换的情况,也可以在事后使用PITR重新将缺失的变更恢复出来。

小结一下:CDC的功能机制已经达到了生产应用的要求,但可靠性的机制还略有欠缺,这个问题可以等待下一个主线版本,或通过审慎地手工操作解决,当然激进的用户也可以自行拉取该补丁提前尝鲜。

PostgreSQL中的锁

详细介绍PostgreSQL中的各种锁

PostgreSQL的并发控制以 快照隔离(SI) 为主,以 两阶段锁定(2PL) 机制为辅。PostgreSQL对DML(SELECT, UPDATE, INSERT, DELETE等命令)使用SSI,对DDL(CREATE TABLE等命令)使用2PL。

PostgreSQL有好几类锁,其中最主要的是 表级锁行级锁,此外还有页级锁,咨询锁等,表级锁 通常是各种命令执行时自动获取的,或者通过事务中的LOCK语句显式获取;而行级锁则是由SELECT FOR UPDATE|SHARE语句显式获取的。执行数据库命令时,都是先获取表级锁,再获取行级锁。本文主要介绍PostgreSQL中的表锁。

表级锁

  • 表级锁通常会在执行各种命令执行时自动获取,或者通过在事务中使用LOCK语句显式获取。
  • 每种锁都有自己的冲突集合,在同一时刻的同一张表上,两个事务可以持有不冲突的锁,不能持有冲突的锁。
  • 有些锁是 自斥(self-conflict) 的,即最多只能被一个事务所持有。
  • 表级锁总共有八种模式,有着并不严格的强度递增关系(例外是Share锁不自斥)
  • 表级锁存在于PG的共享内存中,可以通过pg_locks系统视图查阅。

表级锁的模式

如何记忆这么多类型的锁呢?让我们从演化的视角来看这些锁。

表级锁的演化

最开始只有两种锁:ShareExclusive,共享锁与排它锁,即所谓读锁写锁。读锁的目的是阻止表数据的变更,而写锁的目的是阻止一切并发访问。这很好理解。

多版本并发控制

后来随着多版本并发控制技术的出现(PostgreSQL使用快照隔离实现MVCC),读不阻塞写,写不阻塞读(针对表的增删改查而言)。因而原有的锁模型就需要升级了:这里的共享锁与排他锁都有了一个升级版本,即前面多加一个ACCESSACCESS SHARE是改良版共享锁,即允许ACCESS(多版本并发访问)的SHARE锁,这种锁意味着即使其他进程正在并发修改数据也不会阻塞本进程读取数据。当然有了多版本读锁也就会有对应的多版本写锁来阻止一切访问,即连ACCESS(多版本并发访问)都要EXCLUSIVE的锁,这种锁会阻止一切访问,是最强的写锁。

引入MVCC后,INSERT|UPDATE|DELETE仍然使用原来的Exclusive锁,而普通的只读SELECT则使用多版本的AccessShare锁。因为AccessShare锁与原来的Exclusive锁不冲突,所以读写之间就不会阻塞了。原来的Share锁现在主要的应用场景为创建索引(非并发创建模式下,创建索引会阻止任何对底层数据的变更),而升级的多版本AccessExclusive锁主要用于除了增删改之外的排他性变更(DROP|TRUNCATE|REINDEX|VACUUM FULL等),这个模型如图(a)所示。

当然,这样还是有问题的。虽然在MVCC中读写之间相互不阻塞了,但写-写之间还是会产生冲突。上面的模型中,并发写入是通过表级别的Exclusive锁解决的。表级锁虽然可以解决并发写入冲突问题,但这个粒度太大了,会影响并发度:因为同一时刻一张表上只能有一个进程持有Exclusive锁并执行写入,而典型的OLTP场景是以单行写入为主。所以常见的DBMS解决写-写冲突通常都是采用行级锁来实现(下面会讲到)。

行级锁和表级锁不是一回事,但这两种锁之间仍然存在着联系,协调这两种锁之间的关系,就需要引入意向锁

意向锁

意向锁用于协调表锁与行锁之间的关系:它用于保护较低资源级别上的锁,即说明下层节点已经被加了锁。当进程想要锁定或修改某表上的某一行时,它会在这一行上加上行级锁。但在加行级锁之前,它还需要在这张表上加上一把意向锁,表示自己将会在表中的若干行上加锁。

举个例子,假设不存在意向锁。假设进程A获取了表上某行的行锁,持有行上的排他锁意味着进程A可以对这一行执行写入;同时因为不存在意向锁,进程B很顺利地获取了该表上的表级排他锁,这意味着进程B可以对整个表,包括A锁定对那一行进行修改,这就违背了常识逻辑。因此A需要在获取行锁前先获取表上的意向锁,这样后来的B就意识到自己无法获取整个表上的排他锁了(但B依然可以加一个意向锁,获取其他行上的行锁)。

因此,这里RowShare就是行级共享锁对应的表级意向锁(SELECT FOR SHARE|UPDATE命令获取),而RowExclusiveINSERT|UPDATE|DELETE获取)则是行级排他锁对应的表级意向锁。注意因为MVCC的存在,只读查询并不会在行上加锁。引入意向锁后的模型如图(c)所示。而合并MVCC与意向锁模型之后的锁模型如图(d)所示。

自斥锁

上面这个模型已经相当不错,但仍然存在一些问题,譬如自斥:这里RowExclusiveShare锁都不是自斥的。

举个例子,并发VACUUM不应阻塞数据写入,而且一个表上不应该允许多个VACUUM进程同时工作。因为不能阻塞写入,因此VACUUM所需的锁强度必须要比Share锁弱,弱于Share的最强锁为RowExclusive,不幸的是,该锁并不自斥。如果VACUUM使用该锁,就无法阻止单表上出现多个VACUUM进程。因此需要引入一个自斥版本的RowExclusive锁,即ShareUpdateExclusive锁。

同理,再比如执行触发器管理操作(创建,删除,启用)时,该操作不应阻塞读取和锁定,但必须禁止一切实际的数据写入,否则就难以判断某条元组的变更是否应该触发触发器。Share锁满足不阻塞读取和锁定的条件,但并不自斥,因此可能出现多个进程在同一个表上并发修改触发器。并发修改触发器会带来很多问题(譬如丢失更新,A将其配置为Replica Trigger,B将其配置为Always Trigger,都反回成功了,以谁为准?)。因此这里也需要一个自斥版本的Share锁,即ShareRowExclusive锁。

因此,引入两种自斥版本的锁后,就是PostgreSQL中的最终表级锁模型,如图(e)所示。

表级锁的命名与记忆

PostgreSQL的表级锁的命名有些诘屈聱牙,这是因为一些历史因素,但也可以总结出一些规律便于记忆。

  • 最初只有两种锁:共享锁(Share)与排他锁(Exclusive)。
    • 特征是只有一个单词,表示这是两种最基本的锁:读锁与写锁。
  • 多版本并发控制的出现,引入了多版本的共享锁与排他锁(AccessShareAccessExclusive)。
    • 特征是Access前缀,表示这是用于"多版本并发控制"的改良锁。
  • 为了处理并发写入之间的冲突,又引入了两种意向锁(RowShareRowExclusive
    • 特征是Row前缀,表示这是行级别共享/排他锁对应的表级意向锁。
  • 最后,为了处理意向排他锁与共享锁不自斥的问题,引入了这两种锁的自斥版本(ShareUpdateExclusive, ShareRowExclusive)。这两种锁的名称比较难记:
    • 都是以Share打头,以Exclusive结尾。表示这两种锁都是某种共享锁的自斥版本。
    • 两种锁强度围绕在Share前后,Update弱于ShareRow强于Share
    • ShareRowExclusive可以理解为Share + Row Exclusive,因为Share不排斥其他Share,但RowExclusive排斥Share,因此同时加这两种锁的结果等效于ShareRowExclusive,即SIX。
    • ShareUpdateExclusive可以理解为ShareUpdate + ExclusiveUPDATE操作持有RowExclusive锁,而ShareUpdate指的是本锁与普通的增删改(持RowExclusive锁)相容,而Exclusive则表示自己和自己不相容。
  • Share, ShareRowUpdate, Exclusive 这三种锁极少出现,基本可以无视。所以实际上主要用到的锁是:
    • 多版本两种:AccessShare, AccessExclusive
    • 意向锁两种:RowShare,RowExclusive
    • 自斥意向锁一种:ShareUpdateExclusive

显式加锁

通常表级锁会在相应命令执行中自动获取,但也可以手动显式获取。使用LOCK命令加锁的方式:

LOCK [ TABLE ] [ ONLY ] name [ * ] [, ...] [ IN lockmode MODE ] [ NOWAIT ]
  • 显式锁表必须在事务中进行,在事务外锁表会报错。
  • 锁定视图时,视图定义中所有出现的表都会被锁定。
  • 使用表继承时,默认父表和所有后代表都会加锁,指定ONLY选项则继承于该表的子表不会自动加锁。
  • 锁表或者锁视图需要对应的权限,例如AccessShare锁需要SELECT权限。
  • 默认获取的锁模式为AccessExclusive,即最强的锁。
  • LOCK TABLE只能获取表锁,默认会等待冲突的锁被释放,指定NOWAIT选项时,如果命令不能立刻获得锁就会中止并报错。
  • 命令一旦获取到锁, 会被在当前事务中一直持有。没有UNLOCK TABLE命令,锁总是在事务结束时释放。

例子:数据迁移

举个例子,以迁移数据为例,假设希望将某张表的数据迁移到另一个实例中。并保证在此期间旧表上的数据在迁移期间不发生变化,那么我们可以做的就是在复制数据前在表上显式加锁,并在复制结束,应用开始写入新表后释放。应用仍然可以从旧表上读取数据,但不允许写入。那么根据锁冲突矩阵,允许只读查询的锁要弱于AccessExclusive,阻止写入的锁不能弱于ShareRowExclusive,因此可以选择ShareRowExclusiveExclusive锁。因为拒绝写入意味着锁定没有任何意义,所以这里选择更强的Exclusive锁。

BEGIN;
LOCK TABLE tbl IN EXCLUSIVE MODE;
-- DO Something
COMMIT

锁的查询

PostgreSQL提供了一个系统视图pg_locks,包含了当前活动进程持锁的信息。可以锁定的对象包括:关系,页面,元组,事务标识(虚拟的或真实的),其他数据库对象(带有OID)。

CREATE TABLE pg_locks
(
    -- 锁针对的客体对象
    locktype           text, -- 锁类型:关系,页面,元组,事务ID,对象等
    database           oid,  -- 数据库OID
    relation           oid,  -- 关系OID
    page               integer, -- 关系内页号
    tuple              smallint, -- 页内元组号
    virtualxid         text,     -- 虚拟事务ID
    transactionid      xid,      -- 事务ID
    classid            oid,      -- 锁对象所属系统目录表本身的OID
    objid              oid,      -- 系统目录内的对象的OID
    objsubid           smallint, -- 列号
  
    -- 持有|等待锁的主体
    virtualtransaction text,     -- 持锁|等待锁的虚拟事务ID
    pid                integer,  -- 持锁|等待锁的进程PID
    mode               text,     -- 锁模式
    granted            boolean,  -- t已获取,f等待中
    fastpath           boolean   -- t通过fastpath获取
);
名称 类型 描述
locktype text 可锁对象的类型: relationextendpagetupletransactionidvirtualxidobjectuserlockadvisory
database oid 若锁目标为数据库(或下层对象),则为数据库OID,并引用pg_database.oid,共享对象为0,否则为空
relation oid 若锁目标为关系(或下层对象),则为关系OID,并引用pg_class.oid,否则为空
page integer 若锁目标为页面(或下层对象),则为页面号,否则为空
tuple smallint 若锁目标为元组,则为页内元组号,否则为空
virtualxid text 若锁目标为虚拟事务,则为虚拟事务ID,否则为空
transactionid xid 若锁目标为事务,则为事务ID,否则为空
classid oid 若目标为数据库对象,则为该对象相应系统目录的OID,并引用pg_class.oid,否则为空。
objid oid 锁目标在其系统目录中的OID,如目标不是普通数据库对象则为空
objsubid smallint 锁的目标列号(classidobjid指向表本身),若目标是某种其他普通数据库对象则此列为0,如果目标不是一个普通数据库对象则此列为空。
virtualtransaction text 持有或等待这个锁的虚拟ID
pid integer 持有或等待这个锁的服务器进程ID,如果此锁被一个预备事务所持有则为空
mode text 持有或者等待锁的模式
granted boolean 为真表示已经获得的锁,为假表示还在等待的锁
fastpath boolean 为真表示锁是通过fastpath获取的

样例数据

这个视图需要一些额外的知识才能解读。

  • 该视图是数据库集簇范围的视图,而非仅限于单个数据库,即可以看见其他数据库中的锁。
  • 一个进程在一个时间点只能等待至多一个锁,等待锁用granted=f表示,等待进程会休眠至其他锁被释放,或者系统检测到死锁。
  • 每个事务都有一个虚拟事务标识virtualtransaction(以下简称vxid),修改数据库状态(或者显式调用txid_current获取)的事务才会被分配一个真实的事务标识transactionid(简称txid),vxid|txid本身也是可以锁定的对象
  • 每个事务都会持有自己vxid上的Exclusive锁,如果有txid,也会同时持有其上的Exclusive锁(即同时持有txidvxid上的排它锁)。因此当一个事务需要等待另一个事务时,它会尝试获取另一个事务txid|vxid上的共享锁,因而只有当目标事务结束(自动释放自己事务标识上的Exclusive锁)时,等待事务才会被唤醒。
  • pg_locks视图通常并不会直接显示行级锁信息,因为这些信息存储在磁盘磁盘上(),如果真的有进程在等待行锁,显示的形式通常是一个事务等待另一个事务,而不是等待某个具体的行锁。
  • 咨询锁本质上的锁对象客体是一个数据库范畴内的BIGINT,classid里包含了该整数的高32bit,objid里包含有低32bit,objsubid里则说明了咨询锁的类型,单一Bigint则取值为1,两个int32则取值为2
  • 本视图并不一定能保证提供一个一致的快照,因为所有fastpath=true的锁信息是从每个后端进程收集而来的,而fastpath=false的锁是从常规锁管理器中获取的,同时谓词锁管理器中的数据也是单独获取的,因此这几种来源的数据之间可能并不一致。
  • 频繁访问本视图会对数据库系统性能产生影响,因为要对锁管理器加锁获取一致性快照。

虚拟事务

一个后端进程在整个生命周期中的每一个事务都会有一个自己的虚拟事务ID

PG中事务号是有限的(32-bit整型),会循环使用。为了节约事务号,PG只会为实际修改数据库状态的事务分配真实事务ID,而只读事务就不分配了,用虚拟事务ID凑合一下。txid是事务标识,全局共享,而vxid是虚拟事务标识,在短期内可以保证全局唯一性。因为vxid由两部分组成:BackendIDLocalTransactionId,前者是后端进程的标识符(本进程在内存中进程数组中的序号),后者是一个递增的事务计数器。因此两者组合即可获得一个暂时唯一的虚拟事务标识(之所以是暂时是因为这里的后端ID是有可能重复的)

typedef struct {
	BackendId	backendId;		/* 后端ID,初始化时确定,其实是后端进程数组内索引号 */
	LocalTransactionId localTransactionId;	/* 后端内本地使用的命令标ID,类似自增计数器 */
} VirtualTransactionId;

应用

常见操作的冲突关系

  • SELECTUPDATE|DELETE|INSERT不会相互阻塞,即使访问的是同一行。
  • I|U|D写入操作与I|U|D写入操作在表层面不会互斥,会在具体的行上通过RowExclusive锁实现。
  • SELECT FOR UPDATE锁定操作与I|U|D写入在表层级也不会互斥,仍然是通过具体元组上的行锁实现。
  • 并发VACUUM,并发创建索引等操作不会阻塞读写,但它们是自斥的,即同一时刻只会有一个(所以同时在一个表上执行两个CREATE INDEX CONCURRENTLY是没有意义的,不要被名字骗了)
  • 普通的索引创建CREATE INDEX,不带CONCURRENTLY会阻塞增删改,但不会阻塞查,很少用到。
  • 任何对于触发器的操作,或者约束类的操作,都会阻止增删改,但不会阻塞只读查询以及锁定。
  • 冷门的命令REFRESH MATERIALIZED VIEW CONCURRENTLY允许SELECT和锁定。
  • 大多数很硬的变更:VACUUM FULL, DROP TABLE, TRUNCATE, ALTER TABLE的大多数形式都会阻塞一切读取。

注意,锁虽有强弱之分,但冲突关系是对等的。一个持有AccessShare锁的SELECT会阻止后续的DROP TABLE获得AccessExclusive锁。后面的命令会进入锁队列中。

锁队列

PG中每个锁上都会有一个锁队列。如果事务A占有一个排他锁,那么事务B在尝试获取其上的锁时就会在其锁队列中等待。如果这时候事务C同样要获取该锁,那么它不仅要和事务A进行冲突检测,也要和B进行冲突检测,以及队列中其他的事务。这意味着当用户尝试获取一个很强的锁而未得等待时,已经会阻止后续新锁的获取。一个具体的例子是加列:

ALTER TABLE tbl ADD COLUMN mtime TIMESTAMP;

即使这是一个不带默认值的加列操作(不会重写整个表,因而很快),但本命令需要表上的AccessExclusive锁,如果这张表上面已经有不少查询,那么这个命令可能会等待相当一段时间。因为它需要等待其他查询结束并释放掉锁后才能执行。相应地,因为这条命令已经在等待队列中,后续的查询都会被它所阻塞。因此,当执行此类命令时的一个最佳实践是在此类命令前修改lock_timeout,从而避免雪崩。

SET lock_timeout TO '1s';
ALTER TABLE tbl ADD COLUMN mtime TIMESTAMP;

这个设计的好处是,命令不会饿死:不会出现源源不断的短小只读查询无限阻塞住一个排他操作。

加锁原则

  • 够用即可:使用满足条件的锁中最弱的锁模式
  • 越快越好:如果可能,可以用(长时间的弱锁+短时间的强锁)替换长时间的强锁
  • 递增获取:遵循2PL原则申请锁;越晚使用激进锁策略越好;在真正需要时再获取。
  • 相同顺序:获取锁尽量以一致的顺序获取,从而减小死锁的几率

最小化锁阻塞时长

除了手工锁定之外,很多常见的操作都会"锁表",最常见的莫过于添加新字段与添加新约束。这两种操作都会获取表上的AccessExclusive锁以阻止一切并发访问。当DBA需要在线维护数据库时应当最小化持锁的时间。

例如,为表添加新字段的ALTER TABLE ADD COLUMN子句,根据新列是否提供易变默认值,会重写整个表。

ALTER TABLE tbl ADD COLUMN mtime TIMESTAMP DEFAULT CURRENT_TIMESTAMP;

如果只是个小表,业务负载也不大,那么也许可以直接这么干。但如果是很大的表,以及很高的负载,那么阻塞的时间就会很可观。在这段时间里,命令都会持有表上的AccessExclusive锁阻塞一切访问。

可以通过先加一个空列,再慢慢更新的方式来最小化锁等待时间:

ALTER TABLE tbl ADD COLUMN mtime TIMESTAMP;
UPDATE tbl SET mtime = CURRENT_TIMESTAMP; -- 可以分批进行

这样,第一条加列操作的锁阻塞时间就会非常短,而后面的更新(重写)操作就可以以不阻塞读写的形式慢慢进行,最小化锁阻塞。

同理,当想要为表添加新的约束时(例如新的主键),也可以采用这种方式:

CREATE UNIQUE INDEX CONCURRENTLY tbl_pk ON tbl(id); -- 很慢,但不阻塞读写
ALTER TABLE tbl ADD CONSTRAINT tbl_pk PRIMARY KEY USING INDEX tbl_pk;  -- 阻塞读写,但很快

替代单纯的

ALTER TABLE tbl ADD PRIMARY KEY (id); 

微信公众号原文

GIN搜索的O(n2)负载度

GIN索引如果使用很长的关键词列表进行搜索,会导致性能显著下降。本文解释了为什么GIN索引关键词搜索的时间复杂度为O(n^2)

Here is the detail of why that query have O(N^2) inside GIN implementation.

Details

Inspect the index example_keys_idx

postgres=# select oid,* from pg_class where relname = 'example_keys_idx';
-[ RECORD 1 ]-------+-----------------
oid                 | 20699
relname             | example_keys_idx
relnamespace        | 20692
reltype             | 0
reloftype           | 0
relowner            | 10
relam               | 2742
relfilenode         | 20699
reltablespace       | 0
relpages            | 2051
reltuples           | 300000
relallvisible       | 0
reltoastrelid       | 0
relhasindex         | f
relisshared         | f
relpersistence      | p
relkind             | i
relnatts            | 1
relchecks           | 0
relhasoids          | f
relhasrules         | f
relhastriggers      | f
relhassubclass      | f
relrowsecurity      | f
relforcerowsecurity | f
relispopulated      | t
relreplident        | n
relispartition      | f
relrewrite          | 0
relfrozenxid        | 0
relminmxid          | 0
relacl              |
reloptions          | {fastupdate=off}
relpartbound        |

Find index information via index’s oid

postgres=# select * from pg_index where indexrelid = 20699;
-[ RECORD 1 ]--+------
indexrelid     | 20699
indrelid       | 20693
indnatts       | 1
indnkeyatts    | 1
indisunique    | f
indisprimary   | f
indisexclusion | f
indimmediate   | t
indisclustered | f
indisvalid     | t
indcheckxmin   | f
indisready     | t
indislive      | t
indisreplident | f
indkey         | 2
indcollation   | 0
indclass       | 10075
indoption      | 0
indexprs       |
indpred        |

Find corresponding operator class for that index via indclass

postgres=# select * from pg_opclass where oid = 10075;
-[ RECORD 1 ]+----------
opcmethod    | 2742
opcname      | array_ops
opcnamespace | 11
opcowner     | 10
opcfamily    | 2745
opcintype    | 2277
opcdefault   | t
opckeytype   | 2283

Find four operator corresponding to operator faimily array_ops

postgres=# select * from pg_amop where amopfamily =2745;
-[ RECORD 1 ]--+-----
amopfamily     | 2745
amoplefttype   | 2277
amoprighttype  | 2277
amopstrategy   | 1
amoppurpose    | s
amopopr        | 2750
amopmethod     | 2742
amopsortfamily | 0
-[ RECORD 2 ]--+-----
amopfamily     | 2745
amoplefttype   | 2277
amoprighttype  | 2277
amopstrategy   | 2
amoppurpose    | s
amopopr        | 2751
amopmethod     | 2742
amopsortfamily | 0
-[ RECORD 3 ]--+-----
amopfamily     | 2745
amoplefttype   | 2277
amoprighttype  | 2277
amopstrategy   | 3
amoppurpose    | s
amopopr        | 2752
amopmethod     | 2742
amopsortfamily | 0
-[ RECORD 4 ]--+-----
amopfamily     | 2745
amoplefttype   | 2277
amoprighttype  | 2277
amopstrategy   | 4
amoppurpose    | s
amopopr        | 1070
amopmethod     | 2742
amopsortfamily | 0

https://www.postgresql.org/docs/10/xindex.html

Table 37.6. GIN Array Strategies

Operation Strategy Number
overlap 1
contains 2
is contained by 3
equal 4

When we access that index with && operator, we are using stragety 1 overlap, which corresponding operator oid is 2750.

postgres=# select * from pg_operator where oid = 2750;
-[ RECORD 1 ]+-----------------
oprname      | &&
oprnamespace | 11
oprowner     | 10
oprkind      | b
oprcanmerge  | f
oprcanhash   | f
oprleft      | 2277
oprright     | 2277
oprresult    | 16
oprcom       | 2750
oprnegate    | 0
oprcode      | arrayoverlap
oprrest      | arraycontsel
oprjoin      | arraycontjoinsel

The underlying C function to judge arrayoverlap is arrayoverlap in here

Datum
arrayoverlap(PG_FUNCTION_ARGS)
{
	AnyArrayType *array1 = PG_GETARG_ANY_ARRAY_P(0);
	AnyArrayType *array2 = PG_GETARG_ANY_ARRAY_P(1);
	Oid			collation = PG_GET_COLLATION();
	bool		result;

	result = array_contain_compare(array1, array2, collation, false,
								   &fcinfo->flinfo->fn_extra);

	/* Avoid leaking memory when handed toasted input. */
	AARR_FREE_IF_COPY(array1, 0);
	AARR_FREE_IF_COPY(array2, 1);

	PG_RETURN_BOOL(result);
}

It actually use array_contain_compare to test whether two array are overlap

static bool
array_contain_compare(AnyArrayType *array1, AnyArrayType *array2, Oid collation,
					  bool matchall, void **fn_extra)

Line 4177, we see a nested loop to iterate two array, which makes it O(N^2)

	for (i = 0; i < nelems1; i++)
	{
		Datum		elt1;
		bool		isnull1;

		/* Get element, checking for NULL */
		elt1 = array_iter_next(&it1, &isnull1, i, typlen, typbyval, typalign);

		/*
		 * We assume that the comparison operator is strict, so a NULL can't
		 * match anything.  XXX this diverges from the "NULL=NULL" behavior of
		 * array_eq, should we act like that?
		 */
		if (isnull1)
		{
			if (matchall)
			{
				result = false;
				break;
			}
			continue;
		}

		for (j = 0; j < nelems2; j++)

IP地理逆查询优化

在应用开发中,一个‘很常见’的需求就是GeoIP转换。将请求的来源IP转换为相应的地理坐标,或者行政区划(国家-省-市-县-乡-镇)

IP归属地查询的高效实现

​ 在应用开发中,一个‘很常见’的需求就是GeoIP转换。将请求的来源IP转换为相应的地理坐标,或者行政区划(国家-省-市-县-乡-镇)。这种功能有很多用途,譬如分析网站流量的地理来源,或者干一些坏事。使用PostgreSQL可以多快好省,优雅高效地实现这一需求。

0x01 思路方法

​ 通常网上的IP地理数据库的形式都是:start_ip, stop_ip , longitude, latitude,再缀上一些国家代码,城市代码,邮编之类的属性字段。大概长这样:

Column Type
start_ip text
end_ip text
longitude text
latitude text
country_code text
…… text

说到底,其核心是从IP地址段地理坐标点的映射。

典型查询实际上是给出一个IP地址,返回该地址对应的地理范围。其逻辑用SQL来表示差不多长这样:

SELECT longitude, latitude FROM geoip 
WHERE start_ip <= target_ip AND target_ip <= stop_ip;

不过,想直接提供服务,还有几个问题需要解决:

  • 第一个问题:虽然IPv4实际上是一个uint32,但我们已经完全习惯了123.123.123.123这种文本表示形式。而这种文本表示形式是无法比较大小的。
  • 第二个问题:这里的IP范围是用两个IP边界字段表示的范围,那么这个范围是开区间还是闭区间呢?是不是还需要一个额外字段来表示?
  • 第三个问题:想要高效地查询,那么在两个字段上的索引又该如何建立?
  • 第四个问题:我们希望所有的IP段相互之间不会出现重叠,但简单的建立在(start_ip, stop_ip)上的唯一约束并无法保证这一点,那又如何是好?

令人高兴的是,对于PostgreSQL而言,这些都不是问题。上面四个问题,可以轻松使用PostgreSQL的特性解决。

  • 网络数据类型:高性能,紧凑,灵活的网络地址表示。
  • 范围类型:对区间的良好抽象,对区间查询与操作的良好支持。
  • GiST索引:既能作用于IP地址段,也可以用于地理位置点。
  • Exclude约束:泛化的高级UNIQUE约束,从根本上确保数据完整性。

0x01 网络地址类型

​ PostgreSQL提供用于存储 IPv4、IPv6 和 MAC 地址的数据类型。包括cidrinet以及macaddr,并且提供了很多常见的操作函数,不需要再在程序中去实现一些繁琐重复的功能。

​ 最常见的网络地址就是IPv4地址,对应着PostgreSQL内建的inet类型,inet类型可以用来存储IPv4,IPv6地址,或者带上一个可选的子网。当然这些细节操作都可以参阅文档,在此不详细展开。

​ 一个需要注意的点就是,虽然我们知道IPv4实质上是一个Unsigned Integer,但在数据库中实际存储成INTEGER其实是不行的,因为SQL标准并不支持Unsigned这种用法,所以有一半的IP地址的表示就会被解释为负数,在比大小的时候产生令人惊异的结果,真要这么存请使用BIGINT。此外,直接面对一堆长长的整数也是相当令人头大的问题,inet是最佳的选择。

​ 如果需要将IP地址(inet类型)与对应的整数相互转换,只要与0.0.0.0做加减运算即可;当然也可以使用以下函数,并创建一个类型转换,然后就能直接在inetbigint之间来回转换:

-- inet to bigint
CREATE FUNCTION inet2int(inet) RETURNS bigint AS $$
SELECT $1 - inet '0.0.0.0';
$$ LANGUAGE SQL  IMMUTABLE RETURNS NULL ON NULL INPUT;

-- bigint to inet
CREATE FUNCTION int2inet(bigint) RETURNS inet AS $$
SELECT inet '0.0.0.0' + $1;
$$ LANGUAGE SQL  IMMUTABLE RETURNS NULL ON NULL INPUT;

-- create type conversion
CREATE CAST (inet AS bigint) WITH FUNCTION inet2int(inet);
CREATE CAST (bigint AS inet) WITH FUNCTION int2inet(bigint);

-- test
SELECT 123456::BIGINT::INET;
SELECT '1.2.3.4'::INET::BIGINT;

-- 生成随机的IP地址
SELECT (random() * 4294967295)::BIGINT::INET;

inet之间的大小比较也相当直接,直接使用大小比较运算符就可以了。实际比较的是底下的整数值。这就解决了第一个问题。

0x02 范围类型

​ PostgreSQL的Range类型是一种很实用的功能,它与数组类似,属于一种泛型。只要是能被B树索引(可以比大小)的数据类型,都可以作为范围类型的基础类型。它特别适合用来表示区间:整数区间,时间区间,IP地址段等等。而且对于开区间,闭区间,区间索引这类问题有比较细致的考虑。

​ PostgreSQL内置了预定义的int4range, int8range, numrange, tsrange, tstzrange, daterange,开箱即用。但没有提供网络地址对应的范围类型,好在自己造一个非常简单:

CREATE TYPE inetrange AS RANGE(SUBTYPE = inet)

当然为了高效地支持GiST索引查询,还需要实现一个距离度量,告诉索引两个inet之间的距离应该如何计算:

-- 定义基本类型间的距离度量
CREATE FUNCTION inet_diff(x INET, y INET) RETURNS FLOAT AS $$
  SELECT (x - y) :: FLOAT;
$$ LANGUAGE SQL IMMUTABLE STRICT;

-- 重新创建inetrange类型,使用新定义的距离度量。
CREATE TYPE inetrange AS RANGE(
  SUBTYPE = inet,
  SUBTYPE_DIFF = inet_diff
)

幸运的是,俩网络地址之间的距离定义天然就有一个很简单的计算方法,减一下就好了。

这个新定义的类型使用起来也很简单,构造函数会自动生成:

geo=# select misc.inetrange('64.60.116.156','64.60.116.161','[)');
inetrange | [64.60.116.156,64.60.116.161)

geo=# select '[64.60.116.156,64.60.116.161]'::inetrange;
inetrange | [64.60.116.156,64.60.116.161]

方括号和圆括号分别表示闭区间和开区间,与数学中的表示方法一致。

同时,检测一个IP地址是否落在给定的IP范围内也是很直接的:

geo=# select '[64.60.116.156,64.60.116.161]'::inetrange @> '64.60.116.160'::inet as res;
res | t

有了范围类型,就可以着手构建我们的数据表了。

0x03 范围索引

实际上,找一份IP地理对应数据花了我一个多小时,但完成这个需求只用了几分钟。

假设已经有了这样一份数据:

create table geoips
(
  ips          inetrange,
  geo          geometry(Point),
  country_code text,
  region_code  text,
  city_name    text,
  ad_code      text,
  postal_code  text
);

里面的数据大概长这样:

SELECT ips,ST_AsText(geo) as geo,country_code FROM geoips

 [64.60.116.156,64.60.116.161] | POINT(-117.853 33.7878) | US
 [64.60.116.139,64.60.116.154] | POINT(-117.853 33.7878) | US
 [64.60.116.138,64.60.116.138] | POINT(-117.76 33.7081)  | US

那么查询包含某个IP地址的记录就可以写作:

SELECT * FROM ip WHERE ips @> inet '67.185.41.77';

对于600万条记录,约600M的表,在笔者的机器上暴力扫表的平均用时是900ms,差不多单核QPS是1.1,48核生产机器也就差不多三四十的样子。肯定是没法用的。

CREATE INDEX ON geoips USING GiST(ips);

查询用时从1秒变为340微秒,差不多3000倍的提升。

-- pgbench
\set ip random(0,4294967295)
SELECT * FROM geoips WHERE ips @> :ip::BIGINT::INET;

-- result
latency average = 0.342 ms
tps = 2925.100036 (including connections establishing)
tps = 2926.151762 (excluding connections establishing)

折算成生产QPS差不多是十万QPS,啧啧啧,美滋滋。

如果需要把地理坐标转换为行政区划,可以参考上一篇文章:使用PostGIS高效解决行政区划归属地理编码问题。

一次地理编码也就是100微秒,从IP转换为省市区县整个的QPS,单机几万基本问题不大(全天满载相当于七八十亿次调用,根本用不满)。

0x04 EXCLUDE约束

​ 问题至此已经基本解决了,不过还有一个问题。如何避免一个IP查出两条记录的尴尬情况?

​ 数据完整性是极其重要的,但由应用保证的数据完整性并不总是那么靠谱:人会犯傻,程序会出错。如果能通过数据库约束来Enforce数据完整性,那是再好不过了。

​ 然而,有一些约束是相当复杂的,例如确保表中的IP范围不发生重叠,类似的,确保地理区划表中各个城市的边界不会重叠。传统上要实现这种保证是相当困难的:譬如UNIQUE约束就无法表达这种语义,CHECK与存储过程或者触发器虽然可以实现这种检查,但也相当tricky。PostgreSQL提供的EXCLUDE约束可以优雅地解决这个问题。修改我们的geoips表:

create table geoips
(
  ips          inetrange,
  geo          geometry(Point),
  country_code text,
  region_code  text,
  city_name    text,
  ad_code      text,
  postal_code  text,
  EXCLUDE USING gist (ips WITH &&) DEFERRABLE INITIALLY DEFERRED 
);

​ 这里EXCLUDE USING gist (ips WITH &&) 的意思就是ips字段上不允许出现范围重叠,即新插入的字段不能与任何现存范围重叠(&&为真)。而DEFERRABLE INITIALLY IMMEDIATE 表示在语句结束时再检查所有行上的约束。创建该约束会自动在ips字段上创建GIST索引,因此无需手工创建了。

0x05 小结

​ 本文介绍了如何使用PostgreSQL特性高效而优雅地解决IP归属地查询的问题。性能表现优异,600w记录0.3ms定位;复杂度低到发指:只要一张表DDL,连索引都不用显式创建就解决了这一问题;数据完整性有充分的保证:百行代码才能解决的问题现在只要添加约束即可,从根本上保证数据完整性。

​ PostgreSQL这么棒棒,快快学起来用起来吧~。

​ 什么?你问我数据哪里找?搜索MaxMind有真相,在隐秘的小角落能够找到不要钱的GeoIP数据。

PostgreSQL的触发器

详细了解PostgreSQL中触发器的管理与使用

概览

  • 触发器行为概述
  • 触发器的分类
  • 触发器的功能
  • 触发器的种类
  • 触发器的触发
  • 触发器的创建
  • 触发器的修改
  • 触发器的查询
  • 触发器的性能

触发器概述

触发器行为概述:英文中文

触发器分类

触发时机:BEFORE, AFTER, INSTEAD

触发事件:INSERT, UPDATE, DELETE,TRUNCATE

触发范围:语句级,行级

内部创建:用于约束的触发器,用户定义的触发器

触发模式:origin|local(O), replica(R),disable(D)

触发器操作

触发器的操作通过SQL DDL语句进行,包括CREATE|ALTER|DROP TRIGGER,以及ALTER TABLE ENABLE|DISABLE TRIGGER进行。注意PostgreSQL内部的约束是通过触发器实现的。

创建

CREATE TRIGGER 可以用于创建触发器。

CREATE [ CONSTRAINT ] TRIGGER name { BEFORE | AFTER | INSTEAD OF } { event [ OR ... ] }
    ON table_name
    [ FROM referenced_table_name ]
    [ NOT DEFERRABLE | [ DEFERRABLE ] [ INITIALLY IMMEDIATE | INITIALLY DEFERRED ] ]
    [ REFERENCING { { OLD | NEW } TABLE [ AS ] transition_relation_name } [ ... ] ]
    [ FOR [ EACH ] { ROW | STATEMENT } ]
    [ WHEN ( condition ) ]
    EXECUTE { FUNCTION | PROCEDURE } function_name ( arguments )

event包括
    INSERT
    UPDATE [ OF column_name [, ... ] ]
    DELETE
    TRUNCATE

删除

DROP TRIGGER 用于移除触发器。

DROP TRIGGER [ IF EXISTS ] name ON table_name [ CASCADE | RESTRICT ]

修改

ALTER TRIGGER 用于修改触发器定义,注意这里只能修改触发器名,以及其依赖的扩展。

ALTER TRIGGER name ON table_name RENAME TO new_name
ALTER TRIGGER name ON table_name DEPENDS ON EXTENSION extension_name

启用禁用触发器,修改触发模式是通过ALTER TABLE的子句实现的。

ALTER TABLE 包含了一系列触发器修改的子句:

ALTER TABLE tbl ENABLE TRIGGER tgname; -- 设置触发模式为O (本地连接写入触发,默认)
ALTER TABLE tbl ENABLE REPLICA TRIGGER tgname; -- 设置触发模式为R (复制连接写入触发)
ALTER TABLE tbl ENABLE ALWAYS TRIGGER tgname; -- 设置触发模式为A (总是触发)
ALTER TABLE tbl DISABLE TRIGGER tgname; -- 设置触发模式为D (禁用)

注意这里在ENABLEDISABLE触发器时,可以指定用USER替换具体的触发器名称,这样可以只禁用用户显式创建的触发器,不会把系统用于维持约束的触发器也禁用了。

ALTER TABLE tbl_name DISABLE TRIGGER USER; -- 禁用所有用户定义的触发器,系统触发器不变  
ALTER TABLE tbl_name DISABLE TRIGGER ALL;  -- 禁用所有触发器
ALTER TABLE tbl_name ENABLE TRIGGER USER;  -- 启用所有用户定义的触发器
ALTER TABLE tbl_name ENABLE TRIGGER ALL;   -- 启用所有触发器

查询

获取表上的触发器

最简单的方式当然是psql的\d+ tablename。但这种方式只会列出用户创建的触发器,不会列出与表上约束相关联的触发器。直接查询系统目录pg_trigger,并通过tgrelid用表名过滤

SELECT * FROM pg_trigger WHERE tgrelid = 'tbl_name'::RegClass;

获取触发器定义

pg_get_triggerdef(trigger_oid oid)函数可以给出触发器的定义。

该函数输入参数为触发器OID,返回创建触发器的SQL DDL语句。

SELECT pg_get_triggerdef(oid) FROM pg_trigger; -- WHERE xxx

触发器视图

pg_trigger (中文) 提供了系统中触发器的目录

名称 类型 引用 描述
oid oid 触发器对象标识,系统隐藏列
tgrelid oid pg_class.oid 触发器所在的表 oid
tgname name 触发器名,表级命名空间内不重名
tgfoid oid pg_proc.oid 触发器所调用的函数
tgtype int2 触发器类型,触发条件,详见注释
tgenabled char 触发模式,详见下。O|R|A|D
tgisinternal bool 如果是内部用于约束的触发器则为真
tgconstrrelid oid pg_class.oid 参照完整性约束中被引用的表,无则为0
tgconstrindid oid pg_class.oid 支持约束的相关索引,没有则为0
tgconstraint oid pg_constraint.oid 与触发器相关的约束对象
tgdeferrable bool DEFERRED则为真
tginitdeferred bool INITIALLY DEFERRED则为真
tgnargs int2 传入触发器函数的字符串参数个数
tgattr int2vector pg_attribute.attnum 如果是列级更新触发器,这里存储列号,否则为空数组。
tgargs bytea 传递给触发器的参数字符串,C风格零结尾字符串
tgqual pg_node_tree 触发器WHEN条件的内部表示
tgoldtable name OLD TABLEREFERENCING列名称,无则为空
tgnewtable name NEW TABLEREFERENCING列名称,无则为空

触发器类型

触发器类型tgtype包含了触发器触发条件相关信息:BEFORE|AFTER|INSTEAD OF, INSERT|UPDATE|DELETE|TRUNCATE

TRIGGER_TYPE_ROW         (1 << 0)  // [0] 0:语句级 	1:行级
TRIGGER_TYPE_BEFORE      (1 << 1)  // [1] 0:AFTER 	1:BEFORE
TRIGGER_TYPE_INSERT      (1 << 2)  // [2] 1: INSERT
TRIGGER_TYPE_DELETE      (1 << 3)  // [3] 1: DELETE
TRIGGER_TYPE_UPDATE      (1 << 4)  // [4] 1: UPDATE
TRIGGER_TYPE_TRUNCATE    (1 << 5)  // [5] 1: TRUNCATE
TRIGGER_TYPE_INSTEAD     (1 << 6)  // [6] 1: INSTEAD OF 

触发器模式

触发器tgenabled字段控制触发器的工作模式,参数session_replication_role 可以用于配置触发器的触发模式。该参数可以在会话层级更改,可能的取值包括:origin(default),replica,local

(D)isable触发器永远不会被触发,(A)lways触发器在任何情况下触发, (O)rigin触发器会在origin|local模式触发(默认),而 (R)eplica触发器replica模式触发。R触发器主要用于逻辑复制,例如pglogical的复制连接就会将会话参数session_replication_role设置为replica,而R触发器只会在该连接进行的变更上触发。

ALTER TABLE tbl ENABLE TRIGGER tgname; -- 设置触发模式为O (本地连接写入触发,默认)
ALTER TABLE tbl ENABLE REPLICA TRIGGER tgname; -- 设置触发模式为R (复制连接写入触发)
ALTER TABLE tbl ENABLE ALWAYS TRIGGER tgname; -- 设置触发模式为A (始终触发)
ALTER TABLE tbl DISABLE TRIGGER tgname; -- 设置触发模式为D (禁用)

information_schema中还有两个触发器相关的视图:information_schema.triggers, information_schema.triggered_update_columns,表过不提。

触发器FAQ

触发器可以建在哪些类型的表上?

普通表(分区表主表,分区表分区表,继承表父表,继承表子表),视图,外部表。

触发器的类型限制

  • 视图上不允许建立BEFOREAFTER触发器(不论是行级还是语句级)
  • 视图上只能建立INSTEAD OF触发器,INSERTEAD OF触发器也只能建立在视图上,且只有行级,不存在语句级INSTEAD OF触发器。
  • INSTEAD OF` 触发器只能定义在视图上,并且只能使用行级触发器,不能使用语句级触发器。

触发器与锁

在表上创建触发器会先尝试获取表级的Share Row Exclusive Lock。这种锁会阻止底层表的数据变更,且自斥。因此创建触发器会阻塞对表的写入。

触发器与COPY的关系

COPY只是消除了数据解析打包的开销,实际写入表中时仍然会触发触发器,就像INSERT一样。

理解字符编码

如果不了解字符编码的基本原理,即使只是简单常规的字符串比较、排序、随机访问操作,都可能会一不小心栽进大坑中。尝试写这一篇科普文,希望能讲清楚这个问题。

​ 程序员,是与Code(代码/编码)打交道的,而字符编码又是最为基础的编码。 如何使用二进制数来表示字符,这个字符编码问题并没有看上去那么简单,实际上它的复杂程度远超一般人的想象:输入、比较排序与搜索、反转、换行与分词、大小写、区域设置,控制字符,组合字符与规范化,排序规则,处理不同语言中的特异需求,变长编码,字节序与BOM,Surrogate,历史兼容性,正则表达式兼容性,微妙与严重的安全问题等等等等。

​ 如果不了解字符编码的基本原理,即使只是简单常规的字符串比较、排序、随机访问操作,都可能会一不小心栽进大坑中。但根据我的观察,很多工程师与程序员却对字符编码本身几近一无所知,只是对诸如ASCII,Unicode,UTF这些名词有一些模糊的感性认识。因此尝试写这一篇科普文,希望能讲清楚这个问题。

0x01 基本概念

万物皆数 —— 毕达哥拉斯

为了解释字符编码,我们首先需要理解什么是编码,什么又是字符?

编码

​ 从程序员的视角来看,我们有着许许多多的基础数据类型:整数,浮点数,字符串,指针。程序员将它们视作理所当然的东西,但从数字计算机的物理本质来看,只有一种类型才是真正的基础类型:二进制数。

​ 而编码(Code)就是这些高级类型与底层二进制表示之间映射转换的桥梁。编码分为两个部分:编码(encode)解码(decode),以无处不在的自然数为例。数字42,这个纯粹抽象的数学概念,在计算机中可能就会表示为00101010的二进制位串(假设使用8位整型)。从抽象数字42到二进制数表示00101010的这个过程就是编码。相应的,当计算机读取到00101010这个二进制位串时,它会根据上下文将其解释为抽象的数字42,这个过程就是解码(decode)

​ 任何‘高级’数据类型与底层二进制表示之间都有着编码与解码的过程,比如单精度浮点数,这种看上去这么基础的类型,也存在着一套相当复杂的编码过程。例如在float32中,1.0和-2.0就表示为如下的二进制串:

0 01111111 00000000000000000000000 = 1
1 10000000 00000000000000000000000 = −2

​ 字符串当然也不例外。字符串是如此的重要与基础,以至于几乎所有语言都将其作为内置类型而实现。字符串,它首先是一个串(String),所谓串,就是由同类事物依序构成的序列。对于字符串而言,就是由**字符(Character)**构成的序列。字符串或字符的编码,实际上就是将抽象的字符序列映射为其二进制表示的规则。

​ 不过,在讨论字符编码问题之前,我们先来看一看,什么是字符

字符

​ 字符是指字母、数字、标点、表意文字(如汉字)、符号、或者其他文本形式的书写“原子”。它是书面语中最小语义单元的抽象实体。这里说的字符都是抽象字符(abstract character),其确切定义是:用于组织、控制、显示文本数据的信息单元。

​ 抽象字符是一种抽象的符号,与具体的形式无关:区分字符(character)字形(Glyph)是非常重要的,我们在屏幕上看到的有形的东西是字形(Glyph),它是抽象字符的视觉表示形式。抽象字符通过渲染(Render)呈现为字形,用户界面呈现的字形通过人眼被感知,通过人脑被认知,最终又在人的大脑中还原为抽象的实体概念。字形在这个过程中起到了媒介的作用,但决不能将其等价为抽象字符本身。

​ 要注意的是,虽然多数时候字形与字符是一一对应的,但仍然存在一些多对多的情况:一个字形可能由多个字符组合而成,例如抽象字符à(拼音中的第四声a),我们将其视作单个‘字符’,但它既可以真的是一个单独的字符,也可以由字符a与去声撇号字符 ̀组合而成。另一方面,一个字符也可能由多个字形组成,例如很多阿拉伯语印地语中的文字,由很多图元(字形)组成的符号,复杂地像一幅画,实际上却是单个字符。

>>> print u'\u00e9', u'e\u0301',u'e\u0301\u0301\u0301'
é é é́́

​ 字形的集合构成了字体(font),不过那些都属于渲染的内容:渲染是将字符序列映射为字形序列的过程。 那是另一个堪比字符编码的复杂主题,本文不会涉及渲染的部分,而专注于另一侧:将抽象字符转变为二进制字节序列的过程,即,字符编码(Character Encoding)

思路

​ 我们会想,如果有一张表,能将所有的字符一一映射到字节byte(s),问题不就解决了吗?实际上对于英文和一些西欧文字而言,这么做是很直观的想法,ASCII就是这样做的:它通过ASCII编码表,使用一个字节中的7位,将128个字符编码为相应的二进制值,一个字符正好对应一个字节(单射而非满射,一半字节没有对应字符)。一步到位,简洁、清晰、高效。

​ 计算机科学发源于欧美,因而文本处理的问题,一开始指的就是英文处理的问题。不过计算机是个好东西,世界各族人民都想用。但语言文字是一个极其复杂的问题:学一门语言文字已经相当令人头大,更别提设计一套能够处理世界各国语言文字的编码标准了。从简单的ASCII发展到当代的大一统Unicode标准,人们遇到了各种问题,也走过一些弯路。

​ 好在计算机科学中,有句俗语:“计算机科学领域的任何问题都可以通过增加一个间接的中间层来解决”。字符编码的模型与架构也是随着历史而不断演进的,下面我们就先来概览一下现代编码模型中的体系结构。

0x02 模型概览

  • 现代编码模型自底向上分为五个层次:
  • 抽象字符表(Abstract Character Repertoire, ACR)
  • 编码字符集(Coded Character Set, CCS)
  • 字符编码表(Character Encoding Form, CEF)
  • 字符编码方案(Character Encoding Schema, CES)
  • 传输编码语法(Transfer Encoding Syntax, TES)

我们所熟悉的诸多名词,都可以归类到这个模型的相应层次之中。例如,Unicode字符集(UCS),ASCII字符集,GBK字符集,这些都属于编码字符集CCS;而常见的UTF8,UTF16,UTF32这些概念,都属于字符编码表CEF,不过也有同名的字符编码方案CES。而我们熟悉的base64URLEncode这些就属于传输编码语法TES

​ 这些概念之间的关系可以用下图表示:

​ 可以看到,为了将一个抽象字符转换为二进制,中间其实经过了几次概念的转换。在抽象字符序列与字节序列间还有两种中间形态:码位序列与码元序列。简单来说:

  • 所有待编码的抽象字符构成的集合,称为抽象字符集

  • 因为我们需要指称集合中的某个具体字符,故为每一个抽象字符指定一个唯一的自然数作为标识,这个被指定的自然数,就称作字符的码位(Code Point)

  • 码位与字符集中的抽象字符是一一对应的。抽象字符集中的字符经过编码就形成了编码字符集

  • 码位是正整数,但计算机的整数表示范围是有限的,因此需要调和无限的码位与有限的整型之间的矛盾。字符编码表码位映射为码元序列(Code Unit Sequence),将整数转变为计算机中的整型。

  • 计算机中多字节整型存在大小端字节序的问题,字符编码方案指明了字节序问题的解决方案。

Unicode标准为什么不像ASCII那样一步到位,直接将抽象字符映射为二进制表示呢?实际上如果只有一种字符编码方案,譬如UTF-8,那么确实是一步到位的。可惜因为一些历史原因(比如觉得65536个字符绝对够用了…),我们有好几种编码方案。但不论如何,比起各国自己搞的百花齐放的编码方案,Unicode的编码方案已经是非常简洁了。可以说,每一层都是为了解决一些问题而不得已引入的:

  • 抽象字符集到编码字符集解决了唯一标识字符的问题(字形无法唯一标识字符);
  • 编码字符集到字符编码表解决了无限的自然数有限的计算机整型的映射问题(调和无限与有限);
  • 字符编码方案则解决了字节序的问题(解决传输歧义)。

下面让我们来看一下每个层次之间的细节。

0x03 字符集

​ 字符集,顾名思义就是字符的集合。字符是什么,在第一节中已经解释过了。在现代编码模型中, 有两种层次不同的字符集:抽象字符集 ACR编码字符集 CCS

抽象字符集 ACR

​ 抽象字符集顾名思义,指的是抽象字符的集合。已经有了很多标准的字符集定义,US-ASCII, UCS(Unicode),GBK这些我们耳熟能详的名字,都是(或至少是)抽象字符集

​ US-ASCII定义了128个抽象字符的集合。GBK挑选了两万多个中日韩汉字和其他一些字符组成字符集,而UCS则尝试去容纳一切的抽象字符。它们都是抽象字符集。

  • 抽象字符 英文字母A同时属于US-ASCII, UCS, GBK这三个字符集。
  • 抽象字符 中文文字不属于US-ASCII,属于GBK字符集,也属于UCS字符集。
  • 抽象文字 Emoji 😂不属于US-ASCII与GBK字符集,但属于UCS字符集。

抽象字符集可以使用类似set的数据结构来表示:

# ACR
{"a","啊","あ","Д","α","å","😯"}

编码字符集 CCS

​ 集合的一个重要特性,就是无序性。集合中的元素都是无序的,所以抽象字符集中的字符都是无序的

​ 这就带来一个问题,如何指称字符集中的某个特定字符呢?我们不能抽象字符的字形来指代其实体,原因如前面所说,看上去一样的字形,实际上可能由不同的字符组合而成(如字形à就有两种字符组合方式)。对于抽象字符,我们有必要给它们分配唯一对应的ID,用关系型数据库的话来说,字符数据表需要一个主键。这个码位分配(Code Point Allocation)的操作就称为编码(Encode)。它将抽象字符与一个正整数关联起来。

​ 如果抽象字符集中的所有字符都有了对应的码位(Code Point/Code Position),这个集合就升级成了映射:类似于从set数据结构变成了dict。我们称这个映射为编码字符集 CCS

# CCS
{
  "a": 97,
  "啊": 21834,
  "あ": 12354,
  "Д": 1044,
  "α": 945,
  "å": 229,
  "😯": 128559
}

​ 注意这里的映射是单射,每个抽象字符都有唯一的正整数码位,但并不是所有的正整数都有对应的抽象字符。码位被分为七大类:图形,格式,控制,代理,非字符,保留。像代理(Surrogate, D800-DFFF)区中的码位,单独使用时就不对应任何字符。

​ 抽象字符集与编码字符集之间的区别通常是Trivial的,毕竟指定字符的同时通常也会指定一个顺序,为每个字符分配一个数字ID。所以我们通常就将它们统称为字符集。字符集解决的问题是,将抽象字符单向映射为自然数。那么既然计算机已经解决了整数编码的问题,是不是直接用字符码位的整型二进制表示就可以了呢?

​ 不幸的是,还有另外一个问题。字符集有开放与封闭之分,譬如ASCII字符集定义了128个抽象字符,再也不会增加。它就是一个封闭字符集。而Unicode尝试收纳所有的字符,一直在不断地扩张之中。截止至2016.06,Unicode 9.0.0已经收纳了128,237个字符,并且未来仍然会继续增长,它是一个开放的字符集。开放意味着字符的数量是没有上限的,随时可以添加新的字符,例如Emoji,几乎每年都会有新的表情字符被引入到Unicode字符集中。这就产生了一对内在的矛盾:无限的自然数与有限的整型值之间的矛盾

​ 而字符编码表,就是为了解决这个问题的。

0x04 字符编码表

​ 字符集解决了抽象字符到自然数的映射问题,将自然数表示为二进制就是字符编码的另一个核心问题了。字符编码表(CEF)会将一个自然数,转换为一个或多个计算机内部的整型数值。这些整型数值称为码元码元是能用于处理或交换编码文本的最小比特组合

​ 码元与数据的表示关系紧密,通常计算机处理字符的码元为一字节的整数倍:1字节,2字节,4字节。对应着几种基础的整型:uint8, uint16, uint32,单字节、双字节、四字节整型。整形的计算往往以计算机的字长作为一个基础单元,通常来讲,也就是4字节或8字节。

​ 曾经,人们以为使用16位短整型来表示字符就足够了,16位短整型可以表示2的十六次方个状态,也就是65536个字符,看上去已经足够多了。但是程序员们很少从这种事情上吸取教训:光是中国的汉字可能就有十万个,一个旨在兼容全世界字符的编码不能不考虑这一点。因此如果使用一个整型来表示一个码位,双字节的短整型int16并不足以表示所有字符。另一方面,四字节的int32能表示约41亿个状态,在进入星辰大海宇宙文明的阶段之前,恐怕是不太可能有这么多的字符需要表示的。(实际上到现在也就分配了不到14万个字符)。

​ 根据使用码元单位的不同,我们有了三种字符编码表:UTF8,UTF-16,UTF-32。

属性\编码 UTF8 UTF16 UTF32
使用码元 uint8 uint16 uint32
码元长度 1byte = 8bit 2byte = 16bit 4byte = 32bit
编码长度 1码位 = 1~4码元 1码位 = 1或2码元 1码位 = 1码元
独门特性 兼容ASCII 针对BMP优化 定长编码

定长编码与变长编码

​ 双字节的整数只能表示65536个状态,对于目前已有的十四万个字符显得捉襟见肘。但另一方面,四字节整数可以表示约42亿个状态。恐怕直到人类进入宇宙深空时都遇不到这么多字符。因此对于码元而言,如果采用四字节,我们可以确保编码是定长的:一个(表示字符的)自然数码位始终能用一个uint32表示。但如果使用uint8uint16作为码元,超出单个码元表示范围的字符就需要使用多个码元来表示了。因此是为变长编码。因此,UTF-32是定长编码,而UTF-8和UTF-16是变长编码。

​ 设计编码时,容错是最为重要的考量之一:计算机并不是绝对可靠的,诸如比特反转,数据坏块等问题是很有可能遇到的。字符编码的一个基本要求就是自同步(self-synchronization )。对于变长编码而言,这个问题尤为重要。应用程序必须能够从二进制数据中解析出字符的边界,才可以正确解码字符。如果如果文本数据中出现了一些细微的错漏,导致边界解析错误,我们希望错误的影响仅仅局限于那个字符,而不是后续所有的文本边界都失去了同步,变成乱码无法解析。

​ 为了保证能够从编码的二进制中自然而然的凸显出字符边界,所有的变长编码方案都应当确保编码之间不会出现重叠(Overlap):譬如一个双码元的字符,其第二个码元本身不应当是另一个字符的表示,否则在出现错误时,程序无法分辨出它到底是一个单独的字符,还是某个双码元字符的一部分,就达不到自同步的要求。我们在UTF-8和UTF-16中可以看到,它们的编码表都是针对这一于要求而设计的。

​ 下面让我们来看一下三种具体的编码表:UTF-32, UTF-16, UTF-8。

UTF32

​ 最为简单的编码方案,就是使用一个四字节标准整型int32表示一个字符,也就是采用四字节32位无符号整数作为码元,即,UTF-32。很多时候计算机内部处理字符时,确实是这么做的。例如在C语言和Go语言中,很多API都是使用int来接收单个字符的。

​ UTF-32最突出的特性是定长编码,一个码位始终编码为一个码元,因此具有随机访问与实现简单的优势:第n个字符,就是数组中的第n个码元,使用简单,实现更简单。当然这样的编码方式有个缺陷:特别浪费存储。虽然总共有十几万个字符,但即使是中文,最常用的字符通常码位也落在65535以内,可以使用两个字节来表示。而对于纯英文文本而言,只要一个字节来表示一个字符就足够了。因此使用UTF32可能导致二至四倍的存储消耗,都是真金白银啊。当然在内存与磁盘容量没有限制的时候,用UTF32可能是最为省心的做法。

UTF16

​ UTF16是一种变长编码,使用双字节16位无符号整型作为码元。位于U+0000-U+FFFF之间的码位使用单个16位码元表示,而在U+10000-U+10FFFF之间的码位则使用两个16位的码元表示。这种由两个码元组成的码元对儿,称为代理对(Surrogate Paris)

​ UTF16是针对**基本多语言平面(Basic Multilingual Plane, BMP)**优化的,也就是码位位于U+FFFF以内可以用单个16位码元表示的部分。Anyway,对于落在BMP内的高频常用字符而言,UTF-16可以视作定长编码,也就有着与UTF32一样随机访问的好处,但节省了一倍的存储空间。

​ UTF-16源于早期的Unicode标准,那时候人们认为65536个码位足以表达所有字符了。结果汉字一种文字就足够打爆它了……。**代理(Surrogate)**就是针对此打的补丁。它通过预留一部分码位作为特殊标记,将UTF-16改造成了变长编码。很多诞生于那一时期的编程语言与操作系统都受此影响(Java,Windows等)

​ 对于需要权衡性能与存储的应用,UTF-16是一种选择。尤其是当所处理的字符集仅限于BMP时,完全可以假装它是一种定长编码。需要注意的是UTF-16本质上是变长的,因此当出现超出BMP的字符时,如果以定长编码的方式来计算处理,很可能会出现错误,甚至崩溃。这也是为什么很多应用无法正确处理Emoji的原因。

UTF8

​ UTF8是一种完完全全的变长编码,它使用单字节8位无符号整数作为码元。0xFF以内的码位使用单字节编码,且与ASCII保持完全一致;U+0100-U+07FF之间的码位使用两个字节;U+0800到U+FFFF之间的码位使用三字节,超出U+FFFF的码位使用四字节,后续还可以继续扩展到最多用7个字节来表示一个字符。

​ UTF8最大的优点,一是面向字节编码,二是兼容ASCII,三是能够自我同步。众所周知,只有多字节的类型才会存在大小端字节序的问题,如果码元本身就是单个字节,就压根不存在字节序的问题了。而兼容性,或者说ASCII透明性,使得历史上海量使用ASCII编码的程序与文件无需任何变动就能继续在UTF-8编码下继续工作(ASCII范围内)。最后,自我同步机制使得UTF-8具有良好的容错性。

​ 这些特性这使得UTF-8非常适合用于信息的传输与交换。互联网上大多数文本文件的编码都是UTF-8。而Go、Python3也采用了UTF-8作为其默认编码。

​ 当然,UTF-8也是有代价的。对于中文而言,UTF-8通常使用三个字节进行编码。比起双字节编码而言带来了50%的额外存储开销。与此同时,变长编码无法进行随机访问字符,也使得处理相比“定长编码”更为复杂,也会有更高的计算开销。对于正确性不甚在乎,但对性能有严苛要求的中文文字处理应用可能不会喜欢UTF-8。

​ UTF-8的一个巨大优势就在于,它没有字节序的问题。而UTF-16与UTF-32就不得不操心大端字节在前还是小端字节在前的问题了。这个问题通常在**字符编码方案(Character Encoding Schema)**中通过BOM来解决。

字符编码方案

​ 字符编码表 CEF解决了如何将自然数码位编码为码元序列的问题,无论使用哪种码元,计算机中都有相应的整型。但我们可以说编码问题就解决了吗?还不行,假设一个字符按照UTF16拆成了若干个码元组成的码元序列,因为每个码元都是一个uint16,实际上各由两个字节组成。因此将码元序列化为字节序列的时候,就会遇到一些问题:每个码元究竟是高位字节在前还是低位字节在前呢?这就是大小端字节序问题。

​ 对于网络交换和本地处理,大小端序各有优劣,因此不同的系统往往也会采用不同的大小端序。为了标明二进制文件的大小端序,人们引入了**字节序标记(Byte Order Mark, BOM)**的概念。BOM是放置于编码字节序列开始处的一段特殊字节序列,用于表示文本序列的大小端序。

​ 字符编码方案,实质上就是带有字节序列化方案的字符编码表。即:CES = 解决端序问题的CEF。对于大小端序标识方法的不同选择,产生了几种不同的字符编码方案:

  • UTF-8:没有端序问题。
  • UTF-16LE:小端序UTF-16,不带BOM
  • UTF-16BE:大端序UTF-16,不带BOM
  • UTF-16:通过BOM指定端序
  • UTF-32LE:小端序UTF-32,不带BOM
  • UTF-32BE:大端序UTF-32,不带BOM
  • UTF-32:通过BOM指定端序

UTF-8因为已经采用字节作为码元了,所以实际上不存在字节序的问题。其他两种UTF,都有三个相应地字符编码方案:一个大端版本,一个小端版本,还有一个随机应变大小端带 BOM的版本。

​ 当然要注意,在当前上下文中的UTF-8,UTF-16,UTF-32其实是CES层次的概念,即带有字节序列化方案的CEF,这会与CEF层次的同名概念产生混淆。因此,当我们在说UTF-8,UTF-16,UTF-32时,一定要注意区分它是CEF还是CES。例如,作为一种编码方案的UTF-16产生的字节序列是会带有BOM的,而作为一种编码表的UTF-16产生的码元序列则是没有BOM这个概念的。

0x05 UTF-8

​ 介绍完了现代编码模型,让我们深入看一下一个具体的编码方案:UTF-8。 UTF-8将Unicode码位映射成1~4个字节,满足如下规则:

标量值 字节1 字节2 字节3 字节4
00000000 0xxxxxxx 0xxxxxxx
00000yyy yyxxxxxx 110yyyyy 10xxxxxx
zzzzyyyy yyxxxxxx 1110zzzz 10yyyyyy 10xxxxxx
000uuuuu zzzzyyyy yyxxxxxx 11110uuu 10uuzzzz 10yyyyyy 10xxxxxx

其实比起死记硬背,UTF-8的编码规则可以通过几个约束自然而然地推断出来:

  1. 与ASCII编码保持兼容,因此有第一行的规则。
  2. 需要有自我同步机制,因此需要在首字节中保有当前字符的长度信息。
  3. 需要容错机制,码元之间不允许发生重叠,这意味着字节2,3,4,…不能出现字节1可能出现的码元。

0, 10, 110, 1110, 11110, …这些是不会发生冲突的字节前缀,0前缀被ASCII兼容规则对应的码元用掉了。次优的10前缀就分配给后缀字节作为前缀,表示自己是某个字符的外挂部分。相应地,110,1110,11110这几个前缀就用于首字节中的长度标记,例如110前缀的首字节就表示当前字符还有一个额外的外挂字节,而1110前缀的首字节就表示还有两个额外的外挂字节。因此,UTF-8的编码规则其实非常简单。下面是使用Go语言编写的函数,展示了将一个码位编码为UTF-8字节序列的逻辑:

func UTF8Encode(i uint32) (b []byte) {
	switch {
	case i <= 0xFF: 	/* 1 byte */
		b = append(b, byte(i))
	case i <= 0x7FF: 	/* 2 byte */
		b = append(b, 0xC0|byte(i>>6))
		b = append(b, 0x80|byte(i)&0x3F)
	case i <= 0xFFFF: 	/* 3 byte*/
		b = append(b, 0xE0|byte(i>>12))
		b = append(b, 0x80|byte(i>>6)&0x3F)
		b = append(b, 0x80|byte(i)&0x3F)
	default: 			/* 4 byte*/
		b = append(b, 0xF0|byte(i>>18))
		b = append(b, 0x80|byte(i>>12)&0x3F)
		b = append(b, 0x80|byte(i>>6)&0x3F)
		b = append(b, 0x80|byte(i)&0x3F)
	}
	return
}

0x06 编程语言中的字符编码

​ 讲完了现代编码模型,让我们来看两个现实编程语言中的例子:Go和Python2。这两者都是非常简单实用的语言。但在字符编码的模型设计上却是两个典型:一个正例一个反例。

Go

​ Go语言的缔造者之一,Ken Thompson,同时也是UTF-8的发明人(同时也是C语言,Go语言,Unix的缔造者),因此Go对于字符编码的实现堪称典范。Go的语法与C和Python类似,非常简单。它也是一门比较新的语言,抛开了一些历史包袱,直接使用了UTF-8作为默认编码。

​ UTF-8编码在Go语言中有着特殊的位置,无论是源代码的文本编码,还是字符串的内部编码都是UTF-8。Go绕开前辈语言们踩过的坑,使用了UTF8作为默认编码是一个非常明智的选择。相比之下,Java,Javascript都使用 UCS-2/UTF16作为内部编码,早期还有随机访问的优势,可当Unicode增长超出BMP之后,这一优势也荡然无存了。相比之下,字节序,Surrogate , 空间冗余带来的麻烦却仍让人头大无比。

​ Go语言中有三种重要的基本文本类型: byte, rune,string,分别是字节,字符,与字符串。其中:

  • 字节byte实际上是uint8的别名,[]byte表示字节序列。
  • 字符rune实质上是int32的别名,表示一个Unicode的码位[]rune表示码位序列
  • 字符串string实质上是UTF-8编码的二进制字节数组(底层是字节数组),加上一个长度字段。

而相应的编码与解码操作为:

  • 编码:使用string(rune_array)字符数组转换为UTF-8编码的字符串。
  • 解码:使用for i,r := range str语法迭代字符串中的字符,实际上是依次将二进制UTF-8字节序列还原为码位序列。

更详细的内容可以参阅文档,我也写过一篇博文详细解释了Go语言中的文本类型。

Python2

​ 如果说Go可以作为字符编码处理实现的典范,那么Python2则可以当做一个最典型的反例了。Python2使用ASCII作为默认编码以及默认源文件编码,因此如果不理解字符编码的相关知识,以及Python2的一些设计,在处理非ASCII编码很容易出现一些错误。实际上只要看到Python3与Python2在字符编码处理上的差异有多大就大概有数了。Python2用的人还是不少,所以这里的坑其实很多,但其实最严重的问题是:

  • Python2的默认编码方案的非常不合理。
  • Python2的字符串类型与字符串字面值很容易让人混淆。

第一个问题是,Python2的默认编码方案的非常不合理:

  • Python2使用'xxx'作为字节串字面值,其类型为<str>,但<str>本质上是字节串而不是字符串
  • Python2使用u'xxx'作为字符串字面值的语法,其类型为<unicode><unicode>是真正意义上的字符串,每一个字符都属于UCS。

与此同时,Python2解释器的默认编码方案(CES)是US-ASCII 。作为对照,Java,C#,Javascript等语言内部的默认编码方案都是UTF-16,Go语言的内部默认编码方案使用UTF-8。默认使用US-ASCII的python2简直是骨骼清奇,当然,这也有一部分历史原因在里头。Python3就乖乖地改成UTF-8了。

第二个问题:python的默认’字符串类型<str>与其叫字符串,不如叫字节串,用下标去访问的每一个元素都是一个字节。而<unicode>类型才是真正意义上的字符串,用下标去访问的每一个元素都是一个字符(虽然底下可能每个字符长度不同)。字符串<unicode> 与 字节串<str> 的关系为:

  • 字符串<unicode> 通过 字符编码方案编码得到字节串<str>
  • 字节串<str> 通过 字符编码方案解码得到字符串<unicode>

字节串就字节串,为啥要起个类型名叫<str>呢?另外,字面值语法用一对什么前缀都没有的引号表示str,这样的设计非常反直觉。因此让很多人掉进了坑里。当然,<str><unicode>这样的类型设计以及两者的关系设计本身是无可厚非的。该黑的应该是这两个类型起的名字字面值表示方法。至于怎么改进是好的,Python3已经给出答案。在理解了字符编码模型之后,什么样的操作才是正确的操作,读者应该已经心里有数了。

微信公众号原文

并发异常那些事

并发程序很难写对,更难写好。 本文将阐述SQL92标准中定义的隔离级别及其缺陷,现代模型中的隔离级别与定义这些级别的异常现象。

并发异常那些事

并发程序很难写对,更难写好。很多程序员也没有真正弄清楚这些问题,不过是一股脑地把这些问题丢给数据库而已。并发异常并不仅仅是一个理论问题:这些异常曾经造成过很多资金损失,耗费过大量财务审计人员的心血。但即使是最流行、最强大的关系型数据库(通常被认为是“ACID”数据库),也会使用弱隔离级别,所以它们也不一定能防止这些并发异常的发生。 ​ 比起盲目地依赖工具,我们应该对存在的并发问题的种类,以及如何防止这些问题有深入的理解。 本文将阐述SQL92标准中定义的隔离级别及其缺陷,现代模型中的隔离级别与定义这些级别的异常现象。

0x01 引子

大多数数据库都会同时被多个客户端访问。如果它们各自读写数据库的不同部分,这是没有问题的,但是如果它们访问相同的数据库记录,则可能会遇到并发异常

下图是一个简单的并发异常案例:两个客户端同时在数据库中增长一个计数器。(假设数据库中没有自增操作)每个客户端需要读取计数器的当前值,加1再回写新值。因为有两次增长操作,计数器应该从42增至44;但由于并发异常,实际上只增长至43。

图 两个客户之间的竞争状态同时递增计数器

事务ACID特性中的I,即隔离性(Isolation)就是为了解决这种问题。隔离性意味着,同时执行的事务是相互隔离的:它们不能相互踩踏。传统的数据库教科书将隔离性形式化为可串行化(Serializability),这意味着每个事务可以假装它是唯一在整个数据库上运行的事务。数据库确保当事务已经提交时,结果与它们按顺序运行(一个接一个)是一样的,尽管实际上它们可能是并发运行的。 ​ 如果两个事务不触及相同的数据,它们可以安全地并行(parallel)运行,因为两者都不依赖于另一个。当一个事务读取由另一个事务同时进行修改的数据时,或者当两个事务试图同时修改相同的数据时,并发问题(竞争条件)才会出现。只读事务之间不会有问题,但只要至少一个事务涉及到写操作,就有可能出现冲突,或曰:并发异常。 ​ 并发异常很难通过测试找出来,因为这样的错误只有在特殊时机下才会触发。这样的时机可能很少,通常很难重现。也很难对并发问题进行推理研究,特别是在大型应用中,你不一定知道有没有其他的应用代码正在访问数据库。在一次只有一个用户时,应用开发已经很麻烦了,有许多并发用户使其更加困难,因为任何数据都可能随时改变。 ​ 出于这个原因,数据库一直尝试通过提供事务隔离(transaction isolation)来隐藏应用开发中的并发问题。从理论上讲,隔离可以通过假装没有并发发生,让程序员的生活更加轻松:可串行化的隔离等级意味着数据库保证事务的效果与真的串行执行(即一次一个事务,没有任何并发)是等价的。 ​ 实际上不幸的是:隔离并没有那么简单。可串行化会有性能损失,许多数据库与应用不愿意支付这个代价。因此,系统通常使用较弱的隔离级别来防止一部分,而不是全部的并发问题。这些弱隔离等级难以理解,并且会导致微妙的错误,但是它们仍然在实践中被使用。一些流行的数据库如Oracle 11g,甚至没有实现可串行化。在Oracle中有一个名为“可串行化”的隔离级别,但实际上它实现了一种叫做**快照隔离(snapshot isolation)**的功能,这是一种比可串行化更弱的保证。 ​ 在研究现实世界中的并发异常前,让我们先来复习一下SQL92标准定义的事务隔离等级。

0x02 SQL92标准

按照ANSI SQL92的标准,三种**现象(phenomena)**区分出了四种隔离等级,如下表所示:

隔离等级 脏写P0 脏读 P1 不可重复读 P2 幻读 P3
读未提交RU ⚠️ ⚠️ ⚠️
读已提交RC ⚠️ ⚠️
可重复读RR ⚠️
可串行化SR
  • 四种现象分别缩写为P0,P1,P2,P3,P是**现象(Phenonmena)**的首字母。
  • 脏写没有在标准中指明,但却是任何隔离等级都需要必须避免的异常

这四种异常可以概述如下:

P0 脏写(Dirty Write)

事务T1修改了数据项,而另一个事务T2在T1提交或回滚之前就修改了T1修改的数据项。 ​ 无论如何,事务必须避免这种情况。

P1 脏读(Dirty Read)

事务T1修改了数据项,另一个事务T2在T1提交或回滚前就读到了这个数据项。 ​ 如果T1选择了回滚,那么T2实际上读到了一个事实上不存在(未提交)的数据项。

P2 不可重复读( Non-repeatable or Fuzzy Read)

事务T1读取了一个数据项,然后另一个事务T2修改或删除了该数据项并提交。 ​ 如果T1尝试重新读取该数据项,它就会看到修改过后的值,或发现值已经被删除。

P3 幻读(Phantom)

事务T1读取了满足某一搜索条件的数据项集合,事务T2创建了新的满足该搜索条件的数据项并提交。 ​ 如果T1再次使用同样的搜索条件查询,它会获得与第一次查询不同的结果。

标准的问题

SQL92标准对于隔离级别的定义是有缺陷的 —— 模糊,不精确,并不像标准应有的样子独立于实现。标准其实针对的是基于锁调度的实现来讲的,而基于多版本的实现就很难对号入座。有几个数据库实现了“可重复读”,但它们实际提供的保证存在很大的差异,尽管表面上是标准化的,但没有人真正知道可重复读的意思。 ​ 标准还有其他的问题,例如在P3中只提到了创建/插入的情况,但实际上任何写入都可能导致异常现象。 此外,标准对于可串行化也语焉不详,只是说“SERIALIZABLE隔离级别必须保证通常所知的完全序列化执行”。

现象与异常

现象(phenomena)异常(anomalies)并不相同。现象不一定是异常,但异常肯定是现象。例如在脏读的例子中,如果T1回滚而T2提交,那么这肯定算一种异常:看到了不存在的东西。但无论T1和T2各自选择回滚还是提交,这都是一种可能导致脏读的现象。通常而言,异常是一种严格解释,而现象是一种宽泛解释。

0x03 现代模型

相比之下,现代的隔离等级与一致性等级对于这个问题有更清晰的阐述,如图所示:

isolation-level

图:隔离等级偏序关系图

图:一致性与隔离等级偏序关系

右子树主要讨论的是多副本情况下的一致性等级,略过不提。为了讨论便利起见,本图中刨除了MAV、CS、I-CI、P-CI等隔离等级,主要需要关注的是快照隔离SI

表:各个隔离等级及其可能出现的异常现象

等级\现象 P0 P1 P4C P4 P2 P3 A5A A5B
读未提交 RU ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️
读已提交 RC ⚠️ ⚠️ ⚠️ ⚠️ ⚠️ ⚠️
游标稳定性 CS ⚠️? ⚠️? ⚠️ ⚠️ ⚠️?
可重复读 RR ⚠️
快照隔离 SI ✅? ⚠️
可序列化 SR

带有?标记的表示可能出现异常,依具体实现而异。

主流关系型数据库的实际隔离等级

相应地,将主流关系型数据库为了“兼容标准”而标称的隔离等级映射到现代隔离等级模型中,如下表所示:

表:主流关系型数据库标称隔离等级与实际隔离之间的对照关系

实际\标称 PostgreSQL/9.2+ MySQL/InnoDB Oracle(11g) SQL Server
读未提交 RU RU RU
读已提交 RC RC RC, RR RC RC
可重复读 RR RR
快照隔离 SI RR SR SI
可序列化 SR SR SR SR

以PostgreSQL为例

如果按照ANSI SQL92标准来看,PostgreSQL实际上只有两个隔离等级:RC与SR。

隔离等级 脏读 P1 不可重复读 P2 幻读 P3
RU,RC ⚠️ ⚠️
RR,SR

其中,RU和RC隔离等级中可能出现P2与P3两种异常情况。而RR与SR则能避免P1,P2,P3所有的异常。

当然实际上如果按照现代隔离等级模型,PostgreSQL的RR隔离等级实际上是快照隔离SI,无法解决A5B写偏差的问题。直到9.2引入可串行化快照隔离SSI之后才有真正意义上的SR,如下表所示:

标称 实际 P2 P3 A5A P4 A5B
RC RC ⚠️ ⚠️ ⚠️ ⚠️ ⚠️
RR SI ⚠️
SR SR

作为一种粗略的理解,可以将RC等级视作语句级快照,而将RR等级视作事务级快照。

以MySQL为例

MySQL的RR隔离等级因为无法阻止丢失更新问题,被认为没有提供真正意义上的快照隔离/可重复读。

标称 实际 P2 P3 A5A P4 A5B
RC RC ⚠️ ⚠️ ⚠️ ⚠️ ⚠️
RR RC ✅? ⚠️ ⚠️
SR SR

参考测试用例:ept/hermitage/mysql

0x04 并发异常

isolation-level

回到这张图来,各个异常等级恰好就是通过可能出现的异常来定义的。如果在某个隔离等级A中会出现的所有异常都不会在隔离等级B中出现,我们就认为隔离等级A弱于隔离等级B。但如果某些异常在等级A中出现,在等级B中避免,同时另一些异常在等级B中出现,却在A中避免,这两个隔离等级就无法比较强弱了。

例如在这幅图中:RR与SI是明显强于RC的。但RR与SI之间的相对强弱却难以比较。SI能够避免RR中可能出现的幻读P3,但会出现写偏差A5B的问题;RR不会出现写偏差A5B,但有可能出现P3幻读。

防止脏写与脏读可以简单地通过数据项上的读锁与写锁来阻止,其形式化表示为:

P0: w1[x]...w2[x]...((c1 or a1) and (c2 or a2)) in any order)

P1: w1[x]...r2[x]...((c1 or a1) and (c2 or a2)) in any order)
A1: w1[x]...r2[x]...(a1 and c2 in any order)

因为大多数数据库使用RC作为默认隔离等级,因此脏写P0,脏读P1等异常通常很难遇到,就不再细说了。

下面以PostgreSQL为例,介绍这几种通常情况下可能出现的并发异常现象:

  • P2:不可重复读
  • P3:幻读
  • A5A:读偏差
  • P4:丢失跟新
  • A5B:写偏差

这五种异常有两种分类方式,第一可以按照隔离等级来区分。

  • P2,P3,A5A,P4是RC中会出现,RR不会出现的异常;A5B是RR中会出现,SR中不会出现的异常。

第二种分类方式是按照冲突类型来分类:只读事务与读写事务之间的冲突,以及读写事务之间的冲突。

  • P2,P3,A5A是读事务与写事务之间的并发异常,而P4与A5B则是读写事务之间的并发异常。

读-写异常

让我们先来考虑一种比较简单的情况:一个只读事务与一个读写事务之间的冲突。例如:

  • P2:不可重复读。
  • A5A:读偏差(一种常见的不可重复读问题)
  • P3:幻读

在PostgreSQL中,这三种异常都会在RC隔离等级中出现,但使用RR(实际为SI)隔离等级就不会有这些问题。

不可重复读 P2

假设我们有一张账户表,存储了用户的银行账户余额,id是用户标识,balance是账户余额,其定义如下

CREATE TABLE account(
    id 		INTEGER PRIMARY KEY,
    balance	INTEGER
);

譬如,在事务1中前后进行两次相同的查询,但两次查询间,事务2写入并提交,结果查询得到的结果不同。

START TRANSACTION ISOLATION LEVEL READ COMMITTED; -- T1, RC, 只读
START TRANSACTION ISOLATION LEVEL READ COMMITTED; -- T2, RC, 读写

SELECT * FROM account WHERE k = 'a';  -- T1, 查询账户a,看不到任何结果
INSERT INTO account VALUES('a', 500); -- T2, 插入记录(a,500)
COMMIT; -- T2, 提交

SELECT * FROM account WHERE id = 'a'; -- T1, 重复查询,得到结果(a,500)
COMMIT; -- T1陷入迷惑,为什么同样的查询结果不同?

对于事务1而言,在同一个事务中执行相同的查询,竟然会出现不一样的结果,也就是说读取的结果不可重复。这就是不可重复读的一个例子,即现象P2。在PostgreSQL的RC级别中是会出现的,但如果将事务T1的隔离等级设置为RR,就不会出现这种问题了:

START TRANSACTION ISOLATION LEVEL REPEATABLE READ; -- T1, RR, 只读
START TRANSACTION ISOLATION LEVEL READ COMMITTED;  -- T2, RC, 读写

SELECT * FROM counter WHERE k = 'x'; -- T1, 查询不到任何结果
INSERT INTO counter VALUES('x', 10); -- T2, 插入记录(x,10) @ RR
COMMIT; -- T2, 提交

SELECT * FROM counter WHERE k = 'x'; -- T1, 还是查询不到任何结果
COMMIT; -- T1, 在RR下,两次查询的结果保持一致。

不可重复读的形式化表示:

P2: r1[x]...w2[x]...((c1 or a1) and (c2 or a2) in any order)
A2: r1[x]...w2[x]...c2...r1[x]...c1

读偏差 A5A

另一类读-写异常是读偏差(A5A):考虑一个直观的例子,假设用户有两个账户:a和b,各有500元。

-- 假设有一张账户表,用户有两个账户a,b,各有500元。
CREATE TABLE account(
    id 		INTEGER PRIMARY KEY,
    balance	INTEGER
);
INSERT INTO account VALUES('a', 500), ('b', 500);

现在用户向系统提交从账户b向账户a转账100元的请求,并从网页上并查看自己的账户余额。在RC隔离级别下,下列操作历史的结果可能会让用户感到困惑:

START TRANSACTION ISOLATION LEVEL READ COMMITTED; -- T1, RC, 只读,用户观察
START TRANSACTION ISOLATION LEVEL READ COMMITTED; -- T2, RC, 读写,系统转账

SELECT * FROM account WHERE id = 'a'; -- T1, 用户查询账户a, 500元

UPDATE account SET balance -= 100 WHERE id = 'b'; -- T2, 系统扣减账户b 100元
UPDATE account SET balance += 100 WHERE id = 'a'; -- T2, 系统添加账户a 100元
COMMIT; -- T2, 系统转账事务提交提交

SELECT * FROM account WHERE id = 'a'; -- T1, 用户查询账户b, 400元
COMMIT; -- T1, 用户陷入迷惑,为什么我总余额(400+500)少了100元?

这个例子中,只读事务读取到了系统的一个不一致的快照。这种现象称为读偏差(read skew),记作A5A。但其实说到底,读偏差的根本原因是不可重复读。只要避免了P2,自然能避免A5A。

但读偏差是很常见的一类问题,在一些场景中,我们希望获取一致的状态快照,读偏差是不能接受的。一个典型的场景就是备份。通常对于大型数据库,备份需要花费若干个小时。备份进程运行时,数据库仍然会接受写入操作。因此如果存在读偏差,备份可能会包含一些旧的部分和一些新的部分。如果从这样的备份中恢复,那么不一致(比如消失的钱)就会变成永久的。此外,一些长时间运行的分析查询通常也希望能在一个一致的快照上进行。如果一个查询在不同时间看见不同的东西,那么返回的结果可能毫无意义。

快照隔离是这个问题最常见的解决方案。PostgreSQL的RR隔离等级实际上就是快照隔离,提供了事务级一致性快照的功能。例如,如果我们将T1的隔离等级设置为可重复读,就不会有这个问题了。

START TRANSACTION ISOLATION LEVEL REPEATABLE READ; -- T1, RR, 只读,用户观察
START TRANSACTION ISOLATION LEVEL READ COMMITTED;  -- T2, RC, 读写,系统转账

SELECT * FROM account WHERE id = 'a'; -- T1 用户查询账户a, 500元

UPDATE account SET balance -= 100 WHERE id = 'b'; -- T2 系统扣减账户b 100元
UPDATE account SET balance += 100 WHERE id = 'a'; -- T2 系统添加账户a 100元
COMMIT; -- T2, 系统转账事务提交提交

SELECT * FROM account WHERE id = 'a'; -- T1 用户查询账户b, 500元
COMMIT; -- T1没有观察到T2的写入结果{a:600,b:400},但它观察到的是一致性的快照。

读偏差的形式化表示:

A5A: r1[x]...w2[x]...w2[y]...c2...r1[y]...(c1 or a1)

幻读 P3

在ANSI SQL92中,幻读是用于区分RR和SR的现象,实际上它经常与不可重复读P2混为一谈。唯一的区别在于读取列时是否使用了谓词(predicate),也就是Where条件。 将上一个例子中查询是否存在账户,变为满足特定条件账户的数目,就成了一个所谓的“幻读”问题。

START TRANSACTION ISOLATION LEVEL READ COMMITTED; -- T1, RC, 只读
START TRANSACTION ISOLATION LEVEL READ COMMITTED; -- T2, RC, 读写

SELECT count(*) FROM account WHERE balance > 0;  -- T1, 查询有存款的账户数目。0
INSERT INTO account VALUES('a', 500); -- T2, 插入记录(a,500)
COMMIT; -- T2, 提交

SELECT count(*) FROM account WHERE balance > 0;  -- T1, 查询有存款的账户数目。1
COMMIT; -- T1陷入迷惑,为什么冒出来一个人?

同理,事务1在使用PostgreSQL的RR隔离级别之后,事务1就不会看到满足谓词P的结果发生变化了。

START TRANSACTION ISOLATION LEVEL REPEATABLE READ; -- T1, RR, 只读
START TRANSACTION ISOLATION LEVEL READ COMMITTED;  -- T2, RC, 读写

SELECT count(*) FROM account WHERE balance > 0;  -- T1, 查询有存款的账户数目。0
INSERT INTO account VALUES('a', 500); -- T2, 插入记录(a,500)
COMMIT; -- T2, 提交

SELECT count(*) FROM account WHERE balance > 0;  -- T1, 查询有存款的账户数目。0
COMMIT; -- T1, 读取到了一致的快照(虽然不是最新鲜的)

之所以有这种相当Trivial的区分,因为基于锁的隔离等级实现往往需要额外的谓词锁机制来解决这类特殊的读-写冲突问题。但是基于MVCC的实现,以PostgreSQL的SI为例,就天然地一步到位解决了所有这些问题。

幻读的形式化表示:

P3: r1[P]...w2[y in P]...((c1 or a1) and (c2 or a2) any order)
A3: r1[P]...w2[y in P]...c2...r1[P]...c1

幻读会出现在MySQL的RC,RR隔离等级中,但不会出现在PostgreSQL的RR隔离等级(实际为SI)中。

写-写异常

上面几节讨论了只读事务在并发写入时可能发生的异常。通常这种读取异常可能只要稍后重试就会消失,但如果涉及到写入,问题就比较严重了,因为这种读取到的暂时不一致状态很可能经由写入变成永久性的不一致…。到目前为止我们只讨论了在并发写入发生时,只读事务可以看见什么。如果两个事务并发执行写入,还可能会有一种更有趣的写-写异常:

  • P4: 丢失更新:PostgreSQL的RC级别存在,RR级别不存在(MySQL的RR会存在)。
  • A5B:写入偏差:PostgreSQL的RR隔离级别会存在。

其中,写偏差(A5B)可以视作丢失更新(P4)的泛化情况。快照隔离能够解决丢失更新的问题,却无法解决写入偏差的问题。解决写入偏差需要真正的可串行化隔离等级。

丢失更新-P4-例1

仍然以上文中的账户表为例,假设有一个账户x,余额500元。

CREATE TABLE account(
    id 		TEXT PRIMARY KEY,
    balance	INTEGER
);
INSERT INTO account VALUES('x', 500);

有两个事务T1,T2,分别希望向该账户打入两笔钱,比如一笔100,一笔200。从顺序执行的角度来看,无论两个事务谁先执行,最后的结果都应当是余额 = 500 + 200 + 100 = 800。

START TRANSACTION ISOLATION LEVEL READ COMMITTED; -- T1
START TRANSACTION ISOLATION LEVEL READ COMMITTED; -- T2

SELECT balance FROM account WHERE id = 'x'; -- T1, 查询当前余额=500
SELECT balance FROM account WHERE id = 'x'; -- T2, 查询当前余额也=500

UPDATE account SET balance = 500 + 100; -- T1, 在原余额基础上增加100元
UPDATE account SET balance = 500 + 200; -- T2, 在原余额基础上增加200元,被T1阻塞。

COMMIT; -- T1, 提交前可以看到余额为600。T1提交后解除对T2的阻塞,T2进行了更新。
COMMIT; -- T2, T2提交,提交前可以看到余额为700
-- 最终结果为700

但奇妙的时机导致了意想不到的结果,最后账户的余额为700元,事务1的转账更新丢失了!

但令人意外的是,两个事务都看到了UPDATE 1的更新结果,都检查了自己更新的结果无误,都收到了事务成功提交的确认。结果事务1的更新丢失了,这就很尴尬了。最起码事务应当知道这里可能出现问题,而不是当成什么事都没有就混过去了。

如果使用RR隔离等级(主要是T2,T1可以是RC,但出于对称性尽量都用RR),后执行更新的语句就会报错中止事务。这就允许应用知耻而后勇,进行重试。

START TRANSACTION ISOLATION LEVEL REPEATABLE READ;  -- T1, 这里RC也可以
START TRANSACTION ISOLATION LEVEL REPEATABLE READ;  -- T2, 关键是T2必须为RR

SELECT balance FROM account WHERE id = 'x'; -- T1, 查询当前余额=500
SELECT balance FROM account WHERE id = 'x'; -- T2, 查询当前余额也=500

UPDATE account SET balance = 500 + 100; -- T1, 在原余额基础上增加100元
UPDATE account SET balance = 500 + 200; -- T2, 在原余额基础上增加200元,被T1阻塞。

COMMIT; -- T1, 提交前可以看到余额为600。T1提交后解除对T2的阻塞
-- T2 Update报错:ERROR:  could not serialize access due to concurrent update

ROLLBACK; -- T2, T2只能回滚
-- 最终结果为600,但T2知道了错误可以重试,并在无竞争的环境中最终达成正确的结果800。

p4-8906213

当然我们可以看到,在RC隔离等级的情况中,T1提交,解除对T2的阻塞时,Update操作已经能够看到T1的变更了(balance=600)。但事务2还是用自己先前计算出的增量值覆盖了T1的写入。对于这种特殊情况,可以使用原子操作解决,例如:UPDATE account SET balance = balance + 100;。这样的语句在RC隔离级别中也能正确地并发更新账户。但并不是所有的问题都能简单到能用原子操作来解决的,让我们来看另一个例子。

丢失更新-P4-例2

让我们来看一个更微妙的例子:UPDATEDELETE之间的冲突。

假设业务上每人最多有两个账户,用户最多能选择一个账号作为有效账号,管理员会定期删除无效账号。

账户表有一个字段valid表示该账户是否有效,定义如下所示:

CREATE TABLE account(
    id 		TEXT PRIMARY KEY,
    valid   BOOLEAN
);
INSERT INTO account VALUES('a', TRUE), ('b', FALSE);

现在考虑这样一种情况,用户要切换自己的有效账号,与此同时管理员要清理无效账号。

从顺序执行的角度来看,无论是用户先切换还是管理员先清理,最后结果的共同点是:总会有一个账号被删掉。

START TRANSACTION ISOLATION LEVEL READ COMMITTED;  -- T1, 用户更换有效账户
START TRANSACTION ISOLATION LEVEL READ COMMITTED;  -- T2, 管理员删除账户

UPDATE account SET valid = NOT valid; -- T1, 原子操作,将有效账户无效账户状态反转
DELETE FROM account WHERE NOT valid;  -- T2, 管理员删除无效账户。

COMMIT; -- T1, 提交,T1提交后解除对T2的阻塞
-- T2 DELETE执行完毕,返回DELETE 0

COMMIT; -- T2, T2能正常提交,但检查的话会发现自己没有删掉任何记录。
-- 无论T2选择提交还是回滚,最后的结果都是(a,f),(b,t)

从下图中可以看到,事务2的DELETE原本锁定了行(b,f)准备删除,但因为事务1的并发更新而阻塞。当T1提交解除T2的阻塞时,事务2看见了事务1的提交结果:自己锁定的那一行已经不满足删除条件了,因此只好放弃删除。

p4-2

相应的,改用RR隔离等级,至少给了T2知道错误的机会,在合适的时机重试就可以达到序列执行的效果。

START TRANSACTION ISOLATION LEVEL REPEATABLE READ;  -- T1, 用户更换有效账户
START TRANSACTION ISOLATION LEVEL REPEATABLE READ;  -- T2, 管理员删除账户

UPDATE account SET valid = NOT valid; -- T1, 原子操作,将有效账户无效账户状态反转
DELETE FROM account WHERE NOT valid;  -- T2, 管理员删除无效账户。

COMMIT; -- T1, 提交,T1提交后解除对T2的阻塞
-- T2 DELETE报错:ERROR:  could not serialize access due to concurrent update
ROLLBACK; -- T2, T2只能回滚

SI隔离级别小结

上面提到的异常,包括P2,P3,A5A,P4,都会在RC中出现,但却不会在SI中出现。特别需要注意的是,P3幻读问题会在RR中出现,却不会在SI中出现。从ANSI标准的意义上,SI可以算是可串行化了。SI解决的问题一言以蔽之,就是提供了真正的事务级别的快照。因而各种读-写异常(P2,P3,A5A)都不会再出现了。而且,SI还可以解决**丢失更新(P4)**的问题(MySQL的RR解决不了)。

丢失更新是一种非常常见的问题,因此也有不少应对的方法。典型的方式有三种:原子操作,显式锁定,冲突检测。原子操作通常是最好的解决方案,前提是你的逻辑可以用原子操作来表达。如果数据库的内置原子操作没有提供必要的功能,防止丢失更新的另一个选择是让应用显式地锁定将要更新的对象。然后应用程序可以执行读取-修改-写入序列,如果任何其他事务尝试同时读取同一个对象,则强制等待,直到第一个读取-修改-写入序列完成。(例如MySQL和PostgreSQL的SELECT FOR UPDATE子句)

另一种应对丢失更新的方法是自动冲突检测。如果事务管理器检测到丢失更新,则中止事务并强制它们重试其读取-修改-写入序列。这种方法的一个优点是,数据库可以结合快照隔离高效地执行此检查。事实上,PostgreSQL的可重复读,Oracle的可串行化和SQL Server的快照隔离级别,都会自动检测到丢失更新,并中止惹麻烦的事务。但是,MySQL/InnoDB的可重复读并不会检测丢失更新。一些专家认为,数据库必须能防止丢失更新才称得上是提供了快照隔离,所以在这个定义下,MySQL下没有提供快照隔离。

但正所谓成也快照败也快照,每个事务都能看到一致的快照,却带来了一些额外的问题。在SI等级中,一种称为写偏差(A5B)的问题仍然可能会发生:例如两个事务基于过时的快照更新了对方读取的数据,提交后才发现违反了约束。丢失更新其实是写偏差的一种特例:两个写入事务竞争写入同一条记录。竞争写入同一条数据能够被数据库的丢失更新检测机制发现,但如果两个事务基于各自的写入了不同的数据项,又怎么办呢?

写偏差 A5B

考虑一个运维值班的例子:互联网公司通常会要求几位运维同时值班,但底线是至少有一位运维在值班。运维可以翘班,只要至少有一个同事在值班就行:

CREATE TABLE duty (
  name   TEXT PRIMARY KEY,
  oncall BOOLEAN
);

-- Alice和Bob都在值班
INSERT INTO duty VALUES ('Alice', TRUE), ('Bob', True);

假如应用逻辑约束是:不允许无人值班。即:SELECT count(*) FROM duty WHERE oncall值必须大于0。现在假设A和B两位运维正在值班,两人都感觉不舒服决定请假。不幸的是两人同时按下了翘班按钮。则下列执行时序会导致异常的结果:

START TRANSACTION ISOLATION LEVEL REPEATABLE READ;  -- T1, Alice
START TRANSACTION ISOLATION LEVEL REPEATABLE READ;  -- T2, Bob

SELECT count(*) FROM duty WHERE oncall; -- T1, 查询当前值班人数, 2
SELECT count(*) FROM duty WHERE oncall; -- T2, 查询当前值班人数, 2

UPDATE duty SET oncall = FALSE WHERE name = 'Alice'; -- T1, 认为其他人在值班,Alice翘班
UPDATE duty SET oncall = FALSE WHERE name = 'Bob';   -- T2, 也认为其他人在值班,Bob翘班

COMMIT; -- T1
COMMIT; -- T2

SELECT count(*) FROM duty; -- 观察者, 结果为0, 没有人在值班了! 

两个事务看到了同一个一致性快照,首先检查翘班条件,发现有两名运维在值班,那么自己翘班是ok的,于是更新自己的值班状态并提交。两个事务都提交之后,没有运维在值班了,违背了应用定义的一致性。

但如果两个事务并不是**同时(Concurrently)**执行的,而是分了先后次序,那么后一个事务在执行检查时就会发现不满足翘班条件而终止。因此,事务之间的并发导致了异常现象。

对事务而言,明明在执行翘班操作之前看到值班人数为2,执行翘班操作之后看到值班人数为1,但为啥提交之后看到的就是0了呢?这就好像看见幻象一样,但这与SQL92标准定义的幻读并不一样,标准定义的幻读是因为不可重复读的屁股没擦干净,读到了不该读的东西(对于谓词查询不可重复读取),而这里则是因为快照的存在,事务无法意识到自己读取的记录已经被改变。

问题的关键在于不同读写事务之间的读写依赖。如果某个事务读取了一些数据作为行动的前提,那么如果当该事务执行后续写入操作时,这些被读取的行已经被其他事务修改,这就意味着事务依赖的前提可能已经改变

写偏差的形式化表示:

A5B: r1[x]...r2[y]...w1[y]...w2[x]...(c1 and c2 occur)

此类问题的共性

事务基于一个前提采取行动(事务开始时候的事实,例如:“目前有两名运维正在值班”)。之后当事务要提交时,原始数据可能已经改变——前提可能不再成立。

  1. 一个SELECT查询找出符合条件的行,并检查是否满足一些约束(至少有两个运维在值班)。

  2. 根据第一个查询的结果,应用代码决定是否继续。(可能会继续操作,也可能中止并报错)

  3. 如果应用决定继续操作,就执行写入(插入、更新或删除),并提交事务。

    **这个写入的效果改变了步骤2 中的先决条件。**换句话说,如果在提交写入后,重复执行一次步骤1 的SELECT查询,将会得到不同的结果。因为写入改变符合搜索条件的行集(只有一个运维在值班)。

在SI中,每个事务都拥有自己的一致性快照。但SI是不提供**线性一致性(强一致性)**保证的。事务看到的快照副本可能因为其他事务的写入而变得陈旧,但事务中的写入无法意识到这一点。

与丢失更新的联系

作为一个特例,如果不同读写事务是对同一数据对象进行写入,这就成了丢失更新问题。通常会在RC中出现,在RR/SI隔离级别中避免。对于相同对象的并发写入可以被数据库检测出来,但如果是向不同数据对象写入,违背应用逻辑定义的约束,那RR/SI隔离等级下的数据库就无能为力了。

解决方案

有很多种方案能应对这些问题,可串行化当然是ok的,但也存在一些其他手段,例如

显式锁定
START TRANSACTION ISOLATION LEVEL REPEATABLE READ;  -- T1, 用户更换有效账户
START TRANSACTION ISOLATION LEVEL REPEATABLE READ;  -- T2, 管理员删除账户

SELECT count(*) FROM duty WHERE oncall FOR UPDATE; -- T1, 查询当前值班人数, 2
SELECT count(*) FROM duty WHERE oncall FOR UPDATE; -- T2, 查询当前值班人数, 2

WITH candidate AS (SELECT name FROM duty WHERE oncall FOR UPDATE)
SELECT count(*) FROM candidate; -- T1

WITH candidate AS (SELECT name FROM duty WHERE oncall FOR UPDATE)
SELECT count(*) FROM candidate; -- T2, 被T1阻塞

UPDATE duty SET oncall = FALSE WHERE name = 'Alice'; -- T1, 执行更新
COMMIT; -- T1, 解除T2的阻塞

-- T2报错:ERROR:  could not serialize access due to concurrent update
ROLLBACK; -- T2 只能回滚

使用SELECT FOR UPDATE语句,可以显式锁定待更新的行,这样,当后续事务想要获取相同的锁时就会被阻塞。这种方法在MySQL中称为悲观锁。这种方法本质上属于一种物化冲突,将写偏差的问题转换成了丢失更新的问题,因此允许在RR级别解决原本SR级别才能解决的问题。

在最极端的情况下(比如表没有唯一索引),显式锁定可能蜕化为表锁。无论如何,这种方式都有相对严重的性能问题,而且可能更频繁地导致死锁。因此也存一些基于谓词锁和索引范围锁的优化。

显式约束

如果应用逻辑定义的约束可以使用数据库约束表达,那是最方便的。因为事务会在提交时(或语句执行时)检查约束,违背了约束的事务会被中止。不幸的是,很多应用约束都难以表述为数据库约束,或者难以承受这种数据库约束表示的性能负担。

可串行化

使用可串行化隔离等级可以避免这一问题,这也是可串行化的定义:避免一切序列化异常。这可能是最简单的方法了,只需要使用SERIALIZABLE的事务隔离等级就可以了。

START TRANSACTION ISOLATION LEVEL SERIALIZABLE;  -- T1, Alice
START TRANSACTION ISOLATION LEVEL SERIALIZABLE;  -- T2, Bob

SELECT count(*) FROM duty WHERE oncall; -- T1, 查询当前值班人数, 2
SELECT count(*) FROM duty WHERE oncall; -- T2, 查询当前值班人数, 2

UPDATE duty SET oncall = FALSE WHERE name = 'Alice'; -- T1, 认为其他人在值班,Alice翘班
UPDATE duty SET oncall = FALSE WHERE name = 'Bob';   -- T2, 也认为其他人在值班,Bob翘班

COMMIT; -- T1
COMMIT; -- T2, 报错中止
-- ERROR:  could not serialize access due to read/write dependencies among transactions
-- DETAIL:  Reason code: Canceled on identification as a pivot, during commit attempt.
-- HINT:  The transaction might succeed if retried.

在事务2提交的时候,会发现自己读取的行已经被T1改变了,因此中止了事务。稍后重试很可能就不会有问题了。

PostgreSQL使用SSI实现可串行化隔离等级,这是一种乐观并发控制机制:如果有足够的备用容量,并且事务之间的争用不是太高,乐观并发控制技术往往表现比悲观的要好不少。

数据库约束物化冲突在某些场景下是很方便的。如果应用约束能用数据库约束表示,那么事务在写入或提交时就会意识到冲突并中止冲突事务。但并不是所有的问题都能用这种方式解决的,可串行化的隔离等级是一种更为通用的方案。

0x06 并发控制技术

本文简要介绍了并发异常,这也是事务ACID中的“隔离性”所要解决的问题。本文简单讲述了ANSI SQL92标准定义的隔离等级以及其缺陷,并简单介绍了现代模型中的隔离等级(简化)。最后详细介绍了区分隔离等级的几种异常现象。当然,这篇文章只讲异常问题,不讲解决方案与实现原理,关于这些隔离等级背后的实现原理,将留到下一篇文章来陈述。但这里可以大概提一下:

从宽泛的意义来说,有两大类并发控制技术:多版本并发控制(MVCC)严格两阶段锁定(S2PL),每种技术都有多种变体。

在MVCC中,每个写操作都会创建数据项的一个新版本,同时保留旧版本。当事务读取数据对象时,系统会选择其中的一个版本,来确保各个事务间相互隔离。 MVCC的主要优势在于“读不会阻塞写,而写也不会阻塞读”。相反,基于S2PL的系统在写操作发生时必须阻塞读操作,因为因为写入者获取了对象的排他锁。

PostgreSQL、SQL Server、Oracle使用一种MVCC的变体,称为快照隔离(Snapshot Isolation,SI)。为了实现SI,一些RDBMS(例如Oracle)使用回滚段。当写入新的数据对象时,旧版本的对象先被写入回滚段,随后用新对象覆写至数据区域。 PostgreSQL使用更简单的方法:一个新数据对象被直接插入到相关的表页中。读取对象时,PostgreSQL通过可见性检查规则,为每个事物选择合适的对象版本作为响应。

但数据库技术发展至今,这两种技术已经不是那样泾渭分明,进入了一个你中有我我中有你的状态:例如在PostgreSQL中,DML操作使用SI/SSI,而DDL操作仍然会使用2PL。但具体的细节,就留到下一篇吧。

Reference

【1】Designing Data-Intensive Application,ch7

【2】Highly Available Transactions: Virtues and Limitations

【3】A Critique of ANSI SQL Isolation Levels

【4】Granularity of Locks and Degrees of Consistency in a Shared Data Base

【5】Hermitage: Testing the ‘I’ in ACID

KNN极致优化:从RDS到PostGIS

KNN问题极致优化,从传统关系型设计到PostGIS

概述

灵活应用数据库的功能,可以轻松实现三万倍的性能提升。

Level 方法 性能/耗时(ms) 可维护性/可靠性 备注
1 暴力扫表 30,000 - 形式简单
2 经纬索引 35 复杂度/魔数问题 额外复杂度
3 联合索引 10 复杂度/魔数问题 额外复杂度
4 GIST 4 最简表达,完全精确 形式简单,距离更精确,PostgreSQL限定
5 btree_gist联合索引 1 最简表达,完全精确 形式简单,距离更精确,PostgreSQL限定

场景

互联网中的很多业务都涉及到地理相关的功能需求,最为普遍的需求莫过于最近邻查询了。

例如:

  • 为用户推荐附近的POI(餐厅、加油站、公交站)
  • 为用户推荐附近的用户(聊天匹配)
  • 找到距离用户所处的地址(地理逆编码)
  • 找到用户所处的商圈、省、市、区、县 (以点找面)

这些问题实质上都属于最近邻搜索或其变体。

有一些功能,它看上去和最近邻搜索无关,实际上剥了皮,也是最近邻搜索,典型的例如地理逆编码:

打车选择上车地点的时,点外卖选择送达位置时,都会将用户当前的经纬度坐标转换为文本地理位置,诸如:“某某小区几号楼”。实际上这也是最近邻搜索的问题:找到距离用户当前位置最近的一个坐标点。

最近邻(knn,k nearest neighiboor),顾名思义,就是找出距离某个中心点最近的K个对象。其问题满足这样一种形式:

找出满足某一条件的最近的K个对象(及其属性)。

最近邻搜索是如此常用的功能,优化的效益非常显著。

下面我们从一个具体问题出发,讲述这一功能实现方式的演化——如何实现超过三万倍的性能提升。

问题

我们选择推荐最近的餐厅,作为此类问题的代表。

问题很简单:给定包含中国所有POI点的表pois,及一个经纬度坐标点。在足够快的时间内找出距离该坐标点最近的10家餐馆。并返回这十家餐馆的名称和距离

细节说明:

  • pois表包括一亿条记录,其中类型为餐馆的POI约占一千万。

  • 给定的示例中心店:北京师范大学,116.3660 E, 39.9615 N。

  • 足够快意味着在1毫秒内完成

  • 距离意味着,以米计算的地球表面距离

  • pois表模式定义:

    CREATE TABLE pois (
      id        CHAR(10) PRIMARY KEY,
      name      VARCHAR(100),
      position  GEOMETRY, -- PostGIS ST_Point
      longitude FLOAT,    -- Float64
      latitude  FLOAT,    -- Float64
      category  INTEGER   -- type of POI
    );
    
  • 餐馆的特征是WHERE category BETWEEN 50000 AND 51000

同类问题

这个模式适用于许多的例子,例如对探探而言,其实就可以是:找出离用户所在位置最近的,且年龄位于某个范围,加上一些其他筛选条件的100个人。

对于美团点评而言,就是找出离用户最近的10个,类型为餐馆的POI。

对于逆地理编码而言,实质上就是找出离用户最近的POI(加上可选的类型限制,类型十字路口,地标建筑等)

题外话-坐标系:WGS84与GCJ02

这是另外一个很多人都会搞混的地方。

  • 滴滴打车的魔幻偏移。
  • 港澳台边界,碎屑多边形。

绝大多数互联网中与地理相关的功能,都涉及到最近邻查询的需求。

比如对于谈朋友的场景,把这里的 WHERE category BETWEEN 50000 AND 51000

换成 WHERE age BETWEEN 18 AND 27就好。

很多打ACM的同学,熟练使用各种数据结构与算法。可能已经跃跃欲试了,R树,就决定是你了。

不过在真实项目中,数据表就是数据结构,而索引与查询方式就是算法。

距离如何定义?

欲解此题,需明定义。距离的定义并没有看上去那样简单。

例如,对于导航软件而言,距离可能意味着路径长度而非直线距离。

在二维平面坐标系中,通常距离指的是欧氏距离:$d=\sqrt{(x_2-x_1)^2+(y_2-y_1)^2}$

但在GIS中,通常使用的坐标系是球面坐标系,即通过经纬度来标识一个点。

在球面上,两点之间的距离等于所在球面大圆上的弧长,也就是其球面角 x 半径。

这就引入了一个问题,每一纬度对应的距离是基本恒定的,差不多都是111公里。

然而,每一经度对应的距离,随着纬度不同而变化,在赤道上和一纬度差不多,也是111公里,然而随着纬度升高,到了北纬40°时,一经度对应的弧长只有85公里了,而到了北极点,一经度对应的弧长距离为0。

事实上,还会有其他更棘手的问题。例如,地球实际上是一个椭球体,而非正球体。

地球非球,乃不规则椭球。在最开始的时候,为了省事,我们可以设其为球计算距离。

CREATE OR REPLACE FUNCTION sphere_distance(lon_a FLOAT, lat_a FLOAT, lon_b FLOAT, lat_b FLOAT)
  RETURNS FLOAT AS $$
SELECT asin(
           sqrt(
               sin(0.5 * radians(lat_b - lat_a)) ^ 2 +
               sin(0.5 * radians(lon_b - lon_a)) ^ 2 * cos(radians(lat_a)) * cos(radians(lat_b))
           )
       ) * 127561999.961088 AS distance;
$$
LANGUAGE SQL IMMUTABLE COST 100;

将经纬度坐标当成平面坐标使用并不是不可以,但对于需要精准排序的场景,这样的近似可能会产生很大的问题:

每一经度对应的距离,随着纬度不同而变化,在赤道上一经度和一纬度代表的距离差不多都是111公里,然而随着纬度升高,到了北纬40°时,一经度对应的弧长只有85公里了,而到了极点,一经度对应的弧长距离为0。

因此,平面坐标系上的圆,在球面坐标系上可能只是一个瘦长的椭圆。计算距离时,纬度与经度方向上的距离权重不同会导致严重的正确性问题:一个正北100米处的商店可能比正东70m处的商店距离排序更靠前。对于高纬度地区,这一类问题会变得非常严重。

因此,暴力扫表之后,通常需要使用精确的距离计算公式再次计算并排序。

注意,这里的距离,量纲单位并不是米,而是°的平方,考虑到1经度和1纬度对应的实际距离在不同的地方存在巨大差异,这一结果并没有精确的实际意义。

经纬度是球面坐标系,而不是二维平面坐标系中的坐标。然而对于快速粗略圈选,这种方式是可以接受的。

对于需要精确排序的场景,必须使用地球表面球面距离的计算公式,而不是简单地求欧氏距离。

足够快又是多快?

天下武功,唯快不破,互联网强调的就是一个快,跑的也快,写的也快。

足够快又是多快呢?一毫秒,足够快了。这也是我们的优化目标

好了,开始进入干货环节。在PostGIS展现真正的实力之前,让我们来先看一看传统的关系型数据库,对解决这一问题,能走到多远。

0x02 方案

让我们从传统关系型数据库开始

LEVEL-1 暴力扫表

使用传统关系型数据库,此题有何解法?

暴力算法写起来是非常简单的,我们来看一下。

从POIS表中,首先找出所有的餐馆,拿出餐馆的名字,算出餐馆到我们这儿的距离,然后呢?再按距离排序,取距离最短的,也就是最近的10条记录。

新手拍拍脑袋,也可以很快写出这样Naive的SQL:

SELECT
  id,
  name,
  sphere_distance(longitude, latitude, 
                  116.3660 , 39.9615 ) AS d
FROM pois
WHERE category BETWEEN 50000 AND 51000
ORDER BY d
LIMIT 10;

为了简化问题,让我们暂时忽略经纬度其实是球面坐标,地球又是个椭球体的事实。

在这一前提下,这个SQL确实能正确完成工作。不过,谁要敢在生产环境这么用,DBA肯定得打死他。

让我们先考察其执行计划:

题外话:SQL内联

SQL内联有助于正确使用索引。

在真实环境执行,缓存充分预热,实际耗时30秒;开启PostgreSQL并行查询(2 worker)后实际执行时间16秒。

用时30秒,实际执行时间17秒。

用户对于响应时间是很敏感的,响应时间一上去,用户满意度立马就会掉下来。打王者荣耀的时候,100毫秒的延迟都已经很让人抓狂了。如果是一个实时性

对于几千条记录的表也许可以凑合工作,但对于1亿量级的表,暴力扫表不可取。

用户无法接受十几秒的等待时间,更罔论这样的设计能有任何扩展性可言。

存在的问题

开销离谱

这个查询每次都要计算目标点所有记录点之间的距离,然后再按距离排序取TOP。

对于几千条记录的表也许可以凑合工作,但对于1亿量级的表,暴力扫表不可取。用户无法接受十几秒的等待时间,更罔论这样的设计能有任何扩展性可言。

正确性堪忧

将经纬度坐标当成平面坐标使用并不是不可以,但对于需要精准排序的场景,这样的近似可能会产生很大的问题:

每一经度对应的距离,随着纬度不同而变化,在赤道上一经度和一纬度代表的距离差不多都是111公里,然而随着纬度升高,到了北纬40°时,一经度对应的弧长只有85公里了,而到了极点,一经度对应的弧长距离为0。

因此,平面坐标系上的圆,在球面坐标系上可能只是一个瘦长的椭圆。计算距离时,纬度与经度方向上的距离权重不同会导致严重的正确性问题:一个正北100米处的商店可能比正东70m处的商店距离排序更靠前。对于高纬度地区,这一类问题会变得非常严重。

因此,暴力扫表之后,通常需要使用精确的距离计算公式再次计算并排序。

题外话:错误的索引效果适得其反

有同学会说,这里POI类型字段,category出现在了查询的where条件中,可以通过索引来提高性能

这次他不直接扫表了,它先去扫描category上的索引,把属于餐厅的记录都过滤出来。

然后再按照索引,一个页面接一个页面地扫描。

结果顺序IO变成了随机IO。

那么索引的正确使用方式又是怎么样的呢?

LEVEL-2 经纬索引

索引是关系型数据库的吃饭家伙,既然顺序扫表不可取,我们自然会想到利用索引来加速查询。

朴素的思路是这样的,通过索引筛选出目标点周围一定范围内的候选点,再进一步计算距离并排序。

索引是关系型数据库的吃饭家伙,既然顺序扫表不可取,我们自然会想到利用索引来加速查询。

使用经纬度上的索引是基于这样一种思路:

北师在帝都繁华之地宇宙中心,如果我们用一个边长一公里的正方形(直径一公里的圆)

去地图上画个圈,那么别说十家餐厅了,一百家都有可能。

反过来说呢,既然最近的10家餐厅一定落在这么大的一个圆里,

这个表里的POI点包括了全中国的POI点,

筛选出目标点周围一定范围内的候选点,再进一步计算距离并排序。

CREATE INDEX ON pois1 USING btree(longitude);
CREATE INDEX ON pois1 USING btree(latitude);

同时,为了解决正确性的问题,假设我们已经有了一个从经纬度计算球面距离的SQL函数sphere_distance

CREATE FUNCTION sphere_distance(lon_a FLOAT, lat_a FLOAT, lon_b FLOAT, lat_b FLOAT) RETURNS FLOAT
IMMUTABLE LANGUAGE SQL COST 100 AS $$
SELECT asin(
           sqrt(
               sin(0.5 * radians(lat_b - lat_a)) ^ 2 +
               sin(0.5 * radians(lon_b - lon_a)) ^ 2 * cos(radians(lat_a)) *
               cos(radians(lat_b))
           )
       ) * 127561999.961088 AS distance;
$$;

$$ \Delta\sigma=\arccos\bigl(\sin\phi_1\cdot\sin\phi_2+\cos\phi_1\cdot\cos\phi_2\cdot\cos(\Delta\lambda)\bigr). $$

于是,如果使用以目标点为中心的边长为1公里的正方形来做初筛,这个查询可以写作:

SELECT 
  id, name, 
  sphere_distance(longitude, latitude, 116.365798, 39.966956) as d
FROM pois1
WHERE
  longitude BETWEEN 116.365798 - 0.5 / 85 AND 116.365798 + 0.5 / 85 AND
  latitude BETWEEN 39.966956 - 0.5 / 111 AND 39.966956 + 0.5 / 111  AND
  category = 60000
ORDER BY 3 LIMIT 10;

预热后,实际执行平均耗时35毫秒,相比暴力扫表有了近千倍的性能提高,一个巨大的进步。

对于比较简单粗糙的产品,这种方法已经达到了‘可用’的级别。但这一方法仍然存在许多问题。

存在的问题

这种方法最大的问题在于额外复杂度。它使用了一个(多个)魔数,来确定候选点的大致范围。

而这个魔数的选取,是有赖我们的先验知识的。我们清楚地知道,以繁华的宇宙中心五道口的商铺密度,一公里见方内,商铺个数绝对超过10个了。但对于极端的场景(实际可能很常见),比如在塔克拉玛干大沙漠或者羌塘无人区,最近的商铺,逻辑上是必定存在的,不过其距离可能超过几百公里。

这种方法的性能表现对魔数的选取极其敏感:距离选择的太大,性能会急剧恶化,距离选择的太小,对于乡下偏僻的地方又可能无法返回结果。让程序员头大的事情又多了一个。

用时35毫秒

千倍提升,不错哦,但不能高兴的太早

这么多奇怪的常数又是几个意思?

一千倍的性能提升,让我们来看一下查询执行计划,看看它是怎么做到的。

首先呢,经度上,走了一个索引扫描,生成了一个位图。

然后呢,纬度上,也走了一个索引扫描,又生成了一个位图。

接下来,两个位图做了一个位运算,生成了一个新位图,筛选出了满足经纬度条件的记录。

然后,才去扫描这些满足条件的候选点,计算距离,并排序。

我们这个边界值选的比较巧,所以实际参与距离计算和排序的记录,可能只有三十多条。

比起先前一千多万次的距离计算与排序,显然是要高明的多了。

题外话:超参数与额外复杂度

因为这个边界魔数凑的很好,所以性能比较理想。

这种方法最大的问题在于额外复杂度。它使用了一个(多个)魔数,来确定候选点的大致范围。

而这个魔数的选取,是有赖我们的先验知识的。我们清楚地知道,以繁华的宇宙中心五道口的商铺密度,一公里见方内,商铺个数绝对超过10个了。但对于极端的场景(实际可能很常见),比如在塔克拉玛干大沙漠或者羌塘无人区,最近的商铺,逻辑上是必定存在的,不过其距离可能超过几百公里。

这种方法的性能表现对魔数的选取极其敏感:距离选择的太大,性能会急剧恶化,距离选择的太小,对于乡下偏僻的地方又可能无法返回结果。让程序员头大的事情又多了一个。

让我们先忽略这恼人的问题,看看传统关系型数据库还能不能再压榨压榨。

Bad Case

因为这个边界魔数凑的很好,所以性能比较理想。

这种方法最大的问题在于额外复杂度。它使用了一个(多个)魔数,来确定候选点的大致范围。

而这个魔数的选取,是有赖我们的先验知识的。我们清楚地知道,以繁华的宇宙中心五道口的商铺密度,一公里见方内,商铺个数绝对超过10个了。但对于极端的场景(实际可能很常见),比如在塔克拉玛干大沙漠或者羌塘无人区,最近的商铺,逻辑上是必定存在的,不过其距离可能超过几百公里。

这种方法的性能表现对魔数的选取极其敏感:距离选择的太大,性能会急剧恶化,距离选择的太小,对于乡下偏僻的地方又可能无法返回结果。让程序员头大的事情又多了一个。

让我们先忽略这恼人的问题,看看传统关系型数据库还能不能再压榨压榨。

半径大了性能差 半径小了圈不着
mage-20180321221805
繁荣的五道口,一公里圈10家小意思。 300公里外才有一家,新疆人民哭晕在厕所

LEVEL-3 联合索引与聚簇

抛开魔数带来的烦恼,我们来研究传统关系型数据库能在解决这个问题上走得有多远。

通过多列索引替换每一列上独自的索引,并将表按该索引聚簇。

仍然是一模一样的查询语句

从30毫秒提升到10毫秒,三倍的性能提升

对于传统关系型数据库,这差不多就是极限了

有没有优雅、正确、快速的解决方案呢?

mage-20180321221928

CREATE INDEX ON pois4 USING btree(longitude, latitude, category);
CLUSTER pois4 USING pois4_longitude_latitude_category_idx;

相应的查询保持不变

SELECT id, name,
	sphere_distance(longitude, latitude, 116.365798, 39.966956) as d FROM pois4
WHERE
  longitude BETWEEN 116.365798 - 0.5 / 85  AND 116.365798 + 0.5 / 85  AND
  latitude  BETWEEN  39.966956 - 0.5 / 111 AND 39.966956  + 0.5 / 111 AND
  category = 60000
ORDER BY sphere_distance(longitude, latitude, 116.365798, 39.966956)
LIMIT 10;

联合索引查询的执行计划,实际执行时间可以压缩至7毫秒。

mage-20180321221945

这差不多就是传统关系数据模型的极限了,对于大部分业务,这都是一个可以接受水平了。

因为这个边界魔数凑的很好,所以性能比较理想。

扩展变体:GeoHash

GeoHash是此类方式的变体,通过将二维经纬度编码为一维字符串,可以使用传统的字符串前缀匹配操作来对地理位置进行过滤。然而固定的粒度使得其灵活度有显著下降,采用联合索引还是特殊编码的冗余字段需要针对具体场景进行分析。

仍然是一模一样的查询语句

从30毫秒提升到10毫秒,三倍的性能提升

对于传统关系型数据库,这差不多就是极限了

有没有优雅、正确、快速的解决方案呢?

LEVEL-4 GIST

有没有一种办法,能够优雅,高效,简洁的完成这项工作呢?

PostGIS提出了非常优秀的解决方案,改用Geometry类型,并创建GIST索引。

CREATE TABLE pois5(
  id       CHAR(10) PRIMARY KEY,
  name     VARCHAR(100),
  position GEOGRAPHY(Point), -- PostGIS ST_Point
  category INTEGER   -- type of POI
);

CREATE INDEX ON pois5 USING GIST(position);
SELECT id, name FROM pois6 WHERE category = 60000
ORDER BY position <-> ST_GeogFromText('SRID=4326;POINT(116.365798 39.961576)') LIMIT 10;

R树

R树的核心思想是,聚合距离相近的节点,并在树结构的上一层,将其表示为这些节点的最小外接矩形,这个最小外接矩形就成为上一层的一个节点。因为所有节点都在它们的最小外接矩形中,所以跟某个矩形不相交的查询就一定跟这个矩形中的所有节点都不相交。

mage-20180321220143

实际查询中,该查询能在1.6毫秒完成,这是相当惊人的一个结果了。但要注意,这里position的类型是GEOMETRY,意味着它使用的是二维平面坐标,正确的计算距离需要使用Geography类型。

SELECT
  id,
  name,
  position <-> 
  ST_Point(116.3660, 39.9615)::GEOGRAPHY AS d
FROM pois5
WHERE category BETWEEN 50000 AND 51000
ORDER BY d
LIMIT 10;

因为球面距离的计算开销比平面距离要大很多,使用Geography替换Geometry产开销,约4.5ms。

一倍的性能损失相当可观,因此日常应用中需要仔细权衡精确性与性能之间的关系。

通常拓扑类的查询、粗略的圈人都适合用Geometry类型,而精确的计算与判断则必须使用Geography类型。这里,按照距离排序需要精确的距离,因此使用Geography。

Geometry: 1.6 ms Geography: 3.4 ms
mage-20180321222024

现在,我们来看看PostGIS交出的答卷。

PostGIS,使用了不一样的数据类型、索引、与查询方法。

首先,这里数据类型不再是两个浮点数,而变成一个Geography字段。里面存就是一对经纬度坐标。

然后,我们使用的索引,也不再是常见的Btree索引,而是GIST索引。

Generalized Search Tree. 通用搜索树,平衡树结构。对于空间几何类型而言,实现通常使用的是R树。

通常拓扑类的查询、粗略的圈人都适合用Geometry类型,而精确的计算与判断则必须使用Geography类型。这里,按照距离排序需要精确的距离,因此使用Geography。

题外话:Geometry还是Geography?

因为球面距离的计算开销比平面距离要大很多,使用Geography替换Geometry产开销

拓扑关系,粗略估计使用Geometry,精确计算使用Geography

计算开销约为一倍,需要仔细权衡正确性/精确性与性能之间的关系。

现在,我们来看看PostGIS交出的答卷。

PostGIS,使用了不一样的数据类型、索引、与查询方法。

首先,这里数据类型不再是两个浮点数,而变成一个Geography字段。里面存就是一对经纬度坐标。

然后,我们使用的索引,也不再是常见的Btree索引,而是GIST索引。

Generalized Search Tree. 通用搜索树,平衡树结构。对于空间几何类型而言,实现通常使用的是R树。

通常拓扑类的查询、粗略的圈人都适合用Geometry类型,而精确的计算与判断则必须使用Geography类型。这里,按照距离排序需要精确的距离,因此使用Geography。

LEVEL-5 btree_gist

还能更进一步否?

观察Leve-4中的执行计划,我们发现category上的条件并没有用到索引。

可不可以像Level-3中的优化方式一样,创建一个 position 与 category 的联合索引呢?

不幸的是,B树与R树是两种完全不同的数据结构,甚至连使用方式都不一样

于是我们有这样一个想法,能不能把category当成 position的第三维坐标,让R树直接在三维空间里面进行索引呢?

这个思路是正确的, 但是完全不需要这么麻烦

GIST索引的一个问题在于,它的工作原理与B树不同,无法在不支持GIST索引方法的数据类型上创建GIST索引。

通常,几何类型,范围(range)类型支持GIST索引,但字符串,数值类型等都不支持GIST。这就导致了无法创建形如GIST(position, category)的多列索引。

PostgreSQL内置的btree_gist扩展解决了这一问题。

PostgreSQL内置的扩展 btree_gist,允许创建常规类型与几何类型的联合索引。

CREATE EXTENSION btree_gist;

CREATE INDEX ON pois6 USING GIST(position, category);

CLUSTER VERBOSE pois6 USING idx_pois6_position_category_gist;

同样的查询,可以简写为:

SELECT id, name, position <-> ST_Point(lon, lat) :: GEOGRAPHY AS distance
FROM pois6 WHERE category = 60000 ORDER BY 3 LIMIT 10;
Geometry: 0.85ms / Geography: 1.2ms
CREATE OR REPLACE FUNCTION get_random_nearby_store() RETURNS TEXT
AS $$
DECLARE
  lon FLOAT := 110 + (random() - 0.5) * 10;
  lat FLOAT := 30 + (random() - 0.5) * 10;
BEGIN
  RETURN (
    SELECT jsonb_pretty(jsonb_build_object('list', a.list, 'lon', lon, 'lat', lat)) :: TEXT
    FROM (
           SELECT json_agg(row_to_json(top10)) AS list
           FROM (
             SELECT id, name, position <-> ST_Point(lon, lat) :: GEOGRAPHY AS distance
             FROM pois6 WHERE category = 60000 ORDER BY 3 LIMIT 10 ) top10
         ) a);
END;
$$ LANGUAGE PlPgSQL;
import http, http.server, random, psycopg2

class GetHandler(http.server.BaseHTTPRequestHandler):
    conn = psycopg2.connect("postgres://localhost:5432/geo")
    def do_GET(self):
        self.send_response(http.HTTPStatus.OK)
        self.send_header('Content-type','application/json')
        with GetHandler.conn.cursor() as cursor:
            cursor.execute('SELECT get_random_nearby_store() as res;')
            res = cursor.fetchone()[0]
            self.wfile.write(res.encode('utf-8'))
        return

with http.server.HTTPServer(("localhost", 3001), GetHandler) as httpd: httpd.serve_forever()

案例小结

Level 方法 性能/耗时(ms) 可维护性/可靠性 备注
1 暴力扫表 30,000 - 形式简单
2 经纬索引 35 复杂度/魔数问题 额外复杂度
3 联合索引 10 复杂度/魔数问题 额外复杂度
4 GIST 4 最简表达,完全精确 形式简单,距离更精确,PostgreSQL限定
5 btree_gist联合索引 1 最简表达,完全精确 形式简单,距离更精确,PostgreSQL限定

那么好的,经过这么漫长的旅途,通过PostGIS与PostgreSQL,将原本需要3万毫秒的查询加速至1毫秒,三万倍的提升。相比传统关系型数据库,除了超过十倍以上的性能提升,还有很多优点:

SQL的形式非常简单,就是暴力扫表的SQL,不需要奇奇怪怪的额外复杂度。而且计算距离使用的是更精确的WGS84椭球球面距离。

那么从这个例子中我们可以得出什么结论呢? PostGIS的性能表现是非常优秀的,那么它在实际生产环境里的表现又如何呢?

我们把这里的position,从餐厅的位置换为用户的位置,把poi的种类范围,换成候选人的年龄范围。这就是探探匹配功能所面临的场景。

实际场景中的表现

性能很重要。天下武功,唯快不破。

目前数据库总共用了220台机器,业务QPS近10万。数据库TPS峰值的时候差不多接近250W。其中核心数据库是1主19从的配置。

我厂对于数据库的SLA是:99.99%的普通数据库请求需要在1毫秒内完成,而单个数据库节点的QPS峰值在3万上下。这两者之间其实有着紧密的联系,如果一个请求能在1毫秒内完成,那么对于单个线程而言,每秒钟就可以处理1000个请求。我们使用的数据库物理机CPU为24核48线程,不过超线程的机器CPU利用率在60%~70%左右。可以近似折算为30个可用核。那么,所有核能够承载的QPS量就是30*1000=30000。以极限水位80% CPU算,QPS上限在38k 左右,也与现实压测结果吻合。

整理自本人在2018象形中国北京PostGIS专场所做分享,转载请保留出处。

PostGIS高效解决行政区划归属查询

如何高效解决典型地理逆编码问题:根据用户的经纬度坐标,定位用户的行政区划。

在应用开发中,很多时候我们需要解决这样一个问题:根据用户的经纬度坐标,定位用户的行政区划。

​ 我们收集到的是诸如28°00'00"N 100°00'00.000"E这样的经纬度坐标,但实际感兴趣的是这个点所属的行政区划:(中华人民共和国,云南省,迪庆藏族自治州,香格里拉市)。这种将地理坐标映射到某条记录的操作就称为地理编码(GeoEncode)。高效实现地理编码是一个很有趣的问题。

​ 本文介绍了该问题的解决与优化方案:能在确保正确性的前提下,能用几兆的空间,110μs的执行时间完成一次地理编码。

0x01 正确至上

​ 正确性是第一位的。我们不希望出现用户明明身处A地,却被划分到B地的尴尬情况。然而一个尴尬的现实是,很多地理编码服务的实现粗糙到令人无法直视,Vornoi方法就是一个典型的例子。

​ 假设我们有一系列的坐标点,那么这些坐标点之间两两连线的中垂线就对整个坐标平面做了一个Vornoi划分。每一个细胞的中心点就是细胞核,而元胞内的任意一点到该细胞核的距离是最近的(与其他细胞核相比)。

​ 当我们没有行政区划的边界数据,但有行政区划中心点的数据时,这也是一种能凑合管用办法。找到距离用户最近的某级行政区域中心,然后认为用户就位于该行政区域中心。这个功能实现起来非常简单。

​ 不过,这种方法对于边界情况的处理很差:

最近邻搜索—Vornoi方法

vornoi

现实总是与理想情况相距甚远。也许对于国内而言,这种错误影响也许并不大。但涉及到国际主权边界时,这种粗糙的实现很可能会给自己带来不必要的麻烦:

​ 还有一种思路,和编程中的“查表法”类似,预先计算好所有经纬度到行政区划的映射,使用时只要用经纬度坐标查表就好了。当然无论经度还是维度,都是一个连续的标量,理论上精度必然是有限的。

​ GeoHash就是这样一种方案:它将经度与维度交叉编码为单一字符串,字符串越长精度越高,每一个字符串都对应一个经纬度围成的“矩形”,只要精度足够,理论上是可以这么做的。当然,这种方案难以做到真正意义上的正确,存储开销也极为浪费。好处是实现很简单。只要有数据,一个KV服务就可以轻松搞定。

geohash

​ 相比之下,基于地理边界多边形的解决方案在保证绝对正确的前提下,能在一毫秒内完成这种地理编码功能,而且可能只需要几兆的空间。唯一的难点可能在于如何获取数据上。

0x02 数据为王

​ 地理编码属于典型的数据密集型应用,数据的质量直接决定了最终服务的效果。要想真正做好服务,优质数据必不可少。好在行政区划与地理边界数据也不算什么保密信息,有一些地方提供了公开获取的方式:

民政部信息查询平台与高德地图两者都提供了精确到县级区划的地理边界数据:

  • 高德地图行政区域查询API

    高德的数据更新更及时,形式简单,边界精度较高(点数多),但不够权威,有不少错漏之处

    geohash

  • 民政部全国行政区划信息查询平台

    民政部平台数据相对更加权威,而且采用的是拓扑编码,严格避免了边界重叠的问题,使用无偏的WGS84坐标,但边界精度较低(点数目较少)。

geohash

​ 除了地理围栏数据之外,另一份重要的数据是行政区划代码数据。国家统计局使用的12位城乡统计用行政区划代码编制还是很科学的,具有层次包含关系,尤其适合作为行政区划的唯一标示。但问题是稍显过时,最新的版本是2016年8月发布的,2018年7月后可能会发布一份更新的数据。

笔者整理了一份连接国际统计局行政区划与高德区划边界的数据:https://github.com/Vonng/adcode

民政部的数据可以直接在该网站中打开浏览器的调试工具,从接口返回数据中直接获取。

0x03 牛刀小试

假设我们已经有一张表了,全国行政区划与地理围栏表:adcode_fences

create table adcode_fences
(
  code         bigint,
  parent       bigint,
  name         varchar(64),
  level        varchar(16),
  rank         integer,
  adcode       integer,
  post_code    varchar(8),
  area_code    varchar(4),
  ur_code      varchar(4),
  municipality boolean,
  virtual      boolean,
  dummy        boolean,
  longitude    double precision,
  latitude     double precision,
  center       geometry,
  province     varchar(64),
  city         varchar(64),
  county       varchar(64),
  town         varchar(64),
  village      varchar(64),
  fence        geometry
);

geohash

索引

为了高效执行空间查询,首先需要在表示地理边界的fence列上创建GIST索引。

中国县级行政区划的记录数据并不多(约3000条),但使用索引仍然能带来几十倍的性能提升。因为这个优化太基础太Trivial了,就不单独拎出来说了。(一百多毫秒到几毫秒)

CREATE INDEX ON adcode_fences USING GIST(fence);

查询

PostGIS提供了ST_ContainsST_Within两个函数,用于判断多边形与点之间的包含关系,例如以下SQL就会找出表中所有包含该点(116,40)的行政区划:

SELECT
  code,
  name
FROM adcode_fences
WHERE ST_Contains(fence, ST_Point(116, 40))
ORDER BY rank;

结果是:

100000000000	中华人民共和国
110000000000	北京市
110100000000	市辖区
110109000000	门头沟区

再比如(100,28)的坐标点:

SELECT json_object_agg(level,name) 
FROM adcode_fences WHERE ST_Contains(fence, ST_Point(100, 28));
{
  "country": "中华人民共和国",
  "city": "迪庆藏族自治州",
  "county": "香格里拉市",
  "province": "云南省"
}

相当不可思议,数据就位之后,借力于PostgreSQL与PostGIS,实现这一功能所需的代码少的惊人:一行SQL。

​ 在笔者的笔记本上,该查询执行用时6毫秒。6ms的平均查询时间,换算为48核机器上的QPS差不多就是6400。在我们以前的生产环境代码中基本上就是这么做的,但因为还有其他国家的数据,以及单核主频没有我的机器高,因此一次查询的平均执行时间可能在12毫秒左右。

​ 看上去几毫秒似乎已经很快了,但还是没有达到我们生产环境的性能要求(1毫秒)。对于真实世界的生产业务而言,性能很重要,十倍的性能提升意味着省十倍的机器。还能不能再给力点?实际上通过简单的优化就可以达到百倍的性能提升。

0x04 性能优化

针对数据特性优化

​ 导致上述查询慢的一个重要原因是不必要的相交判断。行政区划是有层级关系的,如果一个用户位于县级行政区划中,那么他一定位于该县级区划所处的省级区划中。因此,知道了最低级的行政区划,其高级区划归属已经自然而然地确定了;那么与省界,国界做相交判断就是没有必要的。 实际上这可能是效果最明显的优化,单是中国地理边界与点做相交判断可能就需要几毫秒。

区域切分

​ R树索引的原理,能为我们带来优化的启发。R树是基于**AABB(Axis Aligned Bounding Box)**的索引。因此越是饱满的凸多边形,索引的效果就越好。而对于拥有遥远飞地的行政区划,效果则可能恶化的很厉害。因此,将区域切分为均匀饱满的小块,能有效提高查询的性能。

​ 最基本的优化,就是将所有的ST_MultiPolygon拆分为ST_Polygon,并指向同一个行政区划。更进一步,可以将长得比较畸形的行政区划切分为形状饱满的小块(典型的比如甘肃这种)。当然,这样的代价就是让所有行政区划与地理围栏从一对一变成了一对多的关系。需要拆出一张单独的表。

​ 实际操作中,如果已经有了县级行政区划的数据,通常只要将带有飞地的MultiPolygon拆为单独的几个Polygon,就已经能有很好的表现了。而县一级的行政区划通常边界也比较饱满,进一步拆分效果相当有限。

精确度

​ 正确性是第一位的,然而有的时候我们宁愿牺牲一些准确性,换来性能的大幅提升。例如高德与民政部的数据对比,显然民政部要粗糙的多,但对于糙猛快的互联网场景,低精度的数据反而可能是更合适的。

高德 民政部
geohash geohash

​ 高德的全国行政区划数据约100M左右,而民政部的数据约为10M(以原始拓扑数据表示则为4M)。但实际使用中效果差别不大,因此推荐使用民政部的数据。

主键设计

​ 行政区划有内在的层次关系,国家包含省,省包含城市,城市包含区县,区县包含乡镇,乡镇包含村庄街道。我国的行政区划代码就很好的体现了这种层次关系,十二位的城乡区划代码包含了很丰富的信息:

  • 第1~2位,为省级代码;
  • 第3~4 位,为地级代码;
  • 第5~6位,为县级代码;
  • 第7~9位,为乡级代码;
  • 第10~12位,为村级代码。

因此这种12位的行政区划代码是很适合作为行政区划表的主键的。此外,当需要国际化支持时,这套区划代码体系还可以通过在前面添加国家代码来扩展(相应地中国行政区划对应地就是高位国家代码为0的特殊情况)。

​ 另一方面,地理围栏表与行政区划表由一对一变为多对一,那么地理围栏表就不再适合用行政区划代码作为主键了。可能自增列是一个更合适的选择。

规范化与反规范化

​ 数据模型设计的一个重要权衡就是规范化与反规范化。将地理围栏表从行政区划表中拆出来是一种规范化,而反规范化也可以用于优化:既然行政区划存在层次关系,那么在子行政区划中保留所有的祖先行政区划信息(或仅仅是代码与名称)是很合理的反规范化操作。这样,通过区划代码主键一次查询就可以取出所有的层次信息。

回溯支持

​ 有时候我们想回溯到历史上某个特定时刻,查询该时刻的行政区划状态。

​ 举个例子,行政区划变更并不会影响该区划内现有公民的身份证号码,只会影响新出生公民的身份证号。因此有时候用公民身份证号前6位去查现在的行政区划表可能一无所获,需要回溯到该公民出生的历史时间才能查询到正确的结果。可以参考PostgreSQL MVCC的实现方式,为行政区划表添加一对PostgreSQL提供的tstzrange类型字段,标识行政区划记录版本的有效时间段,并在查询时指明时间点作为筛选条件。PostgreSQL可以支持在范围类型与空间类型上建立联合GIST索引,提供高效查询支持。

​ 不过,时序数据获取难度是很大的。而且一般这个需求也并不常见。所以这里就不展开了。

0x05 设计实现

​ 既然已经将地理编码的功能从区划代码表拆分出来,本题对adcode中的结构就不甚关注了。我们只需要知道凭借code字段能从该表中快速查出我们感兴趣的东西,比如一连串的行政区划层次,行政区划的人口,面积,等级,行政中心等等。

create table adcode
(
  code         bigint PRIMARY KEY ,
  parent       bigint references adcode(code),
  name         text,
  rank         integer,
  path         text[],
        
  …… <other attrs>
);

相比之下,fences表才是我们需要关注的对象,因为这是性能损耗的关键路径。

CREATE TABLE fences (
  id    BIGSERIAL PRIMARY KEY,
  fence geometry(POLYGON),
  code  BIGINT
);

CREATE INDEX ON fences USING GiST(fence);
CREATE INDEX ON fences USING Btree(code);

CLUSTER TABLE fences USING fences_fence_idx;

​ 不使用行政区划代码code作为主键,给予了我们更多的灵活性与优化空间。任何时候需要修正地理编码的逻辑时,只修改fences中的数据即可。你甚至可以添加冗余字段与条件索引,将不同来源的数据,不同等级的行政区划,相互重叠的地理围栏放在同一张表中,灵活地执行自定义的编码逻辑。

​ 说句题外话:如果您能确保自己的数据不会重叠,则可以考虑使用PostgreSQL提供的Exclude约束确保数据完整性:

CREATE TABLE fences (
  id    BIGSERIAL PRIMARY KEY,
  fence geometry(POLYGON),
  code  BIGINT,
  EXCLUDE USING gist(fence WITH &&) 
     -- no need to create gist index for fence anymore
);

性能测试

那么优化完之后的性能表现又如何?让我们随机生成一些坐标点,检验一下性能。

\set	x	random(75,125)
\set	y	random(20,50)
SELECT code FROM fences2 WHERE ST_Contains(fence,ST_Point(:x,:y));

在笔者的机器上,现在一次查询只要0.1ms了,单进程9k TPS,折算为48核机器约为350kTPS

$ pgbench adcode -T 5 -f run.sql

number of clients: 1
number of threads: 1
duration: 5 s
number of transactions actually processed: 45710
latency average = 0.109 ms
tps = 9135.632484 (including connections establishing)
tps = 9143.947723 (excluding connections establishing)

当然拿到code之后还是需要去行政区划表里查一次,但一次索引扫描的开销是很小的。

总的来说,与优化之前的实现相比,性能提升了60倍。落实在生产环境中,可能就意味着省了百来万的成本。

微信公众号原文

PgSQL Distinct On

使用Distinct On扩展字句快速找出分组内具有最大最小值的记录

Distinct On是PostgreSQL提供的特有语法,可以高效解决一些典型查询问题,例如,快速找出分组内具有最大最小值的记录。

前言

找出分组内具有最大最小值的记录,这是一个非常常见的需求。用传统SQL当然有办法解决,但是都不够优雅,PostgreSQL的SQL扩展语法Distinct ON能一步到位解决这一类问题。

DISTINCT ON 语法

SELECT DISTINCT ON (expression [, expression ...]) select_list ...

Here expression is an arbitrary value expression that is evaluated for all rows. A set of rows for which all the expressions are equal are considered duplicates, and only the first row of the set is kept in the output. Note that the “first row” of a set is unpredictable unless the query is sorted on enough columns to guarantee a unique ordering of the rows arriving at the DISTINCT filter. (DISTINCT ON processing occurs after ORDER BY sorting.)

Distinct On应用案例

例如,找出每台机器的最新日志在日志表中,取出按照机器node_id分组,时间戳ts最大的的日志记录。

CREATE TABLE nodes(node_id INTEGER, ts TIMESTAMP);

INSERT INTO test_data
SELECT (random() * 10)::INTEGER as node_id, t
FROM generate_series('2019-01-01'::TIMESTAMP, '2019-05-01'::TIMESTAMP, '1h'::INTERVAL) AS t;

这里可以制造一些随机数据

5	2019-01-01 00:00:00.000000
0	2019-01-01 01:00:00.000000
9	2019-01-01 02:00:00.000000
1	2019-01-01 03:00:00.000000
7	2019-01-01 04:00:00.000000
2	2019-01-01 05:00:00.000000
8	2019-01-01 06:00:00.000000
3	2019-01-01 07:00:00.000000
1	2019-01-01 08:00:00.000000
4	2019-01-01 09:00:00.000000
9	2019-01-01 10:00:00.000000
0	2019-01-01 11:00:00.000000
3	2019-01-01 12:00:00.000000
6	2019-01-01 13:00:00.000000
9	2019-01-01 14:00:00.000000
1	2019-01-01 15:00:00.000000
7	2019-01-01 16:00:00.000000
8	2019-01-01 17:00:00.000000
9	2019-01-01 18:00:00.000000
10	2019-01-01 19:00:00.000000
5	2019-01-01 20:00:00.000000
4	2019-01-01 21:00:00.000000

现在使用DistinctON,这里Distinct On后面的括号里代表了记录需要按哪一个键进行除重,在括号内的表达式列表上有着相同取值的记录会只保留一条记录。(当然保留哪一条是随机的,因为分组内哪一条记录先返回是不确定的)

SELECT DISTINCT ON (node_id) * FROM test_data

0	2019-04-30 17:00:00.000000
1	2019-04-30 22:00:00.000000
2	2019-04-30 23:00:00.000000
3	2019-04-30 13:00:00.000000
4	2019-05-01 00:00:00.000000
5	2019-04-30 20:00:00.000000
6	2019-04-30 11:00:00.000000
7	2019-04-30 15:00:00.000000
8	2019-04-30 16:00:00.000000
9	2019-04-30 21:00:00.000000
10	2019-04-29 18:00:00.000000

DistinctON有一个配套的ORDER BY子句,用于指明分组内哪一条记录将被保留,排序第一条记录会留下,因此如果我们想要每台机器上的最新日志,可以这样写。

SELECT DISTINCT ON (node_id) * FROM test_data ORDER BY node_id, ts DESC NULLS LAST

0	2019-04-30 17:00:00.000000
1	2019-04-30 22:00:00.000000
2	2019-04-30 23:00:00.000000
3	2019-04-30 13:00:00.000000
4	2019-05-01 00:00:00.000000
5	2019-04-30 20:00:00.000000
6	2019-04-30 11:00:00.000000
7	2019-04-30 15:00:00.000000
8	2019-04-30 16:00:00.000000
9	2019-04-30 21:00:00.000000
10	2019-04-29 18:00:00.000000

使用索引加速Distinct On查询

Distinct On查询当然可以被索引加速,例如以下索引就可以让上面的查询用上索引

CREATE INDEX ON test_data USING btree(node_id, ts DESC NULLS LAST);

set enable_seqscan = off;
explain SELECT DISTINCT ON (node_id) * FROM test_data ORDER BY node_id, ts DESC NULLS LAST;
Unique  (cost=0.28..170.43 rows=11 width=12)
  ->  Index Only Scan using test_data_node_id_ts_idx on test_data  (cost=0.28..163.23 rows=2881 width=12)

注意,排序的时候一定要确保NULLS FIRST|LAST与查询时实际使用的规则匹配。否则可能用不上索引。

PgSQL Exclude约束

Exclude约束是一个PostgreSQL扩展,它可以实现一些更高级,更巧妙的的数据库约束。

Exclude约束是一个PostgreSQL扩展,它可以实现一些更高级,更巧妙的的数据库约束。

前言

数据完整性是极其重要的,但由应用保证的数据完整性并不总是那么靠谱:人会犯傻,程序会出错。如果能通过数据库约束来强制数据完整性那是再好不过了:后端程序员不用再担心竞态条件导致的微妙错误,数据分析师也可以对数据质量充满信心,不需要验证与清洗。

关系型数据库通常会提供PRIMARY KEY, FOREIGN KEY, UNIQUE, CHECK约束,然而并不是所有的业务约束都可以用这几种约束表达。一些约束会稍微复杂一些,例如确保IP网段表中的IP范围不发生重叠,确保同一个会议室不会出现预定时间重叠,确保地理区划表中各个城市的边界不会重叠。传统上要实现这种保证是相当困难的:譬如UNIQUE约束就无法表达这种语义,CHECK与存储过程或者触发器虽然可以实现这种检查,但也相当tricky。PostgreSQL提供的EXCLUDE约束可以优雅地解决这一类问题。

Eclude约束的语法

 EXCLUDE [ USING index_method ] ( exclude_element WITH operator [, ... ] ) index_parameters [ WHERE ( predicate ) ] |
 
exclude_element in an EXCLUDE constraint is:
{ column_name | ( expression ) } [ opclass ] [ ASC | DESC ] [ NULLS { FIRST | LAST } ]

EXCLUDE子句定一个排除约束,它保证如果任意两行在指定列或表达式上使用指定操作符进行比较,不是所有的比较都将会返回TRUE。如果所有指定的操作符都测试相等,这就等价于一个UNIQUE约束,尽管一个普通的唯一约束将更快。不过,排除约束能够指定比简单相等更通用的约束。例如,你可以使用&&操作符指定一个约束,要求表中没有两行包含相互覆盖的圆(见 Section 8.8)。

排除约束使用一个索引实现,这样每一个指定的操作符必须与用于索引访问方法index_method的一个适当的操作符类(见Section 11.9)相关联。操作符被要求是交换的。每一个exclude_element可以选择性地指定一个操作符类或者顺序选项,这些在???中有完整描述。

访问方法必须支持amgettuple(见Chapter 61),目前这意味着GIN无法使用。尽管允许,但是在一个排除约束中使用 B-树或哈希索引没有意义,因为它无法做得比一个普通唯一索引更出色。因此在实践中访问方法将总是GiST或SP-GiST。

predicate允许你在该表的一个子集上指定一个排除约束。在内部这会创建一个部分索引。注意在为此周围的圆括号是必须的。

应用案例:会议室预定

假设我们想要设计一个会议室预定系统,并希望在数据库层面确保不会有冲突的会议室预定出现:即,对于同一个会议室,不允许同时存在两条预定时间范围上存在重叠的记录。那么数据库表可以这样设计:

-- PostgreSQL自带扩展,为普通类型添加GIST索引运算符支持
CREATE EXTENSION btree_gist;

-- 会议室预定表
CREATE TABLE meeting_room
(
    id      SERIAL PRIMARY KEY,
    user_id INTEGER,
    room_id INTEGER,
    range   tsrange,
    EXCLUDE USING GIST(room_id WITH = , range WITH &&)
);

这里EXCLUDE USING GIST(room_id WITH = , range WITH &&)指明了一个排它约束:不允许存在room_id相等,且range相互重叠的多条记录。

-- 用户1预定了101号房间,从早上10点到下午6点
INSERT INTO meeting_room(user_id, room_id, range) 
VALUES (1,101, tsrange('2019-01-01 10:00', '2019-01-01 18:00'));

-- 用户2也尝试预定101号房间,下午4点到下午6点
INSERT INTO meeting_room(user_id, room_id, range) 
VALUES (2,101, tsrange('2019-01-01 16:00', '2019-01-01 18:00'));

-- 用户2的预定报错,违背了排它约束
ERROR:  conflicting key value violates exclusion constraint "meeting_room_room_id_range_excl"
DETAIL:  Key (room_id, range)=(101, ["2019-01-01 16:00:00","2019-01-01 18:00:00")) conflicts with existing key (room_id, range)=(101, ["2019-01-01 10:00:00","2019-01-01 18:00:00")).

这里的EXCLUDE约束会自动创建一个相应的GIST索引:

"meeting_room_room_id_range_excl" EXCLUDE USING gist (room_id WITH =, range WITH &&)

应用案例:确保IP网段不重复

有一些约束是相当复杂的,例如确保表中的IP范围不发生重叠,类似的,确保地理区划表中各个城市的边界不会重叠。传统上要实现这种保证是相当困难的:譬如UNIQUE约束就无法表达这种语义,CHECK与存储过程或者触发器虽然可以实现这种检查,但也相当tricky。PostgreSQL提供的EXCLUDE约束可以优雅地解决这个问题。修改我们的geoips表:

create table geoips
(
  ips          inetrange,
  geo          geometry(Point),
  country_code text,
  region_code  text,
  city_name    text,
  ad_code      text,
  postal_code  text,
  EXCLUDE USING gist (ips WITH &&) DEFERRABLE INITIALLY DEFERRED 
);

​ 这里EXCLUDE USING gist (ips WITH &&) 的意思就是ips字段上不允许出现范围重叠,即新插入的字段不能与任何现存范围重叠(&&为真)。而DEFERRABLE INITIALLY IMMEDIATE 表示在语句结束时再检查所有行上的约束。创建该约束会自动在ips字段上创建GIST索引,因此无需手工创建了。

PgSQL 函数易变性等级分类

PgSQL中的函数默认有三种易变性等级,合理使用可以显著改善性能。

PgSQL中的函数默认有三种易变性等级,合理使用可以显著改善性能。

核心种差

  • VOLATILE : 有副作用,不可被优化。
  • STABLE: 执行了数据库查询。
  • IMMUTABLE : 纯函数,执行结果可能会在规划时被预求值并缓存。

什么时候用?

  • VOLATILE : 有任何写入,有任何副作用,需要看到外部命令所做的变更,或者调用了任何VOLATILE的函数
  • STABLE: 有数据库查询,但没有写入,或者函数的结果依赖于配置参数(例如时区)
  • IMMUTABLE : 纯函数。

具体解释

每个函数都带有一个易变性(Volatility) 等级。可能的取值包括 VOLATILESTABLE,以及IMMUTABLE。创建函数时如果没有指定易变性等级,则默认为 VOLATILE。易变性是函数对优化器的承诺:

  • VOLATILE函数可以做任何事情,包括修改数据库状态。在连续调用时即使使用相同的参数,也可能会返回不同的结果。优化器不会优化掉此类函数,每次调用都会重新求值。
  • STABLE函数不能修改数据库状态,且在单条语句中保证给定同样的参数一定能返回同样的结果,因而优化器可以将相同参数的多次调用优化成一次调用。在索引扫描条件中允许使用STABLE函数,但VOLATILE函数就不行。(一次索引扫描中只会对参与比较的值求值一次,而不是每行求值一次,因而在一个索引扫描条件中不能使用 VOLATILE函数)。
  • IMMUTABLE函数不能修改数据库状态,并且保证任何时候给定输入永远返回相同的结果。这种分类允许优化器在一个查询用常量参数调用该函数 时提前计算该函数。例如,一个 SELECT ... WHERE x = 2 + 2这样的查询可以被简化为SELECT ... WHERE x = 4,因为整数加法操作符底层的函数被 标记为IMMUTABLE

STABLE与IMMUTABLE的区别

调用次数优化

以下面这个函数为例,它只是简单的返回常数2

CREATE OR REPLACE FUNCTION return2() RETURNS INTEGER AS
$$
BEGIN
RAISE NOTICE 'INVOKED';
RETURN 2;
END;
$$ LANGUAGE PLPGSQL STABLE;

当使用STABLE标签时,它会真的调用10次,而当使用IMMUTABLE标签时,它会被优化为一次调用。

vonng=# select return2() from generate_series(1,10);
NOTICE:  INVOKED
NOTICE:  INVOKED
NOTICE:  INVOKED
NOTICE:  INVOKED
NOTICE:  INVOKED
NOTICE:  INVOKED
NOTICE:  INVOKED
NOTICE:  INVOKED
NOTICE:  INVOKED
NOTICE:  INVOKED
 return2
---------
       2
       2
       2
       2
       2
       2
       2
       2
       2
       2
(10 rows)

这里将函数的标签改为IMMUTABLE

CREATE OR REPLACE FUNCTION return2() RETURNS INTEGER AS
$$
BEGIN
RAISE NOTICE 'INVOKED';
RETURN 2;
END;
$$ LANGUAGE PLPGSQL IMMUTABLE;

再执行同样的查询,这次函数只被调用了一次

vonng=# select return2() from generate_series(1,10);
NOTICE:  INVOKED
 return2
---------
       2
       2
       2
       2
       2
       2
       2
       2
       2
       2
(10 rows)

执行计划缓存

第二个例子是有关索引条件中的函数调用,假设我们有这么一张表,包含从1到1000的整数:

create table demo as select * from generate_series(1,1000) as id;
create index idx_id on demo(id);

现在创建一个IMMUTABLE的函数mymax

CREATE OR REPLACE FUNCTION mymax(int, int)
RETURNS int
AS $$
BEGIN
     RETURN CASE WHEN $1 > $2 THEN $1 ELSE $2 END;
END;
$$ LANGUAGE 'plpgsql' IMMUTABLE;

我们会发现,当我们在索引条件中直接使用该函数时,执行计划中的索引条件被直接求值缓存并固化为了id=2

vonng=# EXPLAIN SELECT * FROM demo WHERE id = mymax(1,2);
                               QUERY PLAN
------------------------------------------------------------------------
 Index Only Scan using idx_id on demo  (cost=0.28..2.29 rows=1 width=4)
   Index Cond: (id = 2)
(2 rows)

而如果将其改为STABLE函数,则结果变为运行时求值:

vonng=# EXPLAIN SELECT * FROM demo WHERE id = mymax(1,2);
                               QUERY PLAN
------------------------------------------------------------------------
 Index Only Scan using idx_id on demo  (cost=0.53..2.54 rows=1 width=4)
   Index Cond: (id = mymax(1, 2))
(2 rows)

架构成熟度模型

架构,特别是互联网服务的架构,按照组织的典型特征与负载数量级,可以简单地分为以下九个阶段。

架构,特别是互联网服务的架构,按照组织的典型特征与负载数量级,可以分为以下九个阶段。

这种划分方式虽然简单粗暴,但是很有效果。

1~3为简单系统,4-6为中等系统,7-9为复杂系统。

Go数据库接口教程

同JDBC类似,Go也有标准的数据库访问接口。本文详细介绍了database/sql的使用方法和注意事项。

Go数据库教程: database/sql

​ Go使用SQL与类SQL数据库的惯例是通过标准库database/sql。这是一个对关系型数据库的通用抽象,它提供了标准的、轻量的、面向行的接口。不过database/sql的包文档只讲它做了什么,却对如何使用只字未提。快速指南远比堆砌事实有用,本文讲述了database/sql的使用方法及其注意事项。

1. 顶层抽象

​ 在Go中访问数据库需要用到sql.DB接口:它可以创建语句(statement)和事务(transaction),执行查询,获取结果。

sql.DB并不是数据库连接,也并未在概念上映射到特定的数据库(Database)或模式(schema)。它只是一个抽象的接口,不同的具体驱动有着不同的实现方式。通常而言,sql.DB会处理一些重要而麻烦的事情,例如操作具体的驱动打开/关闭实际底层数据库的连接,按需管理连接池。

sql.DB这一抽象让用户不必考虑如何管理并发访问底层数据库的问题。当一个连接在执行任务时会被标记为正在使用。用完之后会放回连接池中。不过用户如果用完连接后忘记释放,就会产生大量的连接,极可能导致资源耗尽(建立太多连接,打开太多文件,缺少可用网络端口)。

2. 导入驱动

使用数据库时,除了database/sql包本身,还需要引入想使用的特定数据库驱动。

尽管有时候一些数据库特有的功能必需通过驱动的Ad Hoc接口来实现,但通常只要有可能,还是应当尽量只用database/sql中定义的类型。这可以减小用户代码与驱动的耦合,使切换驱动时代码改动最小化,也尽可能地使用户遵循Go的惯用法。本文使用PostgreSQL为例,PostgreSQL的著名的驱动有:

这里以pgx为例,它性能表现不俗,并对PostgreSQL诸多特性与类型有着良好的支持。既可使用Ad-Hoc API,也提供了标准数据库接口的实现:github.com/jackc/pgx/stdlib

import (
	"database/sql"
	_ "github.com/jackx/pgx/stdlib"
)

使用_别名来匿名导入驱动,驱动的导出名字不会出现在当前作用域中。导入时,驱动的初始化函数会调用sql.Register将自己注册在database/sql包的全局变量sql.drivers中,以便以后通过sql.Open访问。

3. 访问数据

加载驱动包后,需要使用sql.Open()来创建sql.DB

func main() {
	db, err := sql.Open("pgx","postgres://localhost:5432/postgres")
	if err != nil {
		log.Fatal(err)
	}
	defer db.Close()
}

sql.Open有两个参数:

  • 第一个参数是驱动名称,字符串类型。为避免混淆,一般与包名相同,这里是pgx
  • 第二个参数也是字符串,内容依赖于特定驱动的语法。通常是URL的形式,例如postgres://localhost:5432
  • 绝大多数情况下都应当检查database/sql操作所返回的错误。
  • 一般而言,程序需要在退出时通过sql.DBClose()方法释放数据库连接资源。如果其生命周期不超过函数的范围,则应当使用defer db.Close()

执行sql.Open()并未实际建立起到数据库的连接,也不会验证驱动参数。第一个实际的连接会惰性求值,延迟到第一次需要时建立。用户应该通过db.Ping()来检查数据库是否实际可用。

if err = db.Ping(); err != nil {
	// do something about db error
}

sql.DB对象是为了长连接而设计的,不要频繁Open()Close()数据库。而应该为每个待访问的数据库创建一个sql.DB实例,并在用完前一直保留它。需要时可将其作为参数传递,或注册为全局对象。

如果没有按照database/sql设计的意图,不把sql.DB当成长期对象来用而频繁开关启停,就可能遭遇各式各样的错误:无法复用和共享连接,耗尽网络资源,由于TCP连接保持在TIME_WAIT状态而间断性的失败等……

4. 获取结果

有了sql.DB实例之后就可以开始执行查询语句了。

Go将数据库操作分为两类:QueryExec。两者的区别在于前者会返回结果,而后者不会。

  • Query表示查询,它会从数据库获取查询结果(一系列行,可能为空)。
  • Exec表示执行语句,它不会返回行。

此外还有两种常见的数据库操作模式:

  • QueryRow表示只返回一行的查询,作为Query的一个常见特例。
  • Prepare表示准备一个需要多次使用的语句,供后续执行用。

4.1 获取数据

让我们看一个如何查询数据库并且处理结果的例子:利用数据库计算从1到10的自然数之和。

func example() {
	var sum, n int32

	// invoke query
	rows, err := db.Query("SELECT generate_series(1,$1)", 10)
    // handle query error
	if err != nil {
		fmt.Println(err)
	}
    // defer close result set
	defer rows.Close()

	// Iter results
	for rows.Next() {
		if err = rows.Scan(&n); err != nil {
			fmt.Println(err)	// Handle scan error
		}
		sum += n				// Use result
	}

	// check iteration error
	if rows.Err() != nil {
		fmt.Println(err)
	}

	fmt.Println(sum)
}
  • 整体工作流程如下:

    1. 使用db.Query()来发送查询到数据库,获取结果集Rows,并检查错误。
    2. 使用rows.Next()作为循环条件,迭代读取结果集。
    3. 使用rows.Scan从结果集中获取一行结果。
    4. 使用rows.Err()在退出迭代后检查错误。
    5. 使用rows.Close()关闭结果集,释放连接。
  • 一些需要详细说明的地方:

    1. db.Query会返回结果集*Rows和错误。每个驱动返回的错误都不一样,用错误字符串来判断错误类型并不是明智的做法,更好的方法是对抽象的错误做Type Assertion,利用驱动提供的更具体的信息来处理错误。当然类型断言也可能产生错误,这也是需要处理的。

      if err.(pgx.PgError).Code == "0A000" {
      // Do something with that type or error
      }
      

      ``

    2. rows.Next()会指明是否还有未读取的数据记录,通常用于迭代结果集。迭代中的错误会导致rows.Next()返回false

    3. rows.Scan()用于在迭代中获取一行结果。数据库会使用wire protocal通过TCP/UnixSocket传输数据,对Pg而言,每一行实际上对应一条DataRow消息。Scan接受变量地址,解析DataRow消息并填入相应变量中。因为Go语言是强类型的,所以用户需要创建相应类型的变量并在rows.Scan中传入其指针,Scan函数会根据目标变量的类型执行相应转换。例如某查询返回一个单列string结果集,用户可以传入[]bytestring类型变量的地址,Go会将原始二进制数据或其字符串形式填入其中。但如果用户知道这一列始终存储着数字字面值,那么相比传入string地址后手动使用strconv.ParseInt()解析,更推荐的做法是直接传入一个整型变量的地址(如上面所示),Go会替用户完成解析工作。如果解析出错,Scan会返回相应的错误。

    4. rows.Err()用于在退出迭代后检查错误。正常情况下迭代退出是因为内部产生的EOF错误,使得下一次rows.Next() == false,从而终止循环;在迭代结束后要检查错误,以确保迭代是因为数据读取完毕,而非其他“真正”错误而结束的。遍历结果集的过程实际上是网络IO的过程,可能出现各种错误。健壮的程序应当考虑这些可能,而不能总是假设一切正常。

    5. rows.Close()用于关闭结果集。结果集引用了数据库连接,并会从中读取结果。读取完之后必须关闭它才能避免资源泄露。只要结果集仍然打开着,相应的底层连接就处于忙碌状态,不能被其他查询使用。

    6. 因错误(包括EOF)导致的迭代退出会自动调用rows.Close()关闭结果集(和释放底层连接)。但如果程序自行意外地退出了循环,例如中途break & return,结果集就不会被关闭,产生资源泄露。rows.Close方法是幂等的,重复调用不会产生副作用,因此建议使用 defer rows.Close()来关闭结果集。

以上就是在Go中使用数据库的标准方式。

4.2 单行查询

如果一个查询每次最多返回一行,那么可以用快捷的单行查询来替代冗长的标准查询,例如上例可改写为:

var sum int
err := db.QueryRow("SELECT sum(n) FROM (SELECT generate_series(1,$1) as n) a;", 10).Scan(&sum)
if err != nil {
	fmt.Println(err)
}
fmt.Println(sum)

不同于Query,如果查询发生错误,错误会延迟到调用Scan()时统一返回,减少了一次错误处理判断。同时QueryRow也避免了手动操作结果集的麻烦。

需要注意的是,对于单行查询,Go将没有结果的情况视为错误。sql包中定义了一个特殊的错误常量ErrNoRows,当结果为空时,QueryRow().Scan()会返回它。

4.3 修改数据

什么时候用Exec,什么时候用Query,这是一个问题。通常DDL和增删改使用Exec,返回结果集的查询使用Query。但这不是绝对的,这完全取决于用户是否希望想要获取返回结果。例如在PostgreSQL中:INSERT ... RETURNING *;虽然是一条插入语句,但它也有返回结果集,故应当使用Query而不是Exec

QueryExec返回的结果不同,两者的签名分别是:

func (s *Stmt) Query(args ...interface{}) (*Rows, error)
func (s *Stmt) Exec(args ...interface{}) (Result, error) 

Exec不需要返回数据集,返回的结果是ResultResult接口允许获取执行结果的元数据

type Result interface {
	// 用于返回自增ID,并不是所有的关系型数据库都有这个功能。
	LastInsertId() (int64, error)
	// 返回受影响的行数。
	RowsAffected() (int64, error)
}

Exec的用法如下所示:

db.Exec(`CREATE TABLE test_users(id INTEGER PRIMARY KEY ,name TEXT);`)
db.Exec(`TRUNCATE test_users;`)
stmt, err := db.Prepare(`INSERT INTO test_users(id,name) VALUES ($1,$2) RETURNING id`)
if err != nil {
	fmt.Println(err.Error())
}
res, err := stmt.Exec(1, "Alice")

if err != nil {
	fmt.Println(err)
} else {
	fmt.Println(res.RowsAffected())
	fmt.Println(res.LastInsertId())
}

相比之下Query则会返回结果集对象*Rows,使用方式见上节。其特例QueryRow使用方式如下:

db.Exec(`CREATE TABLE test_users(id INTEGER PRIMARY KEY ,name TEXT);`)
db.Exec(`TRUNCATE test_users;`)
stmt, err := db.Prepare(`INSERT INTO test_users(id,name) VALUES ($1,$2) RETURNING id`)
if err != nil {
	fmt.Println(err.Error())
}
var returnID int
err = stmt.QueryRow(4, "Alice").Scan(&returnID)
if err != nil {
	fmt.Println(err)
} else {
	fmt.Println(returnID)
}

同样的语句使用ExecQuery执行有巨大的差别。如上文所述,Query会返回结果集Rows,而存在未读取数据的Rows其实会占用底层连接直到rows.Close()为止。因此,使用Query但不读取返回结果,会导致底层连接永远无法释放。database/sql期望用户能够用完就把连接还回来,所以这样的用法很快就会导致资源耗尽(连接过多)。所以,应该用Exec的语句绝不可用Query来执行。

4.4 准备查询

在上一节的两个例子中,没有直接使用数据库的QueryExec方法,而是首先执行了db.Prepare获取准备好的语句(prepared statement)。准备好的语句Stmtsql.DB一样,都可以执行QueryExec等方法。

4.4.1 准备语句的优势

在查询前进行准备是Go语言中的惯用法,多次使用的查询语句应当进行准备(Prepare)。准备查询的结果是一个准备好的语句(prepared statement),语句中可以包含执行时所需参数的占位符(即绑定值)。准备查询比拼字符串的方式好很多,它可以转义参数,避免SQL注入。同时,准备查询对于一些数据库也省去了解析和生成执行计划的开销,有利于性能。

4.4.2 占位符

PostgreSQL使用$N作为占位符,N是一个从1开始递增的整数,代表参数的位置,方便参数的重复使用。MySQL使用?作为占位符,SQLite两种占位符都可以,而Oracle则使用:param1的形式。

MySQL               PostgreSQL            Oracle
=====               ==========            ======
WHERE col = ?       WHERE col = $1        WHERE col = :col
VALUES(?, ?, ?)     VALUES($1, $2, $3)    VALUES(:val1, :val2, :val3)

PostgreSQL为例,在上面的例子中:"SELECT generate_series(1,$1)" 就用到了$N的占位符形式,并在后面提供了与占位符数目匹配的参数个数。

4.4.3 底层内幕

准备语句有着各种优点:安全,高效,方便。但Go中实现它的方式可能和用户所设想的有轻微不同,尤其是关于和database/sql内部其他对象交互的部分。

在数据库层面,准备语句Stmt是与单个数据库连接绑定的。通常的流程是:客户端向服务器发送带有占位符的查询语句用于准备,服务器返回一个语句ID,客户端在实际执行时,只需要传输语句ID和相应的参数即可。因此准备语句无法在连接之间共享,当使用新的数据库连接时,必须重新准备。

database/sql并没有直接暴露出数据库连接。用户是在DBTx上执行Prepare,而不是Conn。因此database/sql提供了一些便利处理,例如自动重试。这些机制隐藏在Driver中实现,而不会暴露在用户代码中。其工作原理是:当用户准备一条语句时,它在连接池中的一个连接上进行准备。Stmt对象会引用它实际使用的连接。当执行Stmt时,它会尝试会用引用的连接。如果那个连接忙碌或已经被关闭,它会获取一个新的连接,并在连接上重新准备,然后再执行。

因为当原有连接忙时,Stmt会在其他连接上重新准备。因此当高并发地访问数据库时,大量的连接处于忙碌状态,这会导致Stmt不断获取新的连接并执行准备,最终导致资源泄露,甚至超出服务端允许的语句数目上限。所以通常应尽量采用扇入的方式减小数据库访问并发数。

4.4.4 查询的微妙之处

数据库连接其实是实现了Begin,Close,Prepare方法的接口。

type Conn interface {
        Prepare(query string) (Stmt, error)
        Close() error
        Begin() (Tx, error)
}

所以连接接口上实际并没有ExecQuery方法,这些方法其实定义在Prepare返回的Stmt上。对于Go而言,这意味着db.Query()实际上执行了三个操作:首先对查询语句做了准备,然后执行查询语句,最后关闭准备好的语句。这对数据库而言,其实是3个来回。设计粗糙的程序与简陋实现驱动可能会让应用与数据库交互的次数增至3倍。好在绝大多数数据库驱动对于这种情况有优化,如果驱动实现sql.Queryer接口:

type Queryer interface {
        Query(query string, args []Value) (Rows, error)
}

那么database/sql就不会再进行Prepare-Execute-Close的查询模式,而是直接使用驱动实现的Query方法向数据库发送查询。对于查询都是即拼即用,也不担心安全问题的情况下,直接Query可以有效减少性能开销。

5. 使用事务

事物是关系型数据库的核心特性。Go中事务(Tx)是一个持有数据库连接的对象,它允许用户在同一个连接上执行上面提到的各类操作。

5.1 事务基本操作

通过db.Begin()来开启一个事务,Begin方法会返回一个事务对象Tx。在结果变量Tx上调用Commit()或者Rollback()方法会提交或回滚变更,并关闭事务。在底层,Tx会从连接池中获得一个连接并在事务过程中保持对它的独占。事务对象Tx上的方法与数据库对象sql.DB的方法一一对应,例如Query,Exec等。事务对象也可以准备(prepare)查询,由事务创建的准备语句会显式绑定到创建它的事务。

5.2 事务注意事项

使用事务对象时,不应再执行事务相关的SQL语句,例如BEGIN,COMMIT等。这可能产生一些副作用:

  • Tx对象一直保持打开状态,从而占用了连接。
  • 数据库状态不再与Go中相关变量的状态保持同步。
  • 事务提前终止会导致一些本应属于事务内的查询语句不再属于事务的一部分,这些被排除的语句有可能会由别的数据库连接而非原有的事务专属连接执行。

当处于事务内部时,应当使用Tx对象的方法而非DB的方法,DB对象并不是事务的一部分,直接调用数据库对象的方法时,所执行的查询并不属于事务的一部分,有可能由其他连接执行。

5.3 Tx的其他应用场景

如果需要修改连接的状态,也需要用到Tx对象,即使用户并不需要事务。例如:

  • 创建仅连接可见的临时表
  • 设置变量,例如SET @var := somevalue
  • 修改连接选项,例如字符集,超时设置。

Tx上执行的方法都保证同一个底层连接执行,这使得对连接状态的修改对后续操作起效。这是Go中实现这种功能的标准方式。

5.4 在事务中准备语句

调用Tx.Prepare会创建一个与事务绑定的准备语句。在事务中使用准备语句,有一个特殊问题需要关注:一定要在事务结束前关闭准备语句。

在事务中使用defer stmt.Close()是相当危险的。因为当事务结束后,它会释放自己持有的数据库连接,但事务创建的未关闭Stmt仍然保留着对事务连接的引用。在事务结束后执行stmt.Close(),如果原来释放的连接已经被其他查询获取并使用,就会产生竞争,极有可能破坏连接的状态。

6. 处理空值

可空列(Nullable Column)非常的恼人,容易导致代码变得丑陋。如果可以,在设计时就应当尽量避免。因为:

  • Go语言的每一个变量都有着默认零值,当数据的零值没有意义时,可以用零值来表示空值。但很多情况下,数据的零值和空值实际上有着不同的语义。单独的原子类型无法表示这种情况。

  • 标准库只提供了有限的四种Nullable type::NullInt64, NullFloat64, NullString, NullBool。并没有诸如NullUint64NullYourFavoriteType,用户需要自己实现。

  • 空值有很多麻烦的地方。例如用户认为某一列不会出现空值而采用基本类型接收时却遇到了空值,程序就会崩溃。这种错误非常稀少,难以捕捉、侦测、处理,甚至意识到。

6.1 使用额外的标记字段

database\sql提供了四种基本可空数据类型:使用基本类型和一个布尔标记的复合结构体表示可空值。例如:

type NullInt64 struct {
        Int64 int64
        Valid bool // Valid is true if Int64 is not NULL
}

可空类型的使用方法与基本类型一致:

for rows.Next() {
	var s sql.NullString
	err := rows.Scan(&s)
	// check err
	if s.Valid {
	   // use s.String
	} else {
	   // handle NULL case
	}
}

6.2 使用指针

在Java中通过装箱(boxing)处理可空类型,即把基本类型包装成一个类,并通过指针引用。于是,空值语义可以通过指针为空来表示。Go当然也可以采用这种办法,不过标准库中并没有提供这种实现方式。pgx提供了这种形式的可空类型支持。

6.3 使用零值表示空值

如果数据本身从语义上就不会出现零值,或者根本不区分零值和空值,那么最简便的方法就是使用零值来表示空值。驱动go-pg提供了这种形式的支持。

6.4 自定义处理逻辑

任何实现了Scanner接口的类型,都可以作为Scan传入的地址参数类型。这就允许用户自己定制复杂的解析逻辑,实现更丰富的类型支持。

type Scanner interface {
  		// Scan 从数据库驱动中扫描出一个值,当不能无损地转换时,应当返回错误
  		// src可能是int64, float64, bool, []byte, string, time.Time,也可能是nil,表示空值。
        Scan(src interface{}) error
}

6.5 在数据库层面解决

通过对列添加NOT NULL约束,可以确保任何结果都不会为空。或者,通过在SQL中使用COALESCE来为NULL设定默认值。

7. 处理动态列

Scan()函数要求传递给它的目标变量的数目,与结果集中的列数正好匹配,否则就会出错。

但总有一些情况,用户事先并不知道返回的结果到底有多少列,例如调用一个返回表的存储过程时。

在这种情况下,使用rows.Columns()来获取列名列表。在不知道列类型情况下,应当使用sql.RawBytes作为接受变量的类型。获取结果后自行解析。

cols, err := rows.Columns()
if err != nil {
	// handle this....
}

// 目标列是一个动态生成的数组
dest := []interface{}{
	new(string),
	new(uint32),
	new(sql.RawBytes),
}

// 将数组作为可变参数传入Scan中。
err = rows.Scan(dest...)
// ...

8. 连接池

database/sql包里实现了一个通用的连接池,它只提供了非常简单的接口,除了限制连接数、设置生命周期基本没有什么定制选项。但了解它的一些特性也是很有帮助的。

  • 连接池意味着:同一个数据库上的连续两条查询可能会打开两个连接,在各自的连接上执行。这可能导致一些让人困惑的错误,例如程序员希望锁表插入时连续执行了两条命令:LOCK TABLEINSERT,结果却会阻塞。因为执行插入时,连接池创建了一个新的连接,而这条连接并没有持有表锁。

  • 在需要时,而且连接池中没有可用的连接时,连接才被创建。

  • 默认情况下连接数量没有限制,想创建多少就有多少。但服务器允许的连接数往往是有限的。

  • db.SetMaxIdleConns(N)来限制连接池中空闲连接的数量,但是这并不会限制连接池的大小。连接回收(recycle)的很快,通过设置一个较大的N,可以在连接池中保留一些空闲连接,供快速复用(reuse)。但保持连接空闲时间过久可能会引发其他问题,比如超时。设置N=0则可以避免连接空闲太久。

  • db.SetMaxOpenConns(N)来限制连接池中打开的连接数量。

  • db.SetConnMaxLifetime(d time.Duration)来限制连接的生命周期。连接超时后,会在需要时惰性回收复用。

9. 微妙行为

database/sql并不复杂,但某些情况下它的微妙表现仍然会出人意料。

9.1 资源耗尽

不谨慎地使用database/sql会给自己挖许多坑,最常见的问题就是资源枯竭(resource exhaustion):

  • 打开和关闭数据库(sql.DB)可能会导致资源枯竭;
  • 结果集没有读取完毕,或者调用rows.Close()失败,结果集会一直占用池里的连接;
  • 使用Query()执行一些不返回结果集的语句,返回的未读取结果集会一直占用池里的连接;
  • 不了解准备语句(Prepared Statement)的工作原理会产生许多额外的数据库访问。

9.2 Uint64

Go底层使用int64来表示整型,使用uint64时应当极其小心。使用超出int64表示范围的整数作为参数,会产生一个溢出错误:

// Error: constant 18446744073709551615 overflows int
_, err := db.Exec("INSERT INTO users(id) VALUES", math.MaxUint64) 

这种类型的错误非常不容易发现,它可能一开始表现的很正常,但是溢出之后问题就来了。

9.3 不合预期的连接状态

连接的状态,例如是否处于事务中,所连接的数据库,设置的变量等,应该通过Go的相关类型来处理,而不是通过SQL语句。用户不应当对自己的查询在哪条连接上执行作任何假设,如果需要在同一条连接上执行,需要使用Tx

举个例子,通过USE DATABASE改变连接的数据库对于不少人是习以为常的操作,执行这条语句,只影响当前连接的状态,其他连接仍然访问的是原来的数据库。如果没有使用事务Tx,后续的查询并不能保证仍然由当前的连接执行,所以这些查询很可能并不像用户预期的那样工作。

更糟糕的是,如果用户改变了连接的状态,用完之后它成为空连接又回到了连接池,这会污染其他代码的状态。尤其是直接在SQL中执行诸如BEGINCOMMIT这样的语句。

9.4 驱动的特殊语法

尽管database/sql是一个通用的抽象,但不同的数据库,不同的驱动仍然会有不同的语法和行为。参数占位符就是一个例子。

9.5 批量操作

出乎意料的是,标准库没有提供对批量操作的支持。即INSERT INTO xxx VALUES (1),(2),...;这种一条语句插入多条数据的形式。目前实现这个功能还需要自己手动拼SQL。

9.6 执行多条语句

database/sql并没有对在一次查询中执行多条SQL语句的显式支持,具体的行为以驱动的实现为准。所以对于

_, err := db.Exec("DELETE FROM tbl1; DELETE FROM tbl2") // Error/unpredictable result

这样的查询,怎样执行完全由驱动说了算,用户并无法确定驱动到底执行了什么,又返回了什么。

9.7 事务中的多条语句

因为事务保证在它上面执行的查询都由同一个连接来执行,因此事务中的语句必需按顺序一条一条执行。对于返回结果集的查询,结果集必须Close()之后才能进行下一次查询。用户如果尝试在前一条语句的结果还没读完前就执行新的查询,连接就会失去同步。这意味着事务中返回结果集的语句都会占用一次单独的网络往返。

10. 其他

本文主体基于[[Go database/sql tutorial]]([Go database/sql tutorial]),由我翻译并进行一些增删改,修正过时错误的内容。转载保留出处。

GO与PG实现缓存同步

巧妙运用Pg的Notify功能,可以方便地通知应用元数据变更,实现基于触发器的逻辑复制。

​ Parallel与Hierarchy是架构设计的两大法宝,缓存是Hierarchy在IO领域的体现。单线程场景下缓存机制的实现可以简单到不可思议,但很难想象成熟的应用会只有一个实例。在使用缓存的同时引入并发,就不得不考虑一个问题:如何保证每个实例的缓存与底层数据副本的数据一致性(和实时性)。

​ PostgreSQL在版本9引入了流式复制,在版本10引入了逻辑复制,但这些都是针对PostgreSQL数据库而言的。如果希望PostgreSQL中某张表的部分数据与应用内存中的状态保持一致,我们还是需要自己实现一种逻辑复制的机制。对于关键的少量元数据而言,使用触发器与Notify-Listen就是一个不错的选择。

传统方法

​ 最简单粗暴的办法就是定时重新拉取,例如每个整点,所有应用一起去数据库拉取一次最新版本的数据。很多应用都是这么做的。当然问题也很多:拉的间隔长了,变更不能及时应用,用户体验差;拉的频繁了,IO压力大。而且实例数目和数据大小一旦膨胀起来,对于宝贵的IO资源是很大的浪费。

​ 异步通知是一种更好的办法,尤其是在读请求远多于写请求的情况下。接受到写请求的实例,通过发送广播的方式通知其他实例。RedisPubSub就可以很好地实现这个功能。如果原本下层存储就是Redis自然是再方便不过,但如果下层存储是关系型数据库的话,为这样一个功能引入一个新的组件似乎有些得不偿失。况且考虑到后台管理程序或者其他应用如果在修改了数据库后也要去redis发布通知,实在太麻烦了。一种可行的办法是通过数据库中间件来监听RDS变动并广播通知,淘宝不少东西就是这么做的。但如果DB本身就能搞定的事情,为什么需要额外的组件呢?通过PostgreSQL的Notfiy-Listen机制,可以方便地实现这种功能。

目标

无论从任何渠道产生的数据库记录变更(增删改)都能被所有相关应用实时感知,用于维护自身缓存与数据库内容的一致性。

原理

PostgreSQL行级触发器 + Notify机制 + 自定义协议 + Smart Client

  • 行级触发器:通过为我们感兴趣的表建立一个行级别的写触发器,对数据表中的每一行记录的Update,Delete,Insert都会出发自定义函数的执行。
  • Notify:通过PostgreSQL内建的异步通知机制向指定的Channel发送通知
  • 自定义协议:协商消息格式,传递操作的类型与变更记录的标识
  • Smart Client:客户端监听消息变更,根据消息对缓存执行相应的操作。

实际上这样一套东西就是一个超简易的WAL(Write After Log)实现,从而使应用内部的缓存状态能与数据库保持实时一致(compare to poll)。

实现

DDL

这里以一个最简单的表作为示例,一张以主键标识的users表。

-- 用户表
CREATE TABLE users (
  id   TEXT,
  name TEXT,
  PRIMARY KEY (id)
);

触发器

-- 通知触发器
CREATE OR REPLACE FUNCTION notify_change() RETURNS TRIGGER AS $$
BEGIN
  IF    (TG_OP = 'INSERT') THEN 
	PERFORM pg_notify(TG_RELNAME || '_chan', 'I' || NEW.id); RETURN NEW;
  ELSIF (TG_OP = 'UPDATE') THEN 
	PERFORM pg_notify(TG_RELNAME || '_chan', 'U' || NEW.id); RETURN NEW;
  ELSIF (TG_OP = 'DELETE') THEN 
	PERFORM pg_notify(TG_RELNAME || '_chan', 'D' || OLD.id); RETURN OLD;
  END IF;
END; $$ LANGUAGE plpgsql SECURITY DEFINER;

这里创建了一个触发器函数,通过内置变量TG_OP获取操作的名称,TG_RELNAME获取表名。每当触发器执行时,它会向名为<table_name>_chan的通道发送指定格式的消息:[I|U|D]<id>

题外话:通过行级触发器,还可以实现一些很实用的功能,例如In-DB Audit,自动更新字段值,统计信息,自定义备份策略与回滚逻辑等。

-- 为用户表创建行级触发器,监听INSERT UPDATE DELETE 操作。
CREATE TRIGGER t_user_notify AFTER INSERT OR UPDATE OR DELETE ON users
FOR EACH ROW EXECUTE PROCEDURE notify_change();

创建触发器也很简单,表级触发器对每次表变更执行一次,而行级触发器对每条记录都会执行一次。这样,数据库的里的工作就算全部完成了。

消息格式

通知需要传达出两个信息:变更的操作类型,变更的实体标记。

  • 变更的操作类型就是增删改:INSERT,DELETE,UPDATE。通过一个打头的字符'[I|U|D]‘就可以标识。
  • 变更的对象可以通过实体主键来标识。如果不是字符串类型,还需要确定一种无歧义的序列化方式。

这里为了省事直接使用字符串类型作为ID,那么插入一条id=1的记录,对应的消息就是I1,更新一条id=5的记录消息就是U5,删除id=3的记录消息就是D3

完全可以通过更复杂的消息协议实现更强大的功能。

SmartClient

数据库的机制需要客户端的配合才能生效,客户端需要监听数据库的变更通知,才能将变更实时应用到自己的缓存副本中。对于插入和更新,客户端需要根据ID重新拉取相应实体,对于删除,客户端需要删除自己缓存副本的相应实体。以Go语言为例,编写了一个简单的客户端模块。

本例中使用一个以User.ID作为键,User对象作为值的并发安全字典Users sync.Map作为缓存。

作为演示,启动了另一个goroutine对数据库写入了一些变更。

package main

import "sync"
import "strings"
import "github.com/go-pg/pg"
import . "github.com/Vonng/gopher/db/pg"
import log "github.com/Sirupsen/logrus"

type User struct {
	ID   string `sql:",pk"`
	Name string
}

var Users sync.Map // Users 内部数据缓存

func LoadAllUser() {
	var users []User
	Pg.Query(&users, `SELECT ID,name FROM users;`)
	for _, user := range users {
		Users.Store(user.ID, user)
	}
}

func LoadUser(id string) {
	user := User{ID: id}
	Pg.Select(&user)
	Users.Store(user.ID, user)
}

func PrintUsers() string {
	var buf []string
	Users.Range(func(key, value interface{}) bool {
		buf = append(buf, key.(string));
		return true
	})
	return strings.Join(buf, ",")
}

// ListenUserChange 会监听PostgreSQL users数据表中的变动通知
func ListenUserChange() {
	go func(c <-chan *pg.Notification) {
		for notify := range c {
			action, id := notify.Payload[0], notify.Payload[1:]
			switch action {
			case 'I':
				fallthrough
			case 'U':
				LoadUser(id);
			case 'D':
				Users.Delete(id)
			}
			log.Infof("[NOTIFY] Action:%c ID:%s Users: %s", action, id, PrintUsers())
		}
	}(Pg.Listen("users_chan").Channel())
}

// MakeSomeChange 会向数据库写入一些变更
func MakeSomeChange() {
	go func() {
		Pg.Insert(&User{"001", "张三"})
		Pg.Insert(&User{"002", "李四"})
		Pg.Insert(&User{"003", "王五"})  // 插入
		Pg.Update(&User{"003", "王麻子"}) // 改名
		Pg.Delete(&User{ID: "002"})    // 删除
	}()
}

func main() {
	Pg = NewPg("postgres://localhost:5432/postgres")
	Pg.Exec(`TRUNCATE TABLE users;`)
	LoadAllUser()
	ListenUserChange()
	MakeSomeChange()
	<-make(chan struct{})
}

运行结果如下:

[NOTIFY] Action:I ID:001 Users: 001          
[NOTIFY] Action:I ID:002 Users: 001,002      
[NOTIFY] Action:I ID:003 Users: 002,003,001  
[NOTIFY] Action:U ID:003 Users: 001,002,003  
[NOTIFY] Action:D ID:002 Users: 001,003      

可以看出,缓存确是与数据库保持了同样的状态。

应用场景

小数据量下这种做法是相当可靠的,大数据量下尚未进行充分的测试。

其实,对于上例中缓存同步的场景,完全不需要自定义消息格式,只要发送发生变更的记录ID,由应用直接拉取,然后覆盖或删除缓存中的记录即可。

PgSQL审计触发器

有时候,我们希望记录一些重要的元数据变更,以便事后审计之用。PostgreSQL的触发器就可以很方便地自动解决这一需求。

有时候,我们希望记录一些重要的元数据变更,以便事后审计之用。

PostgreSQL的触发器就可以很方便地自动解决这一需求。

-- 创建一个审计专用schema,并废除所有非superuser的权限。
DROP SCHEMA IF EXISTS audit CASCADE;
CREATE SCHEMA IF NOT EXISTS audit;
REVOKE CREATE ON SCHEMA audit FROM PUBLIC;

-- 审计表
CREATE TABLE audit.action_log (
  schema_name   TEXT                     NOT NULL,
  table_name    TEXT                     NOT NULL,
  user_name     TEXT,
  time          TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
  action        TEXT                     NOT NULL CHECK (action IN ('I', 'D', 'U')),
  original_data TEXT,
  new_data      TEXT,
  query         TEXT
) WITH (FILLFACTOR = 100
);

-- 审计表权限
REVOKE ALL ON audit.action_log FROM PUBLIC;
GRANT SELECT ON audit.action_log TO PUBLIC;


-- 索引
CREATE INDEX logged_actions_schema_table_idx
  ON audit.action_log (((schema_name || '.' || table_name) :: TEXT));

CREATE INDEX logged_actions_time_idx
  ON audit.action_log (time);

CREATE INDEX logged_actions_action_idx
  ON audit.action_log (action);
---------------------------------------------------------------


---------------------------------------------------------------
-- 创建审计触发器函数
---------------------------------------------------------------
CREATE OR REPLACE FUNCTION audit.logger()
  RETURNS TRIGGER AS $body$
DECLARE
  v_old_data TEXT;
  v_new_data TEXT;
BEGIN
  IF (TG_OP = 'UPDATE')
  THEN
    v_old_data := ROW (OLD.*);
    v_new_data := ROW (NEW.*);
    INSERT INTO audit.action_log (schema_name, table_name, user_name, action, original_data, new_data, query)
    VALUES (TG_TABLE_SCHEMA :: TEXT, TG_TABLE_NAME :: TEXT, session_user :: TEXT, substring(TG_OP, 1, 1), v_old_data,
            v_new_data, current_query());
    RETURN NEW;
  ELSIF (TG_OP = 'DELETE')
    THEN
      v_old_data := ROW (OLD.*);
      INSERT INTO audit.action_log (schema_name, table_name, user_name, action, original_data, query)
      VALUES (TG_TABLE_SCHEMA :: TEXT, TG_TABLE_NAME :: TEXT, session_user :: TEXT, substring(TG_OP, 1, 1), v_old_data,
              current_query());
      RETURN OLD;
  ELSIF (TG_OP = 'INSERT')
    THEN
      v_new_data := ROW (NEW.*);
      INSERT INTO audit.action_log (schema_name, table_name, user_name, action, new_data, query)
      VALUES (TG_TABLE_SCHEMA :: TEXT, TG_TABLE_NAME :: TEXT, session_user :: TEXT, substring(TG_OP, 1, 1), v_new_data,
              current_query());
      RETURN NEW;
  ELSE
    RAISE WARNING '[AUDIT.IF_MODIFIED_FUNC] - Other action occurred: %, at %', TG_OP, now();
    RETURN NULL;
  END IF;

  EXCEPTION
  WHEN data_exception
    THEN
      RAISE WARNING '[AUDIT.IF_MODIFIED_FUNC] - UDF ERROR [DATA EXCEPTION] - SQLSTATE: %, SQLERRM: %', SQLSTATE, SQLERRM;
      RETURN NULL;
  WHEN unique_violation
    THEN
      RAISE WARNING '[AUDIT.IF_MODIFIED_FUNC] - UDF ERROR [UNIQUE] - SQLSTATE: %, SQLERRM: %', SQLSTATE, SQLERRM;
      RETURN NULL;
  WHEN OTHERS
    THEN
      RAISE WARNING '[AUDIT.IF_MODIFIED_FUNC] - UDF ERROR [OTHER] - SQLSTATE: %, SQLERRM: %', SQLSTATE, SQLERRM;
      RETURN NULL;
END;
$body$
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog, audit;

COMMENT ON FUNCTION audit.logger() IS '记录特定表上的插入、修改、删除行为';
---------------------------------------------------------------


---------------------------------------------------------------
-- 最后修改时间审计触发器函数
---------------------------------------------------------------
-- 当记录发生变更前,记录修改时间。
CREATE OR REPLACE FUNCTION audit.update_mtime()
  RETURNS TRIGGER AS $$
BEGIN
  NEW.mtime = now();
  RETURN NEW;
END;
$$ LANGUAGE 'plpgsql';

COMMENT ON FUNCTION audit.update_mtime() IS '更新记录mtime';
---------------------------------------------------------------


---------------------------------------------------------------
-- 元数据变动事件触发器函数
-- 向'change'信道发送数据变动的表名
---------------------------------------------------------------
CREATE OR REPLACE FUNCTION audit.notify_change()
  RETURNS TRIGGER AS $$
BEGIN
  PERFORM pg_notify('change', TG_RELNAME);
  RETURN NULL;
END;
$$ LANGUAGE 'plpgsql';

COMMENT ON FUNCTION audit.notify_change() IS '数据变动事件触发器函数,向`change`信道发送数据变动的表名';
---------------------------------------------------------------

PgSQL 5分钟实现推荐系统

用PostgreSQL 5分钟实现一个最简单ItemCF推荐系统

推荐系统大家都熟悉,猜你喜欢,淘宝个性化什么的,前年双十一搞了个大新闻,还拿了CEO特别贡献奖。

今天就来说说怎么用PostgreSQL 5分钟实现一个最简单ItemCF推荐系统,以推荐系统最喜闻乐见的movielens数据集为例。

原理

ItemCF的原理可以看项亮的《推荐系统实战》,不过还是稍微提一下吧,了解的直接跳过就好。

Item CF,全称Item Collaboration Filter,即基于物品的协同过滤,是目前业界应用最多的推荐算法。ItemCF不需要物品与用户的标签、属性,只要有用户对物品的行为日志就可以了,同时具有很好的可解释性。所以无论是亚马逊,Hulu,YouTube,balabala用的都是该算法。

ItemCF算法的核心思想是:给用户推荐那些和他们之前喜欢的物品相似的物品。

这里有两个要点:

  • 用户喜欢物品怎么表示?
  • 物品的相似度怎样表示?

用户评分表

可以通过用户评分表来判断用户对物品的喜爱程度,例如电影数据的5分制:5分表示非常喜欢,1分表示不喜欢。

用户评分表有三个核心字段:user_id, movie_id, rating,分别是用户ID,物品ID,用户对物品的评分。

怎样得到这个表呢?如果本来就是评论打分网站在做推荐系统,直接有用户对电影,音乐,小说的评分记录那是最好不过。其他的场景,比如电商,社交网络,则可以通过用户对物品的行为日志生成这张评分表。例如可以为“浏览”,“点击”,“收藏”,“购买”,点击“我不喜欢”按钮这些行为分别设一个喜好权重:0.1, 0.2, 0.3, 0.4, -100。将所有行为评分加权求和,最终得到这张用户对物品的评分表来,事就成了一半了。

物品相似度

还需要解决的一个问题是物品相似度的计算表示

假设一共有$N$个物品,则物品相似度数据可以表示为一个$N \times N$的矩阵,第$i$行$j$列的值表示物品$i$与物品$j$之间的相似度。这样相似度表示的问题就解决了。

第二个问题是物品相似度矩阵的计算。

但在计算前,首先必须定义,什么是物品的相似度?

两个物品之间的相似度有很多种定义与计算方式,如果我们有物品的各种属性数据(类型,大小,价格,风格,标签)的话,就可以在属性空间定义各式各样的“距离”,来定义相似度。但ItemCF的亮点就在于,不需要物品的属性标签数据也可以计算其相似度来。其核心思想是:如果一对物品被很多人同时喜欢,则认为这一对物品更为相似。

令$N(i)$为喜欢物品$i$的用户集合,$|N(i)|$为喜欢物品$i$的人数,$|N(i) \cap N(j)|$为同时喜欢物品$i,j$的人数,则物品$i,j$之间的相似度$_{ij}$可w以表示为:

$$ w_{ij} = \frac{|N(i) \cap N(j)|}{ \sqrt{ |N(i)| * |N(j)|}} $$

即:同时喜欢物品$i,j$的人数,除以喜爱物品$i$人数和喜爱物品$j$人数的几何平均数。

这样,就可以通过用户对物品的行为日志,导出一份物品之间的相似矩阵数据来。

推荐物品

现在有一个用户$u$,他对物品$j$的评分可以通过以下公式计算:

$$ \displaystyle p_{uj} = \sum_{i \in N(u) \cap S(i, K)} w_{ji}r_{ui} $$

其中,用户$i$对物品$i_1,i_2,\cdots,i_n$的评分分别为$r_1,r_2,…,r_n$,而物品$i_1,i_2,\cdots,i_n$与目标物品$j$的相似度分别为$w_1,w_2,\cdots,w_n$。以用户$u$评分过的物品集合作为纽带,按照评分以相似度加权求和,就可以得到用户$u$对物品$j$的评分了。

对这个预测评分$p$排序取TopN,就得到了用户$u$的推荐物品列表

实践

说了这么多废话,赶紧燥起来。

第一步:准备数据

下载Movielens数据集,开发测试的话选小规模的(100k)就可以。对于ItemCF来说,有用的数据就是用户行为日志,即文件ratings.csv地址

-- movielens 用户评分数据集
CREATE TABLE mls_ratings (
  user_id   INTEGER,
  movie_id  INTEGER,
  rating    TEXT,
  timestamp INTEGER,
  PRIMARY KEY (user_id, movie_id)
);

-- 从CSV导入数据,并将评分乘以2变为2~10的整数便于处理,将Unix时间戳转换为日期类型
COPY mls_ratings FROM '/Users/vonng/Dev/recsys/ml-latest-small/ratings.csv' DELIMITER ',' CSV HEADER;
ALTER TABLE mls_ratings
  ALTER COLUMN rating SET DATA TYPE INTEGER USING (rating :: DECIMAL * 2) :: INTEGER;
ALTER TABLE mls_ratings
  ALTER COLUMN timestamp SET DATA TYPE TIMESTAMPTZ USING to_timestamp(timestamp :: DOUBLE PRECISION);

得到的数据长这样:第一列用户ID列表,第二列电影ID列表,第三列是评分,最后是时间戳。一共十万条

movielens=# select * from mls_ratings limit 10;
 user_id | movie_id | rating |       timestamp
---------+----------+--------+------------------------
       1 |       31 |      5 | 2009-12-14 10:52:24+08
       1 |     1029 |      6 | 2009-12-14 10:52:59+08
       1 |     1061 |      6 | 2009-12-14 10:53:02+08
       1 |     1129 |      4 | 2009-12-14 10:53:05+08

第二步:计算物品相似度

物品相似度的DDL

-- 物品相似度表,这是把矩阵用<i,j,M_ij>的方式在数据库中表示。
CREATE TABLE mls_similarity (
  i INTEGER,
  j INTEGER,
  p FLOAT,
  PRIMARY KEY (i, j)
);

物品相似度是一个矩阵,虽说PostgreSQL里提供了数组,多维数组,自定义数据结构,不过这里为了方便起见还是使用了最传统的矩阵表示方法:坐标索引法$(i,j,m_{ij})$。其中前两个元素为矩阵下标,各自表示物品的ID。最后一个元素存储了这一对物品的相似度。

物品相似度的计算

计算物品相似度,要计算两个中间数据:

  • 每个物品被用户喜欢的次数:$|N(i)|$
  • 每对物品共同被同一个用户喜欢的次数 $|N(i) \cap N(j)|$

如果是用编程语言,那自然可以一趟(One-Pass)解决两个问题。不过SQL就要稍微麻烦点了,好处是不用操心撑爆内存的问题。

这里可以使用PostgreSQL的With子句功能,计算两个临时结果供后续使用,一条SQL就搞定相似矩阵计算:

-- 计算物品相似度矩阵: 3m 53s
WITH mls_occur AS ( -- 中间表:计算每个电影被用户看过的次数
    SELECT
      movie_id,     -- 电影ID: i
      count(*) AS n -- 看过电影i的人数: |N(i)|
    FROM mls_ratings
    GROUP BY movie_id
),
    mls_common AS ( -- 中间表:计算每对电影被用户同时看过的次数
      SELECT
        a.movie_id AS i, -- 电影ID: i
        b.movie_id AS j, -- 电影ID: j
        count(*)   AS n  -- 同时看过电影i和j的人数: |N(i) ∩ N(j)|
      FROM mls_ratings a INNER JOIN mls_ratings b ON a.user_id = b.user_id
      GROUP BY i, j
  )
INSERT INTO mls_similarity
  SELECT
    i,
    j,
    n / sqrt(n1 * n2) AS p  -- 距离公式
  FROM
    mls_common c,
    LATERAL (SELECT n AS n1 FROM mls_occur WHERE movie_id = i) n1,
    LATERAL (SELECT n AS n2 FROM mls_occur WHERE movie_id = j) n2;

物品相似度表大概长这样:

movielens=# SELECT * FROM mls_similarity LIMIT 10;
   i    | j |         p
--------+---+--------------------
 140267 | 1 |  0.110207753755597
   2707 | 1 |  0.180280682843137
 140174 | 1 |  0.113822078644894
   7482 | 1 | 0.0636284762975778

实际上还可以修剪修剪,比如计算时非常小的相似度干脆可以直接删掉。也可以用整个表中相似度的最大值作为单位1,进行归一化。这里都不弄了。

第三步:进行推荐!

现在假设我们为ID为10的用户推荐10部他没看过的电影,该怎么做呢?

WITH seed AS	-- 10号用户评分过的影片作为种子集合
  (SELECT movie_id,rating FROM mls_ratings WHERE user_id = 10)
SELECT
  j as movie_id,	-- 所有待预测评分的电影ID
  sum(seed.rating * p) AS score -- 预测加权分,按此字段降序排序取TopN
FROM
  seed LEFT JOIN mls_similarity s ON seed.movie_id = s.i 
  WHERE j not in (SELECT DISTINCT movie_id FROM seed) -- 去除已经看过的电影(可选)
GROUP BY j ORDER BY score DESC LIMIT 10; -- 聚合,排序,取TOP

推荐结果如下:

 movie_id |      score
----------+------------------
     1270 | 121.487735902517
     1214 | 116.146138947698
     1580 | 116.015331936539
     2797 | 115.144083402858
     1265 | 114.959033115913
      260 | 114.313571128143
     2716 | 113.087151014987
     1097 |  113.07771922959
     1387 | 112.869891345883
     2916 |  112.84326997566

可以进一步包装一下,把它变成一个存储过程get_recommendation

CREATE OR REPLACE FUNCTION get_recommendation(userid INTEGER)
  RETURNS JSONB AS $$ BEGIN
  RETURN (SELECT jsonb_agg(movie_id)
          FROM (WITH seed AS
          (SELECT movie_id,rating FROM mls_ratings WHERE user_id = userid)
                SELECT
                  j as movie_id,
                  sum(seed.rating * p) AS score
                FROM
                  seed LEFT JOIN mls_similarity s ON seed.movie_id = s.i
                WHERE j not in (SELECT DISTINCT movie_id FROM seed)
                GROUP BY j ORDER BY score DESC LIMIT 10) res);
END $$ LANGUAGE plpgsql STABLE;

这样用起来更方便啦,同时也可以在这里加入一些其他的处理逻辑:比如过滤掉禁片黄片,去除用户明确表示过不喜欢的电影,加入一些热门电影,引入一些随机惊喜,打点小广告之类的。

movielens=# SELECT get_recommendation(11) as res;
                                  res
-----------------------------------------------------------------------
 [80489, 96079, 79132, 59315, 91529, 69122, 58559, 59369, 1682, 71535]

最后写个应用把这个存储过程作为OpenAPI开放出去,事就这样成了。

关于这一步可以参考前一篇:当PostgreSQL遇上GraphQL:Postgraphql中的做法,直接由存储过程生成GraphQL API,啥都不用操心了。

What’s more

几行SQL一条龙执行下来,加上下载数据的时间,总共也就五分钟吧。一个简单的推荐系统就这样搭建起来了。

但一个真正的生产系统还需要考虑许许多多其他问题,例如,性能。

这里比如说计算相似度矩阵的时候,才100k条记录花了三四分钟,不太给力。而且这么多SQL写起来,管理起来也麻烦,有没有更好的方案?

这儿有个基于PostgreSQL源码魔改的推荐数据库:RecDB,直接用C实现了推荐系统相关的功能扩展,性能看起来杠杠地;同时还包装了SQL语法糖,一行SQL建立推荐系统!再一行SQL就开始使用啦。

-- 计算推荐所需的信息
CREATE RECOMMENDER MovieRec ON ml_ratings
USERS FROM userid
ITEMS FROM itemid
EVENTS FROM ratingval
USING ItemCosCF

-- 进行推荐!
SELECT * FROM ml_ratings R
RECOMMEND R.itemid TO R.userid ON R.ratingval USING ItemCosCF
WHERE R.userid = 1
ORDER BY R.ratingval
LIMIT 10

PostgreSQL能干的事情太多了,最先进的开源关系数据库确实不是吹的,其实真的可以试一试。

UUID性质原理与应用

UUID性质原理与应用,以及如何利用PostgreSQL的存储过程操作UUID。

最近一个项目需要生成业务流水号,需求如下:

  • ID必须是分布式生成的,不能依赖中心节点分配并保证全局唯一。
  • ID必须包含时间戳并尽量依时序递增。(方便阅读,提高索引效率)
  • ID尽量散列。(分片,与HBase日志存储需要)

在造轮子之前,首先要看一下有没有现成的解决方案。

Serial

传统实践上业务流水号经常通过数据库自增序列或者发码服务来实现。 MySQLAuto Increment,PostgresSerial,或者Redis+lua写个小发码服务都是方便快捷的解决方案。这种方案可以保证全局唯一,但会出现中心节点依赖:每个节点需要访问一次数据库才能拿到序列号。这就产生了可用性问题:如果能在本地生成流水号并直接返回响应,那为什么非要用一次网络访问拿ID呢?如果数据库挂了,节点也GG了。所以这并不是一个理想的方案。

SnowflakeID

然后就是twitter的SnowflakeID了,SnowflakeID是一个BIGINT,第一位不用,41bit的时间戳,10bit的节点ID,12bit的毫秒内序列号。时间戳,工作机器ID,序列号占用的位域长度是可以根据业务需求不同而变化的。

    0                   1                   2                   3
    0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |x|                    41-bit timestamp                         |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |       timestamp   |10-bit machine node|    12-bit serial      |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

SnowflakeID可以说基本满足了这四个需求,首先,通过不同的时间戳(精确到毫秒),节点ID(工作机器ID),以及毫秒内的序列号,某种意义上确实可以做到唯一。一个比较讨喜的特性是所有ID是依时序递增的,所以索引起来或者拉取数据会非常方便,长整形的索引和存储效率也很高,生成效率也没得说。

但我认为SnowflakeId存在两个致命问题:

  • 虽然ID生成不需要中心节点分配,但工作机器ID还是需要手工分配或者提供中心节点协调的,本质上是改善而不是解决问题。
  • 无法解决时间回溯的问题,一旦服务器时间发生调整,几乎一定会生成出重复ID。

UUID (Universally Unique IDentifier)

其实这种问题早就有经典的解决方案了,譬如:UUID by RFC 4122 。著名的IDFA就是一种UUID

UUID是一种格式,共有5个版本,最后我选择了v1作为最终方案。下面详细简单介绍一下UUID v1的性质。

  • 可以分布式本地生成。
  • 保证全局唯一,且可以应对时间回溯或网卡变化导致ID重复生成的问题。
  • 时间戳(60bit),精确至0.1微秒(1e-7 s)。蕴含在ID中。
  • 在一个连续的时间片段(2^32/1e7 s约7min)内,ID单调递增。
  • 连续生成的ID会被均匀散列,(所以分片起来不要太方便,放在HBase里也可以直接当Rowkey)
  • 有现成的标准,不需要任何事先配置与参数输入,各个语言均有实现,开箱即用。
  • 可以直接通过UUID字面值得知大概的业务时间戳。
  • PostgreSQL直接内建UUID支持(ver>9.0)。

综合考虑,这确实是我能找到的最完美的解决方案了。

UUID概览

# Shell中生成一个随机UUID的简单方式
$ python -c 'import uuid;print(uuid.uuid4())'
8d6d1986-5ab8-41eb-8e9f-3ae007836a71

我们通常见到的UUID如上所示,通常用'-'分隔的五组十六进制数字表示。但这个字符串只不过是UUID的字符串表示,即所谓的UUID Literal。实际上UUID是一个128bit的整数。也就是16个字节,两个长整形的宽度。

因为每个字节用2个hex字符表示,所以UUID通常可以表示为32个十六进制数字,按照8-4-4-4-12的形式进行分组。为什么采用这种分组形式?因为最原始版本的UUID v1采用了这种位域划分方式,后面其他版本的UUID虽然可能位域划分跟这个结构已经不同了,依然采用此种字面值表示方法。UUID1是最经典的UUID,所以我着重介绍UUID1。

下面是UUID版本1的位域划分:

    0                   1                   2                   3
    0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |                          time_low                             |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |       time_mid                |         time_hi_and_version   |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |clk_seq_hi_res |  clk_seq_low  |         node (0-1)            |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |                         node (2-5)                            |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   
 typedef struct {
    unsigned32  time_low;
    unsigned16  time_mid;
    unsigned16  time_hi_and_version;
    unsigned8   clock_seq_hi_and_reserved;
    unsigned8   clock_seq_low;
    byte        node[6];
} uuid_t;

但位域划分是按照C结构体的表示方便来划分的,从逻辑上UUID1包括五个部分:

  • 时间戳 :time_low(32), time_mid(16),time_high(12),共60bit。
  • UUID版本:version(4)
  • UUID类型: variant(2)
  • 时钟序列:clock_seq(14)
  • 节点: node(48),UUID1中为MAC地址。

这五个部分实际占用的位域如下图所示:

    0                   1                   2                   3
    0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |                          time_low                             |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |       time_mid                |  ver  |      time_high        |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |var|       clock_seq           |         node (0-1)            |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |                         node (2-5)                            |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

在UUID中:

  • version固定等于0b0001,即版本号固定为1

    反应在字面值上就是:一个合法的UUID v1第三个分组的第一个hex一定是1:

    • 6b54058a-a413-11e6-b501-a0999b048337

    当然,如果这个值是2,3,4,5,也代表着这就是一个版本2,3,4,5的UUID。

  • varient是用来和其他类型UUID(如GUID)进行区分的字段,指明了UUID的位域解释方法。这里固定为0b10

    反应在字面值上,一个合法的UUID v1第四个分组的第一个hex一定是8,9,A,B之一:

    • 6b54058a-a413-11e6-b501-a0999b048337
  • timestamp由系统时钟获得,形式为60bit的整数,内容是:Coordinated Universal Time (UTC) as a count of 100- nanosecond intervals since 00:00:00.00, 15 October 1582 (the date of Gregorian reform to the Christian calendar).

    即从1582/10/15 00:00:00至今经过的百纳秒数(100 ns= 1e-7 s)。这么蛋疼的设计是为了让产生良好的散列,让输出ID分布的熵最大化。

    unix timestamp换算为所需时间戳的公式为:ts * 10000000 + 122192928000000000

    time_low = (long long)timestamp [32:64) ,将时间戳的最低位的32bit按照同样的顺序填入UUID前32bit

    time_mid = (long long)timestamp [16:32) ,将时间戳中间的16bit按照同样的顺序填入UUID的time_mid

    time_high = (long long)timestamp [4:16) ,将时间戳的最高的12bit按照同样的顺序生成time_hi

    不过time_hiversion是共享一个short int的,所以其生成方法为:

    time_hi_and_version = (long long)timestamp[0:16) & 0x0111 | 0x1000

  • clock_seq是为了防止网卡变更与时间回溯导致的ID重复问题,当系统时间回溯或网卡状态变更时,clock_seq会自动重置,从而避免ID重复问题。其形式为14个bit,换算成整数即0~16383,一般的UUID库都会自动处理,不在乎的话也可以随机生成或者设为固定值提高性能。

  • node字段在UUID1中的涵义等同于机器网卡MAC。48bit正好与MAC地址等长。一般UUID库会自动获取,但因为MAC地址泄露出去可能会有一些安全隐患,所以也有一些库是按照IP地址生成的,或者因为拿不到MAC就用一些系统指纹来生成,总之也不用操心。

所以,其实UUIDv1的所有字段都可以自动获取,压根不用人操心。其实是很方便的。

阅读UUID v1时也有一些经验和技巧。

UUID的第一个分组位域宽度为32bit,以百纳秒表示时间的话,也就是(2 ^ 32 / 1e7 s = 429.5 s = 7.1 min)。即每7分钟,第一个分组经历一次重置循环。所以对于随机到达的请求,生成的ID哈希分布应该是很均匀的。

UUID的第二个分组位域宽度为16bit,也就是2^48 / 1e7 s = 326 Day,也就是说,第二个分组基本上每年循环一次。可以近似的看做年内的业务日期。

当然,最靠谱的方法还是用程序直接从UUID v1中提取出时间戳来。这也是非常方便的。

一些问题

前几天需要合并老的业务日志,老的系统里面日志压根没有流水号这个概念,这就让人蛋疼了。新老日志合并需要为老日志补充生成业务流水ID。

UUID v1生成起来是非常方便的,但要手工构造一个UUID去补数据就比较蛋疼了。我在中英文互联网,StackOverflow找了很久都没发现现成的python,Node,Go,pl/pgsql库或者函数能完成这个功能,这些包大抵就是提供一个uuid.v1()给外面用,压根没想到还会有回溯生成ID这种功能吧……

所以我自己写了一个pl/pgsql的存储过程,可以根据业务时间戳和当初工作机器的MAC重新生成UUID1。编写这个函数让我对UUID的实现细节与原理有了更深的了解,还是不错的。

根据时间戳,时钟序列(非必须),MAC生成UUID的存储过程,其他语言同理:

-- Build UUIDv1 via RFC 4122. 
-- clock_seq is a random 14bit unsigned int with range [0,16384)
CREATE OR REPLACE FUNCTION form_uuid_v1(ts TIMESTAMPTZ, clock_seq INTEGER, mac MACADDR)
  RETURNS UUID AS $$
DECLARE
  t       BIT(60) := (extract(EPOCH FROM ts) * 10000000 + 122192928000000000) :: BIGINT :: BIT(60);
  uuid_hi BIT(64) := substring(t FROM 29 FOR 32) || substring(t FROM 13 FOR 16) || b'0001' ||
                     substring(t FROM 1 FOR 12);
BEGIN
  RETURN lpad(to_hex(uuid_hi :: BIGINT) :: TEXT, 16, '0') ||
         (to_hex((b'10' || clock_seq :: BIT(14)) :: BIT(16) :: INTEGER)) :: TEXT ||
         replace(mac :: TEXT, ':', '');
END
$$ LANGUAGE plpgsql;

-- Usage: SELECT form_uuid_v1(time, 666, '44:88:99:36:57:32');

从UUID1中提取时间戳的存储过程

CREATE OR REPLACE FUNCTION uuid_v1_timestamp(_uuid UUID)
  RETURNS TIMESTAMP WITH TIME ZONE AS $$
SELECT to_timestamp(
    (
      ('x' || lpad(h, 16, '0')) :: BIT(64) :: BIGINT :: DOUBLE PRECISION -
      122192928000000000
    ) / 10000000
)
FROM (
       SELECT substring(u FROM 16 FOR 3) ||
              substring(u FROM 10 FOR 4) ||
              substring(u FROM 1 FOR 8) AS h
       FROM (VALUES (_uuid :: TEXT)) s (u)
     ) s;
$$ LANGUAGE SQL IMMUTABLE;

管理

关于PostgreSQL管理运维的经验文章

PG慢查询诊断方法论

慢查询是在线业务数据库的大敌,本文介绍了使用监控系统定位诊断慢查询的一般方法论。

前言

You can’t optimize what you can’t measure

慢查询是在线业务数据库的大敌,如何诊断定位慢查询是DBA的必修课题。

本文介绍了使用监控系统 —— Pigsty诊断慢查询的一般方法论。

慢查询:危害

对于实际服务于在线业务事务处理的PostgreSQL数据库而言,慢查询的危害包括:

  • 慢查询挤占数据库连接,导致普通查询无连接可用,堆积并导致数据库雪崩。
  • 慢查询长时间锁住了主库已经清理掉的旧版本元组,导致流复制重放进程锁死,导致主从复制延迟。
  • 查询越慢,查询间相互踩踏的几率越高,越容易产生死锁、锁等待,事务冲突等问题。
  • 慢查询浪费系统资源,拉高系统水位。

因此,一个合格的DBA必须知道如何及时定位并处理慢查询。

图:一个慢查询优化前后,系统的整体饱和度从40%降到了4%

慢查询诊断 —— 传统方法

传统上来说,在PostgreSQL有两种方式可以获得慢查询的相关信息,一个是通过官方的扩展插件pg_stat_statements,另一种是慢查询日志。

慢查询日志顾名思义,所有执行时间长于log_min_duration_statement参数的查询都会被记录到PG的日志中,对于定位慢查询,特别是对于分析特例、单次慢查询不可或缺。不过慢查询日志也有自己的局限性。在生产环境中出于性能考虑,通常只会记录时长超出某一阈值的查询,那么许多信息就无法从慢查询日志中获取了。当然值得一提的是,尽管开销很大,但全量查询日志仍然是慢查询分析的终极杀手锏

更常用的慢查询诊断工具可能还是pg_stat_statements。这事是一个非常实用的扩展,它会收集数据库内运行查询的统计信息,在任何场景下都强烈建议启用该扩展

pg_stat_statements 提供的原始指标数据以系统视图表的形式呈现。系统中的每一类查询(即抽取变量后执行计划相同的查询)都分配有一个查询ID,紧接着是调用次数,总耗时,最大、最小、平均单次耗时,响应时间都标准差,每次调用平均返回的行数,用于块IO的时间这些指标类数据。

一种简单的方式当然是观察 mean_time/max_time这类指标,从系统的Catalog中,您的确可以知道某类查询有史以来平均的响应时间。对于定位慢查询来说,也许这样也算得上基本够用了。但是像这样的指标,只是系统在当前时刻的一个静态快照,所以能够回答的问题是有限的。譬如说,您想看一看某个查询在加上新索引之后的性能表现是不是有所改善,用这种方式可能就会非常繁琐。

pg_stat_statements需要在shared_preload_library中指定,并在数据库中通过CREATE EXTENSION pg_stat_statements显式创建。创建扩展后即可通过视图pg_stat_statements访问查询统计信息

慢查询的定义

多慢的查询算慢查询?

应该说这个问题取决于业务、以及实际的查询类型,并没有通用的标准

作为一种经验阈值,频繁的CRUD点查,如果超过1ms,可列为慢查询。

对于偶发的单次特例查询而言,通常超过100ms或1s可以列为慢查询。

慢查询诊断 —— Pigsty

监控系统就可以更全面地回答关于慢查询的问题。监控系统中的数据是由无数历史快照组成的(如5秒一次快照采样)。因此用户可以回溯至任意时间点,考察不同时间段内查询平均响应时间的变化。

上图是Pigsty中 PG Query Detail提供的界面,这里展现出了单个查询的详细信息。

这是一个典型的慢查询,平均响应时间几秒钟。为它添加了一个索引后。从右中Query RT仪表盘的上可以看到,查询的平均响应世界从几秒降到了几毫秒。

用户可以利用监控系统提供的洞察迅速定位数据库中的慢查询,定位问题,提出猜想。更重要的是,用户可以即时地在不同层次审视表与查询的详细指标,应用解决方案并获取实时反馈,这对于紧急故障处理是非常有帮助的。

有时监控系统的用途不仅仅在于提供数据与反馈,它还可以作为一种安抚情绪的良药:设想一个慢查询把生产数据库打雪崩了,如果老板或客户没有一个地方可以透明地知道当前的处理状态,难免会焦急地催问,进一步影响问题解决的速度。监控系统也可以做作为精确管理的依据。您可以有理有据地用监控指标的变化和老板与客户吹牛逼。

一个模拟的慢查询案例

Talk is cheap, show me the code

假设用户已经拥有一个 Pigsty沙箱演示环境,下面将使用Pigsty沙箱,演示模拟的慢查询定位与处理流程。

慢查询:模拟

因为没有实际的业务系统,这里我们以一种简单快捷的方式模拟系统中的慢查询。即pgbench自带的类tpc-b场景。

通过make ri / make ro / make rw,在pg-test集群上初始化 pgbench 用例,并对集群施加读写负载

# 50TPS 写入负载
while true; do pgbench -nv -P1 -c20 --rate=50 -T10 postgres://test:test@pg-test:5433/test; done

# 1000TPS 只读负载
while true; do pgbench -nv -P1 -c40 --select-only --rate=1000 -T10 postgres://test:test@pg-test:5434/test; done

现在我们已经有了一个模拟运行中的业务系统,让我们通过简单粗暴的方式来模拟一个慢查询场景。在pg-test集群的主库上执行以下命令,删除表pgbench_accounts的主键:

ALTER TABLE pgbench_accounts DROP CONSTRAINT pgbench_accounts_pkey ;

该命令会移除 pgbench_accounts 表上的主键,导致相关查询从索引扫描变为顺序全表扫描,全部变为慢查询,访问PG Instance ➡️ Query ➡️ QPS,结果如下图所示:

图1:平均查询响应时间从1ms飙升为300ms,单个从库实例的QPS从500下降至7。

与此同时,实例因为慢查询堆积,系统会在瞬间雪崩过载,访问PG Cluster首页,可以看到集群负载出现飙升。

图2:系统负载达到200%,触发机器负载过大,与查询响应时间过长的报警规则。

慢查询:定位

首先,使用PG Cluster面板定位慢查询所在的具体实例,这里以 pg-test-2 为例。

然后,使用PG Query面板定位具体的慢查询:编号为 -6041100154778468427

图3:从查询总览中发现异常慢查询

该查询表现出:

  • 响应时间显著上升: 17us 升至 280ms
  • QPS 显著下降: 从500下降到 7
  • 花费在该查询上的时间占比显著增加

可以确定,就是这个查询变慢了!

接下来,利用PG Stat Statements面板或PG Query Detail,根据查询ID定位慢查询的具体语句

图4:定位查询语句为SELECT abalance FROM pgbench_accounts WHERE aid = $1

慢查询:猜想

获知慢查询语句后,接下来需要推断慢查询产生的原因

SELECT abalance FROM pgbench_accounts WHERE aid = $1

该查询以 aid 作为过滤条件查询 pgbench_accounts 表,如此简单的查询变慢,大概率是这张表上的索引出了问题。 用屁股想都知道是索引少了,因为就是我们自己删掉的嘛!

分析查询后, 可以提出猜想: 该查询变慢是pgbench_accounts表上aid列缺少索引。

下一步,我们就要验证猜想

第一步,使用PG Table Catalog,我们可以检视表的详情,例如表上建立的索引。

第二步,查阅 PG Table Detail 面板,检查 pgbench_accounts 表上的访问,来验证我们的猜想

图5: pgbench_accounts 表上的访问情况

通过观察,我们发现表上的索引扫描归零,与此同时顺序扫描却有相应增长。这印证了我们的猜想!

慢查询:方案

假设一旦成立,就可以着手提出方案,解决问题了。

解决慢查询通常有三种方式:修改表结构修改查询修改索引

修改表结构与查询通常涉及到具体的业务知识和领域知识,需要具体问题具体分析。但修改索引通常来说不需要太多的具体业务知识。

这里的问题可以通过添加索引解决,pgbench_accounts 表上 aid 列缺少索引,那么我们尝试在 pgbench_accounts 表上为 aid 列添加索引,看看能否解决这个问题。

CREATE UNIQUE INDEX ON pgbench_accounts (aid);

加上索引后,神奇的事情发生了。

图6:可以看到,查询的响应时间与QPS已经恢复正常。

图7:系统的负载也恢复正常

慢查询:评估

作为慢查询处理的最后一步,我们通常需要对操作的过程进行记录,对效果进行评估。

有时候一个简单的优化可以产生戏剧性的效果。也许本来需要砸几十万加机器的问题,创建一个索引就解决了。

这种故事,就可以通过监控系统,用很生动直观的形式表达出来,赚取KPI与Credit。

图:一个慢查询优化前后,系统的整体饱和度从40%降到了4%

(相当于节省了X台机器,XX万元,老板看了心花怒放,下一任CTO就是你了!)

慢查询:小结

通过这篇教程,您已经掌握了慢查询优化的一般方法论。即:

  • 定位问题

  • 提出猜想

  • 验证假设

  • 制定方案

  • 评估效果

监控系统在慢查询处理的整个生命周期中都能起到重要的效果。更能将运维与DBA的“经验”与“成果”,以可视化,可量化,可复制的方式表达出来。

黄金监控指标

了解PostgreSQL中的黄金监控指标

前言

玩数据库和玩车有一个共通之处,就是都需要经常看仪表盘。

盯着仪表盘干什么,看指标。为什么看指标,掌握当前运行状态才能有效施加控制。

车有很多指标:车速,胎压,扭矩,刹车片磨损,各种温度,等等等等,各式各样。

但人的注意力空间有限,仪表盘也就那么大,

所以,指标可以分两类:

  • 你会去看的黄金指标 / 关键指标 / 核心指标
  • 你不会看的:黑匣子指标 / 冷指标。

黄金指标就是那几个关键性的核心数据,需要时刻保持关注(或者让自动驾驶系统/报警系统替你时刻保持关注),而冷指标通常只有故障排查时才会去看,故障排查与验尸要求尽可能还原现场,黑匣子指标多多益善。需要时没有就很让人抓狂

今天我们来说说PostgreSQL的核心指标,数据库的核心指标是什么?

数据库的指标

在讲数据库的核心指标之前,我们先来瞄一眼有哪些指标。

avg(count by (ins) ({__name__=~"pg.*"}))
avg(count by (ins) ({__name__=~"node.*"}))

1000多个pg的指标,2000多个机器的指标。

这些指标都是数据宝藏,挖掘与可视化可以提取出其中的价值。

但对于日常管理,只需要少数几个核心指标就可以了。

可用指标千千万,哪些才是核心指标?

核心指标

根据经验和使用频度,不断地做减法,可以筛选出一些核心指标:

指标 缩写 层次 来源 种类
错误日志条数 Error Count SYS/DB/APP 日志系统 错误
连接池排队数 Queue Clients DB 连接池 错误
数据库负载 PG Load DB 连接池 饱和度
数据库饱和度 PG Saturation DB 连接池&节点 饱和度
主从复制延迟 Repl Lag DB 数据库 延迟
平均查询响应时间 Query RT DB 连接池 延迟
活跃后端进程数 Backends DB 数据库 饱和度
数据库年龄 Age DB 数据库 饱和度
每秒查询数 QPS APP 连接池 流量
CPU使用率 CPU Usage SYS 机器节点 饱和度

紧急情况下:错误是始终是第一优先级的黄金指标。

常规情况下:应用视角的黄金指标:QPS与RT

常规情况下:DBA视角的黄金指标:DB饱和度(水位)

为什么是它们?

错误指标

第一优先级的指标永远是错误,错误往往是直接面向终端用户的。

如果只能选一个指标进行监控,那么选错误指标,比如应用,系统,DB层的每秒错误日志条数可能最合适。

一辆车,只能选一个仪表盘上的功能,你会选什么?

错误指标,小车不停只管推。

错误类指标非常重要,直接反映出系统的异常,譬如连接池排队。但错误类指标最大的问题就是,它只在告警时有意义,难以用于日常的水位评估与性能分析,此外,错误类指标也往往难以精确量化,往往只能给出定性的结果:有问题 vs 没问题。

此外,错误类指标难以精确量化。我们只能说:当连接池出现排队时,数据库负载比较大;队列越长,负载越大;没有排队时,数据库负载不怎么大,仅此而已。对于日常使用管理来说,这样的能力肯定也是不够的。

定指标,做监控报警系统的一个重要原因就是用于预防系统过载,如果系统已经过载大量报错,那么使用错误现象反过来定义饱和度是没有意义的

指标的目的,是为了衡量系统的运行状态。,我们还会关注系统其他方面的能力:吞吐量/流量,响应时间/延迟,饱和度/利用率/水位线。这三者分别代表系统的能力,服务质量,负载水平。

关注点不同,后端(数据库用户)关注系统能力与服务质量,DBA(数据库管理者)更关注系统的负载水平。

流量指标

流量类的指标很有潜力,特别是QPS,TPS这样的指标相当具有代表性。

流量指标可以直接衡量系统的能力,譬如每秒处理多少笔订单,每秒处理的多少个请求。

与车速计有异曲同工之妙,高速限速,城市限速。环境,负载。

但像TPS QPS这样流量也存在问题。一个数据库实例上的查询往往是五花八门各式各样的,一个耗时10微秒的查询和一个10秒的查询在统计时都被算为一个Q,类似于QPS这样的指标无法进行横向比较,只有比较粗略的参考意义,甚至当查询类型发生变化时,都无法和自己的历史数据进行纵向比较。此外也很难针对QPS、TPS这样的指标设置利用率目标,同一个数据库执行SELECT 1可以打到几十万的QPS,但执行复杂SQL时可能就只能打到几千的QPS。不同负载类型和机器硬件会对数据库的QPS上限产生显著影响,只有当一个数据库上的查询都是高度单一同质且没有复杂变化的条件下,QPS才有参考意义,在这种苛刻条件下倒是可以通过压力测试设定一个QPS的水位目标。

延迟指标

与档位类似,查询慢,档位低,车速慢。查询档次低,TPS水位低。查询档次高,TPS水位高

延迟适合衡量系统的服务质量。

比起QPS/TPS,RT(响应时间 Response Time)这样的指标反而更具有参考价值。因为响应时间增加往往是系统饱和的前兆。根据经验法则,数据库的负载越大,查询与事务的平均响应时间也会越高。RT相比QPS的一个优势是**,RT是可以设置一个利用率目标的**,比如可以为RT设定一个绝对阈值:不允许生产OLTP库上出现RT超过1ms的慢查询。但QPS这样的指标就很难画出红线来。不过,RT也有自己的问题。第一个问题是它依然是定性而非定量的,延迟增加只是系统饱和的预警,但没法用来精确衡量系统的饱和度。第二个问题通常能从数据库与中间件获取到的RT统计指标都是平均值,但真正起到预警效果的有可能是诸如P99,P999这样的统计量。

饱和度指标

饱和度指标类似汽车的发动机转速表,油量表,水温表。

饱和度指标适合衡量系统的负载

即用户期待的负载指标是一个饱和度(Saturation)指标,所谓饱和度,即服务容量有多”满“,通常是系统中目前最为受限的某种资源的某个具体指标的度量。通常来说,0%的饱和度意味着系统完全空闲,100%的饱和度意味着满载,系统在达到100%利用率前就会出现性能的严重下降,因此设定指标时还需要包括一个利用率目标,或者说水位红线、黄线,当系统瞬时负载超过红线时应当触发告警,长期负载超过黄线时应当进行扩容。