Citus是一个非常实用的能够使PostgreSQL具有进行水平扩展能力的插件,或者说是一款以PostgreSQL插件形式部署的基于PostgreSQL的分布式HTAP数据库。

citus ha方案

citus集群由一个cn节点和n个worker节点组成。cn节点的高可用可以使用任何通用的pg高可用方案,即cn节点通过流复制配置主备2台PG机器;worker节点的高可用除了可以像cn一样采用pg原生的高可用方案,还支持另一种多副本分片的高可用方案。

多副本高可用方案是Citus早期版本默认的worker高可用方案(当时citus.shard_replication_factor默认值为2),这种方案部署非常简单,而且坏一个worker节点也不影响业务。采用多副本高可用方案时,每次写入数据,cn节点需要在2个worker上分别写数据,这也带来一系列不利的地方。

  1. 数据写入的性能下降
  2. 对多个副本的数据一致性的保障也没有pg原生的流复制强
  3. 存在功能上的限制,比如不支持citus mx架构

因此,citus的多副本高可用方案适用场景有限,citus 官方文档上也说可能它只适用于append only的业务场景,不作为推荐的高可用方案了(在Citus 6.1的时候,citus.shard_replication_factor默认值从2改成了1)。

因此,建议citus和cn和worker节点都使用pg的原生流复制部署高可用。

实验环境

主要软件

项目软件环境
Centos 7.4
Postgresql 13.2
Etcd 3.3.11
Patroni 2.0.2
Citus 10.0

机器和vip资源

Citus CN

node1:10.20.192.81
node2:10.20.192.82

Citus Worker

node3:10.20.192.83
node4:10.20.192.84

etcd

node1: 10.20.192.81
node2: 10.20.192.82
node3: 10.20.192.83

VIP(Citus CN)

读写VIP:10.20.192.101
只读VIP:10.20.192.102

VIP(Citus Worker)

读写VIP:10.20.192.103
只读VIP:10.20.192.104

patroni部署

创建Patroni配置文件/etc/patroni.yml,以下是node1的配置示例

scope: cn
namespace: /service/
name: pg1
​
restapi:
  listen: 0.0.0.0:8008
  connect_address: 10.20.192.81:8008
​
etcd:
  hosts: 10.20.192.81:2379
​
bootstrap:
  dcs:
    ttl: 30
    loop_wait: 10
    retry_timeout: 10
    maximum_lag_on_failover: 1048576
    master_start_timeout: 300
    synchronous_mode: false
    postgresql:
      use_pg_rewind: true
      use_slots: true
      parameters:
        listen_addresses: "0.0.0.0"
        port: 5432
        wal_level: logical
        hot_standby: "on"
        wal_keep_segments: 1000
        max_wal_senders: 10
        max_replication_slots: 10
        wal_log_hints: "on"
        max_connections: "100"
        max_prepared_transactions: "100"
        shared_preload_libraries: "citus,timescaledb"
        citus.node_conninfo: "sslmode=prefer"
        citus.replication_model: streaming
        citus.task_assignment_policy: round-robin
​
  initdb:
  - encoding: UTF8
  - locale: C
  - lc-ctype: zh_CN.UTF-8
  - data-checksums
​
  pg_hba:
  - host replication repl 0.0.0.0/0 md5
  - host all all 0.0.0.0/0 md5
​
postgresql:
  listen: 0.0.0.0:5432
  connect_address: 10.20.192.81:5432
  data_dir: /var/lib/pgsql/13/data
  bin_dir: /usr/pgsql-13/bin
​
  authentication:
    replication:
      username: repl
      password: "123456"
    superuser:
      username: postgres
      password: "123456"
​
  basebackup:
    max-rate: 100M
    checkpoint: fast
​
tags:
  nofailover: false
  noloadbalance: false
  clonefrom: false
  nosync: false

其他PG节点的patroni.yml需要相应修改下面4个参数

scope

node1,node2设置为cn
node3,node4设置为wk1

name

node1~node2分别设置pg1~pg2

restapi.connect_address

根据各自节点IP设置

postgresql.connect_address

根据各自节点IP设置

配置citus mx

在cn和woker的主节点创建citus扩展

create extension citus

在cn的主节点上,添加cn和wk1的主节点IP,groupid设置为1和2。

SELECT * from master_add_node('10.20.192.81', 5432, 1, 'primary');
SELECT * from master_add_node('10.20.192.83', 5432, 2, 'primary');

将普通的Worker变成Citus MX节点

select start_metadata_sync_to_node('10.20.192.83', 5432);

默认情况下,Citus MX节点上也会分配分片。官方的Citus MX架构中,Citus MX集群中所有Worker都是Citus MX节点。

如果我们只想让少数几个Worker节点专门用于分担CN负载,那么这些节点上是不需要放分片的。 可以通过设置节点的shouldhaveshards属性进行控制。

SELECT master_set_node_property('10.20.192.81', 5432, 'shouldhaveshards', false);

在Worker的主备节点上分别修改/pgsql/data/pg_hba.conf配置文件,以下内容添加到其它配置项前面允许CN免密连接Worker。

host all all 10.20.192.0/24 trust

修改后重新加载配置

su - postgres
pg_ctl reload

创建分片表测试验证

CREATE TABLE conditions (
      time TIMESTAMPTZ NOT NULL,
      device INTEGER NOT NULL,
      temperature FLOAT NOT NULL,
      PRIMARY KEY(time, device)
);
set citus.shard_count = 32;
select create_distributed_table('conditions', 'device');

读写分离

根据上面的配置,Citus CN不会访问Woker的备机,这些备机闲着也是闲着,能否把这些备节用起来,让Citus CN支持读写分离呢?具体而言就是让CN的备机优先访问Worker的备机,Worker备节故障时访问Worker的主机。

Citus本身支持读写分离功能,可以把一个worker的主备2个节点作为2个worker分别以primary和secondary的角色加入到同一个worker group里。但是,由于Citus的pg_dist_node元数据中要求nodename:nodeport必须唯一,所以前面的动态修改Citus元数据中的worker IP的方式无法同时支持primary节点和secondary节点的动态更新。

解决办法有2个

  1. Citus元数据中只写固定的主机名,比如wk1,wk2...,然后通过自定义的Worker流量自动切换脚本将这个固定的主机名解析成不同的IP地址写入到/etc/hosts里,在CN主库上解析成Worker主库的IP,在CN备库上解析成Worker备库的IP。
  2. 在Worker上动态绑定读写VIP和只读VIP。在Citus元数据中读写VIP作为primary角色的worker,只读VIP作为secondary角色的worker。

Patroni动态绑定vip的方法参考高可用部署。

下面按方法2进行配置。

创建Citus集群时,在CN的主节点上,添加CN的读写VIP(10.20.192.101)和只读VIP(10.20.192.102),分别作为primary worker和secondary worker,groupid设置为1。

SELECT * from master_add_node('10.20.192.101', 5432, 1, 'primary');
SELECT * from master_add_node('10.20.192.102', 5432, 1, 'secondary');

添加wk1的读写VIP(10.20.192.103)和只读VIP(10.20.192.103),分别作为primary worker和secondary worker,groupid设置为2。

SELECT * from master_add_node('10.20.192.103', 5432, 2, 'primary');
SELECT * from master_add_node('10.20.192.104', 5432, 2, 'secondary');

为了让CN备库连接到secondary的worker,还需要在CN备库上设置以下参数

alter system set citus.use_secondary_nodes=always;
select pg_reload_conf();

这个参数的变更只对新创建的会话生效,如果希望立即生效,需要在修改参数后杀掉已有会话。

现在分别到CN主库和备库上执行同一条SQL,可以看到SQL被发往不同的worker。

CN主库(未设置citus.use_secondary_nodes=always):

postgres=# explain select * from conditions;
                                         QUERY PLAN
---------------------------------------------------------------------------------------------
 Custom Scan (Citus Adaptive)  (cost=0.00..0.00 rows=100000 width=20)
   Task Count: 8
   Tasks Shown: One of 8
   ->  Task
         Node: host=10.20.192.101 port=5432 dbname=postgres
         ->  Seq Scan on conditions_102016 conditions  (cost=0.00..83.78 rows=5078 width=20)
(6 rows)

CN备库(设置了citus.use_secondary_nodes=always):

postgres=# explain select * from conditions;
                                         QUERY PLAN
---------------------------------------------------------------------------------------------
 Custom Scan (Citus Adaptive)  (cost=0.00..0.00 rows=100000 width=20)
   Task Count: 8
   Tasks Shown: One of 8
   ->  Task
         Node: host=10.20.192.102 port=5432 dbname=postgres
         ->  Seq Scan on conditions_102016 conditions  (cost=0.00..83.78 rows=5078 width=20)
(6 rows)

由于CN也会发生主备切换,citus.use_secondary_nodes参数必须动态调节。这可以使用Patroni的回调脚本实现

创建动态设置参数的/pgsql/patroni_callback.sh


#!/bin/bash
​
DBNAME=postgres
KILL_ALL_SQL="select pg_terminate_backend(pid) from pg_stat_activity  where backend_type='client backend' and application_name <> 'Patroni' and pid <> pg_backend_pid()"
​
RWVIP=10.20.192.101
RVIP=10.20.192.102
GATEWAY=10.20.192.1
DEV=ens3
​
action=$1
role=$2
cluster=$3
​
log()
{
  echo "patroni_callback: $*"|logger
}
​
load_vip()
{
ip a|grep -w ${DEV}|grep -w $1 >/dev/null
if [ $? -eq 0 ] ;then
  log "vip exists, skip load vip"
else
  sudo ip addr add $1/32 dev ${DEV} >/dev/null
  rc=$?
  if [ $rc -ne 0 ] ;then
    log "fail to add vip $1 at dev ${DEV} rc=$rc"
    exit 1
  fi
​
  log "added vip $1 at dev ${DEV}"
​
  arping -U -I ${DEV} -s $1 ${GATEWAY} -c 5 >/dev/null
  rc=$?
  if [ $rc -ne 0 ] ;then
    log "fail to call arping to gateway ${GATEWAY} rc=$rc"
    exit 1
  fi
​
  log "called arping to gateway ${GATEWAY}"
fi
}
​
unload_vip()
{
ip a|grep -w ${DEV}|grep -w $1 >/dev/null
if [ $? -eq 0 ] ;then
  sudo ip addr del $1/32 dev ${DEV} >/dev/null
  rc=$?
  if [ $rc -ne 0 ] ;then
    log "fail to delete vip $1 at dev ${DEV} rc=$rc"
    exit 1
  fi
​
  log "deleted vip $1 at dev ${DEV}"
else
  log "vip not exists, skip delete vip"
fi
}
​
alter_use_secondary_nodes()
{
  value="$1"
  oldvalue=`psql -d postgres -Atc "show citus.use_secondary_nodes"`
  if [ "$value" = "$oldvalue" ] ; then
    log "old value of use_secondary_nodes already be '${value}', skip change"
    return
  fi
​
  psql -d ${DBNAME} -c "alter system set citus.use_secondary_nodes=${value}" >/dev/null
  rc=$?
  if [ $rc -ne 0 ] ;then
    log "fail to alter use_secondary_nodes to '${value}' rc=$rc"
    exit 1
  fi
​
  psql -d ${DBNAME} -c 'select pg_reload_conf()' >/dev/null
  rc=$?
  if [ $rc -ne 0 ] ;then
    log "fail to call pg_reload_conf() rc=$rc"
    exit 1
  fi
​
  log "changed use_secondary_nodes to '${value}'"
​
  ## kill all existing connections
  killed_conns=`psql -d ${DBNAME} -Atc "${KILL_ALL_SQL}" | wc -l`
  rc=$?
  if [ $rc -ne 0 ] ;then
    log "failed to kill connections rc=$rc"
    exit 1
  fi
​
  log "killed ${killed_conns} connections"
}
​
log "patroni_callback start args:'$*'"
case $action in
  on_start|on_restart|on_role_change)
    case $role in
      master)
        load_vip ${RWVIP}
        unload_vip ${RVIP}
        alter_use_secondary_nodes never
        ;;
      replica)
        load_vip ${RVIP}
        unload_vip ${RWVIP}
        alter_use_secondary_nodes always
        ;;
      *)
        log "wrong role '$role'"
        exit 1
        ;;
    esac
    ;;
  *)
    log "wrong action '$action'"
    exit 1
    ;;
esac

修改Patroni配置文件/etc/patroni.yml,配置回调函数

postgresql:
...
  callbacks:
    on_start: /bin/bash /var/lib/pgsql/patroni_callback.sh
    on_restart: /bin/bash /var/lib/pgsql/patroni_callback.sh
    on_role_change: /bin/bash /var/lib/pgsql/patroni_callback.sh

所有节点的Patroni配置文件都修改后,重新加载Patroni配置

patronictl reload cn

CN上执行switchover后,可以看到use_secondary_nodes参数发生了修改

/var/log/messages:


Sep 10 00:10:25 node2 postgres: switch_use_secondary_nodes: switch_use_secondary_nodes start args:'on_role_change replica cn'
Sep 10 00:10:25 node2 postgres: switch_use_secondary_nodes: changed use_secondary_nodes to 'always'
Sep 10 00:10:25 node2 postgres: switch_use_secondary_nodes: killed 0 connections

客户端多URL

PostgreSQL 10.0 libpq层,也增加了多连接功能,设计时引入了target_session_attrs参数,可以设置为read-write或者any。不同的target_session_attrs配置,对应不同的节点检测机制。

target_session_attrs=read-write,使用show transaction_read_only检测节点,返回on,表示这是只读节点,off表示这是可读写节点。(standby返回on, 同时通过default_transaction_read_only可以让master也返回on)。

target_session_attrs=any,表示不检测。

libpq用法介绍

URL格式

postgresql://[user[:password]@][netloc][:port][,...][/dbname][?param1=value1&...]  
  
postgresql://  
postgresql://localhost  
postgresql://localhost:5433  
postgresql://localhost/mydb  
postgresql://user@localhost  
postgresql://user:secret@localhost  
postgresql://other@localhost/otherdb?connect_timeout=10&application_name=myapp  
postgresql://host1:123,host2:456/somedb?target_session_attrs=any&application_name=myapp 

配置多个目标节点,host:port使用逗号隔开即可。

基于libpq实现的其他语言的驱动相应地也可以支持多主机URL,比如python和php。下面是python程序使用多主机URL创建连接的例子


import psycopg2
​
conn = psycopg2.connect("postgres://192.168.234.201:5432,192.168.234.202:5432/postgres?target_session_attrs=read-write&password=123456")