Quantcast
Channel: 数据库内核月报
Viewing all 687 articles
Browse latest View live

PgSQL · 应用案例 · PG 12 tpcc - use sysbench-tpcc by Percona-Lab

$
0
0

背景

PostgreSQL 已与2019.10.3正式发布,测试其tpcc性能。

环境

阿里云虚拟机

[root@PostgreSQL12 ~]# lscpu      
Architecture:          x86_64      
CPU op-mode(s):        32-bit, 64-bit      
Byte Order:            Little Endian      
CPU(s):                16      
On-line CPU(s) list:   0-15      
Thread(s) per core:    2      
Core(s) per socket:    8      
Socket(s):             1      
NUMA node(s):          1      
Vendor ID:             GenuineIntel      
CPU family:            6      
Model:                 85      
Model name:            Intel(R) Xeon(R) Platinum 8163 CPU @ 2.50GHz      
Stepping:              4      
CPU MHz:               2500.014      
BogoMIPS:              5000.02      
Hypervisor vendor:     KVM      
Virtualization type:   full      
L1d cache:             32K      
L1i cache:             32K      
L2 cache:              1024K      
L3 cache:              33792K      
NUMA node0 CPU(s):     0-15      
Flags:                 fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ss ht syscall nx pdpe1gb rdtscp lm constant_tsc rep_good nopl eagerfpu pni pclmulqdq ssse3 fma cx16 pcid sse4_1 sse4_2 x2apic movbe popcnt tsc_deadline_timer aes xsave avx f16c rdrand hypervisor lahf_lm abm 3dnowprefetch ibrs ibpb stibp fsgsbase tsc_adjust bmi1 hle avx2 smep bmi2 erms invpcid rtm mpx avx512f avx512dq rdseed adx smap avx512cd avx512bw avx512vl xsaveopt xsavec xgetbv1 spec_ctrl intel_stibp      
      
[root@PostgreSQL12 ~]# lsblk      
NAME   MAJ:MIN RM  SIZE RO TYPE MOUNTPOINT      
vda    253:0    0  200G  0 disk       
└─vda1 253:1    0  200G  0 part /      
vdb    253:16   0  1.8T  0 disk       
└─vdb1 253:17   0  1.8T  0 part /data01      
vdc    253:32   0  1.8T  0 disk       
└─vdc1 253:33   0  1.8T  0 part /data02      
      
[root@PostgreSQL12 ~]# uname -a      
Linux PostgreSQL12 3.10.0-957.21.3.el7.x86_64 #1 SMP Tue Jun 18 16:35:19 UTC 2019 x86_64 x86_64 x86_64 GNU/Linux      
      
[root@PostgreSQL12 ~]# free -g      
              total        used        free      shared  buff/cache   available      
Mem:            125           5          79          19          39          91      
Swap:             0           0           0      

测试方法

1、快设备设置

parted -s /dev/vdb mklabel gpt      
parted -s /dev/vdc mklabel gpt      
parted -s /dev/vdb mkpart primary 1MiB 100%      
parted -s /dev/vdc mkpart primary 1MiB 100%      

2、文件系统设置

mkfs.ext4 /dev/vdb1 -m 0 -O extent,uninit_bg -E lazy_itable_init=1 -T largefile -L data01      
mkfs.ext4 /dev/vdc1 -m 0 -O extent,uninit_bg -E lazy_itable_init=1 -T largefile -L data02      
      
vi /etc/fstab         
LABEL=data01 /data01 ext4 defaults,noatime,nodiratime,nodelalloc,barrier=0,data=writeback 0 0      
LABEL=data02 /data02 ext4 defaults,noatime,nodiratime,nodelalloc,barrier=0,data=writeback 0 0        
      
mkdir /data01       
mkdir /data02      
      
mount -a       

3、系统内核设置

vi /etc/sysctl.conf      
      
# add by digoal.zhou            
fs.aio-max-nr = 1048576            
fs.file-max = 76724600            
            
# 可选:kernel.core_pattern = /data01/corefiles/core_%e_%u_%t_%s.%p                     
# /data01/corefiles 事先建好,权限777,如果是软链接,对应的目录修改为777            
            
kernel.sem = 4096 2147483647 2147483646 512000                
# 信号量, ipcs -l 或 -u 查看,每16个进程一组,每组信号量需要17个信号量。            
            
kernel.shmall = 107374182                  
# 所有共享内存段相加大小限制 (建议内存的80%),单位为页。            
kernel.shmmax = 274877906944               
# 最大单个共享内存段大小 (建议为内存一半), >9.2的版本已大幅降低共享内存的使用,单位为字节。            
kernel.shmmni = 819200                     
# 一共能生成多少共享内存段,每个PG数据库集群至少2个共享内存段            
            
net.core.netdev_max_backlog = 10000            
net.core.rmem_default = 262144                   
# The default setting of the socket receive buffer in bytes.            
net.core.rmem_max = 4194304                      
# The maximum receive socket buffer size in bytes            
net.core.wmem_default = 262144                   
# The default setting (in bytes) of the socket send buffer.            
net.core.wmem_max = 4194304                      
# The maximum send socket buffer size in bytes.            
net.core.somaxconn = 4096            
net.ipv4.tcp_max_syn_backlog = 4096            
net.ipv4.tcp_keepalive_intvl = 20            
net.ipv4.tcp_keepalive_probes = 3            
net.ipv4.tcp_keepalive_time = 60            
net.ipv4.tcp_mem = 8388608 12582912 16777216            
net.ipv4.tcp_fin_timeout = 5            
net.ipv4.tcp_synack_retries = 2            
net.ipv4.tcp_syncookies = 1                
# 开启SYN Cookies。当出现SYN等待队列溢出时,启用cookie来处理,可防范少量的SYN攻击            
net.ipv4.tcp_timestamps = 1                
# 减少time_wait            
net.ipv4.tcp_tw_recycle = 0                
# 如果=1则开启TCP连接中TIME-WAIT套接字的快速回收,但是NAT环境可能导致连接失败,建议服务端关闭它            
net.ipv4.tcp_tw_reuse = 1                  
# 开启重用。允许将TIME-WAIT套接字重新用于新的TCP连接            
net.ipv4.tcp_max_tw_buckets = 262144            
net.ipv4.tcp_rmem = 8192 87380 16777216            
net.ipv4.tcp_wmem = 8192 65536 16777216            
            
net.nf_conntrack_max = 1200000            
net.netfilter.nf_conntrack_max = 1200000            
            
vm.dirty_background_bytes = 409600000                   
#  系统脏页到达这个值,系统后台刷脏页调度进程 pdflush(或其他) 自动将(dirty_expire_centisecs/100)秒前的脏页刷到磁盘            
#  默认为10%,大内存机器建议调整为直接指定多少字节            
            
vm.dirty_expire_centisecs = 3000                         
#  比这个值老的脏页,将被刷到磁盘。3000表示30秒。            
vm.dirty_ratio = 95                                      
#  如果系统进程刷脏页太慢,使得系统脏页超过内存 95 % 时,则用户进程如果有写磁盘的操作(如fsync, fdatasync等调用),则需要主动把系统脏页刷出。            
#  有效防止用户进程刷脏页,在单机多实例,并且使用CGROUP限制单实例IOPS的情况下非常有效。              
            
vm.dirty_writeback_centisecs = 100                        
#  pdflush(或其他)后台刷脏页进程的唤醒间隔, 100表示1秒。            
            
vm.swappiness = 0            
#  不使用交换分区            
            
vm.mmap_min_addr = 65536            
vm.overcommit_memory = 0                 
#  在分配内存时,允许少量over malloc, 如果设置为 1, 则认为总是有足够的内存,内存较少的测试环境可以使用 1 .              
            
vm.overcommit_ratio = 90                 
#  当overcommit_memory = 2 时,用于参与计算允许指派的内存大小。            
vm.swappiness = 0                        
#  关闭交换分区            
vm.zone_reclaim_mode = 0                 
# 禁用 numa, 或者在vmlinux中禁止.             
net.ipv4.ip_local_port_range = 40000 65535                
# 本地自动分配的TCP, UDP端口号范围            
fs.nr_open=20480000            
# 单个进程允许打开的文件句柄上限            
            
# 以下参数请注意            
vm.extra_free_kbytes = 4096000            
vm.min_free_kbytes = 2097152    # vm.min_free_kbytes 建议每32G内存分配1G vm.min_free_kbytes        
# 如果是小内存机器,以上两个值不建议设置            
# vm.nr_hugepages = 66536                
#  建议shared buffer设置超过64GB时 使用大页,页大小 /proc/meminfo Hugepagesize            
vm.lowmem_reserve_ratio = 1 1 1            
# 对于内存大于64G时,建议设置,否则建议默认值 256 256 32        
      
      
sysctl -p      

4、系统资源限制设置

vi /etc/security/limits.conf      
      
* soft    nofile  1024000            
* hard    nofile  1024000            
* soft    nproc   unlimited            
* hard    nproc   unlimited            
* soft    core    unlimited            
* hard    core    unlimited            
* soft    memlock unlimited            
* hard    memlock unlimited      

5、自启动

vi /etc/rc.local        
             
if test -f /sys/kernel/mm/transparent_hugepage/enabled; then            
   echo never > /sys/kernel/mm/transparent_hugepage/enabled            
fi            
su - postgres -c "pg_ctl start"
chmod +x /etc/rc.d/rc.local      

6、EPEL包

rpm -ivh https://dl.fedoraproject.org/pub/epel/epel-release-latest-7.noarch.rpm      
      
yum -y install coreutils glib2 lrzsz dstat sysstat e4fsprogs xfsprogs ntp readline-devel zlib-devel openssl-devel pam-devel libxml2-devel libxslt-devel python-devel tcl-devel gcc gcc-c++ make smartmontools flex bison perl-devel perl-ExtUtils* openldap-devel jadetex  openjade bzip2 git iotop       

7、PG 12包

yum install -y https://download.postgresql.org/pub/repos/yum/reporpms/EL-7-x86_64/pgdg-redhat-repo-latest.noarch.rpm      
      
yum install -y postgresql12*      

8、PG12 环境变量

su - postgres        
        
vi .bash_profile        
        
export PS1="$USER@`/bin/hostname -s`-> "            
export PGPORT=1921            
export PGDATA=/data01/pg12/pg_root$PGPORT            
export LANG=en_US.utf8            
export PGHOME=/usr/pgsql-12          
export LD_LIBRARY_PATH=$PGHOME/lib:/lib64:/usr/lib64:/usr/local/lib64:/lib:/usr/lib:/usr/local/lib:$LD_LIBRARY_PATH            
export DATE=`date +"%Y%m%d%H%M"`          
export PATH=$PGHOME/bin:$PATH:.            
export MANPATH=$PGHOME/share/man:$MANPATH            
export PGHOST=$PGDATA            
export PGUSER=postgres            
export PGDATABASE=postgres            
alias rm='rm -i'            
alias ll='ls -lh'            
unalias vi         

9、部署PG12文件系统

mkdir /data01/pg12        
mkdir /data02/pg12        
chown postgres:postgres /data01/pg12        
chown postgres:postgres /data02/pg12        

10、初始化PG12数据库实例

su - postgres        
      
initdb -D $PGDATA -X /data02/pg12/pg_wal1921 -U postgres -E SQL_ASCII --locale=C       

11、数据库参数设置

vi $PGDATA/postgresql.auto.conf       
      
listen_addresses = '0.0.0.0'      
port = 1921      
max_connections = 1000      
superuser_reserved_connections = 13      
unix_socket_directories = '., /var/run/postgresql, /tmp'      
unix_socket_permissions = 0700      
tcp_keepalives_idle = 60      
tcp_keepalives_interval = 10      
tcp_keepalives_count = 10      
tcp_user_timeout = 60      
shared_buffers = 32GB      
maintenance_work_mem = 2GB      
dynamic_shared_memory_type = posix      
max_files_per_process = 2000      
vacuum_cost_delay = 0      
bgwriter_delay = 10ms      
bgwriter_lru_maxpages = 1000      
bgwriter_lru_multiplier = 10.0      
effective_io_concurrency = 0      
max_worker_processes = 8      
max_parallel_maintenance_workers = 4      
max_parallel_workers_per_gather = 0      
max_parallel_workers = 8      
wal_level = minimal        
synchronous_commit = off      
full_page_writes = off      
wal_buffers = 16MB      
wal_writer_delay = 10ms      
checkpoint_timeout = 15min      
max_wal_size = 128GB      
min_wal_size = 16GB      
checkpoint_completion_target = 0.1      
max_wal_senders = 0      
random_page_cost = 1.2      
effective_cache_size = 128GB      
jit = off      
log_destination = 'csvlog'      
logging_collector = on      
log_directory = 'log'      
log_filename = 'postgresql-%a.log'      
log_truncate_on_rotation = on      
log_rotation_age = 1d      
log_rotation_size = 0      
log_checkpoints = on      
log_error_verbosity = verbose        
log_line_prefix = '%m [%p] '      
log_statement = 'ddl'      
log_timezone = 'Asia/Shanghai'      
autovacuum = on      
log_autovacuum_min_duration = 0      
autovacuum_max_workers = 3      
autovacuum_vacuum_scale_factor = 0.02      
autovacuum_analyze_scale_factor = 0.01      
autovacuum_freeze_max_age = 800000000      
autovacuum_multixact_freeze_max_age = 900000000      
autovacuum_vacuum_cost_delay = 0ms      
vacuum_freeze_min_age = 500000000      
vacuum_freeze_table_age = 750000000      
vacuum_multixact_freeze_min_age = 5000000      
vacuum_multixact_freeze_table_age = 750000000      
datestyle = 'iso, mdy'      
timezone = 'Asia/Shanghai'      
lc_messages = 'C'      
lc_monetary = 'C'      
lc_numeric = 'C'      
lc_time = 'C'      
default_text_search_config = 'pg_catalog.english'      

12、数据库防火墙设置

vi $PGDATA/pg_hba.conf      
      
host all all 192.168.0.0/24 trust      

13、启动数据库

pg_ctl start       

14、数据库表空间设置

mkdir /data01/pg12/tbs1      
mkdir /data02/pg12/tbs2      
      
psql      
create tablespace tbs1 location '/data01/pg12/tbs1';      
create tablespace tbs2 location '/data02/pg12/tbs2';      

15、sysbench部署

curl -s https://packagecloud.io/install/repositories/akopytov/sysbench/script.rpm.sh | sudo bash        
sudo yum -y install sysbench      
      
      
su - postgres      
git clone https://github.com/digoal/sysbench-tpcc      
      
      
cd sysbench-tpcc      
      
chmod 700 *.lua      

16、清理数据方法

drop schema public cascade;      
create schema public;      
grant all on schema public to public;      

17、初始化数据(装载速度约每秒37MB)

export pgsql_table_options="tablespace tbs1"      
export pgsql_index_options="tablespace tbs2"

测1000个仓库(1套表,112GB)

nohup time ./tpcc.lua --pgsql-host=/tmp --pgsql-port=1921 --pgsql-user=postgres --pgsql-db=postgres --threads=64 --tables=1 --scale=1000 --trx_level=RC --db-ps-mode=auto --db-driver=pgsql prepare >./out.log 2>&1 &      

测10000个仓库(10套表,每套1000个仓库, 1120GB)

nohup time ./tpcc.lua --pgsql-host=/tmp --pgsql-port=1921 --pgsql-user=postgres --pgsql-db=postgres --threads=64 --tables=10 --scale=1000 --trx_level=RC --db-ps-mode=auto --db-driver=pgsql prepare >./out.log 2>&1 &      

18、压测

远程建议3倍cpu客户端,本地建议2倍cpu客户端。

run 时不调用purge

测1000个仓库      
./tpcc.lua --pgsql-host=/tmp --pgsql-port=1921 --pgsql-user=postgres --pgsql-db=postgres --threads=64 --tables=1 --scale=1000 --trx_level=RC --db-ps-mode=auto --db-driver=pgsql --time=3600 --report-interval=5 run      
      
      
测10000个仓库      
./tpcc.lua --pgsql-host=/tmp --pgsql-port=1921 --pgsql-user=postgres --pgsql-db=postgres --threads=64 --tables=10 --scale=1000 --trx_level=RC --db-ps-mode=auto --db-driver=pgsql --time=3600 --report-interval=5 run      

run 时调用purge

测1000个仓库      
./tpcc.lua --pgsql-host=/tmp --pgsql-port=1921 --pgsql-user=postgres --pgsql-db=postgres --threads=64 --tables=1 --scale=1000 --trx_level=RC --db-ps-mode=auto --db-driver=pgsql --time=3600 --report-interval=5 --enable_purge=yes run      
      
      
测10000个仓库      
./tpcc.lua --pgsql-host=/tmp --pgsql-port=1921 --pgsql-user=postgres --pgsql-db=postgres --threads=64 --tables=10 --scale=1000 --trx_level=RC --db-ps-mode=auto --db-driver=pgsql --time=3600 --report-interval=5 --enable_purge=yes run      

cleanup

测1000个仓库      
./tpcc.lua --pgsql-host=/tmp --pgsql-port=1921 --pgsql-user=postgres --pgsql-db=postgres --threads=64 --tables=1 --scale=1000 --trx_level=RC --db-driver=pgsql cleanup      
      
      
测10000个仓库      
./tpcc.lua --pgsql-host=/tmp --pgsql-port=1921 --pgsql-user=postgres --pgsql-db=postgres --threads=64 --tables=10 --scale=1000 --trx_level=RC --db-driver=pgsql cleanup      

结果解读

run 时不调用purge

./tpcc.lua --pgsql-host=/tmp --pgsql-port=1921 --pgsql-user=postgres --pgsql-db=postgres --threads=32 --tables=1 --scale=1000 --trx_level=RC --db-ps-mode=auto --db-driver=pgsql --time=60 --report-interval=5 run    
sysbench 1.0.17 (using system LuaJIT 2.0.4)    
    
Running the test with following options:    
Number of threads: 32    
Report intermediate results every 5 second(s)    
Initializing random number generator from current time    
    
    
Initializing worker threads...    
    
Threads started!    
    
[ 5s ] thds: 32 tps: 3248.44 qps: 93258.54 (r/w/o: 42390.64/44038.35/6829.54) lat (ms,95%): 27.66 err/s 10.00 reconn/s: 0.00    
[ 10s ] thds: 32 tps: 3626.37 qps: 102832.62 (r/w/o: 46883.60/48696.28/7252.74) lat (ms,95%): 23.52 err/s 14.00 reconn/s: 0.00    
[ 15s ] thds: 32 tps: 3838.38 qps: 109478.46 (r/w/o: 49903.95/51897.94/7676.56) lat (ms,95%): 21.50 err/s 18.60 reconn/s: 0.00    
[ 20s ] thds: 32 tps: 4006.41 qps: 114816.04 (r/w/o: 52365.11/54437.71/8013.22) lat (ms,95%): 20.00 err/s 19.20 reconn/s: 0.00    
[ 25s ] thds: 32 tps: 4103.01 qps: 116394.38 (r/w/o: 53051.28/55137.28/8205.81) lat (ms,95%): 20.00 err/s 17.20 reconn/s: 0.00    
[ 30s ] thds: 32 tps: 4115.59 qps: 116128.74 (r/w/o: 52981.68/54915.87/8231.18) lat (ms,95%): 20.00 err/s 15.20 reconn/s: 0.00    
[ 35s ] thds: 32 tps: 4109.69 qps: 117433.18 (r/w/o: 53571.93/55641.86/8219.39) lat (ms,95%): 19.65 err/s 19.19 reconn/s: 0.00    
[ 40s ] thds: 32 tps: 4169.11 qps: 118802.26 (r/w/o: 54157.77/56306.27/8338.22) lat (ms,95%): 19.65 err/s 15.81 reconn/s: 0.00    
[ 45s ] thds: 32 tps: 4170.78 qps: 118412.12 (r/w/o: 53997.63/56072.92/8341.57) lat (ms,95%): 19.65 err/s 18.80 reconn/s: 0.00    
[ 50s ] thds: 32 tps: 4225.57 qps: 120878.63 (r/w/o: 55162.50/57264.98/8451.15) lat (ms,95%): 19.65 err/s 22.20 reconn/s: 0.00    
[ 55s ] thds: 32 tps: 4128.25 qps: 116929.64 (r/w/o: 53310.25/55362.88/8256.51) lat (ms,95%): 20.00 err/s 19.40 reconn/s: 0.00    
[ 60s ] thds: 32 tps: 4096.19 qps: 116335.90 (r/w/o: 53103.86/55039.66/8192.38) lat (ms,95%): 20.37 err/s 18.00 reconn/s: 0.00    

统计方法:

SQL statistics:    
    queries performed:    
        read:                            3104738    
        write:                           3224417    
        other:                           480086  -- 统计 begin;commit;rollback;     
        total:                           6809241 -- 统计所有请求,以上相加     
    transactions:                        239227 (3973.25 per sec.)     -- 统计每秒完成事务数(不包括rollback;) 使用这个计算 total tpmc = 3973.25*60 = 238395     
    queries:                             6809241 (113092.77 per sec.)  -- 所有请求     
    ignored errors:                      1038   (17.24 per sec.)    
    reconnects:                          0      (0.00 per sec.)    
    
General statistics:    
    total time:                          60.2077s    
    total number of events:              239227    
    
Latency (ms):    
         min:                                    0.42    
         avg:                                    8.02  -- 平均事务处理时间    
         max:                                  329.15    
         95th percentile:                       20.37  -- 95% 的事务处理时间低于 20.37 ms    
         sum:                              1919757.02  -- 总耗时= --threads=32 乘以 --time=60    
    
Threads fairness:    
    events (avg/stddev):           7475.8438/78.44    
    execution time (avg/stddev):   59.9924/0.01    

统计结果如下:

total tpmc= 3973.25*60=238395     
    
new orders tpmc= (total tpmc)*(10/23) = 103650   # (取决于run时是否 ```--enable_purge=yes```) 或   调用purge : (total tpmc)*(10/24)     
function event()    
  -- print( NURand (1023,1,3000))    
  local max_trx =  sysbench.opt.enable_purge == "yes" and 24 or 23    
  local trx_type = sysbench.rand.uniform(1,max_trx)    
  if trx_type <= 10 then    
    trx="new_order"    
  elseif trx_type <= 20 then    
    trx="payment"    
  elseif trx_type <= 21 then    
    trx="orderstatus"    
  elseif trx_type <= 22 then    
    trx="delivery"    
  elseif trx_type <= 23 then    
    trx="stocklevel"    
  elseif trx_type <= 24 then    
    trx="purge"    
  end    

32c64ht 512G 1000仓库 机器测试结果

  
Architecture:          x86_64  
CPU op-mode(s):        32-bit, 64-bit  
Byte Order:            Little Endian  
CPU(s):                64  
On-line CPU(s) list:   0-63  
Thread(s) per core:    2  
Core(s) per socket:    32  
Socket(s):             1  
NUMA node(s):          1  
Vendor ID:             GenuineIntel  
CPU family:            6  
Model:                 85  
Model name:            Intel(R) Xeon(R) Platinum 8163 CPU @ 2.50GHz  
Stepping:              4  
CPU MHz:               2500.008  
BogoMIPS:              5000.01  
Hypervisor vendor:     KVM  
Virtualization type:   full  
L1d cache:             32K  
L1i cache:             32K  
L2 cache:              1024K  
L3 cache:              33792K  
NUMA node0 CPU(s):     0-63  
Flags:                 fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ss ht syscall nx pdpe1gb rdtscp lm constant_tsc rep_good nopl eagerfpu pni pclmulqdq ssse3 fma cx16 pcid sse4_1 sse4_2 x2apic movbe popcnt tsc_deadline_timer aes xsave avx f16c rdrand hypervisor lahf_lm abm 3dnowprefetch ibrs ibpb stibp fsgsbase tsc_adjust bmi1 hle avx2 smep bmi2 erms invpcid rtm mpx avx512f avx512dq rdseed adx smap avx512cd avx512bw avx512vl xsaveopt xsavec xgetbv1 spec_ctrl intel_stibp  
  
[root@pg11-test ~]# free -g  
              total        used        free      shared  buff/cache   available  
Mem:            503         313           3          17         186         170  
Swap:             0           0           0  
  
  
[root@pg11-test ~]# lsblk  
NAME            MAJ:MIN RM  SIZE RO TYPE MOUNTPOINT  
vda             253:0    0  200G  0 disk   
└─vda1          253:1    0  200G  0 part /  
vdb             253:16   0  1.8T  0 disk   
├─vgdata01-lv01 252:0    0    4T  0 lvm  /data01  
├─vgdata01-lv02 252:1    0    4T  0 lvm  /data02  
├─vgdata01-lv03 252:2    0    4T  0 lvm  /data03  
└─vgdata01-lv04 252:3    0    2T  0 lvm  /data04  
vdc             253:32   0  1.8T  0 disk   
├─vgdata01-lv01 252:0    0    4T  0 lvm  /data01  
├─vgdata01-lv02 252:1    0    4T  0 lvm  /data02  
├─vgdata01-lv03 252:2    0    4T  0 lvm  /data03  
└─vgdata01-lv04 252:3    0    2T  0 lvm  /data04  
vdd             253:48   0  1.8T  0 disk   
├─vgdata01-lv01 252:0    0    4T  0 lvm  /data01  
├─vgdata01-lv02 252:1    0    4T  0 lvm  /data02  
├─vgdata01-lv03 252:2    0    4T  0 lvm  /data03  
└─vgdata01-lv04 252:3    0    2T  0 lvm  /data04  
vde             253:64   0  1.8T  0 disk   
├─vgdata01-lv01 252:0    0    4T  0 lvm  /data01  
├─vgdata01-lv02 252:1    0    4T  0 lvm  /data02  
├─vgdata01-lv03 252:2    0    4T  0 lvm  /data03  
└─vgdata01-lv04 252:3    0    2T  0 lvm  /data04  
vdf             253:80   0  1.8T  0 disk   
├─vgdata01-lv01 252:0    0    4T  0 lvm  /data01  
├─vgdata01-lv02 252:1    0    4T  0 lvm  /data02  
├─vgdata01-lv03 252:2    0    4T  0 lvm  /data03  
└─vgdata01-lv04 252:3    0    2T  0 lvm  /data04  
vdg             253:96   0  1.8T  0 disk   
├─vgdata01-lv01 252:0    0    4T  0 lvm  /data01  
├─vgdata01-lv02 252:1    0    4T  0 lvm  /data02  
├─vgdata01-lv03 252:2    0    4T  0 lvm  /data03  
└─vgdata01-lv04 252:3    0    2T  0 lvm  /data04  
vdh             253:112  0  1.8T  0 disk   
├─vgdata01-lv01 252:0    0    4T  0 lvm  /data01  
├─vgdata01-lv02 252:1    0    4T  0 lvm  /data02  
├─vgdata01-lv03 252:2    0    4T  0 lvm  /data03  
└─vgdata01-lv04 252:3    0    2T  0 lvm  /data04  
vdi             253:128  0  1.8T  0 disk   
├─vgdata01-lv01 252:0    0    4T  0 lvm  /data01  
├─vgdata01-lv02 252:1    0    4T  0 lvm  /data02  
├─vgdata01-lv03 252:2    0    4T  0 lvm  /data03  
└─vgdata01-lv04 252:3    0    2T  0 lvm  /data04  
  
  
[root@pg11-test ~]# pvs  
  PV         VG       Fmt  Attr PSize  PFree  
  /dev/vdb   vgdata01 lvm2 a--  <1.75t    0   
  /dev/vdc   vgdata01 lvm2 a--  <1.75t    0   
  /dev/vdd   vgdata01 lvm2 a--  <1.75t    0   
  /dev/vde   vgdata01 lvm2 a--  <1.75t    0   
  /dev/vdf   vgdata01 lvm2 a--  <1.75t    0   
  /dev/vdg   vgdata01 lvm2 a--  <1.75t    0   
  /dev/vdh   vgdata01 lvm2 a--  <1.75t    0   
  /dev/vdi   vgdata01 lvm2 a--  <1.75t    0   
[root@pg11-test ~]# vgs  
  VG       #PV #LV #SN Attr   VSize   VFree  
  vgdata01   8   4   0 wz--n- <13.97t    0   
[root@pg11-test ~]# lvs  
  LV   VG       Attr       LSize  Pool Origin Data%  Meta%  Move Log Cpy%Sync Convert  
  lv01 vgdata01 -wi-ao----  4.00t                                                      
  lv02 vgdata01 -wi-ao----  4.00t                                                      
  lv03 vgdata01 -wi-ao----  4.00t                                                      
  lv04 vgdata01 -wi-ao---- <1.97t   
  
  
[root@pg11-test ~]# lvdisplay -vv  
      devices/global_filter not found in config: defaulting to global_filter = [ "a|.*/|" ]  
      Setting global/locking_type to 1  
      Setting global/use_lvmetad to 1  
      global/lvmetad_update_wait_time not found in config: defaulting to 10  
      Setting response to OK  
      Setting protocol to lvmetad  
      Setting version to 1  
      Setting global/use_lvmpolld to 1  
      Setting devices/sysfs_scan to 1  
      Setting devices/multipath_component_detection to 1  
      Setting devices/md_component_detection to 1  
      Setting devices/fw_raid_component_detection to 0  
      Setting devices/ignore_suspended_devices to 0  
      Setting devices/ignore_lvm_mirrors to 1  
      devices/filter not found in config: defaulting to filter = [ "a|.*/|" ]  
      Setting devices/cache_dir to /etc/lvm/cache  
      Setting devices/cache_file_prefix to   
      devices/cache not found in config: defaulting to /etc/lvm/cache/.cache  
      Setting devices/write_cache_state to 1  
      Setting global/use_lvmetad to 1  
      Setting activation/activation_mode to degraded  
      metadata/record_lvs_history not found in config: defaulting to 0  
      Setting activation/monitoring to 1  
      Setting global/locking_type to 1  
      Setting global/wait_for_locks to 1  
      File-based locking selected.  
      Setting global/prioritise_write_locks to 1  
      Setting global/locking_dir to /run/lock/lvm  
      Setting global/use_lvmlockd to 0  
      Setting response to OK  
      Setting token to filter:3239235440  
      Setting daemon_pid to 11015  
      Setting response to OK  
      Setting global_disable to 0  
      report/output_format not found in config: defaulting to basic  
      log/report_command_log not found in config: defaulting to 0  
      Obtaining the complete list of VGs before processing their LVs  
      Setting response to OK  
      Setting response to OK  
      Setting name to vgdata01  
      Processing VG vgdata01 jwrfAR-tEXe-qf6u-rd95-yhPW-O7Xw-JPUjyr  
      Locking /run/lock/lvm/V_vgdata01 RB  
      Reading VG vgdata01 jwrfAR-tEXe-qf6u-rd95-yhPW-O7Xw-JPUjyr  
      Setting response to OK  
      Setting response to OK  
      Setting name to vgdata01  
      Setting metadata/format to lvm2  
      Setting id to 8Wny3c-lLb1-27xY-9rFC-HCOc-XsaD-HmvN5l  
      Setting format to lvm2  
      Setting device to 64784  
      Setting dev_size to 3749707776  
      Setting label_sector to 1  
      Setting ext_flags to 1  
      Setting ext_version to 2  
      Setting size to 1044480  
      Setting start to 4096  
      Setting ignore to 0  
      Setting id to ClcfJi-9Omy-hZdN-ll46-B6J2-fAAL-MLrleE  
      Setting format to lvm2  
      Setting device to 64800  
      Setting dev_size to 3749707776  
      Setting label_sector to 1  
      Setting ext_flags to 1  
      Setting ext_version to 2  
      Setting size to 1044480  
      Setting start to 4096  
      Setting ignore to 0  
      Setting id to uFhANC-PCAV-JwJL-zSNn-O8np-I2Wi-ue8Vv1  
      Setting format to lvm2  
      Setting device to 64816  
      Setting dev_size to 3749707776  
      Setting label_sector to 1  
      Setting ext_flags to 1  
      Setting ext_version to 2  
      Setting size to 1044480  
      Setting start to 4096  
      Setting ignore to 0  
      Setting id to hKBbU0-a3gm-sHq1-eU7Q-ZJ3m-Iwoo-MuKzzj  
      Setting format to lvm2  
      Setting device to 64832  
      Setting dev_size to 3749707776  
      Setting label_sector to 1  
      Setting ext_flags to 1  
      Setting ext_version to 2  
      Setting size to 1044480  
      Setting start to 4096  
      Setting ignore to 0  
      Setting id to cOZaeJ-Drns-9BcP-5Aoq-oZ88-0hVs-M7K8SU  
      Setting format to lvm2  
      Setting device to 64848  
      Setting dev_size to 3749707776  
      Setting label_sector to 1  
      Setting ext_flags to 1  
      Setting ext_version to 2  
      Setting size to 1044480  
      Setting start to 4096  
      Setting ignore to 0  
      Setting id to EgaC5R-Q0An-X79Q-xGRL-5zDI-MN16-lclIBO  
      Setting format to lvm2  
      Setting device to 64864  
      Setting dev_size to 3749707776  
      Setting label_sector to 1  
      Setting ext_flags to 1  
      Setting ext_version to 2  
      Setting size to 1044480  
      Setting start to 4096  
      Setting ignore to 0  
      Setting id to NnvDT4-eUM4-V2dP-Fqv1-O28z-OVoH-z939Bh  
      Setting format to lvm2  
      Setting device to 64880  
      Setting dev_size to 3749707776  
      Setting label_sector to 1  
      Setting ext_flags to 1  
      Setting ext_version to 2  
      Setting size to 1044480  
      Setting start to 4096  
      Setting ignore to 0  
      Setting id to XZsZfn-y2aH-MiNA-mo95-jpdQ-Jufp-eIgBga  
      Setting format to lvm2  
      Setting device to 64896  
      Setting dev_size to 3749707776  
      Setting label_sector to 1  
      Setting ext_flags to 1  
      Setting ext_version to 2  
      Setting size to 1044480  
      Setting start to 4096  
      Setting ignore to 0  
      Setting response to OK  
      Setting response to OK  
      /dev/vdb: size is 3749707776 sectors  
      /dev/vdc: size is 3749707776 sectors  
      /dev/vdd: size is 3749707776 sectors  
      /dev/vde: size is 3749707776 sectors  
      /dev/vdf: size is 3749707776 sectors  
      /dev/vdg: size is 3749707776 sectors  
      /dev/vdh: size is 3749707776 sectors  
      /dev/vdi: size is 3749707776 sectors  
      Adding vgdata01/lv01 to the list of LVs to be processed.  
      Adding vgdata01/lv02 to the list of LVs to be processed.  
      Adding vgdata01/lv03 to the list of LVs to be processed.  
      Adding vgdata01/lv04 to the list of LVs to be processed.  
      Processing LV lv01 in VG vgdata01.  
  --- Logical volume ---  
      global/lvdisplay_shows_full_device_path not found in config: defaulting to 0  
  LV Path                /dev/vgdata01/lv01  
  LV Name                lv01  
  VG Name                vgdata01  
  LV UUID                GtVTn9-mWcL-sTJA-QyRq-VocV-eu1s-374mkU  
  LV Write Access        read/write  
  LV Creation host, time pg11-test, 2018-08-24 20:44:30 +0800  
  LV Status              available  
  # open                 1  
  LV Size                4.00 TiB  
  Current LE             32768  
  Segments               1  
  Allocation             inherit  
  Read ahead sectors     auto  
  - currently set to     8192  
  Block device           252:0  
     
      Processing LV lv02 in VG vgdata01.  
  --- Logical volume ---  
      global/lvdisplay_shows_full_device_path not found in config: defaulting to 0  
  LV Path                /dev/vgdata01/lv02  
  LV Name                lv02  
  VG Name                vgdata01  
  LV UUID                17VdCH-KVNZ-FF3a-g7ic-IY4y-qav3-jdX3CJ  
  LV Write Access        read/write  
  LV Creation host, time pg11-test, 2018-08-24 20:44:37 +0800  
  LV Status              available  
  # open                 1  
  LV Size                4.00 TiB  
  Current LE             32768  
  Segments               1  
  Allocation             inherit  
  Read ahead sectors     auto  
  - currently set to     8192  
  Block device           252:1  
     
      Processing LV lv03 in VG vgdata01.  
  --- Logical volume ---  
      global/lvdisplay_shows_full_device_path not found in config: defaulting to 0  
  LV Path                /dev/vgdata01/lv03  
  LV Name                lv03  
  VG Name                vgdata01  
  LV UUID                XY3M0w-EJdu-rx4z-Jn9n-QigT-mAVi-zps4te  
  LV Write Access        read/write  
  LV Creation host, time pg11-test, 2018-08-24 20:44:57 +0800  
  LV Status              available  
  # open                 1  
  LV Size                4.00 TiB  
  Current LE             32768  
  Segments               1  
  Allocation             inherit  
  Read ahead sectors     auto  
  - currently set to     8192  
  Block device           252:2  
     
      Processing LV lv04 in VG vgdata01.  
  --- Logical volume ---  
      global/lvdisplay_shows_full_device_path not found in config: defaulting to 0  
  LV Path                /dev/vgdata01/lv04  
  LV Name                lv04  
  VG Name                vgdata01  
  LV UUID                vWtHPq-ycHf-n8AO-3E0V-R5F6-WTXc-LocpJ8  
  LV Write Access        read/write  
  LV Creation host, time pg11-test, 2018-09-28 10:08:27 +0800  
  LV Status              available  
  # open                 1  
  LV Size                <1.97 TiB  
  Current LE             16120  
  Segments               1  
  Allocation             inherit  
  Read ahead sectors     auto  
  - currently set to     8192  
  Block device           252:3  
     
      Unlocking /run/lock/lvm/V_vgdata01  
      Setting global/notify_dbus to 1  
  
  
  
  
  
./tpcc.lua --pgsql-host=/tmp --pgsql-port=4801 --pgsql-user=postgres --pgsql-db=postgres --threads=96 --tables=1 --scale=1000 --trx_level=RC --db-ps-mode=auto --db-driver=pgsql --time=60 --report-interval=5 --enable_purge=yes run  
  
SQL statistics:  
    queries performed:  
        read:                            12443189  
        write:                           12830786  
        other:                           1992972  
        total:                           27266947  
    transactions:                        994038 (16549.36 per sec.)  
    queries:                             27266947 (453957.01 per sec.)  
    ignored errors:                      4229   (70.41 per sec.)  
    reconnects:                          0      (0.00 per sec.)  
  
General statistics:  
    total time:                          60.0634s  
    total number of events:              994038  
  
Latency (ms):  
         min:                                    0.36  
         avg:                                    5.79  
         max:                                  138.96  
         95th percentile:                       16.41  
         sum:                              5757585.45  
  
Threads fairness:  
    events (avg/stddev):           10354.5625/127.26  
    execution time (avg/stddev):   59.9748/0.01  
  
  
数据装载速度 : 89.5 MB/s  
  
tpmc total : 99.3万   
  
tpcm neworder : 41.4万   
1000仓库结果  
  
tpmc total: 36万  
  
tpmc neworder :  15万  
  
瓶颈:io  

性能小结

1、pg 12 (单机自建) ecs 16c128g + 1.8T local ssd*2
1000仓库,64并发,tpmc total: 26万
10000仓库,64并发,tpmc total: 13万

2、pg 12 (单机自建) ecs 64c512g + 1.8T local ssd*8
1000仓库,64并发,tpmc total: 99万
10000仓库,64并发,tpmc total: 41万

3、pg 12 (单机自建)(104c 768g,essd 32TB,hugepage,sharedbuffer=600GB)
unlogged table:
1000仓库,208并发,tpmc total: 184万
logged table:
1000仓库,104并发,tpmc total: 168万

参考

《PostgreSQL 11 tpcc 测试(103万tpmC on ECS) - use sysbench-tpcc by Percona-Lab》

https://github.com/digoal/sysbench-tpcc/blob/master/README.md


PgSQL · 应用案例 · 阿里云RDS PG 11开放dblink, postgres_fdw权限

$
0
0

背景

阿里云RDS PG 11 云盘版开放相同vpc的实例间的dblink, postgres_fdw功能。相同vpc内如果有自建的pg实例,也可以被访问,如果要访问vpc外部的其他实例,通过相同vpc内ecs的端口跳转也能实现。

阿里云RDS PG 11 云盘版购买入口:

https://rds-buy.aliyun.com/rdsBuy?spm=5176.7920951.1393245.1.41c64ce1pOvLll&aly_as=JoVfjAtF#/create/rds?initData=%7B%22data%22:%7B%22rds_dbtype%22:%22PostgreSQL%22%7D%7D

支持距离

rds pg 11 (postgres_fdw , dblink) 可以访问如下:

1、same vpc ecs/rds pg

2、same vpc ecs(端口转发) -> other vpc/network pg

3、same vpc ecs(自建pg+oracle_fdw,mysql_fdw) -> other vpc\/network oracle|mysql

rds pg 11 (将支持oracle_fdw, mysql_fdw) 可以访问如下:

1、same vpc ecs(oracle, mysql)\/rds mysql

2、same vpc ecs(端口转发) -> other vpc\/network mysql|oracle

通过同vpc内ecs的端口转发功能,可以访问ecs可以访问的任意目标。(只要ecs能访问的,rds pg就可以通过这个端口代理去访问)

端口转发见本文参考部分。

例子

dblink例子

postgres=> select dblink_connect('a', 'host=同一vpc下的另一rds的内网域名 port=同一vpc下的另一rds的内网监听端口 user=远程数据库用户名 password=密码 dbname=库名');  
 dblink_connect   
----------------  
 OK  
(1 row)  
  
postgres=> select version();  
     version       
-----------------  
 PostgreSQL 11.5  
(1 row)  
  
postgres=> SELECT * FROM dblink('a', 'SELECT version()') as t(ver text);  
       ver          
------------------  
 PostgreSQL 10.10  
(1 row)  

postgres_fdw例子

新建一个数据库

postgres=> create database db1;  
CREATE DATABASE  
  
postgres=> \c db1  

新建postgres_fdw插件

db1=> create extension postgres_fdw;  
CREATE EXTENSION  

新建远程数据库server

db1=> CREATE SERVER foreign_server                                                              
        FOREIGN DATA WRAPPER postgres_fdw  
        OPTIONS (host '同一vpc下的另一rds的内网域名 port=同一vpc下的另一rds的内网监听端口', port '同一vpc下的另一rds的内网监听端口', dbname '库名');  
CREATE SERVER  
  
db1=> CREATE USER MAPPING FOR digoal      
        SERVER foreign_server  
        OPTIONS (user '远程数据库用户', password '密码');  
CREATE USER MAPPING  

导入外部表

db1=> import foreign schema public from server foreign_server into ft;  
IMPORT FOREIGN SCHEMA  
  
db1=> \det ft.*  
            List of foreign tables  
 Schema |       Table        |     Server       
--------+--------------------+----------------  
 ft     | customer1          | foreign_server  
 ft     | district1          | foreign_server  
 ft     | ha_health_check    | foreign_server  
 ft     | history1           | foreign_server  
 ft     | item1              | foreign_server  
 ft     | new_orders1        | foreign_server  
 ft     | order_line1        | foreign_server  
 ft     | orders1            | foreign_server  
 ft     | pg_stat_statements | foreign_server  
 ft     | stock1             | foreign_server  
 ft     | warehouse1         | foreign_server  
(11 rows)  

参考

https://www.postgresql.org/docs/12/postgres-fdw.html

https://www.postgresql.org/docs/12/dblink.html

《使用 ssh -R 建立反向/远程TCP端口转发代理》

《使用 ssh -R 建立反向/远程TCP端口转发代理》

《一个端口代理软件 - inlab balance》

PgSQL · 应用案例 · Oracle 20c 新特性 - 翻出了PG十年前的特性

$
0
0

背景

熟悉PG的小伙伴一定知道,PG每年都会发布一个大版本,已经坚持了24年,每个大版本都有非常大的进步,这在软件行业都是非常罕见的,所以PG蝉联了2017,2018两届dbengine年度数据库,同时在2019年继linux后荣获OSCON 终身成就奖。

pic

PG大版本特征矩阵:

https://www.postgresql.org/about/featurematrix/

近些年,Oracle也开始和PG一样每年都在发版本。今年oow发布了20c,我们看一下20c支持了那些特性:可以参考盖老师发布的如下文档

https://www.modb.pro/db/7242?from=timeline&isappinstalled=0

pic

实际上在Oracle发布的新特性里面,有大多数特性在PG内核或扩展模块已经支持(主要原因是PG的架构非常灵活,可以很方便的开发扩展功能,同时允许被商业或非商业的形式使用):

《PostgreSQL Oracle 兼容性 - Oracle 19c 新特性在PostgreSQL中的使用》

《PostgreSQL 覆盖 Oracle 18c 重大新特性》

来看一下20C里面PG有哪些是已经覆盖的功能呢?

1. 原生的区块链支持 - Native Blockchain Tables

pic

这个特性在PG 中的支持方法

https://github.com/postgrespro/pg_credereum

2. 持久化内存存储支持 - Persistent Memory Store

pic

3. SQL的宏支持 - SQL Macro

pic

这个特性在PG 中的支持方法

https://github.com/postgrespro/pg_variables

4. SQL新特性和函数扩展 - Extensions

pic

在分析计算中,20c 提供了两种新的分布聚类算法,偏态 - SKEWNESS、峰度 - KURTOSIS,通过这两个算法,可以对给定数据进行更丰富的分布计算,新特性支持物化视图,遵循和方差(VARIANCE)相同的语义。

新的位运算符也被引入,20c 中支持的新的位运算包括:BIT_AND_AGG、BIT_OR_AGG、BIT_XOR_AGG 。

这个特性在PG 中的支持方法

https://pgxn.org/dist/aggs_for_arrays/

https://www.postgresql.org/docs/12/functions-aggregate.html

5. 自动化的In-Memory 管理 - Self-Managing In-Memory

pic

In-Memory 技术引入之后,为Oracle数据库带来了基于内存的列式存储能力,支持 OLTP 和 OLAP 混合的计算。

6. 广泛的机器学习算法和AutoML支持

pic

在Oracle 20c中,更多的机器学习算法被加入进来,实现了更广泛的机器学习算法支持。

极限梯度助推树 - eXtreme Gradient Boosting Trees(XGBoost) 的数据库实现,以及各种算法,如分类(Classification)、回归(regression)、排行(ranking)、生存分析(survial analysitic)等;

MSET-SPRT 支持传感器、物联网数据源的异常检测等,非线性、非参数异常检测ML技术;

这个特性在PG 中的支持方法

https://pivotal.io/cn/madlib

7. 多租户细粒度资源模型 - New Resource Modeling Scheme

pic

在20c之前,多租户的数据库管理是服务驱动的,通过服务来决定PDB的资源放置,PDB的开启也是通过服务来进行隐式驱动的。

在集群环境中,这就存在一个问题,PDB 可能被放置在某个资源紧张的服务器上,服务驱动的模型并不完善。

在 20c 中,Oracle 引入了细粒度的资源模型,将负载和 PDB 的重要性等引入管理视角。例如,用户可以通过Cardinality 和 Rank 定义,改变 PDB 的优先级,在数据库启动时,优先打开优先级别高的PDB。

这个特性在PG 中的支持方法

《PostgreSQL 多租户》

《PostgreSQL 用户、会话、业务级 资源隔离(cgroup, 进程组, pg_cgroups) - resource manage》

《PostgreSQL 商用版本EPAS(阿里云ppas(Oracle 兼容版)) HTAP功能之资源隔离管理 - CPU与刷脏资源组管理》

8. 零影响的计划停机维护 - Zero Downtime for Planned Outages

pic

在 Oracle 不同版本的不断演进中,一直在加强数据库的可用性能力。在 20c 中,对于计划停机维护或者滚动升级等,Oracle 通过 Smart DRM 等特性以实现对应用的零影响。

对于维护操作,数据库可以在实例关闭前进行动态的资源重分配,这一特性被称为 Smart DRM,通过GRD的动态资源重组织,重新选出的Master节点不需要进行任何的恢复和维护,对于应用做到了完全无感知、无影响。

9. In-Memory 的 Spatial 和 Text 支持

pic

针对 Oracle 数据库内置的多模特性,地理信息 -Spatial 和 全文检索 - Text 组件,在 20c 中,通过 In-Memory 的内存特性,获得了进一步的支持。

对于空间数据,Oracle 在内存中为空间列增加空间摘要信息(仅限于内存中,无需外部存储),通过 SIMD 矢量快速过滤、替换 R-Tree 索引等手段,以加速空间数据查询检索,可以将查询速度提升10倍。

这个特性在PG 中的支持方法

暂时不支持内存特性,但是空间计算、文本搜索都是很早很早以前就支持的。

http://postgis.org/

https://github.com/postgrespro/rum

https://www.postgresql.org/docs/12/textsearch.html

https://www.postgresql.org/docs/12/indexes-types.html

10. 备库的 Result Cache 支持 - Standby Result Cache

pic

在 Oracle 12.2 和 18c 中,已经实现了 ADG 的 会话连接保持 和 Buffer Cache保持,在 20c 中,Result Cache 在备库上进一步得以保留,以确保这个细节特性的主备性能通过。

Result Cache 特性是指,对于特定查询(例如结果集不变化的),将查询结果保留在内存中,对于反复查询(尤其是大规模聚合)的语句,其成本几乎降低为 0 。

参考

https://www.modb.pro/db/7242?from=timeline&isappinstalled=0

《PostgreSQL Oracle 兼容性 - Oracle 19c 新特性在PostgreSQL中的使用》

《PostgreSQL 覆盖 Oracle 18c 重大新特性》

MySQL · 最佳实践 · 今天你并行了吗?---洞察PolarDB 8.0之并行查询

$
0
0

前言

今天你并行了吗?在PolarDB 8.0中,我们领先MySQL官方版本,率先支持了SQL的并行执行,充分利用硬件多核多CPU的优势,大幅提高了类OLAP的查询性能,体现了自研PolarDB数据库极高的性价比。

如何深入分析SQL的并行执行情况,以协助DBA解决可能存在的性能瓶颈,是一个迫在眉睫的问题,因此PolarDB 8.0在Performance Schema中添加了对并行查询的支持,可以帮助DBA洞察并行执行过程中的各种情况,为后续的查询优化提供丰富的情报。下面我们就详细了解一下并行查询的分析过程。

准备工作

打开并行查询

image.png

  • 若是等于0,意味着关闭并行模式。
  • 当max_parallel_degree大于0时,表示允许最多有多少个worker参与并行执行, 建议设置为实例cpu 核数的一半。比如对于8core32g 的实例, 建议设置为4, 对于16core128g的实例,建议设置为8, 对于2核或4核的实例, 建议关掉并行查询功能。 

打开performance_schema

image.png

注意: 打开performance_schema 需要重启实例, 因此用户请在合适时间进行设置

开启并行查询相关的消费者consumer

后续操作都是由实例的超级用户,或者库performance_schema 已经授权给当前用户

update performance_schema.setup_consumers
set ENABLED= 'YES'
where name like 'events_parallel_query_current' ;
update performance_schema.setup_consumers
set ENABLED= 'YES'
where name like 'events_parallel_operator_current' ;
update performance_schema.setup_consumers
set ENABLED= 'YES'
where name like 'events_parallel_query_history' ;
update performance_schema.setup_consumers
set ENABLED= 'YES'
where name like 'events_parallel_operator_history' ;

启动时自动开启对并行查询相关事件的监测。

在my.cnf中添加以下行:

performance-schema-instrument='parallel_query%=ON'

性能分析

(1)检测是否并行

      当出现性能问题时,首先我们要确定查询是否真的是并行执行。虽然通过查看执行计划,我们可以看到执行计划是并行的执行计划,但这并不意味着在执行时一定会并行执行,不能并行执行的原因通常是资源不足,CPU、内存、线程等资源的限制都要求我们不能无限制的并行。如果并行不受限制的执行,可能会导致一个并行查询就耗光了所有的资源,从而导致其它所有任务都被堵塞,可用性也受到极大影响。

        因此针对这个问题,系统提供了一个参数max_parallel_workers来限制同时执行的并行数量。max_parallel_workers指在同一时刻用于并行执行的worker线程的最大数目(系统硬性设置为实例cpu数的4倍)。当超过max_parallel_workers时,之后就不会再产生并行执行计划,即使这些查询的执行计划中并行执行计划最优。

        我们不能实时的去查询每个执行计划,因此当查询执行完成后,需要一些方法去确定刚刚执行完成的查询是否以并行方式执行。在performance schema中的events_statements_current/events_statements_history可以查看某条查询是否以并行方式执行。

      如果发现当前情况下, 原本预期应该是并行的查询,但变成非并行查询, 需要进一步分析

  • 运行的SQL 是否可以并行, 查询并行查询文档,检查 “并行查询的限制” 一节, 比如当前表记录数小于20000行,无法满足并行查询
  • 检查当前系统负荷是否比较重, 如果当前系统负荷,比如内存利用率已经接近实例上限或cpu负荷较重时,会退化为非并行查询。 

查询当前连接的内部thread_id:

select thread_id from performance_schema.threads where processlist_id=connection_id();

然后执行以下SQL:

select th.thread_id,
       processlist_id,
       SQL_TEXT,
       PARALLEL
       from performance_schema.events_statements_history stmth,
       performance_schema.threads th
       where stmth.thread_id= th.thread_id
       order by PARALLEL desc;

下图是在控制台dms上的输出结果 image.png

找到你关注的SQL 的thread_id, PARALLEL 状态。 

注意:

  • 查询出来的thread_id 很重要, 后续会大量依赖这个thread_id
  • 直接从events_statements_xxx查到的thread_id与通过show processlist查到ID是两个完全不同的ID,processlist中的ID是connection_id,也可以认为是session_id,它也可以通过内部函数connection_id()查询到。如果想通过connection_id查询thread_id,可以通过以下SQL得到。

select thread_id from performance_schema.threads where processlist_id=connection_id();

  • events_statements_current表只显示最近执行的一条SQL的执行信息,因此不能在当前session中查询,因为如果在当前sesssion中查询的话,只能查询到当前这条查询语句的执行信息。但可以在其它session中查询,并通过thread_id定位到需要查询的SQL的执行信息。

如想查询在session-1上执行的并行查询信息:
1)在session-1上执行以下语句,得到session-1的connection_id=2143;
select connection_id();
2)  在session-1上执行并行查询
select sum(c3), c4 from test.t1 where c3 > 5000 group by c4;
3)  在session-2上查询session-1刚刚执行的并行查询的执行信息

select processlist_id,
       SQL_TEXT,
       PARALLEL
       from performance_schema.events_statements_current stmt,
       performance_schema.threads th
       where stmt.thread_id= th.thread_id
       and processlist_id= 2143\G

结果如下所示:

(2)查看并行查询的运行时信息

当我们确定一个查询以并行方式执行时,也许更想知道到底这个查询的并行执行过程是怎样的?有多少个并行算子?有多个并行执行的线程?每个线程都做了什么,扫描多少数据,又返回了多少数据?……
        通过查询performance schema中的events_parallel_query_xxx和events_parallel_operator_xxx可以为你提供以上的所有信息。

events_parallel_query_xxx中保存并行查询在执行过程中的运行时信息,参与执行的每个线程,包括session线程和worker线程,每个线程都有自己的并行执行信息。因此一条并行查询会有多条并行执行信息。

  • events_parallel_query_current表中保存最近一次执行的并行查询的执行信息;
  • events_parallel_query_history表中保存最近执行的并行查询的历史执行信息,默认是每个session最近10条并行查询的历史信息。

当并行查询执行正在执行或执行完成后,可以通过events_parallel_query_xxx来查看并行执行的相关信息。

select ph.thread_id,
ph.event_id,
sh.parallel,
ph.timer_wait/1000000000000 as exec_time,
sh.sql_text,
ph.state,
ph.*
from performance_schema.events_parallel_query_history ph,
performance_schema.events_statements_history sh
where ph.thread_id= sh.thread_id
and ph.thread_id= 87267   /* 替换为前面查询的thread_id */
and ph.nesting_event_id = sh.event_id
\G

表events_parallel_query_xxx中包含以下内容:

  • THREAD_ID:指当前线程ID
  • EVENT_ID: 指当前事件ID,在每个线程中事件ID单调递增。
  • END_EVENT_ID: 指当前事件完成时的最大事件ID,当为NULL时表示当前事务尚在进行中。
  • EVENT_NAME:指当前正在进行中的事件,并行相关的事件以parallel_query开始,role表示执行角色,最后是角色名,可以为leader或worker。
  • PARENT_THREAD_ID: 父线程ID,只有worker线程有父线程ID,leader线程的父线程ID为NULL。
  • OPERATOR_ID:与当前worker线程相关的并行算子ID,若是leader线程,则为NULL
  • WORKER_ID:Worker ID,如果是leader,则为NULL
  • STATE:执行状态,COMPLETED表示已完成,STARTED表示正在运行中
  • SOURCE:表示此事件嵌入代码的位置;
  • TIMER_START:表示此事件开始时刻,单位是皮秒picoseconds (trillionths of a second). 
  • TIMER_END:事件结束的时刻
  • TIMER_WAIT:事件持续的时间
  • MYSQL_ERRNO:执行出错时输出错误码
  • RETURNED_SQLSTATE:执行出错时输出错误状态
  • MESSAGE_TEXT:执行出错时输出错误消息
  • ERRORS:是否正确执行的标志,出错时输出为1,否则为0
  • WARNINGS:警告消息的个数
  • ROWS_AFFECTED:受影响的行数
  • ROWS_SENT:返回的行数
  • ROWS_EXAMINED:检查的行数,不包含worker扫描的行数,通常等于所有worker返回的行数总和
  • CREATED_TMP_DISK_TABLES:在磁盘上创建的临时表个数
  • CREATED_TMP_TABLES:在内存中创建的临时表个数
  • SELECT_FULL_JOIN:JOIN时全表扫描的数目,此处不为0,应检查索引的合理性
  • SELECT_FULL_RANGE_JOIN:JOIN时RANGE扫描的数目
  • SELECT_RANGE:JOIN时第一个表采用RANGE扫描的数目
  • SELECT_RANGE_CHECK: RANGE扫描检查的数目
  • SELECT_SCAN:第一个表采用全表扫描的JOIN数目
  • SORT_MERGE_PASSES:归并排序的次数
  • SORT_RANGE:RANGE排序次数
  • SORT_ROWS:排序的行数
  • SORT_SCAN:通过全表扫描完成的排序数目
  • NO_INDEX_USED:若未使用索引扫描,为1;否则为0
  • NO_GOOD_INDEX_USED:若未找到好的能用的索引,为1;否则为0
  • NESTING_EVENT_ID:关联的上级事件的ID,可以通过此ID查询父事件即并行查询事件
  • NESTING_EVENT_TYPE:关联的上级事件的类型
  • NESTING_EVENT_LEVEL:级别的层数。

举例来说,
13.png
14.png
其中:

  • EVENT_NAME= parallel_query/role/leader, 表示此事件是并行查询事件,角色是leader
  • PARENT_THREAD_ID=NULL,仅对worker有效
  • OPERATOR_ID= NULL,仅对worker有效
  • WORKER_ID= NULL,仅对worker有效
  • STATE= COMPLETED,表示此并行查询已执行完成
  • TIMER_WAIT= 50230309628000,表示执行的时间,单位是皮秒,转成秒应该是50230309628000/1000000000000= 50.2303
  • ROWS_SENT= 4,表示此查询返回4行数据;
  • ROWS_EXAMINED= 8,表示leader线程共检查了74行数据
  • CREATED_TMP_TABLES= 1,表示创建了1个内存临时表
  • NESTING_EVENT_ID= 70,表示与其关联的父事件ID为,可通过此ID从events_statements_xxx表中得到此查询的SQL文本;
  • NESTING_EVENT_TYPE= STATEMENT,表示与其关联的父事件类型
  • NESTING_EVENT_LEVEL= 1,级别事件层级为1

(3)查看并行算子的运行时信息

events_parallel_operator_xxx表中提供并行查询中并行算子的相关信息,所谓并行算子,指可以由多个线程并行执行完成的任务,比如gather,gather会创建一组worker线程,由一组worker线程共同来完成扫描任务。

  • events_parallel_operator_current表中保存session中最近一次执行的并行查询相关的并行算子的执行信息。
  • events_parallel_operator_history表中保存session中最近执行的并行查询相关的并行算子的历史信息,默认是每个session最近10条并行查询的并行算子信息。

并行算子在执行计划中也可以查看到,如下所示:
explain select sum(c3), c4 from test.t1 where c3 > 5000 group by c4;

        其中就是一个并行算子,它会执行Parallel scan,计划由32workers来完成。当然受限于资源的限制,实际上worker的数量可能会少于计划的32个workers.
        当查询执行计划过程中或完成后,可以通过events_parallel_operator_current查看最近执行语句的并行算子相关信息。

select *
from performance_schema.events_parallel_operator_history
where thread_id= xxx /* 替换为前面查询的thread_id*/

表events_parallel_operator_xxx中包含以下内容:

  • THREAD_ID:指当前线程ID
  • EVENT_ID: 指当前事件ID,在每个线程中事件ID单调递增。
  • END_EVENT_ID: 指当前事件完成时的最大事件ID,当为NULL时表示当前事务尚在进行中。
  • EVENT_NAME:指当前正在进行中的事件,并行相关的事件以parallel_query开始,operator表示并行算子,最后是具体的算子名称,目前只有一种gather类型。
  • OPERATOR_ID:并行算子ID,每个并行查询可能有1个或多个并行算子,ID单调递增。
  • OPERATOR_TYPE:并行算子类型,目前只有一种类型gather。
  • STATE:执行状态,COMPLETED表示已完成,STARTED表示正在运行中。
  • SOURCE:表示此事件嵌入代码的位置;
  • TIMER_START:表示此事件开始时刻,单位是皮秒picoseconds (trillionths of a second). 
  • TIMER_END:事件结束的时刻
  • TIMER_WAIT:事件持续的时间
  • PLANNED_DOP:并行计划的并行度,即计划最多可以有多少worker线程
  • ACTUAL_DOP:实际执行时的并行度,即实际执行时的worker线程数
  • PARTITIONED_OBJECT:并行执行的目标对象
  • NUMBER_OF_PARTITIONS:目标对象的内部分片数,与表的分区不同,不分区的表也可以内部分片,每个分片由一个worker来执行扫描,分片越多,并行度也越高,分片越少,并行度越低。
  • MYSQL_ERRNO:执行出错时输出错误码
  • RETURNED_SQLSTATE:执行出错时输出错误状态
  • MESSAGE_TEXT:执行出错时输出错误消息
  • ERRORS:是否正确执行的标志,出错时输出为1,否则为0
  • WARNINGS:警告消息的个数
  • ROWS_SENT:返回的行数
  • ROWS_EXAMINED:检查的行数,不包含worker扫描的行数,通常等于所有worker返回的行数总和
  • CREATED_TMP_DISK_TABLES:在磁盘上创建的临时表个数
  • CREATED_TMP_TABLES:在内存中创建的临时表个数
  • NESTING_EVENT_ID:关联的上级事件的ID,可以通过此ID查询父事件即并行查询事件
  • NESTING_EVENT_TYPE:关联的上级事件的类型
  • NESTING_EVENT_LEVEL:级别的层数。

15.png

在本例中

  • Event_name是parallel_query/operator/gather,表示是并行算子gather类型的事件
  • OPERATOR_ID=0,表示是本并行查询中的第1个并行算子
  • OPERATOR_TYPE = GATHER,表示是GATHER
  • STATE= COMPLETED表示此并行算子的执行已经完成
  • TIMER_WAIT= 50229944318000,是此算子的执行时间,单位是皮秒,转成秒应该是50229944318000/1000000000000= 50.2299s
  • PLANNED_DOP=32,表示计划的并行度为32
  • ACTUAL_DOP=32,表示实际执行时的并行度为32,此值有时会小于计划的并行度 – 注意此处真实情况下是否
  • PARTITIONED_OBJECT=lineitem:表示此gather执行并行扫描的表是lineitem表。
  • NUMBER_OF_PARTITIONS=2760,表示表lineitem内部有2760个分片,这些分片会依次分配到不同的worker上执行扫描,worker当一个分片扫描完成后,会自动申请下一个分片,直到所有分片全部处理完毕。
  • ROWS_SENT= 1,表示gather算子执行完成后,会产生1行数据。
  • ROWS_EXAMINED = 128,表示gather算子共检查了128行数据,这些数据由worker返回,因为每个worker上会优先执行已经下推的where条件。
  • CREATED_TMP_DISK_TABLES=0,表示没有在磁盘上产生临时表。
  • CREATED_TMP_TABLES=1,表示产生1个内存临时表。
  • NESTING_EVENT_ID=72,表示与此并行算子事件关联的父事件ID是72,这个ID可用于指定查询并行执行的主事件信息;
  • NESTING_EVENT_TYPE= PARALLEL_QUERY,表示与此并行牌子事件关联的父事件类型是PARALLEL_QUERY,即并行查询事件;
  • NESTING_EVENT_LEVEL=2,表示嵌套事件层级为2

(4)查看worker的运行时信息

    每个并行算子可以有多个worker,每个worker独立完成部分子任务,如扫描某个表的若干个分片。每个worker也都有自己的运行时信息,这些信息也是保存在events_parallel_query_xxx中,只是角色是worker而已。
        因为worker是关联到并行算子的,所以可以通过并行算子的event_id和thread_id来过滤查询,
/纪君祥, 原文中, 有/

select *
from performance_schema.events_parallel_query_current
where parent_thread_id= xxx /*前面查询的thread_id*/
UNION
select *
from performance_schema.events_parallel_query_history
where parent_thread_id= xxx \G; /*前面查询的thread_id*/

16.png
其中:

  • EVENT_NAME= parallel_query/role/worker,表示是worker事件
  • PARENT_THREAD_ID= 100927,表示其父线程ID是100927
  • OPERATOR_ID= 0,表示此worker所属并行算子ID为0
  • WORKER_ID= 1,表示此worker的worker ID为1
  • TIMER_WAIT= 49935536065000,此worker的运行时间为49935536065000/1000000000000=49.94s
  • ROWS_SENT= 4,表示此worker返回4条数据
  • ROWS_EXAMINED= 18413405,表示此worker共扫描了18413405行数据;
  • CREATED_TMP_TABLES= 1,表示此worker创建了1个内存临时表
  • NESTING_EVENT_ID= 73,表示此worker的父事件ID为73,即其关联的并行算子的event_id为73
  • NESTING_EVENT_TYPE= PARALLEL_OPERATOR,表示worker的父事件类型是并行算子
  • NESTING_EVENT_LEVEL= 3,表示级联事件层级为3。

(5)查看内存消耗

select *
from performance_schema.memory_summary_global_by_event_name
where event_name like '%parallel%' \G




    其中:

  1. memory/performance_schema/parallel_query_class指为parallel_query事件类分配的内存,目前只有3种parallel_query事件类型,role/leader,role/worker及operator/gather,主要用于保存事件类的各种属性。
  2. memory/performance_schema/parallel_query_objects指为记录并行查询执行过程中的运行时信息所需对象所分配的内存,以page为单位分配,每个page包含128个parallel_query对象,此内存一经分配,除非系统重新启动,不能被释放。
  3. memory/performance_schema/parallel_operator_objects指为记录并行算子执行过程中的运行时信息所需对象所分配的内存,以page为单位分配,每个page包含128个parallel_operator对象,此内存一经分配,除非系统重新启动,不能被释放。
  4. memory/performance_schema/parallel_query_history指为保存历史并行查询的运行时信息所分配的内存,此处只为其分配存储指针的内存,默认每个线程10条历史记录,与线程对象同时分配。此内存一经分配,除非系统重新启动,不能被释放。
  5. COUNT_ALLOC指分配的次数,对于事件相关对象是可变的,当新分配page时,自动加1,此数值只能单调递增。
  6. CURRENT_NUMBER_OF_BYTES_USED指当前已经分配的内存,单位是byte。

    注意:

  • 所有performance schema所需的内存,如果开启了performance schema,大部分内存是在系统初始化时就已经预先分配好的,与事件相关对象的内存可以按需分配,但只能按page增加,每个page包含若干对象,一经分配,除非系统重启,不能释放。
  • 所有事件相关的对象按page分配后,将由container来统一管理,对象可以重用,只有当container中没有可用的对象时,才会申请分配新的page。
  • 此处没有memory/performance_schema/parallel_operator_history,是因为所有并行算子都链接在leader对象,而所有的worker对象都链接在并行算子对象上。因此历史记录只需要保存leader对象的指针即可,不需要单独保存并行算子的历史记录。

(6)查看parallel query 对象

每个并行查询的并行度可能都不一样,每个并行查询包含的并行算子个数可能都不一样,因此每个并行查询所需记录运行时信息的parallel query对象和operator对象并不确定,为防止无限制的使用内存,系统做了以下限制:

  • 每个并行查询最多只记录8个并行算子的运行时信息,多余的并行算子的运行时信息将被丢弃;
  • 每个并行算子最多只记录32个worker的运行时信息,多余的worker运行时信息将被丢弃;
  • 可以配置参数performance_schema_max_parallel_query_objects来限制parallel query的对象数目,以限制内存使用;
  • 可以配置参数performance_schema_max_parallel_operator_objects来限制parallel operator的对象数目,以限制内存使用。
  • 每个session中超出performance_schema_events_parallel_query_history_size的parallel query对象及parallel operator对象将被释放给container,以供将来重用。
  • 当session退出时,当前session中记录的所有parallel query对象及parallel operator对象将被释放给container,以供将来重用。

此外,可以查看并行对象的使用情况,如
show status like '%objects_used';

总结

首先,我们需要了解的是,并行并不一定是最好的执行计划,在许多场景下,单线程顺序执行也许效率更高,执行更快。如表数据比较少,或能分配到worker上执行的任务比较少,或资源不足等。另外目前对并行的支持还有很多需要提升的地方,不是每个场景都适合并行执行。所以不要因为开启了并行,就期待对所有场景都有极大的提升,性能的提升还是需要DBA不断的去分析、去优化,并行执行只是其中的一个手段而已。

并行查询的支持只是一个开始,让我们期待PolarDB有更多更好的自研特性出来,为客户提供更美好的用户体验。

MySQL · 新特征 · MySQL 哈希连接实现介绍

$
0
0

关键字

哈希连接, 火山模型,hash join,cost base optimizer(CBO),基于代价的优化器,nest-loop连接, build table, probe table,block nested loop(BNL)

摘要

本文将介绍一下MySQL的哈希连接设计与实现,包括MySQL在8.0.18版本中哈希连接实现的情况与限制。 同时作为内核月报,我们也会带领大家去看一看hash在MySQL中实现的一下比较详细的细节。

0. 哈希连接如何实现原理介绍

从MySQL 8.0.18开始,MySQL的执行引擎开始支持哈希连接这种多表连接的执行方式,哈希连接的支持对于MySQL执行引擎执行提供了更多的查询执行能力配套,因此后续在MySQL重构CBO优化器的时候,对于查询计划的选择提供了一个更多的一种可能性,同时nest-loop连接和哈希连接在不同场景下会有不一样的表现(如哈希连接特别适合在连接字段上没有索引的场景)。 后续我们可以对于MySQL的表现有一个比较大的期待。

好了,在开始写这篇月报前,网上简单搜索了一下关于MySQL Hash Join的文章,还真是很多介绍的文章。(包括官方和非官方相关的查询结果) 2019-11-yamin-searchresult.png那我这篇内核月报,希望读者在阅读完之后能够获得一些怎样不一样的东西呢?因为本篇文章会发布在数据库内核月刊的公众号中,因此我会在介绍完哈希连接的一些概要信息之后(照顾到数据库经验不足的读者朋友们),将会带着读者更深入的了解MySQL哈希连接内部的一些事情。希望对有志加入数据库内核研发的读者有所帮助。

好的我们先来普及一下哈希连接的原理已经在MySQL数据库上真正实现哈希连接的时候我们会遇因为物理资源的限制而导致的了不同场景与不同的实现。

什么是哈希连接

哈希连接是一种执行连接的方法。如图2:(关于数据库连接的基础知识,本文就不做详细介绍,感兴趣的读者可以参看32019-11-yamin-hash-join.jpg图片来源www.2cto.com 如图2所示,要完成一个hash join算法实现需要三个步骤:

  1. 选择合适的连接参与表作为内表(build table),构建hash表;
  2. 然后使用另外一个表(probe table)的每一条记录去探测第一步已经构建完成的哈希表寻找符合连接条件的记录;
  3. 输出匹配后符合需求的记录;

哈希连接根据内存是否能够存放的下hash表

经典哈希连接实现

经典哈希连接的主要特征是内存可以存放哈希连接创建的hash表。 经典哈希连接主要包括两部分:

  • 选择参加连接的一个表作为哈希表候选表,扫描该表并创建哈希表;
  • 选择另外一个表作为探测表,扫描每一行,在哈希表中找寻对应的满足条件的记录; 特点:
  • 所有两个参加连接的表只需要被扫描一次;
  • 需要整个哈希表都能够被存放在内存里面; 如果内存不能存放完整的哈希表,那么情况会变的比较复杂,一种解决方案如下:
    1. 读取最大内存可以容纳的记录创建哈希表;
    2. 另外一张表对于本部分哈希表进行一次全量探测;
    3. 清理掉哈希表;
    4. 返回第一步重新执行,知道用于创建哈希表的表记录全部都被处理了一遍; 这种方案会导致用于探测的表会被扫描多次。

需要落盘哈希连接实现

因为经典哈希连接对于创建内存不能容纳的哈希表不友好,产生了另外一种算法就是可落盘的哈希算法。算法具体步骤如下:

  1. 首先现使用哈希方法将参与连接的build table和probe table按照连接条件进行哈 希,将它们的数据划分到不同的磁盘小文件中去。这里小文件是经过计算的这些小的 build table可以全部读入并创建哈希表。
  2. 当所有的数据划分完毕之后,按顺序加载每一组对应的build table和probe table的片段,进行一次经典哈希连接算法执行。因为每一个小片段都可以创建一个 能够完全被存放在内存中的哈希表。因此每一个片段的数据都只会被扫描一次。 ** 注意:** 这里需要注意第一步划分数据的时候要防止数据倾斜。因为如果第一步划分分片数据不能划分好数据,可能会导致有的分区没用完用于创建哈希表的内存配额,而另外一些分区又放不下的尴尬情况。

1. MySQL的哈希连接实现

上面的部分都是用来普及一下哈希连接的基础知识,从现在开始我们将带大家去看看MySQL的hash join会是一个怎样的实现?

1.1. 选择哈希连接的标准

看到这个选择哈希连接的标准,很多读者可能会认为优化器根据哈希连接和nested-loop连接的性能比较来选择。在这里我要告诉大家,这个是MySQL hash join特征的最终目标,但是目前我们讲的标准是,在8.0.18这个版本,MySQL hash join启用的标准。

“在MySQL 8.0.18版本下,优化器会仅仅简单的最大程度的替代Block Nested Loop方式执行为哈希连接执行。”

  • 每个连接当中,至少有一个等值连接条件;
  • 在连接条件的两边出现的列都属于同一个表; 上面这个简单的标准就是当下哈希连接启动的标准。 实例:
  CREATE TABLE t1 (t1_1 INT, t1_2 INT);
  CREATE TABLE t2 (t2_1 INT, t2_2 INT)

  1) SELECT * FROM t1 JOIN t2 ON (t1.t1_1 = t2.t2_1);
  2) SELECT * FROM t1 JOIN t2 ON (t1.t1_1 = t2.t2_1 AND t1.t1_2 = t2.t2_2);
  3) SELECT * FROM t1 JOIN t2 ON (t1.t1_1 = t2.t2_1 AND t2.t2_2 > 43);
  4) SELECT * FROM t1 JOIN t2 ON (t1.t1_1 + t1.t1_2 = t2.t2_1);
  5) SELECT * FROM t1 JOIN t2 ON (FLOOR(t1.t1_1 + t1.t1_2) = CEIL(t2.t2_1 = 
t2.t2_2));

上面所有的t1和t2连接都会被启动哈希连接,

1.2. 当前哈希连接的限制

SELECT * FROM t1 JOIN t2 ON (t1.col1 < t2.col1);
SELECT * FROM t1
   JOIN t2 ON (t1.col1 = t2.col1)
   JOIN t3 ON (t2.col1 < t3.col1);

上面两条查询都不会启动哈希连接执行,第一条查询因为没有等值连接条件;第二条查询因为t2和t3的连接没有等值连接条件使得整个查询都回退到原来的查询执行模式;

2.用户可干预的变化

MySQL用户可以通过三种手段来不同程度的干预MySQL哈希连接的选择与运行;

  1. optimizer hint;
  2. optimizer switch;
  3. join_buffer_size调整; 用户可以通过optimizer hint和optimizer switch可以通知优化器是否采用哈希连接,join_buffer_size参数可以用来决定采用经典哈希连接实现还是需要落盘的哈希连接实现; 注意:当用户使用explain命令来观察哈希连接是否启动时,请选择 EXPLAIN FORMAT=tree 参数。

Performance schema

哈希连接所使用来创建build table的内存使用情况全部记录在performance schema中。用户可以通过查询事件”memory/sql/hash_join”,in memory_summary_*:

mysql> select * from memory_summary_global_by_event_name where event_name like "%hash_join%"\G
*************************** 1. row ***************************
                  EVENT_NAME: memory/sql/hash_join
                 COUNT_ALLOC: 139
                  COUNT_FREE: 139
   SUM_NUMBER_OF_BYTES_ALLOC: 2577684
    SUM_NUMBER_OF_BYTES_FREE: 2577684
              LOW_COUNT_USED: 0
          CURRENT_COUNT_USED: 0
             HIGH_COUNT_USED: 27
    LOW_NUMBER_OF_BYTES_USED: 0
CURRENT_NUMBER_OF_BYTES_USED: 0
   HIGH_NUMBER_OF_BYTES_USED: 358580
1 row in set (0.00 sec)

如果哈希连接采用需落盘的方式执行,文件的使用信息在file_* 表中。所有的哈希连接创建的文件信息由事件”wait/io/file/sql/hash_join”跟踪;

mysql> select * from file_summary_by_event_name where event_name like "%hash%"\G
*************************** 1. row ***************************
               EVENT_NAME: wait/io/file/sql/hash_join
               COUNT_STAR: 90
           SUM_TIMER_WAIT: 900042640
           MIN_TIMER_WAIT: 0
           AVG_TIMER_WAIT: 10000205
           MAX_TIMER_WAIT: 267632555
               COUNT_READ: 35
           SUM_TIMER_READ: 79219890
           MIN_TIMER_READ: 0
           AVG_TIMER_READ: 2263240
           MAX_TIMER_READ: 10019380
 SUM_NUMBER_OF_BYTES_READ: 271578
              COUNT_WRITE: 35
          SUM_TIMER_WRITE: 516922895
          MIN_TIMER_WRITE: 0
          AVG_TIMER_WRITE: 14769175
          MAX_TIMER_WRITE: 267632555
SUM_NUMBER_OF_BYTES_WRITE: 271578
               COUNT_MISC: 20
           SUM_TIMER_MISC: 303899855
           MIN_TIMER_MISC: 0
           AVG_TIMER_MISC: 15194860
           MAX_TIMER_MISC: 63246820
1 row in set (0.00 sec)

优化小贴士:

  • 尽量使用大的join_buffer_size来避免使用落盘哈希连接方式;
  • 确保哈希连接所打开的文件总数,小于max_open_files,避免因为哈希连接需要打开文件总数超过上限而导致查询执行终止;

3. 实现细节

从这部分开始,我们将更加深入到哈希连接实现的各个方面去给读者更为详细的设计与实现细节情况。

3.1 哈希功能

哈希功能是哈希连接算法的最最核心的部分。这部分被称作哈希策略使用

  • 创建哈希表,MySQL选择了std::unordered_multimap作为哈希表的基础数据结构。这个是一个作为通用哈希表目的的一种实现;
    • 支持多值产生同一个哈希键;
    • 支持哈希查找;
    • 支持MySQL可以部署的所有操作系统平台; 使用xxHash64作为哈希函数,提供快速和高质量的hash服务;

3.2 划分数据与哈希表大小

哈希表的大小受join_buffer_size值的影响。 如果构建哈希表的时候达到了join_buffer_size所达到的上限;执行引擎将会将build表划分为多个分区。

// Get the estimated number of rows produced by the join.
  const size_t rows_produced_by_join = QEP_TAB::->position()->prefix_rowcount;

  // Get how many rows we managed to put in the row buffer, and reduce it by a
  // given factor.
  const double reduction_factor = 0.9;
  const size_t rows_in_hash_table = row_buffer.Rows() * reduction_factor;

  // See how many rows we expect to find in the build input
  const size_t remaining_rows = rows_produced_by_join - rows_in_hash_table;

  // Finally, the number of files needed is the number of remaining rows divided
  // by the number of rows the row buffer can hold.
  const size_t files_needed = remaining_rows / rows_in_hash_table;

注意:这里有一个”reduction factor” 参数,将它设置为0.9使得,这个参数使得我们能够将内存中的哈希表完成的保存到文件,并能够顺利的读取回内存当中。如果这种哈希MySQL的选择是宁可多分配一些文件,来避免因为哈希表不能一次加载到内存导致的需要扫描两次probe table数据。

3.3 引入的新数据结构和函数

这一部分我们来介绍因为哈希连接特征,引入的数据结构。 ###3.3.1 HashJoinRowBuffer HashJoinRowBuffer类是用来管理在内存哈希表中BufferRows。 HashJoinRowBuffer保存了因为创建哈希表而读入的行加上从连接条件中提取的hash键值。所有的内存都是从MEM_ROOT上分配,并在row buffer内部管理;

该类提供的接口

HashJoinRowBuffer(const TableCollection &tables, size_t max_mem_available);
//Construct a row buffer that will hold the data given by "tables", and at most "max_mem_available" bytes of data.
bool Init(std::uint32_t hash_seed);
//Initialize the row buffer with the given seed to be used in the xxHash64 hashing.
void Clear(std::uint32_t hash_seed);
//Clears the row buffer of all data.
StoreRowResult StoreRow(const std::vector<Item_func_eq *> &join_conditions);
//Store the rows that currently lies in the tables' record buffers, where the key is extracted from the given join conditions.
void LoadRange(const Key &key);
//Prepare the row buffer for reading, by loading all rows that matches the given key.
bool Next();
//Get the next matching row in the row buffer.
BufferRow *GetCurrentRow() const;
//Return a pointer to the current matching row

3.3.2 BufferRow

BufferRow类用来存储一行记录的所有数据,它按照记录格式使用指针和长度来标记记录中的字段,记录的各个字段被存放在一段连续分配的内存当中,这样可以非常方便的写入文件。 这个类使用Field::pack() 方法提取数据并包装成对应的数据存储起来,使用Field::unpack()提取和恢复数据到字段。

该类提供的接口

bool StoreFromTableBuffers(const TableCollection &tables, MEM_ROOT *mem_root);
//Takes the row that currently lies in the tables record buffers and store it in this object. The data is allocated on the supplied MEM_ROOT.
void LoadIntoTableBuffers(const TableCollection &tables);
//Takes the data in this object and puts it back to the tables record buffer.
const uchar *data() const;
//Returns a pointer to the data
size_t data_length() const;
//Returns the length of the data.

3.3.3 HashJoinChunk

HashJoinChunk类代表一个磁盘文件,用来存储行的。内部该类使用IO_CACHE结构来从磁盘读/写数据。

该类提供的接口

bool Init(size_t file_buffer_size);
// Initialize the chunk file, and set the IO_CACHE with a buffer size of "file_buffer_size"
ha_rows num_rows() const;
// Return the number of rows that this chunk file holds
bool WriteRowToChunk(const hash_join_buffer::TableCollection &tables);
// Write the row that lies in the tables' record buffer out to this chunk file.
void PositionFile(ha_rows row_index);
// Position the chunk file for read at the given row index.
bool PutNextRowInTableBuffers(const hash_join_buffer::TableCollection &tables);
// Take the row that the chunk file is positioned at and put it back to the tables' record buffer. The file position is advanced by one row.
bool PrepareForRead();
// Flush all the file contents to disk. This must be called after we are done writing out data to the chunk file.

3.3.4 HashJoinIterator

增加一个HashJoinIterator类,HashJoinIterator由两个迭代器构成,分别是左输入和右输入。左输入迭代器代表build哈希表,右输入迭代器代表探测表迭代器;

3.4 增加哈希连接迭代器到迭代器引擎系统

哈希连接只使用新的迭代器执行引擎。当优化器构建迭代器树时,每当遇到BNL会调用JOIN_CACHE::can_be_replaced_with_hash_join() 来判断是否能够启用哈希连接。当can_be_replaced_with_hash_join()发现至少一个符合条件的等值连接条件,则返回true,当发现有不满足条件的连接,MySQL将会回退到原生的迭代器。 当所有的连接通过函数ConnectJoins()函数构造完成后,我们将所有等值连接条件发送到哈希连接迭代器。 任何不能在哈谢迭代器执行的条件将会放在哈希连接迭代器后应用。 ##4. 总结 本文通过对与MySQL 8.0.18新支持的哈希连接的介绍,让MySQL的开发人员可以更加了解哈希连接是如何设计与实现的。目前优化MySQL研发团队正在实现火山模型的查询执行引擎,后续等火山模型实现完成后,MySQL研发团队会重新构建一个基于代价的优化器。到了那个时候哈希连接的启用与否将会成为优化器构建的一种原生选择。

5. 参考

MySQL · 最佳实践 · 性能分析的大杀器—Optimizer trace

$
0
0

1. 前言

当听到PolarDB支持并行的消息时,我感到十分兴奋,终于MySQL家族也能支持并行了。但当我真正使用并行的时候,却发现不知所措,结果并未如我所期望的那样欢快的在多核CPU上跑起来,仍然在单行线上慢如老牛。难道所谓的并行只是个噱头?还是只是PPT吗?经过一番深入的研究,终于发现,不是法拉利太差,而是司机太菜。

PolarDB为DBA提供了一个非常厉害的大杀器—optimizer trace,通过它我们可以了解到每个SQL是如何被解析、优化并到最终执行的。在其中我们可以清楚的看到并行优化器是如何生成并行执行计划,如果SQL不能被并行化,就会给出清晰的理由。看到这里,终于对改善慢如老牛的查询有了一点点信心,也许我们慢如老牛的查询并未如我们所愿在并行的快车道上执行呢?下面我们就以案例来分析trace的灵活运用。

2. 开启大杀器-optimizer trace

Optimizer trace并不是自动就会默认开启的,开启trace多多少少都会有一些额外的工作要做,因此并不建议一直开着。但trace属于轻量级的工具,开启和关闭都非常简便,对系统的影响也微乎其微。而且支持在session中开启,不影响其它session,对系统的影响降到了最低。

如果发现某个SQL有问题,只需要在session中设置optimizer_trace,将trace开启即可,当不再需要时,直接关闭即可。

SET SESSION optimizer_trace=”enabled=on”;

然后执行有问题的SQL,如果SQL的执行时间很长的话,也可以只进行explain 操作,即:

EXPLAIN your SQL;

最后,通过

SELECT * FROM information_schema.OPTIMIZER_TRACE\G

查询即可得到trace信息。trace信息以json格式输出,通过\G可以格式化输出trace信息,更宜于阅读。如下所示:

pic

3. 如何分析trace来改善查询的执行效率

下面我们以实例来分析一下trace在实践中的应用。TPCH的数据的scale为1G,以tpch的query 5为例:

select
n_name,
  sum(l_extendedprice * (1 - l_discount)) as revenue
  from
  customer,
  orders,
  lineitem,
  supplier,
  nation,
  region
  where
  c_custkey = o_custkey
  and l_orderkey = o_orderkey
  and l_suppkey = s_suppkey
  and c_nationkey = s_nationkey
  and s_nationkey = n_nationkey
  and n_regionkey = r_regionkey
  and r_name = 'AMERICA'
  and o_orderdate >= date '1995-01-01'
  and o_orderdate < date '1995-01-01' + interval '1' year
  group by
  n_name
  order by
  revenue desc
  limit 1;

TPCH的query5是一个多表JOIN,其中customer,orders,lineitem,supplier表比较大,nation和region比较小。

首先我们来看一下未开启并行的查询计划,如下所示: pic

然后开启并行,再看一下查询计划:

开启并行的SQL如下:

SET SESSION MAX_PARALLEL_DEGREE=16; //设置最大并行度为16

pic

比较串行的查询计划和并行的查询计划,可以发现有些不同之处:

  • 首先在并行查询计划中多了一个gather,gather是在并行查询计划中主要用于合并不同worker线程并行执行的结果,并进行后续必需的操作如group by/order by等,最终将结果返回给用户。 除此之外,还可以发现对于customer表一行,在Extra中有Parallel scan (13 workers);这表示会对customer表进行并行扫描,共有13个workers线程。
  • 后分别以串行和并行方式执行Query 5语句:结果如下:
Query 5串行执行(秒)并行执行(秒) DOP=16
Round - 14.001.71
Round - 23.961.60
Round - 33.981.55
Round - 43.961.58
Round - 53.971.54
Avg3.9741.596i

从结果中可以看到,性能提升了大约150%,那么还有没有提升的空间呢?

下来我们来看一下并行计划的trace。 pic

在trace的输出中有很多项,这里我们主要看是如何选择并行执行计划的。上图中可以看到一些并行计划的基础条件检查:

  • max_parallel_degree:表示最大并行度,当其为0时,表示不允许选择并行计划。
  • max_parallel_workers:表示系统同时允许的最大worker线程数。当可用worker线程数不足时,不允许选择并行计划;
  • force_parallel_mode:表示强制采用并行模式,不建议在生产环境下使用,可用于测试,它用于当数据量不是很大时,正常情况下不会选择并行计划,但为了测试,使用此参数强制使用并行计划。
  • serializable_isolation:是当前事务的隔离方式,串行化隔离方式的事务不支持并行化;
  • multi_stmt_transaction_mode:表示单语句事务或长事务,目前只支持单语句事务的并行化;

当这些条件已经满足,则开始选择可能并行扫描的表:

pic

在potential_parallel_tables列表中会显示此语句中潜在的可能并行化的表。

在considered_parallel_tables子项中会依次检查潜在表,以确定可以并行化的表。

pic

每个子项如上图所示,其中包含表名、访问类型、是否支持并行化等信息,其中与并行化有关的最重要信息是

  • partitions:表示表用于并行化的分片数,如表customer是125个分片;
  • efficient_partitions:表示有效的分片数,因为表本身可能会有condition条件等,所以并不是所有分片都需要扫描,因此可能会小于分片总数;
  • chosen:表示当前片已经被候选为并行化表,如之后再没有其它表被选中,则最后一个被选中的表就是并行化表;

当chosen为false时,trace中会输出选择失败的原因,如下所示:

pic

prefix_cost_too_large表示到目前为止cost已经太大,无法继续选择其它表作为并行化表。

下面我们来看如何优化提升Query 5的性能:

从trace中我们可以看到,可并行化的表customer的efficient_partitions为13,而我们设置的最大并行度为16,也就是说最大可以有16个worker可以使用,但任务分片却只有13个,显然没有充分利用所有资源。

通过分析所有可选择的表我们发现还有orders、lineitem表也是很大的表,若是选择其它表是不是就可以充分利用这些资源呢?

我们来看下orders,如何让优化器先尝试orders表呢?其中可以通过hint:join_order()来改变Join的顺序来间接实现选择并行化表的顺序。

hint如下:/*+ join_order(orders, customer) */

然后我们再来看一下并行的查询计划:

pic

与没有hint的查询计划相比,会发现JOIN的表顺序发生了变化,orders表与customer表交换了顺序,并且orders表的 Parallel scan (16 workers)变成为16个worker。

下面我们重新做下测试:

Query 5串行执行(秒)并行执行(秒) DOP=16并行执行—hint DOP=16
Round - 14.001.710.75
Round - 23.961.600.76
Round - 33.981.550.78
Round - 43.961.580.77
Round - 53.971.540.77
Avg3.9741.5960.766

通过测试发现,通过修改join_order后,发现性能有明显提升,对比串行计划提升大约420%,对比未hint的性能提升大约100%。 另外,也对其它表做并行化进行了测试,结果与customer表并行化的结果相关不大。

4. 总结

通过trace,我们可能发现一些我们在explain中看不到的东西,当发现query并未产生并行查询计划时,可以将trace打开,可以协助我们发现查询不能并行化的原因,针对这些原因可以进行调整,如增加资源、调整参数、转换存储引擎、修改JOIN顺序等。

另外,trace还可以帮我们探索更高性能优化的可能,如前述实例,通过trace有针对性的调整JOIN顺序、增加索引等,也许可以收到更大的性能提升。

PgSQL · 未来特性调研 · TDE

$
0
0

背景

很多行业对行业数据的存储都有自己的监管标准,例如:

  • Payment Card Industry Data Security Standard (PCI DSS)
  • Health Insurance Portability and Accountability Act (HIPAA)
  • General Data Protection Regulation (GDPR)
  • California Consumer Protection Act (CCPA)
  • And more

这些监管标准中都要求数据必须是加密存储的。而为了对数据进行加密存储,首先想到的是文件级加密(FDE)。文件级加密虽然简单直接,但是依赖支持加解密接口的文件系统,或者是其他层面的加解密,例如云盘块存储层面。这对很多系统来说,代价比较高。很多商业数据库管理系统在数据库层面实现了TDE(Transparent Data Encryption),相对于FDE,优势如下:

  • 不依赖文件系统
  • 文件系统访问出的数据都是密文
  • 可以选择加密某一列或者某个表从而降低性能影响

目前很多数据库已经实现了TDE,我们首先对各个数据库最后TDE 实现的效果有个大体的了解,然后再去探究PostgreSQL 社区当前对TDE 的讨论和一些结论。

MySQL (InnoDB)

MySQL 支持每个tablespace 数据级别的静态数据加密。MySQL 中tablespace 涉及到一个包含一个或者多个InnoDB 表和对应索引的文件(在PostgreSQL 中tablespace 对应一个目录)。 MySQL 8.0.16 支持了redo log 和undo log 的加密和系统表的加密,并且支持了2层加密体系,每个tablespace 在文件头中都存储着自己的密钥。主密钥可以通过keyring 插件来获取。

MySQL 加密redo log 和 undo log 使用和表数据加密不同的密钥。这些密钥存储在redo/undo log 加密后文件的头部。

Oracle DB

Oracle DB 支持列级别和tablespace 级别的TDE,都采用了二层加密的结构。主加密密钥(MEK)存放在额外的密钥存储中,支持软硬件的密钥存储。MEK 用来加密列级别和tablespace 级别的密钥。列级别每个表使用相同的加密密钥,tablespace 级别的每个tablespace 使用相同的加密密钥。支持3DES168 和AES (128,192,256 位)的加密算法。列级TDE 默认为AES-192算法,tablespace 级别的TDE 默认为AES-128 算法。在加密 之前,会在明文中增加一点额外信息(比如16字节的随机字符来保证相同明文加密得到不同的密文)。同时还会在密文上增加20字节的完整性检查值来保证密文的完整性,当然可以通过NOMAC 参数取消完整性检查值的填充来提高加密性能。

MS SQL Server

MS SQL Server 支持数据库级别的TDE,采用了三层的密钥加密构架,并支持对称和非对称加密算法。服务主密钥(Service Master Key 简称为SMK)在初始化数据的时候生成。数据库主密钥(Database Master Key,简称为DMK)在默认数据库中产生,并由SMK 加密存储。DMK 被用来生成证书来保证数据库加密密钥(Database Encryption Key,简称为DEK)的安全。DEK 使用对称加密算法对数据和日志文件进行加解密。

PostgreSQL

PostgreSQL 社区自2016年已经对TDE 展开了讨论,目前仍然存在很多的争论,但是确认了在PostgreSQL 13 的版本中会输出TDE 第一个版本,实现如下的功能:

  • 集群级别的加密
  • 数据库内部的密钥管理系统
  • 加密持久化的所有,不加密内存中的数据

在这之前社区会对很多相关的问题进行讨论:

  • 安全风险模型
  • 加密的粒度
  • 哪些文件需要加密
  • 何时加密
  • 如何加密
  • 如何管理密钥

安全风险模型

在社区的讨论中,都一再强调一定要明确了TDE 要防护的安全风险模型之后,再去讨论具体的实现。目前基本明确TDE 保护突破了文件系统访问控制的安全威胁:

  • 偷取存储设备,并且直接访问数据库文件的恶意用户
  • 通过备份恢复数据的恶意用户
  • 保护静态数据(持久化的数据)

加密的粒度

各个数据库在实现TDE 的时候加密的粒度都是不同的,当前加密的粒度可以分为:

  • 数据库集群级别,例如PostgreSQL 社区2016 年邮件列表中的实现1和PostgreSQL 社区2019 年邮件列表中 的实现2
  • 数据库级别,例如SQL Server,详见链接
  • 表级别,例如Oracle 和MySQL
  • Tablespace 级别,例如MySQL,PostgreSQL 2019 年邮件列表中的提交的patch,但是目前社区对tablespace 级别的加密有很多反对的声音,觉得即复杂又没有太大意义,详见邮件列表
  • 列级别,例如Oracle。PostgreSQL 社区认为这种更适合使用pgcrypto 插件,触发器和视图来实现。
  • 定义新的范围,例如定义一组表支持加密

根据上文可知,PostgreSQL 13 第一个版本会实现集群级别的加密,这样的决定主要基于:

  • 简单的结构,利于实现和扩展
  • 适合于加密所有数据的需求 集群级别的加密已经满足TDE 需求中的一个,也满足了静态数据加密的标准。

当然,社区还是有其他不同的声音,认为更加细粒度的加密比集群级别的加密更具有优势:

  • 减少不必要的性能开销
  • 减少使用同一key 加密的数据
  • 使得密文攻击更加困难
  • 更加安全
  • 重新加密不需要重建整个数据库集群
  • 更利于多租户状态下的数据加密

当然这样也会引入其他问题,加密数据的表查找将会带来额外的开销。

哪些文件需要加密

哪些文件需要加密首先是取决于上文说的加密的粒度。在集群级别的加密中,它将会加密整个数据库集群数据包含所有的表,索引以及SLRU 数据。计划中的PostgreSQL 13 实现的第一个版本将会实现数据集群所有的数据同时也包含WAL 日志,临时文件等等,但是不加密内存中的数据。

另一方面,更细粒度级别的加密,数据库只加密部分数据,一般包含相关的:

  • 表和索引
  • WAL 日志
  • 系统表记录
  • 临时文件

在社区的计划中,目前对SLRU 数据,例如clog, commit timestamp, 2pc file 等等以及服务端日志pg_log 不需要进行加密,当然这个结论随着后面讨论的加深可能也会被推翻。另外,对prepared transaction 产生的持久化文件,目前Sawada-san 认为没有任何的重要数据,所以没有必要进行加密。社区目前将这部分作为一个todo list 等待未来继续讨论。

何时加密

大多数TDE 的实现都是数据在buffer 中的时候是明文,刷到磁盘上的时候进行加密,从磁盘上读取的时候进行解密,保证用于数据处理的时候是明文,而持久化到磁盘上的数据是密文。

不过在具体实现上,仍然有很多问题需要讨论。

  • 在细粒度级别的加密中,WAL 日志的加密密钥是否要和表数据的相同?目前社区的讨论中第一个版本决定使用不同的密钥来加密WAL 日志。这样是更加符合静态数据加密的需求。但是,这样就必须要求backends 不能直接写WAL 日志,WAL 日志完全得交由独立的进程来刷盘。虽然当前已经有walwriter,但是不一定满足需求。
  • 集群级别的加密,在使用pg_basebackup 拉取数据的同时可能需要支持更新密钥的需求,这样用户就很容易恢复出一个另外密钥的数据集群或者备库。

如何加密

社区中讨论了时下多种加密的算法,初步决定使用AES (Advanced Encryption Standard)对数据块进行加密。 对于AES 来说,有三个关键组成部分,包括算法/模式,密钥(key),初始向量(IV)。

AES 模式

计划使用AES 的CTR 模式对WAL 日志和表/索引数据进行加密。

密钥

计划使用密钥支持128 或者256位长度,可以在initdb 的时候选择密钥的长度。

初始向量

为了使得加密的密文随机化,AES 引入了IV。IV 要求每次加密必须是唯一的,但是不需要保密,对外可见。因为每次产生随机数来作为IV,开销比较大,我们可以用一些被加密对象的唯一值来作为IV。

  • 使用(page LSN,page number) 来作为每个数据页的IV,page LSN 8个字节,page number 4个字节,通过补齐可以满足16字节IV 的需要
  • 使用WAL 日志的段号来作为每个WAL 日志段的IV。每个WAL 日志文件使用不同的IV。 不过需要注意的是LSN 不需要加密,必须是可见的,才能保证IV 对外可见。同时也不会去加密CRC,在加密完成后计算CRC,这样就能保证pg_checksums 不通过解密数据依然能检查页面的完整性。

如何管理密钥

上文社区当前计划使用AES 的加密方法,所以本文接下来讨论的就是AES 的密钥如何来管理。一般情况下,为了减少密钥更换带来重解密重加密的开销,业界的方法都是采用多层密钥管理的结构,例如上文的Oracle,MySQL,MS SQL Server 等。其中Oracle,MySQL 使用的是两层密钥结构,MS SQL Server 使用的是三层密钥结构。

两层密钥结构

一般两层密钥结构中包含主密钥(master key,简称为MK)和 数据加密密钥(data encryption key,DEK)。MK 用来加密DEK,而且必须保存在数据库之外,例如KMS。DEK 用来加密数据库数据,可以使用MK 加密后保存在数据库本地。 如果我们使用单层密钥结构,更换密钥的时候必须要重新解密并加密。而使用两层密钥结构,我们需要重新加密和使用新MK 解密的只有DEK,更换密钥的速度会非常的快。

上文提到的PostgreSQL 2019 年邮件列表中的提交的tablespace 级别的TDE patch中就使用两层密钥结构。

三层密钥结构

MS SQL Server 使用的就是三层密钥结构。但是在PostgreSQL 社区中讨论的结构可能略有不同,包含:

  1. 主密钥加密密钥(master key encryption key,简称为KEK):数据库管理员提供的密钥类似于ssl_passphrase_command 参数。
  2. 主数据加密密钥(master data encryption key,MDEK):是通过密码学安全随机数生成器生成的密钥,会被KEK 加密,按照一定的方式进行包装。
  3. 表数据加密密钥(Per table data encryption keys,TDEK)和WAL 数据加密密钥(WAL data encryption keys,WDEK):其中TDEK 是使用MDEK 和HKDF(HMAC-based Extract-and-Expand Key Derivation Function,基于HMAC 的提取和扩展密钥导出函数)生成出来加密表数据的密钥。WDEK 与之生成方法相同,不过是加密WAL 日志的密钥。

临时文件密钥

除了上文的两种密钥,社区认为临时文件的加密密钥需要单独拿出来讨论。临时文件密钥只在服务器运行过程中存在,可以只保存在内存中。在并发查询进行中,多个worker 是可以使用同一个临时文件的。所以临时文件的密钥需要多个并发查询worker 共享,其密钥的管理需要单独进行考虑。

如何拿到顶层密钥

无论是两层还是三层的密钥结构,需要更换的只有最顶层的密钥,而且该密钥必须要求存储在数据库之外。目前社区达成一致意见,增加一个类似ssl_passphrase_command 的参数来运行命令得到相应的密钥,例如上文中的支持集群级别的加密提供了cluster_passphrase_command 参数。

不过,社区中支持tablespace 的patch中实现了密钥管理的API 用来支持外部的密钥管理服务。

前端工具如何拿到密钥

一些前端工具如果需要直接读取数据库文件或者WAL 日志文件需要通过KMS 得到对应的顶层密钥,通过解密数据库中的密文得到加密数据的密钥,从而得到数据明文。

至此,我们已经对PostgreSQL 中实现TDE 的各个各个相关问题进行了讨论,但是目前很多具体的细节并未确认。不过基本确认的是PostgreSQL 13 会推出支持集群级别加密的TDE 功能,敬请期待。

Database · 理论基础 · Multi-ART

$
0
0

这篇文章介绍 Adaptive Radix Tree并且提供其并发算法 Multi-ART

论文链接:The Adaptive Radix Tree: ARTful Indexing for Main-Memory Databases

Adaptive Radix Tree

Adaptive Radix Tree(ART) 中文名字是可变基数树,相较于传统的 radix tree,其最大的区别在于每个节点可以容纳的 key 是动态变化的,这样既可以节省空间,又可以提高缓存局部性。

art 内部节点分为4种类型,分别是 node4, node16, node48, node256

Node4

img

Node4有4个槽,存储4个 unsigned char和4个指针,这些指针可以指向叶子节点(即 key 指针),也可以指向下一层内部节点。当 Node4存放第5个 key byte 时需要将其扩大为 Node16

Node16

img

Node16Node4在结构上是一致的,但是 Node16可以存放16个 unsigned char和16个指针。当存放第17个 key byte 时需要将其扩大为 Node48

Node48

img

Node48结构上和 Node4, Node16有所不同,它有256个索引槽和48个指针,这256个索引槽对应 unsigned char的 0-255,然后每个索引槽的值对应指针的位置,分别为 1-48,如果某个字节不存在的话,那么它的索引槽的值就是0。当存放第49个 key byte 时需要将其扩大为 Node256

Node256

img

Node256直接存放256个指针,每个指针对应 unsigned char的 0-255 区间。

除了以上的可变节点,ART 还引入了两种技术来进一步减少内存占用(尤其是在 long key 情况下),分别是 Path CompressionLazy Expansion

Path Compression & Lazy Expansion

img

Lazy Expansion 用于区别两个叶子节点时才进行创建内部节点,比如上图中的 FOO,为了节省空间,两个内部节点不会被创建,只会存在一个叶子节点,当另一个 key 比如 FPO被插入时,才会创建一个内部节点以区别 OP

Path Compression 用于移除只有单个子节点的节点,比如上图中带有 A的节点会被合并入父节点。

节点合并带来了前缀,前缀需要在下降时进行比较,所以产生了两种方法,一种是悲观方法,即每个节点专门开辟一个变长区间存放前缀,每次下降时需要进行比较;另一种是乐观方法,只存储前缀的长度,下降时跳过这个长度,然后到达叶子节点时再回头利用叶子节点进行前缀的比较。在 ART 的实现中结合了这两种方法,每个节点存放最多8个字节的前缀,下降会根据前缀长度进行动态切换。

查找算法

img

查找流程很简单:

  1. 如果当期节点为空代表不存在;
  2. 如果当前节点是叶子节点并且 key 相等那么即代表找到;
  3. 如果当前节点是内部节点需要首先比较前缀,前缀不同则表明 key 不存在,相同则可以继续进行下降。

插入算法

img

插入流程大致分为以下几步:

  1. 如果下降到的节点是空节点,那么用叶子节点替换掉这个叶子节点;
  2. 如果下降到的节点是叶子节点,那么需要进行比较,如果 key 存在退出;否则使用新的内部节点替换到当前叶子节点,同时需要根据两个 key 获取前缀
  3. 如果下降到的是内部节点,需要首先比较前缀,如果前缀不符合的话,生成新节点,令公共前缀为其前缀,公共前缀后一字节作为区分两个 key 的字节,然后将叶子节点和截断公共前缀后的老节点插入到这个新节点中
  4. 如果下降到的是内部结点并且前缀相等,如果存在下一层节点的话,继续进行下降;否则直接将叶子节点插入,并根据需要进行节点大小的调整

删除算法

参考插入算法。

Multi-ART

Multi-ART 是 ART 的并发算法,是我断断续续设计了三个月后的成果。Multi-ART 参考了 Mass Tree的并发策略即 lock-free read + fine-grained-locking write,然后根据 ART 的特点做出了调整。

ART 是很久之前就知道的一种索引,当时将某个 C 实现的 ART 翻译成了 C++ 实现,然后非常惊讶于其性能,所以选择了 ART 来设计并发算法。

整个设计中最核心的问题就是“如何正确下降到下一层节点”

在并发 B-Tree(包括 Mass Tree)中,下降最大的障碍在于分裂,下降前根据 key 判断应该下降到 A 节点,但实际上由于节点的横向分裂实际需要下降到另一个节点 B,但是在 Multi-ART 中,情况又有很大的不同,ART 节点不存在横向分裂的行为,取而代之的是:

  1. 节点竖向分裂或合并,由节点前缀变更引起
  2. 节点原地扩展或收缩,由插入和删除引起

img

对于 B Tree 或者 Mass Tree 来说,为了保证下降到正确的节点,有两种机制,第一种是每个节点自带一个 next域和一个 sentinel key,用于进行节点之间的右移,比如 B Link Tree;第二种是重试,即从某个子树进行重新下降,比如 Mass Tree。

对于 Multi-ART 来说,我们首先考虑节点的原地扩展或收缩,即上图中 ART Node Expand这种情况。为了保证正确地下降,对每个节点引入 old这个域(只需占用某个 bit),每次节点发生扩展或收缩时需要将当前节点标记为 old。如果在下降到这个节点时发现这个节点已经处于 old状态,即代表它已经被一个新的节点取代了,那么为了获取正确的节点,这里可以有两种辅助机制:

  1. 每个节点引入 new指针,如果当前节点被替换了,通过 new来获取新节点
  2. 根据父节点来获取新节点,因为父节点中旧的节点会被原地替换为新节点

以上两种机制都能正常工作,考虑到 ART 原有算法,我采用了 old域 + 通过父节点获取新节点这种方式。

最后我们考虑节点的竖向分裂或合并,即上图中 ART Node Split这种情况。这里需要两个机制来保证正确性。

一是对每个节点引入 offset域,即如果下降到这个节点,该从哪个偏移开始比较。比如某个节点前缀是 ABC,节点的 offset是4,如果在另一个线程下降过程中发生了竖向分裂,那么可能前缀变成了 BCoffset变成了 5,那么另一个线程就不能用 offset 4进行比较,当它发现 offset 5时,即知道发生了前缀变化,需要进行重试。

二是对每个节点引入 expand version域,比较节点前缀前后都需要获取这个域来保证节点的前缀在比较时没有发生改变。

三是对每个节点引入 parent域,因为两个线程可能一前一后更改了前缀,所以第二个线程替换父节点中的节点时应该获取上图中的绿色节点,而不是下降时的红色节点。

为了支持这个算法,对每个 ART 节点引入了 version(8字节) 和 parent(8字节) 域。

img

off: 节点的 offset
count: 节点的 key 数量
prefix_len: 前缀长度
type: node4 | node16 | node48 | node256
old: 节点是否是旧的
lock: 用于写线程加锁
expand: 是否正在发生前缀变化
vexpand: 前缀的 version
insert 和 vinsert 目前没有使用

以上就是 Multi-ART 的设计核心问题,其实花的精力远远不止以上这点篇幅,还有很多其它的设计细节和实现细节,懒得展开了。

性能

img

Multi-ART 的性能相较于 B Tree based 的并发算法,几乎是碾压。(当然这里需要指出的是为了实现的简单,并不保证 Multi-ART 的 Node4, Node16 和 Node48 中 key 是有序的)。

img

可以看到 Multi-ART 可以在使用 64 个线程时跑到单机1亿 tps。

Multi-ART 的高性能有很多因素,比如算法时间复杂度低,树高与 key 数量不一定相关,cache 友好的节点设计,良好的并发策略设计等等。

总结

这篇文章介绍了 ART 以及 Multi-ART 的设计与实现。

GitHub 实现:UncP/aili,代码在 /art这个文件夹里。


MySQL · 引擎特性 · RDS三节点企业版 一致性协议

$
0
0

本文介绍三节点企业版如何在AliSQL的基础上集成X-Paxos一致性协议,来实现高可用强一致的特性。

背景介绍

RDS 5.7三节点企业版是孵化于阿里巴巴集团内部的高可用、强一致,支持全球部署的数据库产品。该产品从2017年在阿里巴巴集团自有业务推广,平稳支持多年双十一。经过2年的内部打磨,该版本在2019年7月正式上线公有云售卖。相比RDS 5.6三节点版本,我们对内核进行的全新的设计,特别是一致性协议方面。

三节点企业版的核心是一致性协议。在5.7的版本,我们把阿里巴巴自研的一致性协议库X-Paxos集成到AliSQL中,在100%兼容MySQL的基础上,实现了数据库的自动选主,日志同步,数据强一致,在线配置变更等功能。X-Paxos采用了unique proposer的Multi-Paxos实现方案,同时又做了很多创新性的功能和性能优化,是一个更具生产环境实用意义的一致性协议。

节点角色

熟悉Paxos论文的人都知道,整个Paxos算法中包含三种角色:Proposer、Accepter和Learner。在X-Paxos中,节点的角色分为四类:

角色同步日志投票权状态机回放读写状态Paxos角色映射
Leader111rwProposer / Accepter / Learner
Follower111roProposer / Accepter / Learner
Logger110-Accepter / Learner
Learner101roLearner

整个一致性协议的持久存储分两块:日志和状态机。日志代表了对状态机的更新操作,状态机存放了外部业务读写的实际数据。

Leader是集群中唯一可读写的节点。它给集群所有节点发送新写入的日志,达成多数派后允许提交,并回放到本地的状态机。众所周知,标准的Paxos存在活锁的问题(livelock),即两个Proposer交替发起Prepare请求,导致每一轮Prepare的Accept请求都失败,提案编号不断递增,陷入死循环永远达不成一致。因此业界的最佳实践是选取一个主Proposer,来保证算法的活性。另一方面,针对数据库场景,只允许主Proposer发起提案,简化了事务的冲突处理,保证了高性能。这个主Proposer被称之为Leader。

Follower是灾备节点,用于收集Leader发送的日志,并负责把达成多数派的日志回放到状态机。当Leader发生故障时,集群中的剩余节点会选一个新的Follower升级成Leader接受读写请求。

Logger是一种特殊类型的Follower,不对外提供服务。Logger做两件事:存储最新的日志用于Leader的多数派判定;选主阶段行使投票权。Logger不回放状态机,会定期清理老旧的日志,占用极少的计算和存储资源。因此,基于Leader/Follower/Logger的部署方式,三节点相比双节点高可用版,只额外增加很少的成本。

Learner没有投票权,不参加多数派的计算,仅从Leader同步已提交的日志,并回放到状态机。在实际使用中,我们把Learner作为只读副本,用于应用层的读写分离。此外,X-Paxos支持Learner和Follower之间的节点变更,基于这个功能可以实现故障节点的迁移和替换。

集群管理

三节点企业版支持丰富的集群变更和配置管理功能,列举如下:

  • Leader节点主动切换
  • 加减Learner节点
  • Follower降级成Learner、Learner升级为Follower
  • 修改节点的选举权重
  • 修改Learner节点的复制拓扑
  • 修改日志发包的配置模式(Pipelining、Batching、压缩、加密)
  • 高性能异步模式

日志

首先回顾MySQL双节点高可用版本的复制模式。其中Master节点负责写入binary log,并提交事务。Slave节点通过IO线程从Master节点发起dump协议拉取binary log,并存储到本地的relay log中。最后由Slave节点的SQL线程负责回放relay log。

双节点复制模式可以用下图表示:

一般情况下,Slave节点还需要开启log-slave-updates来保证从库也可以为下游提供日志同步,因此Slave线程除了relay log,还会有一份冗余的binary log。

三节点企业版创新性的整合了binary log和relay log,实现了统一的consensus log,节省了日志存储的成本。当某个节点是Leader的时候,consensus log扮演了binary log的角色;同理当某个节点被切换成Follower/Learner时,consensus log扮演了relay log的角色。X-Paxos一致性协议层接管consensus log的同步逻辑,同时提供对外的接口来实现日志写入和状态机回放。新的consensus log基于一致性协议和State Machine Replication理论,保证了多个节点之间的数据一致性。此外,三节点企业版日志的实现遵循了MySQL binary log的标准,可以无缝兼容aliyun DTS、Canal等业内常用的binlog增量订阅工具。

三节点复制模式如下图所示:

状态机

三节点企业版的状态机实现改造了MySQL原有事务提交的流程。

MySQL组提交(Group Commit)相关的技术文章网上有很多,原有Group Commit分为三个阶段:flush stage、sync stage、commit stage。对于Leader节点,三节点企业版修改了其中commit stage的实现方式。所有进入commit stage的事务会被统一推送到一个异步队列中,进入quorum决议的判定阶段,等待事务日志同步到多数节点上,满足quorum条件的事务才允许commit。另外,Leader上consensus log的本地写入和日志同步可以并行执行,保证了高性能。

对于Follower节点,SQL线程读取consensus log,开始等待Leader的通知。Leader会定期同步给Follower每一条日志的提交状态,达成多数派的日志会被分发给worker线程并行执行。

Learner节点相对Follower的逻辑更加简单,一致性协议保证了它不会接收到未提交的日志,SQL线程不用等待任何条件,只需分发最新的日志给worker线程即可。此外,三节点企业版使用特殊版本的Xtrabackup进行实例备份和恢复。我们基于X-Paxos的snapshot接口改进了Xtrabackup,支持创建带有一致性位点的物理备份快照,可以十分快捷的孵化一个全新的Learner节点,并加入到集群中提供读能力的扩展。

部署模式

同城三副本

同城三副本是公有云上默认的部署模式。比较传统的双机房主备高可用版,三节点在满足高可用强一致特性的基础上,基本不增加存储成本:

  • 三节点单机房不可用场景下数据0丢失,秒级切换,主备有丢数据的风险;
  • 三节点和主备都只存储两份状态机数据;三节点存储三份consensus log日志,而主备版本常态化有两份binary log和一份relay log,总量基本持平。

跨域五副本

对于跨域容灾场景,我们推荐跨域五副本的架构。相比简单的搭建跨域三副本,五副本有以下优势:

  • 和跨域三副本一样有Region级别的容灾能力,链路上仅有少量性能损耗;
  • 通过增加一个Follower和Logger节点,实现单机房故障下的同城容灾,对用户端友好;
  • 通过X-Paxos的选举权重功能,可实现定制化的region切换顺序。

总结

随着当前互联网的发展,云上客户对数据安全越来越重视,大量行业对数据存储有跨机房跨地域的需求。RDS 5.7三节点企业版是基于阿里巴巴内部自研技术的沉淀,针对数据质量要求较高的用户,在云上推出的数据库解决方案。此外,对于RDS 5.7高可用版的老用户,也支持一键升级三节点。

购买方式:

MySQL · 引擎特性 · RDS三节点企业版 Learner 只读实例

$
0
0

本文介绍三节点企业版只读实例的相关功能和技术实现。

背景介绍

读写分离是数据库常见的使用模式。类似MySQL proxy这样的中间件把写入和更新流量发送到主节点,把查询流量转发到只读节点,可以释放主节点的CPU和IO资源,提升数据库整体的可用性。在《RDS三节点企业版 · 一致性协议》文章中,我们介绍了三节点企业版借助X-Paxos的Learner角色,实现了只读实例的功能。

Learner特性

三节点企业版通过新加Learner的方式实现只读实例的功能。Learner从Leader接收已经提交的日志存储到consensus log中,由Slave线程读取并分发给worker线程,最终并行回放到状态机。对于外部客户端来说,Learner节点是只读状态的。

实际上用过MySQL云产品的人,对只读节点的概念并不陌生。在双节点高可用版本中,初始状态会生产两个实例。一个作为Master,是提供读写的主节点。另一个作为Slave,是处于read only状态的备节点,不过该节点不暴露给客户,也不对外提供读服务。如果需要增加只读实例支持读写分离,控制台后台会通过备份新建一个Slave节点,挂载在Master上。当该节点追平Master最新的数据后,即Second_Behind_Master追到0,对外开启读服务。部署模式如下:

三节点企业版的只读节点十分类似,首先通过备份创建一个新的Learner节点,并挂载在Leader上,挂载后Learner开始接收增量的consensus log并开始回放。当Learner节点的日志回放追平后,对外开启读服务。部署模式如下:

相比高可用版本的只读节点,Learner的优势在于接入到X-Paxos的体系中,保证了主节点(Leader/Master)和灾备节点(Follower/Slave)无论如何容灾切换,Learner都会保持和三节点集群一致的数据。考虑这样一个场景:双节点高可用场景下,主库把x=1更新成x=2,同步给了只读节点但还未同步给备库,之后主库故障。备库会切换成新的主库,只读节点也会指向这个备实例。这个时刻新主库和只读节点的数据就出现了不一致,新主库x=1,只读节点x=2。如果此时业务或DBA检测到数据库的不一致问题,执行数据回补,在新的备库重新执行把x=1更新成x=2。当这个事务binary log同步到只读节点,就会造成只读节点的SQL线程报错退出,需要人工介入处理。假设这个回补的数据量很大,在人工运维上就完全没有可操作性了,只能基于新主库的备份重搭只读节点,导致只读节点一段时间的不可用。在三节点企业版中,就完全不会发生这样的问题。

Learner的孵化

三节点企业版使用特殊版本的Xtrabackup进行实例备份和恢复。我们基于X-Paxos的snapshot接口改进了Xtrabackup,支持创建带有一致性位点的物理备份快照,可以十分快捷的孵化一个全新的Learner节点,并加入到集群中提供读能力的扩展。在即将推出的RDS 8.0三节点版本中,我们还会整合官方8.0新出的Clone Plugin功能,推出基于Clone Plugin的一致性位点快照,Learner节点孵化功能运维会更简单,速度也会更快。

Clone Plugin相关资料可以参考:
https://mysqlserverteam.com/clone-create-mysql-instance-replica
http://mysql.taobao.org/monthly/2019/08/05/

自定义数据源

三节点企业版的只读节点借助X-Paxos的LearnerSource功能,通过自定义数据源,轻松实现了灵活的复制拓扑。三节点的复制拓扑配置都是通过Leader上的Membership Change相关管控SQL命令完成的。通过中心化配置管理,保证集群维度一致。自定义数据源的好处是当只读节点数量较多时,可以分流Leader日志发送的压力,打散网络传输的数据量,减小日志同步的延迟。

三节点企业版的自定义数据源还支持基于region的load balance和LearnerSource的自动容灾。具体来说,支持通过load balance功能一键将每个region的只读节点自动挂载到同region的Follower/Learner节点上。如果同region数据源出现故障,能够将数据源短暂退化到Leader节点直到恢复。该拓扑保证了各自region的只读节点从同region的节点同步数据,通过这样的级联部署,极大地减少了跨region的网络带宽占用,避免了带宽瓶颈造成的跨region延迟。

以下是阿里巴巴集团内部的一个部署样例:

当然传统的MySQL也可以构造一系列Master-Slave-Slave这样的拓扑,逐个实例通过change master配置复制关系,不过这种方式容错性差,管理成本和运维成本都很高。同时随着只读节点数量的规模上升,主备容灾后,数据不一致的风险会被放大。

会话读一致性

只读节点接收日志并回放,接受外部查询请求,这里存在一个问题,Learner的日志同步和回放是异步的,虽然大部分场景延迟在5s以内,也不能保证每次查询的数据一定是最新的。特别是主库执行了大表DDL或者大事务,会造成只读节点出现明显的延迟。为了解决这个问题,三节点企业版引入了MaxScale作为读写分离的代理,并在MaxScale中实现了会话读一致性,即在同一个Session内部,保证后续的读取可以读到之前同Session写入的数据,但不保证可以读到其他Session最新版本的数据。

X-Paxos的每一条日志都有一个LogIndex,对应Multi-Paxos概念中的Instance number。同时,只读节点在多线程乱序回放日志到状态机的过程中,会维护日志并发回放的窗口,通过该窗口可以计算出一个已回放的Logindex的低水位线(Lwm AppliedIndex)。在Lwm AppliedIndex之前的所有日志,都已经回放到状态机,之后的日志,依然存在空洞。三节点企业版读写分离层的代理,会跟踪缓存各个只读节点的Lwm AppliedIndex,同时每个Leader的更新,都会记录当前事务的Logindex。当有新请求到来时代理层会比较Session最新的Logindex和当前各个只读节点的Lwm AppliedIndex,仅将请求发往Lwm AppliedIndex >= Session Logindex的节点,从而保证了会话一致性。在读多写少的场景下,该机制可以起到非常好的读写分离效果。

总结

通过X-Paxos的Learner角色,支持创建只读实例,实现读取能力的弹性扩展,分担主数据库压力。利用只读实例满足大量的数据库读取需求,增加应用的吞吐量。目前阿里云官网已经开放了RDS 5.7三节点企业版只读实例的创建和使用,欢迎试用。

MySQL · 引擎特性 · 动态元信息持久化

$
0
0

背景

MySQL 在 8.0 中引入了动态元信息持久化功能,目的是能持久化表上快速变化的元信息(fast-changing metadata),重启后元信息可以恢复到重启前的状态,比如 autoinc、update_time、index corrupt 信息等。目前实现了 2 种元信息的持久化,index corrupt 信息 和 autoinc。

关于这个功能,Upstream 有 2 个 worklog,WL#7816WL #6204WL#7816引入了整个设计框架,并且实现了 index corrupt 的持久化;WL #6204是在这个框架之上,实现了 autoinc 持久化功能。

关于 index corrupt 信息持久化,因为实际使用中遇到也比较少,所以大家可能会比较陌生。简单来说,当 InnodB 在运行过程中,发现索引坏掉,不管是物理数据的损坏(比如 index root page 无效、数据页损坏),还是逻辑数据的损坏(如 dup key),都会将 index 设置为 corrupted,后面对 index 的访问就会被屏蔽掉,或者报错。在 8.0 之前,这个 corrupt 信息是持久化在 SYS_INDEXES 内部系统表中的 (TYPE 字段)(参考 dict_set_corrupted())。但是因为对系统表的更新,是比较上层的,而发现 corrupt 时,是在 InnoDB 比较底层的逻辑,从底层去更新系统表,要持有上层的锁,这就很可能导致死锁,因此很多情况下,只是更新索引的内存结构,而不做持久化到系统表里(参考 dict_set_corrupted_index_cache_only())。8.0 引入 DD 表后,同样存在这样软件架构上下层的问题。因为可能没有持久化,corrupted 信息重启后就丢失,坏的索引就可能在重启后被访问,这会导致潜在的数据问题。

关于 autoinc 的持久化问题,相信 MySQL DBA 或者内核研发同学应该都很熟悉。著名的 bug #199就是 autoinc 持久化问题。长期以来,InnoDB 都没有对 autoinc 做持久化,只在内存表对象 cache 中维护 autoinc 信息,重启后表的 autoinc 值是通过类似 SELECT MAX()来初始化的,所以 InnoDB 表一定要对 autoinc 字段建索引,如果是组合索引,autoinc 字段必须是索引中第一个字段,这样 SELECT MAX()逻辑才会比较快。

关于 autoinc 持久化的问题,AliSQL 和 PolarDB 很早就有了自己的解决方案,我们在早期的月报中介绍过,大家可以参考 InnoDB自增列重复值问题。简单来说,这个方案是将 autoinc 写入 PK root page,保存在一个原来不用的位置(PAGE_MAX_TRX_ID)。这个方案实现也发布在 AliSQL 开源版本AliSQL Persistent AUTO_INCREMENT,同时我们也将这个方案贡献到 MariaDB MDEV-6076

下面笔者将会基于自己的理解,给大家介绍 8.0 动态元信息持久化功能,其中代码分析基于目前最新的 8.0.18 版本。

持久化框架原理

如前面所说,整个持久化设计方案是在 work log WL#7816引入的,work log 也相当详细,大家也可以直接看 work log.

整体方案的核心是复用 (piggy-back) InnoDB redo log,,通过新增加一种逻辑 redo 类型 MLOG_TABLE_DYNAMIC_META,将元信息更新写入到 redo,一方面 redo 可以提供持久化保证,另一方面 redo 的层次比较底,基本可以在所有元信息变化的地方写入,不存在架构层次问题。

除了利用 redo 之外,还引入了一张 DD Buffer Table 来做辅助持久化。因为 checkpoint 之后,checkpoint lsn 之前的 redo 理论上就是丢弃的,所以之前的所有元信息更新,就需要重新写入 redo,这就会导致频繁重复写入元信息到 redo(copy across checkpoint)。DD Buffer Table 的目的就是为避免这种重复写入,相当于这种元信息的 checkpoint。DD buffer table 是一张 InnoDB 内部字典表,其本身的数据写入是受 redo 保护的。

整体的流程是这样的:

  1. 一旦元信息发生变化,就将新的元信息写入 redo log(MLOG_TABLE_DYNAMIC_META)。
  2. 在做 checkpoint 时,将上一次 checkpoint 后变化的所有元信息,写入到 DD Buffer Table。
  3. 在 slow shutdown 或者 export tablespace 时,将最新的元信息持久到 DD 表中,这时可以清空 DD Buffer Table。需要注意的是,这个只是规划中 (in plan)的逻辑,目前没有实现。
  4. 下次重启时,将 DD Buffer Table 中的元信息,和 redo log 中的元信息,apply 到表内存对象上。

具体实现

下面我们看下具体的代码实现

1. 全局 dict_persist_t

这是管理元信息持久化的一个全局数据结构,类似于 dict_sys_t,管理运行时元信息变化。

struct dict_persist_t {
  // 保护当前结构数据
  ib_mutex_t mutex; 

  // 所有元信息变化的表,都挂在这个list 上
  UT_LIST_BASE_NODE_T(dict_table_t)
  dirty_dict_tables;

  // 被标记为 METADATA_DIRTY 的表数量
  std::atomic<uint32_t> num_dirty_tables;

  // 负责对 DD Buffer Table (mysql.innodb_dynamic_metadata)的操作,
  DDTableBuffer *table_buffer;

  // 元信息持久化实现集合,目前有 2 种,autoinc 和 index corrupt
  Persisters *persisters;
}

参考函数

dict_persist_init()
dict_persist_close()

2. DDTableBuffer

操作 mysql.innodb_dynamic_metadata buffer table 表的实现。

mysql.innodb_dynamic_metadata 表结构如下:

CREATE TABLE `innodb_dynamic_metadata` (
  `table_id` bigint(20) unsigned NOT NULL,
  `version` bigint(20) unsigned NOT NULL,
  `metadata` blob NOT NULL,
  PRIMARY KEY (`table_id`)
) /*!50100 TABLESPACE `mysql` */ ENGINE=InnoDB

每个元信息表化的表,都会在 innodb_dynamic_metadata 中有一条记录,目前所有类型的元信息都拼在一个 blob 里的。

参考函数

DDTableBuffer::init()
DDTableBuffer::open()
DDTableBuffer::get()
DDTableBuffer::remove()
DDTableBuffer::replace()

3. 持久化 Persister 和 PersistentTableMetadata

PersistentTableMetadata是动态元信息的内存表示,对应每个 dict_table_t的所动态元信息。

Persister 负责: a) 在写入 Buffer Table 前将 PersistentTableMetadata 序列化成 btye stream,最终写入 blob 字段 b) 将从 Buffer Table 读取出的 blob 字段,反序列到 PersistentTableMetadata 中。

Persister 是一个基类,每种元信息要基于这个基类,实现自己的具体逻辑。 目前有 2 中元信息corrupted index 和 autoinc,对应 2 种 Persister, CorruptedIndexPersisterAutoIncPersister。序列化时会先序列化 corrupted index,再序列化 autoinc。

同时 Persister 还负责写入 redo log 时,redo record body 的构造。

目前所有动态元信息,都是用一种逻辑 redo record 类型 MLOG_TABLE_DYNAMIC_META来记录的,那么怎么区分这个 record 是 corrupted index 还是 autoinc 呢?

序列化 stream 的头部第一个 byte 用来标识类型,每一种元信息对应一种类型

enum persistent_type_t {
  /** The smallest type, which should be 1 less than the first
  true type */
  PM_SMALLEST_TYPE = 0,

  /** Persistent Metadata type for corrupted indexes */
  PM_INDEX_CORRUPTED = 1,

  /** Persistent Metadata type for autoinc counter */
  PM_TABLE_AUTO_INC = 2,

  /* TODO: Will add following types
  PM_TABLE_UPDATE_TIME = 3,
  Maybe something tablespace related
  PM_TABLESPACE_SIZE = 4,
  PM_TABLESPACE_MAX_TRX_ID = 5, */

  /** The biggest type, which should be 1 bigger than the last
  true type */
  PM_BIGGEST_TYPE = 3
};

对 corrupted index,redo record body 是这样的

     1B (类型)      |     1B (index 个数) |     12B index id              | index id    |
 PM_INDEX_CORRUPTED |  corrupted index num  | table_id (4B) + index_id (8B) |  .....      |

对 corrupted index,redo record body 是这样的

     1B (类型)     |  1 ~ 11B (auto inc 值) |
 PM_TABLE_AUTO_INC |   auto inc compressed    |

所以通过对 body 第 1 个 byte 的复用,同一个 MLOG_TABLE_DYNAMIC_META redo record 类型,就可以表示多种元信息了。

innodb_dynamic_metadata.metadata 字段中的数据,和 redo body 是一样,不同的地方的,redo 只会有一种类型,而 innodb_dynamic_metadata.metadata 中可能是多种类型信息拼到一起的(目前最多2种)。

参考函数

Persister::write_log()
Persisters::write()
AutoIncPersister::write()
CorruptedIndexPersister::write()
dict_init_dynamic_metadata()
dict_table_read_dynamic_metadata()
CorruptedIndexPersister::read()
AutoIncPersister::read()

4. dirty_status 和 write back

dict_table_t结构中,新增 dirty_status状态标识,和 dirty_dict_tables链表节点。

dirty_status 有三种状态:

enum table_dirty_status {
  METADATA_DIRTY = 0,

  METADATA_BUFFERED,

  METADATA_CLEAN
};

dirty_dict_tables 链表节点,用来将 METADATA_DIRTYMETADATA_BUFFERED状态的 dict_table_t挂到 dict_persist_t::dirty_dict_tables链表上。

METADATA_DIRTY的表,需要在 checkpoint 时,写入到 DD Buffer Table 中。写入 Buffer Table 后,状态变成 METADATA_BUFFERED,但是依然在 dict_persist_t::dirty_dict_tables链表上。目前只在将 dict_table_t对象从 cache 淘汰时,才会将其从 dirty_dict_tables 链表上移除。但是当被淘汰的表被打开时,依然会把加入到 dirty_dict_tables。

目前并没有将 dirty_status 从 METADATA_DIRTY或者 METADATA_BUFFERED变化为 METADATA_CLEAN的逻辑。因为还没有将动态元信息写回到 DD 表中的逻辑。

参考函数

dict_persist_to_dd_table_buffer()
dict_table_persist_to_dd_table_buffer()
dict_table_persist_to_dd_table_buffer_low()
dict_table_remove_from_cache_low()
dict_table_load_dynamic_metadata()

5. 关闭和启动初始化

对于正常关闭,关闭前会做一次 checkpoint,将所有 METADATA_DIRTY状态的元信息,写回到 DD Buffer Table 表。启动时,只需要初始化好 dict_persist->table_buffer就可以,后续打开表创建 dict_table_t对象时,会自动从 DD Buffer Table load 数据来 apply。

参考函数

dict_table_load_dynamic_metadata()

对于异常关闭,可能从最新的 checkpoint 后,有新的元信息变动还没写回 DD Buffer Table,这时就需要通过扫描 redo log 把这些元信息找出来。

这里新增了一个数据结构 MetadataRecover,挂在 recv_sys->metadata_recover。在 crash recover 扫描解析 redo log 过程中,如果遇到 MLOG_TABLE_DYNAMIC_META类型的 redo 日志,就解析出元信息并缓存到 metadata_recover->m_tables map 中。在 crash recover 后,字典系统初始化时,会将之前缓存的元信息全部 apply 掉。

参考函数

MetadataRecover::parseMetadataLog()
MetadataRecover::apply()
srv_dict_recover_on_restart()

autoinc 持久化

前面是通用的分析和介绍,下面我们专门看下 autoinc 的持久化。

1. 持久化时机

autoinc 的持久化,并不是在产生时就做持久化,而是在 InnoDB 插入或者更新 PK 记录时,才持久化的,(参考row_ins_clust_index_entry_low()row_upd_clust_rec())。并且这个时候是不能访问 table->autoinc member 的,因为锁优先级问题,不能加 autoinc_mutex 锁,所以 autoinc 的值,是从 tuble 中解析出来的。

参考函数

row_ins_clust_index_entry_low()
row_upd_clust_rec()
row_get_autoinc_counter()
dict_table_autoinc_log()

2. 持久化粒度

因为 autoinc 是一个频繁更新的元信息,如果每次更新都用将 redo 落盘,会对性能有比较大的影响,同时引入新 mtr 的代价也比较大,所以 autoinc 写 redo 时,是当前上下文的 mtr,不做 mtr_commit,也不用 log_write_up_to()来等 redo 真的 flush 下去。

这点和 index corrupt 是不同的,因为发生 index corrupt 相对来说是很小概率的,所以 index corrupt 是用一个自己独立 mtr,并且等 redo flush。

所以 autoinc 持久化并不是 100% 持久化的,不能保证 crash 场景的持久化: a) 一方面由于实现的原因,并不是 autoinc 递增后,就立马写 redo b) 另一方面出于性能的考虑,autoinc redo 并不马上落盘,redo 落盘依赖于事务提交

所以事务过程中,autoinc 发生变化后,InnoDB crash 是会导致 autoinc 回退的。因为通常我们的业务逻辑是依赖事务提交的,所以这个问题也是可以接受的。

参考函数

dict_set_corrupted()
dict_table_autoinc_log()

3. 用户侧行为变化

a) ALTER TABLE AUTO_INCREMENT = N 并不能将 autoinc 改成一个比实际数据小的值。 b) 做完 ALTER TABLE AUTO_INCREMENT = N 后,就立马重启,并不会取消这个 alter 效果,因为已经持久化了

有一种情况是可以 alter 回去的,比如因为数据插入,自增现在是 20,做 ALTER TABLE AUTO_INCREMENT = 100 后,自增变成100,在新的数据插入进来前,就立马做再做一次 ALTER TABLE AUTO_INCREMENT = 20 是可以改回 20 的。但是 ALTER TABLE AUTO_INCREMENT = 10 是改不回去的,因为表数据中已经有 20 这条记录了。

官方文档对行为变化也有说明,可以参考 InnoDB AUTO_INCREMENT Counter Initialization

4. 自增列强制索引限制是否可以去掉

因为自增值已经持久化了,我们在初始化时,就不需要 SELECT MAX(),是不是可以去掉自增上一定要加索引的限制呢?

a) 对于老版本数据(比如 5.7),升级到 8.0,因为老版本没有持久化,所以 SELECT MAX()还是要的。但是老版本的索引限制是有的,所以表结构里自增肯定有是索引的。 b) 对于在新版本上新建的表,持久化机制会保证持久化。

所以理论上是可以的,我们也向 Upstream report 这个 feature request,感兴趣的可以关注下 bug #98093,期待 Upstream 后续版本可以移除这个限制。

5. 查看 DD Buffer Table

熟悉 8.0 的同学可能知道,默认情况下 DD 表是不让访问的,但是 Debug 版本可以去除这个访问限制

SET SESSION debug='+d,skip_dd_table_access_check';

但是貌似 DD Buffer Table 一直是空的,查不出数据:

mysql> select * from mysql.innodb_dynamic_metadata;
Empty set (0.00 sec)

这是为什么呢?我们知道 InnoDB 是支持 MVCC 的,在 PK 每条记录上有 DB_TRX_ID 表示最后更新的事务 id,其它人访问到这条记录后,用自己的 read view 和这个事务 id 比较,来判断是否可见。对于 DB Buffer Table 每条记录的事务 id 都被强制记为 0XFFFFFFFFFFFF,所以是看不到的(参考 DDTableBuffer::create_tuples())。绕过方法也很简单,把隔离级别改成 RU。

mysql> set transaction_isolation = "read-uncommitted";
Query OK, 0 rows affected (0.00 sec)
mysql> select table_id, version, hex(metadata) from mysql.innodb_dynamic_metadata;
+----------+---------+---------------+
| table_id | version | hex(metadata) |
+----------+---------+---------------+
|        6 |       0 | 0201          |
|        7 |       0 | 0280FF        |
|        9 |       0 | 028135        |
|       12 |       0 | 028F4E        |
|       15 |       0 | 0233          |
|       19 |       0 | 02810C        |
|       21 |       0 | 0255          |

祝玩得开心!

MySQL · 引擎特性 · Binlog encryption 浅析

$
0
0

背景介绍

为了保障数据安全,MySQL 在 5.7 版本就支持了 InnoDB 表空间加密,之前写了一篇月报介绍过,参考InnoDB 表空间加密。文章开头也提到过,MariaDB 除了对表空间加密,也可以对 redo log 和 binlog 加密,本质上 redo log 和 binlog 中也保存着明文的数据,如果文件被拖走数据也有丢失的风险,因此在 MySQL 8.0 中也支持两种日志的加密,本文介绍 Binlog 的加密方式,建议先了解一下表空间加密,更容易理解。

使用方式

首先需要在 DB 启动的时候加载 Keyring,关于 Keyring 可以参考官方文档或者上个小节提到的表空间加密的月报。

[mysqld]
early-plugin-load=keyring_file.so

控制是否对 Binlog 文件加密的开关是:binlog_encryption,此开关可以动态打开或者关闭,修改会引起一次 Binlog rotate。需要用户具有 BINLOG_ENCRYPTION_ADMIN权限。

mysql> set global binlog_encryption = ON;

配置完成后新的 Binlog 文件就是加密的了,加密是文件级别的,可以查看具体哪个文件被加密了:

mysql> show binary logs;
+------------------+-----------+-----------+
| Log_name         | File_size | Encrypted |
+------------------+-----------+-----------+
| mysql-bin.000001 |       178 | No        |
| mysql-bin.000002 |       178 | No        |
| mysql-bin.000003 |       202 | No        |
| mysql-bin.000004 |       714 | Yes       |
| mysql-bin.000005 |       178 | No        |
| mysql-bin.000006 |       178 | No        |
| mysql-bin.000007 |       856 | No        |
| mysql-bin.000008 |       707 | Yes       |
+------------------+-----------+-----------+

原理解析

同样为了支持 Key rotate,秘钥分为 master key 和 file password, 其中 master key 保存在 keyring 中,用来加密 file password, 这样每次 key rotate 的时候,只需要用新的 master key 把所有 Binlog 文件的 file password 重新加密一遍即可。

image.png

如图所示,master key 的密文是保存在 Keyring 中的,明文是固定的格式: MySQLReplicationKey_{UUID}_{SEQ_NO} , 其中 SEQ_NO 是每次 Key rotate 的时候自增的。因为由明文获得 Keyring 中的密文是不可逆的加密,因此明文简单点也不要紧,我们需要保证的是 Keyring 的安全。

filepassword 是保存在每个 Binlog 文件的头部的,文件头部新增的数据格式如下:

image.png

这部分是不加密的,一个文件是否加密是用 Magic num 来确定的,(0xFE62696E) 不加密, (0xFD62696E), 加密。每次打开一个文件的时候,都先判断 Magic num,确定是否需要解密。Version 不用多解释,数据格式高低版本兼容的时候用的到。Encryption Key Id 保存的就是 master key 的明文。File Password 就是加密过之后的 filepassword。IV 是从 OpenSSL 中随机生成的,解密算法需要 key 和 IV。

为了保证 key rotate 的崩溃恢复,在 Keyring 中的保存的不仅仅是 master key 的密文,还有 seqno, 那么保存 seqno 的明文是什么呢 ? 有以下几种:

  • MySQLReplicationKey_{UUID}
  • old_MySQLReplicationKey_{UUID}
  • new_MySQLReplicationKey_{UUID}
  • last_purged_MySQLReplicationKey_{UUID}

举个例子,Rotate 的时候需要获得一个新的 seqno,如果出现了 crash,重启的时候如何获得老的 seqno 呢 ?因此在 rotate 的时候会先把老的 seqno 放到 old_MySQLReplicationKey_{UUID} 为明文的 keyring 中。

代码解析

核心类

Binlog_encryption_ostream 类负责写入流程,继承了 Truncatable_ostream,和之前写文件的 IO_CACHE_stream 类似, m_down_ostream 是 IO_CACHE_stream 接口,加密后写到文件中,从 m_header 中获得 file password。 具体的加密和解密工作由 m_encryptor 负责。

class Binlog_encryption_ostream : public Truncatable_ostream {
  public: 
  private:
    std::unique_ptr<Truncatable_ostream> m_down_ostream;
    std::unique_ptr<Rpl_encryption_header> m_header;
    std::unique_ptr<Rpl_cipher> m_encryptor;
}

这两个类负责管理 Binlog 文件头保存的信息,V1 是目前的版本,说明官方设计代码的时候考虑到了以后数据格式的变化。

class Rpl_encryption_header_v1 : public Rpl_encryption_header {
  private:
    /* The key ID of the keyring key that encrypted the password */
    std::string m_key_id;
    /* The encrypted file password */
    Key_string m_encrypted_password;
    /* The IV used to encrypt/decrypt the file password */
    Key_string m_iv;
}

Rpl_encryption 类负责管理 master key,和 keyring 交互,包括 key rotate 和崩溃恢复, 在代码中是一个单例。

class Rpl_encryption {
  /* master key id 接口*/
  struct Rpl_encryption_key {
    std::string m_id;
    Key_string m_value;
  };
}

初始化

加密是文档级别的,在打开每个 binlog 的文件会去判断 Encryption 是不是 enable 了,如果判断需要加密,就初始化 m_pipiline_head 为 Binlog_encryption_ostream.

/* 照常打开 Binlog_ofile */
bool MYSQL_BIN_LOG::Binlog_ofile::open(
    const char *binlog_name, myf flags, bool existing = false)) {
      /* 正常的打开 IO_CACHE_ostream */
      std::unique_ptr<IO_CACHE_ostream> file_ostream(new IO_CACHE_ostream);
      if (file_ostream->open(log_file_key, binlog_name, flags)) DBUG_RETURN(true);

      m_pipeline_head = std::move(file_ostream);

      /* Setup encryption for new files if needed */
      if (!existing && rpl_encryption.is_enabled()) {
        std::unique_ptr<Binlog_encryption_ostream> encrypted_ostream(
            new Binlog_encryption_ostream());
        /* 把刚刚打开的 IO_CACHE_ostream 放到 Binlog_encryption_ostream::down_ostream */
        /* 加密完成之后会继续用 down_ostream 写到文件里 */
        if (encrypted_ostream->open(std::move(m_pipeline_head)))
          DBUG_RETURN(true);
        m_encrypted_header_size = encrypted_ostream->get_header_size();
        m_pipeline_head = std::move(encrypted_ostream);
      }
    }

加密

加密的入口是 Binlog_encryption_ostream::write 函数,具体加密的工作是由 Rpl_cipher::encrypt 来做的,而 Rpl_cipher 需要的加密所用的 key 是由 Rpl_encryption_header 提供的。

bool Binlog_encryption_ostream::open(
    std::unique_ptr<Truncatable_ostream> down_ostream) {
  DBUG_ASSERT(down_ostream != nullptr);

  m_header = Rpl_encryption_header::get_new_default_header();
  /* 从 header 中产生一个 random 的 filepassword,然后用 master key 加密*/
  const Key_string password_str = m_header->generate_new_file_password();
  /* 取出 Aes_ctr,目前的加密方式是 Aes,是一个子类的具体实现 */
  m_encryptor = m_header->get_encryptor();
}·

Binlog_encryption_ostream::write 中按照 ENCRYPT_BUFFER_SIZE = 2048 的大小块加密文件,加密后写到 IO_CACHE_ostream 中。

bool Binlog_encryption_ostream::write(const unsigned char *buffer,
    my_off_t length) { 
  /*
     Split the data in 'buffer' to ENCRYPT_BUFFER_SIZE bytes chunks and
     encrypt them one by one.
   */
  while (length > 0) {
    int encrypt_len =
      std::min(length, static_cast<my_off_t>(ENCRYPT_BUFFER_SIZE));

    if (m_encryptor->encrypt(encrypt_buffer, ptr, encrypt_len)) {
      THROW_RPL_ENCRYPTION_FAILED_TO_ENCRYPT_ERROR;
      return true;
    }

    if (m_down_ostream->write(encrypt_buffer, encrypt_len)) return true;

    ptr += encrypt_len;
    length -= encrypt_len;
  }
}

解密

一个 Binlog 文件是不是加密的,是有文件头部的 magic num 决定的,当打开一个文件后,会调用函数 Basic_binlog_ifile::read_binlog_magic(),取出 magic num 后判断是否加密,以此来初始化。encryption_istream 的管理类似 Binlog_encryption_ostream,不在赘述。

bool Basic_binlog_ifile::read_binlog_magic() {
  /*
     If this is an encrypted stream, read encryption header and setup up
     encryption stream pipeline.
   */
  if (memcmp(magic, Rpl_encryption_header::ENCRYPTION_MAGIC,
        Rpl_encryption_header::ENCRYPTION_MAGIC_SIZE) == 0) {

    std::unique_ptr<Binlog_encryption_istream> encryption_istream{
      new Binlog_encryption_istream()};
    if (encryption_istream->open(std::move(m_istream), m_error))
      DBUG_RETURN(true);

    /* Setup encryption stream pipeline */
    m_istream = std::move(encryption_istream);

    /* Read binlog magic from encrypted data */
    if (m_istream->read(magic, BINLOG_MAGIC_SIZE) != BINLOG_MAGIC_SIZE) {
      DBUG_RETURN(m_error->set_type(Binlog_read_error::BAD_BINLOG_MAGIC));
    }

  }
}

MASTER KEY ROTATE

Rotate 分为几个阶段,代码上从上面的阶段可以走到下面的阶段,在 recover_master_key 的时候会直接走到对应的的阶段去。

enum class Key_rotation_step {
  START,
  DETERMINE_NEXT_SEQNO,
  GENERATE_NEW_MASTER_KEY,
  REMOVE_MASTER_KEY_INDEX,
  STORE_MASTER_KEY_INDEX,
  ROTATE_LOGS,
  PURGE_UNUSED_ENCRYPTION_KEYS,
  REMOVE_KEY_ROTATION_TAG
};

每个阶段都做什么:

  1. START: 把现有的 seqno 放到 keyring 中,key 是 ‘old’ 字样的开头
    if (m_master_key_seqno > 0) {
      /* We do not store old master key seqno into Keyring if it is zero. */
      if (set_old_master_key_seqno_on_keyring(m_master_key_seqno)) goto err1;
    }
    
  2. DETERMINE_NEXT_SEQNO: 循环遍历下一个 sequno 是多少,从当前的 seqno 递增。
     do {
    ++new_master_key_seqno;
    /* Check if the key already exists */
    std::string candidate_key_id =
      Rpl_encryption_header::seqno_to_key_id(new_master_key_seqno);
    auto pair =
      get_key(candidate_key_id, Rpl_encryption_header::get_key_type());
    /* If unable to check if the key already exists */
    if ((pair.first != Keyring_status::KEY_NOT_FOUND &&
          pair.first != Keyring_status::SUCCESS) ||
        DBUG_EVALUATE_IF("fail_to_fetch_key_from_keyring", true, false)) {
      Rpl_encryption::report_keyring_error(pair.first);
      goto err1;
    }
    /* If the key already exists on keyring */
    candidate_key_fetch_status = pair.first;
     } while (candidate_key_fetch_status != Keyring_status::KEY_NOT_FOUND);
    // 找到之后放到 keyring 中,加上 new 关键字。
    if (set_new_master_key_seqno_on_keyring(new_master_key_seqno)) goto err1;
    
  3. GENERATE_NEW_MASTER_KEY:这一步会重新获得全局 Rpl_encryption 中的 master key,用来加密后面的数据
    /*
    Request the keyring to generate a new master key by key id
    "MySQLReplicationKey\_{UUID}\_{SEQNO}" using
    `new master key SEQNO` as SEQNO.
     */
    if (generate_master_key_on_keyring(new_master_key_seqno)) goto err1;
    
  4. REMOVE_MASTER_KEY_INDEX:把老的 seqno 移除。
    /*
    We did not store a master key seqno into keyring if
    m_master_key_seqno is 0.
     */
    if (m_master_key_seqno != 0) {
      if (remove_master_key_seqno_from_keyring()) goto err1;
    }
    
  5. STORE_MASTER_KEY_INDEX : 把新的 seqno 用正常的 key (不带关键字)存起来
    if (set_master_key_seqno_on_keyring(new_master_key_seqno)) goto err1;
    
  6. ROTATE_LOGS:rotate binlog 和 relay log, 从后往前遍历所有文件,重新加密 filepassword
    /* We do not rotate and re-encrypt logs during recovery. */
    if (m_master_key_recovered && current_thd) {
      /*
      Rotate binary logs and re-encrypt previous existent
      binary logs.
    */
      if (mysql_bin_log.is_open()) {
     if (DBUG_EVALUATE_IF("fail_to_rotate_binary_log", true, false) ||
         mysql_bin_log.rotate_and_purge(current_thd, true)) {
       goto err2;
     }
     if (mysql_bin_log.reencrypt_logs()) return true;
      }
      /* Rotate relay logs and re-encrypt previous existent relay logs. */
      if (flush_relay_logs_cmd(current_thd)) goto err2;
      if (reencrypt_relay_logs()) return true;
    }
    
  7. PURGE_UNUSED_ENCRYPTION_KEYS : 把带 ‘last_purged’ 的关键字 keyring 的 seqno 删除。
  8. REMOVE_KEY_ROTATION_TAG : 把第二步带 ‘new’ 关键字的 keyring 的 seqno 删除。

总结

Binlog 加密对于数据安全性非常必要,在 8.0.17 开始使用 AES-CTR 加密 binlog temp file, 网络传输中的依然是明文,需要使用网络加密来保证。

MySQL · 代码阅读 · MYSQL开源软件源码阅读小技巧

$
0
0

开源软件已经广泛的被互联网公司所应用,不仅仅是因为其能给企业节省一大笔成本,而且最重要的是拥有更多的自主可控性,能从源头上对软件质量进行把控。另一方面,由于开源软件背后往往没有大型的商业公司,所以文档相对来说不是非常完善(或者说文档和代码不一定相互对应),因此,作为一名合格程序员,尤其是基础软件开发的程序员,阅读开源软件源码的能力是必备的素质。

MySQL作为world most popular的开源数据库,被广大程序员所使用,其简单、高效、易用等优点被大家赞不绝口,作为一款已经有20多年的开源数据库,不少开源狂热分子对其源码进行了详细的剖析,然后面对MySQL上百万行的代码,初学者往往无从下手。古语说的好,工欲善其事必先利其器,本文分享分享一些Linux下阅读修改源码常用工具的小技巧,笔者认为这些小技巧对MySQL源码(其实对其他开源项目也一样)分析以及后续的修改有莫大的帮助。

另外说明一下,这篇文章需要你对这些常见的工具有所了解,如果之前对vim/git/gdb/Ctags/Cscope/Taglist/gcc等没有什么了解,建议先上网找找基础教程。

Tip 1: 不同文件自动加载不同格式

众所周知,MySQL数据库采用插件式存储引擎模式,即MySQL分Server层和plugin层,Server层主要做SQL语法的解析、优化、缓存,连接创建、认证以及Binlog复制等通用的功能,而plugin层才是真正负责数据的存储,读取,奔溃恢复等操作。Server层定义一些接口,plugin层只要实现这些接口,那么这个引擎就能在MySQL中使用,因此才有了这么多的引擎,例如InnoDB,TokuDB,MyRock等,但这个同时也代表着,引擎层的代码和Server层的代码风格会完全不一样,例如在Server层中,代码缩进是2个空格而在InnoDB层中,代码缩进是8个空格,当需要经常同时修改不同层的代码时,容易造成格式混乱,从而影响阅读。

Vim作为一款Linux下常用的文本查看编辑工具,在源码的阅读中必属主力。针对这个问题,常用的解决办法是,在家目录下,写两个不同的vimrc文件,一个对应Server层的风格,一个对应InnoDB层的风格,还需要编写一个简单的切换脚本,当需要修改Server层的代码时,切换到Server层的风格,反之亦然。但是当需要同时修改Server和InnoDB多处代码时候,会比较繁琐,同时,在文件中切换,往往使用的是Ctags和Cscope,直接从Server层切换到InnoDB层的代码了,根本没有给你切换的机会(可以直接在Vim中执行source命令,但是依然麻烦),如果Vim能根据不同的文件加载不同的格式那就方便多了。

在Vim的配置文件中有个内置的命令autocmd,后面可以跟一些事件E,再后面可以跟一些文件名F,最后放一些命令C,表示,当这些文件F触发这些事件E后,执行这些命令C。在另外一方面,MySQL的Server层代码和InnoDB层代码放在不同的目录下,虽然有很多,但是可以用通配符匹配。结合autocmd这个命令以及MySQL源码分布的规律,可以写出下面的vimrc配置文件:

" mysql server type
au BufRead,BufNewFile /home/yuhui.wyh/polardb/sql/* source ~/.vimrc_server
au BufRead,BufNewFile /home/yuhui.wyh/polardb/include/* source ~/.vimrc_server
au BufRead,BufNewFile /home/yuhui.wyh/polardb/mysql-test/* source ~/.vimrc_server
au BufRead,BufNewFile /home/yuhui.wyh/polardb/client/* source ~/.vimrc_server
" mysql innodb type
au BufRead,BufNewFile /home/yuhui.wyh/polardb/storage/innobase/* source ~/.vimrc_innodb

第一部分,这里重点介绍一下BufRead和BufNewfile这两个事件,前者表示当开始编辑新缓存区,读入文件后。说的通俗易懂点就是,当你打开一个已经存在的文件后且这个文件内容都已经被加载完毕后,这个事件被触发。后者,表示开始编辑不存在的文件,简单的说,就是打开一个新的文件。

第二部分,其中,au是autocmd的缩写,/home/yuhui.wyh/polardb是笔者MySQL的根目录,sql、include目录下面放了大部分Server层的代码,client目录下是客户端的代码(比如mysqlbinlog, mysql等)也沿用了Server层的风格,同时团队在testcase中也规定用Server层的代码风格,因此也把它放在一块。另外一方面,InnoDB层的代码就相对比较统一,都在storage/innobase下面。

第三部分,就是source命令,这个命令表示加载并执行后面这个文件里面的配置。vimrc_server和vimrc_innodb分别表示Server层和InnoDB层的不同格式,需要自己编写。

综上所述,我们可以分析出这个vimrc配置文件所表达出的意思,这里以最后一行为例,其他几行类似。最后一行的意思就是,当打开/home/yuhui.wyh/polardb/storage/innodb/这个目录下的所有文件或者在此目录下创建一个新文件的时候,执行~/.vimrc_innodb这个配置文件。

至此,完美解决上述问题。

同时由于这个方式是以缓存区为粒度的,所以下述几种使用方式都有效:

  1. 当前文件A属于Server层,使用Ctags跳转到InnoDB层文件B,则文件B使用InnoDB风格,编辑或者阅读后,如果使用Ctrl+T返回(或者其他方式)A,则A依然使用Server层风格,不会被影响。
  2. 多窗口支持,由于缓存区独立加载,即使同时打开多个终端中的多个vim,也不会相互影响。
  3. 如果先打开Server层的文件A,然后使用:e命令打开另外一个InnoDB层的文件B,然后使用:bn相互切换,格式依然不会乱掉,A永远使用Server层风格,B永远使用InnoDB风格。
  4. 如果使用vim -O方式同时打开多个InnoDB和Server层文件,然后使用Ctrl+w在其之间切换,依然没有什么问题。 BufRead事件的威力就是如此牛X。 BTW,上面这图只是我的配置文件的一部分,完整的文件如下:
    " normal type
    au BufRead,BufNewFile * source ~/.vimrc_normal
    " mysql server type
    au BufRead,BufNewFile /home/yuhui.wyh/polardb/sql/* source ~/.vimrc_server
    au BufRead,BufNewFile /home/yuhui.wyh/polardb/include/* source ~/.vimrc_server
    au BufRead,BufNewFile /home/yuhui.wyh/polardb/mysql-test/* source ~/.vimrc_server
    au BufRead,BufNewFile /home/yuhui.wyh/polardb/client/* source ~/.vimrc_server
    " mysql innodb type
    au BufRead,BufNewFile /home/yuhui.wyh/polardb/storage/innobase/* source ~/.vimrc_innodb
    

倒数第二行的意思是,当遇到.ic结尾的文件时,把这个文件当作是C语言的文件来解析,这样语法就会高亮啦~

这里还有一点要说明的是,如果同时多个事件被触发,则按照配置文件中出现的顺序依次执行,所以如上图所示,vimrc_normal放的是我自己常用的风格,毕竟不能被MySQL完全同化么~。而最后vimrc_base里面放的是三种模式(normal,server,innodb)共有的配置,代码复用么,嘿嘿

Tip 2: 使用Ctags/Cscope/Taglist提高源码阅读效率

Ctags和Cscope是很有名的Linux命令行下阅读代码的神器,有Linux下的sourceinsight的美称,网上已经有很多介绍,不熟悉的可以先去网上找找。这里分享一下笔者常用的配置,不同的配置可能导致搜索结果的不同。

[Sun Dec 11  17:45:10 ~]
$ alias csfile
alias csfile='find . -name "*.c" -o -name "*.cc" -o -name "*.cp" -o -name "*.cpp" -o -name "*.cxx" -o -name "*.h" -o -name "*.hh" -o -name "*.hp" -o -name "*.hpp" -o -name "*.hxx" -o -name "*.C" -o -name "*.H" -o -name "*.cs" -o -name "*.ic" -o -name "*.yy" -o -name "*.i" -o -name "errmsg-utf8.txt"> cscope.files'

[Sun Dec 11  17:46:09 ~]
$ alias cs
alias cs='cscope -bqR -i cscope.files && ctags --extra=+q --fields=+aimSn --c-kinds=+l --c++-kinds=+l --totals --sort=foldcase -L cscope.files'

由于源码经常变动,因此我写了一个alias方便重建tag数据库。csfile其实就是生成源码文件列表(并不是MySQL源码目录下的所有文件都是源码文件),这里要注意把.ic和.i为后缀名的文件也加进去,这种文件也是MySQL源码文件,其他的后缀名基本都是比较常规的。生成了源码文件列表后就可以用从scope和ctags生成对应的标签了。这里介绍一下我使用的参数:

cscope: -b 建立tag数据库文件,默认文件名为cscope.out

-q 建立倒排索引加速检索,会产生cscope.in.out和cscope.po.out两个文件

-R 在目录下递归搜索

-i 从指定文件中获取源码文件路径,有了这个参数,不用上面这个参数也可以

ctags:–extra=+q 在tag中增加类的信息,这样当一个tag有多处定义的时候,搜索时可以帮助辨认

–field=+aimSn 主要也是在tag中增加一些信息(类的访问权限,继承关系,函数原型等),搜索时可以根据这些额外信息把最有可能的定义排在前面

–c-kinds=+l 增加局部变量定义的索引,MySQL有一些函数很大,不方便查找,把这个开起来就方便多了–total 产生tag文件后,输出一些统计信息,例如,扫描了多少个源文件,多少行源代码以及产生了多少个tags

–sort=foldcase 对产生tags数据库使用大小写不敏感的排序,便于后续检索

-L cscope.files 从文件中获取源代码文件的路径

这里只是简单的提一下,详细可以看帮助文档。

接下来分享一下笔者常用的使用方法:

为了方便跳转,笔者在vimrc文件中加入了如下定义:

set cscopetagorder=1

这样,当我搜索一个标签(Ctrl+])的时候,先从ctags产生的标签库中搜索,然后再从cscope中搜索。

nmap <C-\>s :csfind s <C-R>=expand("<cword>")<CR><CR>
nmap <C-\>g :csfind g <C-R>=expand("<cword>")<CR><CR>
nmap <C-\>c :csfind c <C-R>=expand("<cword>")<CR><CR>
nmap <C-\>t :csfind t <C-R>=expand("<cword>")<CR><CR>
nmap <C-\>e :csfind e <C-R>=expand("<cword>")<CR><CR>
nmap <C-\>f :csfind f <C-R>=expand("<cfile>")<CR><CR>
nmap <C-\>i :csfind i ^<C-R>=expand("<cfile>")<CR>$<CR>
nmap <C-\>d :csfind d <C-R>=expand("<cword>")<CR><CR>

同时在vimrc中加入上图的定义,方便使用cscope的功能:

Ctrl+\+g:寻找定义处,笔者一般很少用了,一般用Ctrl+]代替。

Ctrl+\+s:当你想查看一下这个标签以C语言标准Symbol在哪些地方出现过时,可以用这个,也就是说,搜索出的结果都是标准C语言Symbol的。

**Ctrl+\+t: ** 这个是搜索出所有出现这个tag的位置,不管是不是C语言Symbol。 这里介绍一个上面两个命令的区别,一般来说,Ctrl+\+s这个命令搜索出的一般都在源代码中且是全词匹配的,而Ctrl+\+t这个命令可能搜索出注释中的tag,也有可能是半个词匹配,但是Ctrl+\+t这个命令有实时性,即当你修改过文件后,如果不重建整个tags数据库,用Ctrl+\+s搜索不到最新的标签,而用Ctrl+\+t就可以,当然Ctrl+\+t这个速度也会慢一点。换句话说,Ctrl+\+t是Ctrl+\+s的超集,如果你用Ctrl+\+s搜索不到,然后用Ctrl+\+t可能就能找到了,这种情况在MySQL源码中还比较常见,因为其用了很多宏定义来简化代码,这些宏定义有些不能被ctags正确的解析成C语言Symbol,所以只能用Ctrl+\+t才能搜索到,一个常见的例子就是InnoDB层线程函数基本都用类似DECLARE_THREAD()的形式来定义,只能用Ctrl+\+t来找,才能找到这个函数正确的定义处。

Ctrl+\+c:查找当前的标签在哪些地方被引用过。笔者经常用这个功能,因为常常需要看当前这个函数在哪些地方被调用过。如下图,可以一眼看出recv_parse_log_recs这个函数被三个函数调用过(分别用《《和》》包括起来)。

image.png

在这例子中,如果你查看了编号为1调用的地方,不用返回,可以直接按下:tn(:tN代表反向)这个命令,然后会自动跳到编号为2调用的地方,这样可以快速的在调用处查看。这个小技巧在cscope其他命令中也支持。

Ctrl+\+d:查找这个函数中引用了哪些函数,用的相对较少一点。

Ctrl+\+f:打开指定文件名的文件,需要在索引中。这个命令也还是经常用的,例如,你当前在sql_parse.cc的Server层代码中,需要查看一下ha_innodb.cc这个InnoDB层的文件,你可以直接输入:cs f f ha_innodb.cc,这样文件可以直接打开,而不需要你用:e或者其他命令输入完整的文件路径,提高了不少效率。当然,你把光标停在一个include语句的头文件上,也是可以直接打开的。

Ctrl+\+e:使用了这个,你可以在tag中指定通配符,这样就支持模糊查询了。

此外,当你没打开任何一个文件的时候,突然想查看一个tag(例如rds_update_malloc_size)的定义,你可以直接在命令行输入vim -t rds_update_malloc_size,注意要在tags数据库所在的目录,然后就会直接打开rds_update_malloc_size定义的文件并跳转到定义处。这里要求tag不能拼错一点,也就是不支持模糊查询,如果你想要模糊查询的话,直接打开一个空的vim,然后输入:tag rds_update,然后按Tab键,就可以自动补全,如果补全的不是你想要的,接着按Tab直到找到你想要的。

最后,介绍一下TagList的小工具。这个工具就是把一个文件中的所有定义给抽取出来,显示在一个分屏中,方便你查看。类似下图: image.png

它统计了变量,结构体,宏定义以及函数,打开后你可以得到这个文件的概览,有些时候,你想查看一个函数,但是这个函数的名字又想不起来,你可以打开这个,然后在函数列表里面找,比你在文件中用]]命令一个个找快的多。常用命令:

回车:当你停留在某个标签上,直接回车,即可跳转到这个标签的定义上,同时光标也会停留在定义所在的窗口上,如果你想接着查看TagList窗口,需要重新切换。

p: 同回车作用差不多,不同的就是,跳转后光标依然停留在TagList窗口,你可以接着查看其他标签,这个比较实用,一般现在TagList窗口中查找,找到后在敲回车,切换过去,同时可以把TagList窗口关掉。

x: 如果你嫌TagList窗口太小,就可以用这放大窗口

+,-,*,=:这些都是折叠或者展开某一类或者全部的标签

s:排序有两种,一种是按照出现顺序,一种是按照首字母排序,可以用这个命令切换

此外,你可以在vimrc中配置TagList相关配置,例如:

let Tlist_Exit_OnlyWindow = 1 
let Tlist_Show_One_File = 1 
let Tlist_Sort_Type = "name"
let Tlist_Auto_Open = 1
let Tlist_Use_Right_Window = 1

其中,Tlist_Exit_OnlyWindow表示当只剩下TagList这个窗口时,退出vim。Tlist_Use_Right_Window表示TagList窗口显示在vim右边。当你打开多个文件的时候,如果不设置Tlist_Show_One_File为1,就会把所有文件里面的定义都输出在TagList窗口中。Tlist_Auto_Open则表示TagList窗口是否默认打开。Tlist_Sort_Type表示默认按照首字符出现顺序排序。

总之,在阅读源码的过程中,要善于使用各种工具便于我们快速找到我们想要的东西,如果还有什么使用技巧值得分享,可以留言告诉笔者哈

Tip 3: 定制vimrc函数简化常用复杂的操作

有时候,当你在源码中游走的时候,会被搞的晕头转向,不知道自己在哪里了,这个时候你可以使用Ctrl+G来查看自己在哪个文件中,但是你还想知道自己在哪个函数中呢?这个vim貌似没有提供默认的快捷键,那么我们就自己造个轮子吧:

fun! ShowFuncName()
  let lnum = line(".")
  let col = col(".")
  echohl ModeMsg
  echo getline(search("^[^ \t#/]\\{2}.*[^:]\s*$", 'bW'))
  echohl None
  call search("\\%" . lnum . "l" . "\\%" . col . "c")
endfun
map f :call ShowFuncName() <CR>

这个showFuncName的函数跟快捷键f绑定起来了,你只需把这个函数放在vimrc中,然后在源码中按下f,就可以查看当前在哪个函数中,但是有些时候会有问题,可能没有找到正确的函数头,这个时候,就只能用最原始的[[和]]命令来找函数头了,然后使用Ctrl+O的方式返回之前停留的地方。

MySQL Server层的代码对单行的注释有点小要求:如果这行有代码也有注释,必须从第48列开始写注释。这个时候如果你用手调整到48列,会很麻烦,依然可以写一个函数,然后绑定一个快捷键(Shift+Tab):

function InsertShiftTabWrapper()
  let num_spaces = 48 - virtcol('.')
  let line = ' '
  while (num_spaces > 0)
    let line = line . ' '
    let num_spaces = num_spaces - 1
  endwhile
  return line
endfunction
" jump to 48th column by Shift-Tab - to place a comment there
inoremap <S-tab> <c-r>=InsertShiftTabWrapper()<cr>

介于MySQL Server层和InnoDB层的格式很容易搞错,你需要经常查看格式是否正确,这个时候你可能需要把所有隐藏的不可见的字符给显示出来,命令你给是set list,同样,如果你频繁使用,还不如加个快捷键绑定:

map l :set list! <CR>**

这样你只要按下l就可以在是否显示不可见字符中切换。

我们在写代码中,一般不希望有多余的空格,尤其在一行代码的结束后,后面不应该有多余的空格,但是空格又是不可见的字符,很难察觉到,除了用上述set list查看外,可以用一下的命令,这个命令会查找多余的空格,然后用红色高亮出来,时刻提醒你。

highlight WhitespaceEOL ctermbg=red guibg=red
match WhitespaceEOL /\s\+$/

此外,这边总结了一些常用好用的vim命令,在阅读源码中很有用。

set number:显示代码行数

set ignorecase:忽略大小写,这个在使用/搜索中很有用

set hlsearch:搜索结果高亮

set incsearch:当你在搜索时,每输入一个字母就开始搜索一次,这样当你要搜索一个很复杂的东西时候,只需要输入部分,就可以找到了。例如,你要搜InsertShiftTabWrapper这个函数,如果这个参数不打开,需要等你输入完所有,然后按回车才开始搜索,而打开这个参数,则每输入一个字母,就搜索一次,你可能只需要输入Insert这个单词,vim可能就已经跳转到InsertShiftTabWrapper这个函数了。

set showmatch:当你输入后半个括号时候,打开这个开关,前半个括号会闪一下,提示你当前输入的括号是跟他匹配的。

set paste:可以进入复制模式,复制入的东西不会被重排。

批量注释连续多行: 光标移到第一列,切换到列选择模式Ctrl+v,然后选择中所有需要注释的行,然后按一下Shift+i,接着输入//,最后按两下Esc键即可。

*: 光标停留在一个tag上,然后按下这个,就可以在文件中找到所有这个tag,并且高亮出来,可以用n查看下一个,用N查看上一个。

%:停留在括号上,可以用来查看另外半个括号,一般用来查看括号匹配。

Ctrl+F,Ctrl+B:整页滚动

gd:查看局部变量定义

gD:查看全局变量定义,只能查看这个文件中的

[[: 跳转到上面一个定义

]]: 跳转到下面一个定义

Tip 4: GDB高效化调试

用gdb记得加上-g以及关掉-O的优化,不然单步调试中,无法跟源代码对应,看不清楚。

gdb启动参数中加上-q可以把烦人的版本信息给去除掉。

gdb可以使用—args启动,然后程序的参数就可以直接写在后面,不需要进入gdb后再指定。

可以在家目录下建立.gdbinit文件,把常用配置写进去,如下图:

setprint elements 0
setprint array-indexes onsetprint pretty onsetprint object onset history filename ~/.gdb_history
set history save on

set print elements 0:如果你要打印一个数组,set print elements 5,表示最多只打印5个元素,set print elements 0表示打印所有元素

set print array-indexes on:打印数组的时候,同时把索引也打印出来

set print pretty on:打开的时候,显示结构体会比较漂亮,按照多行缩进的格式显示,关闭的时候,只是在一行中打印整个结构

set print object on:打开的时候,如果使用type命令查看变量类型,会考虑虚函数的影响,即打印真正的类型,否则只打印编译时候确定的父类型

set history save on:打开历史命令记录功能,这样当你再次进入gdb的时候,你可以使用方向键查看之前使用过的命令了

使用-tui参数启动gdb,或者启动gdb后按Ctrl+x+a,可以进入gdb的图形化调试界面,上半部分为源代码窗口,下半部分为命令行界面,再按一下这个组合键就能返回传统的字符界面: image.png

源码界面,执行到的代码行会高亮出来,断点行前面会有个B+>标识。默认的焦点在代码窗口,即方向键控制的是代码的移动,可以使用focus cmd将焦点切换到命令行窗口,方向键即可控制查看之前执行过的命令,否则需要使用Ctrl+p或者Ctrl+n。其他命令跟命令行gdb类似。

另外,我们常常会碰到MySQL hang住的情况,虽然这个时候你用kill命令杀掉,然后重启,能解决燃眉之急,不过为了找到hang的原因,最好的办法是保留住内存现场,方便后面排查。一种方法是使用kill -11的方法,让内核产生一个coredump,但是如果当时MySQL内存使用的比较多,需要产生一个很大的文件,这对磁盘写入造成很大的冲击。另外一种方式是使用pstack产生一个所有线程的函数调用堆栈关系,类似gdb中的bt命令,如下图:

Thread 4 (Thread 0x7ff8f05fa700 (LWP 15335)):
#00x0000003330ce0263inselect () from /lib64/libc.so.6
#10x000000000116e8cain os_thread_sleep(unsigned long) ()
#20x00000000010ef1ddin log_wait_for_more(unsigned long, bool, log_reader_t*) ()
#30x000000000113eff3in log_reader_t::read_log_state(unsigned char*, unsigned int*) ()
#40x000000000113e920in log_reader_t::acquire_data(unsigned char*, unsigned int*, unsigned int*) ()
#50x0000000001013532in innobase_read_redo_log(void*&, unsigned long, unsigned char*, unsigned int*, unsigned int*) ()
#60x0000000000a2a0a3in com_polar_dump(THD*, char*, unsigned int) ()
#70x00000000009df128in dispatch_command(enum_server_command, THD*, char*, unsigned int) ()
#80x00000000009dab90in do_command(THD*) ()
#90x000000000096ea42in do_handle_one_connection(THD*) ()
#100x000000000096e117in handle_one_connection ()
#110x00000000016c1a11in pfs_spawn_thread ()
#120x00007ff8f56e8851in start_thread () from /lib64/libpthread.so.0
#130x0000003330ce767din clone () from /lib64/libc.so.6
Thread 3 (Thread 0x7ff8f0578700 (LWP 15400)):
#00x0000003330cda37din read () from /lib64/libc.so.6
#10x0000003330c711e8in _IO_new_file_underflow () from /lib64/libc.so.6
#20x0000003330c72ceein _IO_default_uflow_internal () from /lib64/libc.so.6
#30x0000003330c674dain _IO_getline_info_internal () from /lib64/libc.so.6
#40x0000003330c66339in fgets () from /lib64/libc.so.6
#50x0000000000fbc817in rds_pstack ()
#60x0000000000875085in handle_fatal_signal ()
#7  <signal handler called>
#80x000000000107bae9in i_s_innodb_log_reader_fill_table(THD*, TABLE_LIST*, Item*) ()
#90x0000000000aae83din do_fill_table(THD*, TABLE_LIST*, st_join_table*) ()
#100x0000000000aaf023in get_schema_tables_result(JOIN*, enum_schema_table_state) ()
#110x0000000000a56065in JOIN::prepare_result(List<Item>**) ()
#120x00000000009834d3in JOIN::exec() ()
#130x0000000000a578f0in mysql_execute_select(THD*, st_select_lex*, bool) ()
#140x0000000000a57f95in mysql_select(THD*, TABLE_LIST*, unsigned int, List<Item>&, Item*, SQL_I_List<st_order>*, SQL_I_List<st_order>*, Item*, unsigned longlong, select_result*, st_select_lex_unit*, st_select_lex*) ()
#150x0000000000a53ffein handle_select(THD*, select_result*, unsigned long) ()
#160x00000000009f67c9in execute_sqlcom_select(THD*, TABLE_LIST*) ()
#170x00000000009e5366in mysql_execute_command(THD*) ()
#180x00000000009fc078in mysql_parse(THD*, char*, unsigned int, Parser_state*) ()
#190x00000000009dd917in dispatch_command(enum_server_command, THD*, char*, unsigned int) ()
#200x00000000009dab90in do_command(THD*) ()
#210x000000000096ea42in do_handle_one_connection(THD*) ()
#220x000000000096e117in handle_one_connection ()
#230x00000000016c1a11in pfs_spawn_thread ()
#240x00007ff8f56e8851in start_thread () from /lib64/libpthread.so.0
#250x0000003330ce767din clone () from /lib64/libc.so.6

这里仅仅截取了两个线程的函数堆栈信息。通过这个可以看出,程序在i_s_innodb_log_reader_fill_table这个函数处奔溃了,然后你需要去那个函数里面看到底发生了什么。后面这种方法由于只需要产生一个很小的文本文件,线上出问题了经常使用这种方式。但是这里还是有点小不爽,奔溃的位置既然能定位到函数级别,那么能不能直接定位到源码中的行级别,这样即使这个函数很大,后期诊断起来也方便多了。解决方法很简单,只需要改一下pstack的源码:

$GDB --quiet $readnever -nx /proc/$1/exe $1 <<EOF 2>&1 |

把这行中的$readnever去掉就行了。readnever这个参数的作用如下:

`--readnever'
     Do not read each symbol file's symbolic debug information.  This     makes startup faster but at the expense of not being able to
     perform symbolic debugging.

说白了就是启动效率,但是个人感觉得不偿失,既然程序已经发生问题了,提供更加详细的诊断信息才是王道。去掉这个参数后,以后看到的pstack结果就是类似下图了:

Thread 2 (Thread 0x7ffa4c106700 (LWP 44741)):
#00x0000003330cda37din read () from /lib64/libc.so.6
#10x0000003330c711e8in _IO_new_file_underflow () from /lib64/libc.so.6
#20x0000003330c72ceein _IO_default_uflow_internal () from /lib64/libc.so.6
#30x0000003330c674dain _IO_getline_info_internal () from /lib64/libc.so.6
#40x0000003330c66339in fgets () from /lib64/libc.so.6
#50x0000000000fbfe7fin rds_pstack () at /home/yuhui.wyh/polardb/mysys/stacktrace.c:758
#60x0000000000878605in handle_fatal_signal (sig=11) at /home/yuhui.wyh/polardb/sql/signal_handler.cc:269
#7  <signal handler called>
#80x00000000010134b2in innobase_get_read_lsn (uuid=0x7ffa4c105d40, start_lsn=0x7ffa4c105d50, orig_start_lsn=0x7ffa4c105d38) at /home/yuhui.wyh/polardb/storage/innobase/handler/ha_innodb.cc:11035
#90x0000000000a2a43cin polar_io_thread (arg=0x0) at /home/yuhui.wyh/polardb/sql/sql_polar.cc:1827
#100x00000000016dd785in pfs_spawn_thread (arg=0x4872800) at /home/yuhui.wyh/polardb/storage/perfschema/pfs.cc:1858
#110x00007ffa78d7c851in start_thread () from /lib64/libpthread.so.0
#120x0000003330ce767din clone () from /lib64/libc.so.6
Thread 1 (Thread 0x7ffa7979e720 (LWP 44707)):
#00x00007ffa78d807bbin pthread_cond_timedwait@@GLIBC_2.3.2 () from /lib64/libpthread.so.0
#10x0000000000fc7afdin safe_cond_timedwait (cond=0x2bfd900, mp=0x2bfd780, abstime=0x7ffff07562e0, file=0x19a97b0"/home/yuhui.wyh/polardb/include/mysql/psi/mysql_thread.h", line=1199) at /home/yuhui.wyh/polardb/mysys/thr_mutex.c:278
#20x0000000000fbad17in inline_mysql_cond_timedwait (that=0x2bfd900, mutex=0x2bfd780, abstime=0x7ffff07562e0, src_file=0x19a97f0"/home/yuhui.wyh/polardb/mysys/my_thr_init.c", src_line=240) at /home/yuhui.wyh/polardb/include/mysql/psi/mysql_thread.h:1199
#30x0000000000fbb994in my_thread_global_end () at /home/yuhui.wyh/polardb/mysys/my_thr_init.c:239
#40x0000000000faf4c6in my_end (infoflag=0) at /home/yuhui.wyh/polardb/mysys/my_init.c:205
#50x000000000067c836in mysqld_exit (exit_code=1) at /home/yuhui.wyh/polardb/sql/mysqld.cc:1913
#60x000000000067c72ein unireg_abort (exit_code=1) at /home/yuhui.wyh/polardb/sql/mysqld.cc:1894
#70x0000000000689655in init_server_components () at /home/yuhui.wyh/polardb/sql/mysqld.cc:5185
#80x000000000068bee1in mysqld_main (argc=26, argv=0x410e6d8) at /home/yuhui.wyh/polardb/sql/mysqld.cc:5850
#90x00000000006741d2in main (argc=9, argv=0x7ffff0756ba8) at /home/yuhui.wyh/polardb/sql/main.cc:25

可以看到函数在哪个文件中的哪一行了。在MySQL发生死锁时,用这招进行诊断很有效。当然,记住,在编译MySQL的时候一定要带上-g,不然还是没有这些调试信息的。

Tip 5: git reset/git rebase简化提交的代码

在平时的代码开发中,需要加新的feature,或者fix bug以及optimize等操作时,一般都会从master上拉一个分支出来,然后自己在上面随便折腾,这也就导致在同一分支上,会有多次commit,最后在把这些commit都提交到主干,会导致主干上比较乱,这时候git reset命令就有用了:

git reset –soft HEAD^^:把最近的两次提交的变动合并,结果以提交到暂存区的形式存在,即git add之后的文件状态,这个时候,你只需要再git commit一下,就能把多次提交合并。

git reset –mixed HEAD^^:跟上面的类似,只不过文件回退到未加入暂存区之前的状态,也就是说,你还需要执行一把git add,然后才能执行git commit。

git reset –hard HEAD^^: O这个操作直接把最近两次的提交都给删除掉,代码没有了,慎用。

合并自己的commit后,也不能直接就提交,最好把master上的变更给同步过来,因为在你开发分支的时候master上可能有新的提交。这个时候git rebase命令就上场了。笔者常用的方法是,首先checkout出master,然后git pull一把,然后切换回之前的分支并执行git rebase master,这样就会把master上的变动给同步过来,master上的变动在前,你自己的变动在后,如果两者有冲突,git rebase会停下来,你自己把冲突的文件给处理好后,然后git add,再执行git rebase –continue。最后再把分支提交,发起code review过程,如果通过的话,就可以直接merge到master,不会有冲突。使用git rebase还有一个好处是,能保证master上的提交是一条线,不像使用git merge提交的,会导致master上有很多分支,当然也有一个不好的地方,那就是会导致提交的时间发生变动,提交的时间不会保证是递增的顺序。

此外,还有一些命令也挺好用的:

git blame:当你发现源码中的Bug的时候,想找出这是谁的锅,然后这条命令就排上用场了。当然其实更有用的一种用法是通过它来找到这个新的feature的issue:比如说,代码中多了一个变量var_path,你想知道这个变量是干啥的,除了看注释和源码,你可以通过git blame找到提交的commit id,然后在git log的commit message中找到Issue id信息以及简介,找到Issue id后就可以在gitlab等代码仓库中,找到Issue的详细信息,比如为何创建,什么时候创建以及解决的办法等。

Tip 6: GCC使用技巧

开源软件为了兼容各个操作系统不同平台,需要维护不同的代码,在C语言中,常用#ifdef等宏定义来区分不同平台的代码,另外一方面,很多时候当代码逻辑很复杂的时候,为了不牺牲效率同时保持高可阅读性,部分代码需要使用宏定义来简化。上面提到的两点,做的的确合理,但是大大增大了阅读代码的成本,这个时候我们可以直接在gcc编译选项中加入save-temps这个参数,这个参数可以把编译时的临时文件保存下来,包括预编译后的文件,生成汇编的文件以及最后的二进制文件。这里我们只需要查看预编译后的文件(后缀名为.i)即可,里面的信息往往很清晰,举个栗子。下图是InnoDB handler层的一段代码:

mysql_declare_plugin(innobase)
{
  MYSQL_STORAGE_ENGINE_PLUGIN,
  &innobase_storage_engine,
  innobase_hton_name,
  plugin_author,
  "Supports transactions, row-level locking, and foreign keys",
  PLUGIN_LICENSE_GPL,
  innobase_init, /* Plugin Init */
  NULL, /* Plugin Deinit */
  INNODB_VERSION_SHORT,
  innodb_status_variables_export,/* status variables             */
  innobase_system_variables, /* system variables */
  NULL, /* reserved */0,    /* flags */
},
i_s_innodb_trx,
i_s_innodb_locks,
i_s_innodb_lock_waits,
i_s_innodb_cmp,
i_s_innodb_cmp_reset,
i_s_innodb_cmpmem,
i_s_innodb_cmpmem_reset,
i_s_innodb_cmp_per_index,
i_s_innodb_cmp_per_index_reset,
i_s_innodb_buffer_page,
i_s_innodb_buffer_page_lru,
i_s_innodb_buffer_stats,
i_s_innodb_metrics,
i_s_innodb_ft_default_stopword,
i_s_innodb_ft_deleted,
i_s_innodb_ft_being_deleted,
i_s_innodb_ft_config,
i_s_innodb_ft_index_cache,
i_s_innodb_ft_index_table,
i_s_innodb_sys_tables,
i_s_innodb_sys_tablestats,
i_s_innodb_sys_indexes,
i_s_innodb_sys_columns,
i_s_innodb_sys_fields,
i_s_innodb_sys_foreign,
i_s_innodb_sys_foreign_cols,
i_s_innodb_sys_tablespaces,
i_s_innodb_sys_datafiles

mysql_declare_plugin_end;

这段代码是用来定义InnoDB这个引擎的接口信息的,方便Server层的代码调用。第一次看,你可能根本不知道这是个啥玩意,即使你用Ctags等工具跳转,也不一定看的清楚,尤其针对源码的初学者,这个时候你可以打开预编译文件看一下:

int builtin_innobase_plugin_interface_version= 0x0104; 
int builtin_innobase_sizeof_struct_st_plugin= sizeof(struct st_mysql_plugin); 
struct st_mysql_plugin builtin_innobase_plugin[]= {
{
  1,
  &innobase_storage_engine,
  innobase_hton_name,
  plugin_author,
  "Supports transactions, row-level locking, and foreign keys",
  1,
  innobase_init,
  __null,
  (5 << 8 | 6),
  innodb_status_variables_export,
  innobase_system_variables,
  __null,
  0,
},
i_s_innodb_trx,
i_s_innodb_locks,
i_s_innodb_lock_waits,
i_s_innodb_cmp,
i_s_innodb_cmp_reset,
i_s_innodb_cmpmem,
i_s_innodb_cmpmem_reset,
i_s_innodb_cmp_per_index,
i_s_innodb_cmp_per_index_reset,
i_s_innodb_buffer_page,
i_s_innodb_buffer_page_lru,
i_s_innodb_buffer_stats,
i_s_innodb_metrics,
i_s_innodb_ft_default_stopword,
i_s_innodb_ft_deleted,
i_s_innodb_ft_being_deleted,
i_s_innodb_ft_config,
i_s_innodb_ft_index_cache,
i_s_innodb_ft_index_table,
i_s_innodb_sys_tables,
i_s_innodb_sys_tablestats,
i_s_innodb_sys_indexes,
i_s_innodb_sys_columns,
i_s_innodb_sys_fields,
i_s_innodb_sys_foreign,
i_s_innodb_sys_foreign_cols,
i_s_innodb_sys_tablespaces,
i_s_innodb_sys_datafiles

,{0,0,0,0,0,0,0,0,0,0,0,0,0}};

这下就很清楚了,这段代码干了两件事:定义两个int变量和定义一个结构体。同时还把结构体里面两个常量给打印了出来,看过去清晰多了。同样道理,你还可以在#ifdef分不清走哪条路径的时候用这招,很好用的。

MySQL · 引擎特性 · 多线程调试工具DEBUG_SYNC的源码实现和使用

$
0
0

背景介绍

在MySQL的开发过程中为了验证某个需要多线程之间配合的功能时,就需要有一种机制使开发人员能够控制每个线程的执行流程,完成多个线程之间的配合,验证特殊并发逻辑下代码处理的正确性。MySQL 提供了DEBUG_SYNC 功能,就是让开发者可以在MySQL服务器代码中通过DEBUG_SYNC宏定义同步点的。你可以在代码中加入你希望定义的同步点。

使用方式

DEBUG_SYNC的功能,默认是关闭的。除非在启动的时候指定了–debug-sync-timeout[=N] 选项,N是可选的,可以指定也可以不指定。不指定的话,默认是300秒。

#define DEBUG_SYNC_DEFAULT_WAIT_TIMEOUT 300

这个选项是启动时变量,为了能在测试中使用DEBUG_SYNC功能,必须在启动的时候指定–debug-sync-timeout[=N] 选项。这个参数有两个作用: 1) 其一是指定wait_for一个同步点的最大等待时间(单位:秒),若超过这个时间就会timeout; 2) 另一个是打开/关闭DEBUG_SYNC功能的选项,当其后的参数N为0时,就关闭了DEBUG_SYNC功能。

代码片段解析

其核心代码主要通过定义的宏DEBUG_SYNC做为入口,其定义如下:

#define DEBUG_SYNC(_thd_, _sync_point_name_)                 \
  do {                                                       \
    if (unlikely(opt_debug_sync_timeout))                    \
      debug_sync(_thd_, STRING_WITH_LEN(_sync_point_name_)); \
  } while (0)

这个宏的源码实现,主要是通过定义一个同步点(这个同步点是通过这里定义的名字来表示的_sync_point_name_),线程就会在这个同步点执行定义的行为动作,比如是在这个同步点发信号给等在其他同步点的线程、还是等在某个定义的事件上。在DEBUG_SYNC目前同步点的行为只定义了给其它同步点发信号、和等在某个信号上。其实现的主要数据结构如下:

struct st_debug_sync_action {
  ulong activation_count = 0; /* max(hit_limit, execute) */
  ulong hit_limit = 0;        /* hits before kill query */
  ulong execute = 0;          /* executes before self-clear */
  ulong timeout = 0;          /* wait_for timeout */
  String signal;              /* signal to emit */
  String wait_for;            /* signal to wait for */
  String sync_point;          /* sync point name */
  bool need_sort = false;     /* if new action, array needs sort */
  bool clear_event = false;   /* do not clear signal if false */
};

而其功能的实现主要就是通过debug_sync_execute函数来实现的。以下是在debug_sync中调用debug_sync_find和debug_sync_execute的代码片段。

  if (ds_control->ds_active &&
      (action = debug_sync_find(ds_control->ds_action, ds_control->ds_active,
                                sync_point_name, name_len)) &&
      action->activation_count) {
    /* Sync point is active (action exists). */
    debug_sync_execute(thd, action);

    /* Statistics. */
    ds_control->dsp_executed++;

    /* If action became inactive, remove it to shrink the search array. */
    if (!action->activation_count) debug_sync_remove_action(ds_control, action);
  }

首先在debug_sync_find里通过二分查找是否有同步点的要执行的行为动作,若是找到的话,就通过debug_sync_execute函数去执行。在debug_sync_execute根据定义的同步点执行次数,去判断是否达到了执行的次数,若没有达到执行的次数,则会在每次都会等这个event的信号。

if (action->execute) {
  …
  action->execute--;
  …
   /** 如果本线程也需要等待某个信号,它首先把自己在processlist表里的状态设置成等待状态,为了能让其它线程能及时的看到*/
  if (action->wait_for.length()) {
    …
    debug_sync_thd_proc_info(thd, ds_control->ds_proc_info);
  }

   /* 如果定义了需要唤醒的同步点,就需要把这些唤醒的同步点设置成signaled,加入到全局变量中,然后唤醒其它等待线程*/
  if (action->signal.length()) {
    …
    /**把这些唤醒的同步点设置成signaled,加入到全局变量中*/
    if (!s.empty()) add_signal_event(&s);

     /* 唤醒等待同步点读线程*/
     mysql_cond_broadcast(&debug_sync_global.ds_cond);
  }

  /* 然后自己再等待在自己定义的事件上,等待被唤醒*/
  if (action->wait_for.length()) {
    …
      while (!is_signalled(&wait_for) && !thd->killed &&
             opt_debug_sync_timeout) {
        error = mysql_cond_timedwait(&debug_sync_global.ds_cond,
                                     &debug_sync_global.ds_mutex, &abstime);

                …
             }

       /* 如果定义了 CLAER行为则清除等待事件,以后再执行到此不必再等待该事件 */
       if (action->clear_event) clear_signal_event(&wait_for);
  }
  /* 如果定义了 HIT_LIMIT行为,则达到了指定的次数,会返回错误消息,并kill这个线程 */
  if (action->hit_limit) {
    if (!--action->hit_limit) {
      thd->killed = THD::KILL_QUERY;
      my_error(ER_DEBUG_SYNC_HIT_LIMIT, MYF(0));
    }
       。。。
      }

  …

}

用法简介

在源代码中定义一个同步点

在源码中使用的例子如下所示,开发者可以在任意的位置加入同步点,并给同步点命名,这样这个同步点就可以在接下来的测试案例中使用了。

      open_tables(...)

      DEBUG_SYNC(thd, "after_open_tables");

      lock_tables(...)

在测试场景中使用同步点

测试场景使用的语法,可以参考 https://dev.mysql.com/doc/internals/en/syntax-debug-sync-values.html。在测试场景中,同步点的使用主要有以下几种情况: 1)SET DEBUG_SYNC=‘sync point name SIGNAL signal name WAIT_FOR signal name 是最常用的方法。 比如: SET DEBUG_SYNC= ‘after_open_tables SIGNAL opened WAIT_FOR flushed’; 大部分情况下同步点都是未激活状态,当对整个同步点请求某个行为时就激活了这个同步点。比如上面这个例子,当执行到同步点after_open_tables后会向等待opened事件发送信号同时等在flushed时间上时,就激活了after_open_tables同步点。

2)SET DEBUG_SYNC= ‘after_open_tables SIGNAL a,b,c WAIT_FOR flushed’; 这中用法和1)的主要区别就是一次唤醒多个事件a、b、c,其它和1)相同

3)SET DEBUG_SYNC= ‘WAIT_FOR flushed NO_CLEAR_EVENT’; 默认情况下, 当等待线程收到唤醒的信号后,就会从全局信号中把这个信号清除。但如果等待这个信号的线程有多个的时候,就不能其中一个线程被唤醒后马上清除它,这样就需要在等待线程,在等待信号上指定NO_CLEAR_EVENT。直到所有的等待线程都唤醒了,然后再通过SET DEBUG_SYNC= ‘RESET’; 去清除这个event的唤醒信号。

4)SET DEBUG_SYNC= ‘name SIGNAL sig EXECUTE 3’; 一般情况下,等待线程被激活执行完后,马上就清除唤醒等待线程的信号。为了不立马清除激活信号,我们可以通过关键字EXECUTE指定执行的次数,执行完指定的次数后,才清除激活信号。比如这个例子指定了执行3次、每执行完一次这个数字就会减1,直到减到0为止。

5) SET DEBUG_SYNC= ‘name WAIT_FOR sig TIMEOUT 10 EXECUTE 2’; 在MySQL启动的时候,可以通过参数debug-sync-timeout指定一个等待事件的超时时间,也可以通过TIMEOUT 关键字为每个等待事件单独指定超时时间。这个例子就是等待线程最长等待10秒,若超过10秒还没收到唤醒等待事件的信号,就会超时不再等待了。

6)SET DEBUG_SYNC= ‘name SIGNAL sig EXECUTE 2 HIT_LIMIT 3’; 如果你想在执行完指定的次数后,返回一个错误消息并且中断这个线程的话,可以通过HIT_LIMIT来指定。这个例子中就是在执行完3次后,会返回一个错误消息并且中断这个查询。

7)SET DEBUG_SYNC= ‘name CLEAR’; 这个是可以在任何时候都清除name指定的同步点,不管它执行了还是没执行。

参考

https://dev.mysql.com/doc/internals/en/debug-sync-facility.html

MySQL · 引擎特性 · InnoDB Parallel read of index

$
0
0

parallel read是什么

现代服务器硬件里,两路甚至四路的CPU成为主流,主流的公有云供应商普遍推出88 CORES的实例。

数据库学术界的研究热点也一直都是如何提高并行能力(如mass-tree),在工业界,以POLARDB,HANA为代表的新型数据库,都把并行处理的能力做为自己的核心竞争力,然而MySQL官方演进的节奏一直偏慢,迟迟没有推出自己的并行处理方案,直到8.0.14,INNODB团队首次推出了parallel read,先看release notes:

  • InnoDB:InnoDB now supports parallel clustered index reads, which can improve CHECK TABLE performance. This feature does not apply to secondary index scans. The innodb_parallel_read_threads session variable must be set to a value greater than 1 for parallel clustered index reads to occur. The default value is 4. The actual number of threads used to perform a parallel clustered index read is determined by theinnodb_parallel_read_threads setting or the number of index subtrees to scan, whichever is smaller.

从官方的介绍上看,第一个版本的pread仅仅支持check table以及select count(),从代码提交记录看,编码最早可以追溯到2018年1月,经历了一年多的时间的研发, 可以说这一步迈得非常谨慎,不过我们相信这套并行执行的框架随着新版本的不断推出,通用性以及适用场景会越来越强。

在这篇文章里,我们一起来探索一下原厂是如何实现并行查询的:

parallel read 如何使用

Parallel Read的使用方法延续了MySQL简单易用的传统,仅增加了一个innodb会话级别的参数parallel_read_threads,取值从1~256。如果不指定,默认线程数是4;

这里需要注意,最大256线程同样是实例级别限制,遵循先得先到的原则,一旦threads消耗完,后续的并行查询只能回退到传统的一行行scan。

打开parallel read方法也很简单,

set local innodb_parallel_read_threads=64;

select count(*) from sbtest.sbtest1;

count查询就会走到并行的逻辑里,略微遗憾的是,这个功能还在初级阶段,最新的release版本仅仅支持check table和count查询。

parallel read性能表现如何

接下来我们对parallel read做一个性能评测,测试服务器的cpu是 Intel(R) Xeon(R) CPU E5-2682 @ 2.50GHz,总共32个逻辑核,因此我们期望在32线程时获得最低的延时,并且有接近线性的加速比。

我们生成了单表2.5亿行记录,分别使用1 ~ 128并发去测试select count(*)的耗时 ,结果如下图:

image-20191231225726478

从这个结果看,parellel read的表现是符合我们的预期的,在低并发的时候可以获得接近线程的加速比,整体可以把延时从22秒降低到1.2秒。

高并发时加速比下降明显,这也是因为多线程调度的overhead太大导致的。

我们也观察到超线程对并行执行的效果不理想,并发数超过了cpu核数之后,出现了明显的性能衰退,可见当前的实现并不能很好的利用cpu的流水线,在榨干cpu性能方面还有很大的潜力。

parallel read是如何实现的

1. 主要数据结构
  1. Parallel_reader

    并行查义的执行主体对象,主要提供三个接口:

    • add_scan()

      把scan的目标index注册到reader里,虽然当前仅支持clustered index,但从接口的设计看,未来会支持多个index,甚至多个table的parallel scan。

      这里还会对B+tree进行预分片,为什么是预分片,主要原因还是add_scan是单线程执行,计算需要尽可能的轻量,后面的执行线程会做更细粒度的分片,这样的设计带来的好处后面做进一步的解释。

    • run()

      启动worker线程和read_ahead线程,worker线程也就是真正的执行线程,对一个切好的sub tree做scan,或者做分片计算。

  2. Parallel_reader::Scan_ctx

    对应一个index,通过add_scan()注册到reader中。

  3. Parallel_reader::Ctx

    执行的上下文,对应B+ tree的一个分片。

2. B+tree 分片策略

parallel read自从8.0.14 release之后,有过一个大的改动,就是修复分片算法上的一个设计缺陷。

在最初的设计里,把 B+tree 切成N个subtrees的策略很简单,假如并发度是N,从最一层开始逐层扫描每层的节点数,找到节点数大于N的一层。

假如我们有4个线程,找到了5个sub-trees,这时就会出现数据倾斜,必然有个线程需要处理两个sub-trees,而此时其他线程都是IDLE的状态。

这个问题在数据量非常大的时候会比较明显,因此在8.0.18,对这个算法进行了重新设计,具体的做法如下:

前面提到了add_scan()时做第一次分片,这时粒度是比较粗的,worker线程需要进行二次分片,通常此时,整体B+tree会切成粒度很细的sub-trees,数量远远超过work threads,从而比较优雅的解决数据倾斜的问题;

3. 数据预读

pre-fetching也是后面引入的优化,假如数据都在不BufferPool中,scan table的bottlenect就不是cpu,而是IO,增加cpu效果自然不理想。对于逻辑上的顺序读,一个常见的优化是,额外采用一组线程,提前把数据从磁盘读到BufferPool中,尽可能减少scan时的IO。

4. Handler API

parallel reader目前仅仅是innodb内部使用的一个框架,但己经定义了handler api,这也是一个信号,表明innodb团队在后面的版本里会提供一些系列并行加速的能力,例如parallel DDL。


MySQL · 引擎特性 · 二级索引分析

$
0
0

前言

在MySQL中,创建一张表时会默认为主键创建聚簇索引,B+树将表中所有的数据组织起来,即数据就是索引主键所以在InnoDB里,主键索引也被称为聚簇索引,索引的叶子节点存的是整行数据。而除了聚簇索引以外的所有索引都称为二级索引,二级索引的叶子节点内容是主键的值。

二级索引

创建二级索引

CREATE INDEX [index name] ON [table name]([column name]);

或者

ALTER TABLE [table name] ADD INDEX [index name]([column name]);

在MySQL中,CREATE INDEX操作被映射为 ALTER TABLE ADD_INDEX

二级索引格式

例如创建如下一张表:

CREATE TABLE users(
    id INT NOT NULL,
    name VARCHAR(20) NOT NULL,
    age INT NOT NULL,
    PRIMARY KEY(id)
);

新建一个以age字段的二级索引:

ALTER TABLE users ADD INDEX index_age(age);

MySQL会分别创建主键id的聚簇索引和age的二级索引:

secondary_index

在MySQL中主键索引的叶子节点存的是整行数据,而二级索引叶子节点内容是主键的值.

二级索引的创建流程

在MySQL8.0中,二级索引的创建具体流程如下图:

create_secondary_index

二级索引所属的Onine DDL可以分为三个阶段: DDL prepare 阶段, DDL执行阶段和 DDL commit 阶段.

DDL prepare 阶段

  • 升级至X锁, 禁止读写.

  • ha_prepare_inplace_alter_table()根据ALTER TABLE语句传入的参数进行检查,构建被创建的索引信息,创建索引的B+树.

DDL执行阶段

在MySQL8.0实现中,基本上所有的ALTER TABLE操作都实现在mysql_alter_table()函数,而Online DDL支持使用Inplace方式创建二级索引:

  • row_merge_build_indexes()用来构建二级索引的索引内容,在MySQL中,二级索引的组织关系是<Key, Primay key>即指定的索引column与主键组成的映射关系. 所以需要读取聚簇索引来构建二级索引内容:

    • 申请内存用来排序,大小为3 * srv_sort_buf_size,申请临时文件merge_file_t用来合并排序.

    • 读取扫描表中的整个聚簇索引B+树构建二级索引,假如merge buffer的空间不满足Index的排序,则需要利用临时文件进行合并排序.

    • 根据prepare阶段构建的索引信息,遍历聚簇索引,构造对应的索引字段. 假如建表时没有指定主键,InnoDB会默认创建一个名为DB_ROW_ID的自增字段,所以二级索引的映射关系就是<Key, DB_ROW_ID>.

    • 将合并排序后的二级索引内容通过 Bulk Load 的方式写入Page,使用flush_observer落盘对应的数据脏页.

    • 关闭删除临时文件,释放排序内存merge_buf.

MySQL8.0要求DDL保持原子性,所以在上述的合并排序后插入 Page 的过程中,可以使用 flush_observer直接落盘数据页或者记录Redo. 这样来保证整个DDL操作是原子的.

DDL commit 阶段

  • 为Table加上X锁, 禁止读写.

  • 更新InnoDB的数据字典DD.

  • 提交 DDL 事务.

  • 清理操作clean up.

在一些需要 rebuild table 的 Online DDL 操作中,例如Dropping a column, 为了不阻塞 DML 操作,需要引入row_log来暂存在 DDL 过程中用户的数据修改操作,而在二级索引的创建过程中并不需要 rebuild table, 所以不需要row_log, 用户对于数据的修改可以直接基于聚簇索引进行修改.

假如二级索引创建的过程中发生 crash, 重启后打开临时文件的 Tablespace 会清理上次意外 crash 遗留的临时文件.

索引定义

/** Definition of an index being created */
struct index_def_t {
  const char *name;          /*!< index name */
  bool rebuild;              /*!< whether the table is rebuilt */
  ulint ind_type;            /*!< 0, DICT_UNIQUE,
                             or DICT_CLUSTERED */
  ulint key_number;          /*!< MySQL key number,
                             or ULINT_UNDEFINED if none */
  ulint n_fields;            /*!< number of fields in index */
  index_field_t *fields;     /*!< field definitions */
  /* ... */
};
  • name即索引名.
  • rebuild表示是否需要重建表.
  • ind_type表示索引类型.
  • key_number表示表中索引数量.
  • n_fields表示索引字段的数量.
  • fields表示索引字段的定义.

二级索引的检索过程

在MySQL的查询过程中,SQL优化器会选择合适的索引进行检索,在使用二级索引的过程中,因为二级索引没有存储全部的数据,假如二级索引满足查询需求,则直接返回,即为覆盖索引,反之则需要回表去主键索引(聚簇索引)查询。

例如执行SELECT * FROM users WHERE age=35;则需要进行回表:

search_secondary_index

使用 EXPLAIN查看执行计划可以看到使用的索引是我们之前创建的 index_age:

MySQL [sbtest]> EXPLAIN SELECT * FROM users WHERE age=35;
+----+-------------+-------+------------+------+---------------+-----------+---------+-------+------+----------+-------+
| id | select_type | table | partitions | type | possible_keys | key       | key_len | ref   | rows | filtered | Extra |
+----+-------------+-------+------------+------+---------------+-----------+---------+-------+------+----------+-------+
|  1 | SIMPLE      | users | NULL       | ref  | index_age     | index_age | 4       | const |    1 |   100.00 | NULL  |
+----+-------------+-------+------------+------+---------------+-----------+---------+-------+------+----------+-------+
1 row in set, 1 warning (0.00 sec)

总结

二级索引是指定字段与主键的映射,主键长度越小,普通索引的叶子节点就越小,二级索引占用的空间也就越小,所以要避免使用过长的字段作为主键。

MySQL · 引擎特性 · X-Engine OnlineDDL

$
0
0

概述

X-Engine是阿里自研的数据库存储引擎,以插件的方式接入到MySQL生态,支持行锁,事务,MVCC等OLTP场景的核心功能。X-Engine的核心优势是低成本,高性价比,尤其适用于历史库场景,目前阿里巴巴内部的核心交易历史库(原来是Hbase),钉钉消息历史库(原来是MySQL(InnoDB)),淘宝商家的图片空间等业务均通过X-Engine解决了成本问题。同时,X-Engine也赋能阿里云数据库服务,作为云上RDS-MySQL的存储引擎,对外售卖,让更多的用户享受到新技术带来的红利,有关X-Engine的详细介绍,请移步2019年10月的数据库内核月报。本文主要介绍X-Engine引擎的一个核心功能,OnlineDDL。

OnlineDDL毫无疑问是MySQL生态的一个重要的功能,想当初MySQL 5.6以前,DBA执行DDL变更时,为了保证7*24小时服务,只能采用最老土的主备切换的方式来进行。数据库存储引擎区别于NoSQL引擎的一种重要指标就是是否支持SQL,是否有schema(数据字典)。有了schema,还需灵活地支持在线变更,这样才能从容应对业务快速变化的需求。MySQL生态中这么多存储引擎只有InnoDB完整地支持了OnlineDDL,X-Engine作为MySQL生态的新成员,虽然采用了完全不同于InnoDB的存储架构,但OnlineDDL给用户的体验是一样的。

整体流程

X-Engine采用类LSM的分层架构,数据按照时序逻辑分成多层,每一层数据有序,新数据在较高的层次,最老的历史数据在最底层。对于X-Engine来说,每个主表和二级索引数据都是一颗分层的LSM-tree结构,内部称之为Subtable。每个Subtable分为4层,Memtable,L0,L1和L2,每一层都保持有序,数据按新旧顺序依次往更深的层次迁移,其中Memtable在内存中,其它几层按需可以在不同的存储介质上。OnlineDDL功能实现充分利用了X-Engine的数据组织特点,将新build数据分为两部分,基线数据和增量数据。基线数据是指变更开始时,通过拿snapshot能遍历得到的数据;增量数据是指,变更开始后,用户写入的新数据。拿Snapshot过程需要短时间禁写,因为我们强依赖这个一致性位点,确保基线+增量数据的完整性。

OnlineDDL总共包括了4个阶段,包括Prepare阶段,Inplace-build阶段,Commit阶段和Post-ddl阶段。
1. Prepare阶段,这个阶段主要是准备数据字典,构建底层存储数据的Subtable,为后续的增量写入做准备。
2. Inplace-build阶段,这个阶段OnlineDDL的核心阶段,一方面通过Snapshot获取基线,另一方面还需要实时维护增量数据,利用X-Engine的数据组织的append-only特性,将基线和增量合并,即完成了OnlineDDL新数据构建的过程。这个过程的详细逻辑会在下一个小节详细介绍。
3. Commit阶段,这个阶段是OnlineDDL引擎层变更生效阶段,如果整个OnlineDDL过程中没有出现异常或错误,那么Commit阶段会生效新的数据字典,生效新的数据。
4. Post-ddl阶段,这个阶段是OnlineDDL真正生效阶段,这个阶段完成后,才会返回给用户DDL成功。这个阶段的引入,主要是因为MySQL是一个二层结构,Server层+引擎层,每一个层都自己的数据字典,Commit阶段只能保证引擎层的数据和数据字典是完整的,为了保证DDL变更的原子性(Server层和引擎层数据字典保持一致),引入Post-ddl阶段做清理和善后工作,有关DDL原子性的讨论会在下面的章节详细介绍。

核心逻辑

OnlineDDL的核心逻辑在于如何做到执行DDL变更时,不堵塞用户对该表的DML和SELECT操作。X-Engine实现OnlineDDL有两个关键点,第1,利用X-Engine的数据组织append-only特点,增量维护在Memtable,L0,L1中,而基线数据维护在L2中;第2,维护增量时,采用双写,同时维护old-table和new-table中的数据。与InnoDB引擎类似,根据DDL是否涉及到数据记录格式变更,将DDL变更分为Inplace-rebuild和Inplace-norebuild两种类型。对于X-Engine来说,两者本质是一样的,区别在于维护的索引个数。Inplace-rebuild类型的DDL需要同时维护new-table中所有索引;而Inplace-norebuild类型的DDL只需要维护new-table的新增的索引。

整个Inplace-Build按照时间序有3个关键节点,t0时刻是获取快照的时间点,t1时刻是build基线完成的时间点,t2时刻是唯一约束检查完成的时间点。那么两个阶段的主要逻辑如下:
t0–>t1主要工作是在build新表的基线,通过将old-table的数据结合new-table的数据字典生成新的记录,最终写入新表对应的L2层;在build新表基线的过程中,产生的增量写入到新表的(Mem,L0,L1)。DDL过程中,需要对后台的Compaction任务做一定的控制,确保不执行合并到L2的Compaction任务。
t1–>t2是唯一性校验阶段,确保新增的主键或者唯一索引的唯一性,t2时刻将(Mem,L0,L1,L2)中的数据合并,最终得到new-table的全量数据。
记录转换的过程如下:

其中,DDL事务表示DDL线程,它的任务是扫描基线,生成新表的基线数据;DML事务表示DDL过程中,并发的DML事务,它们的任务是,通过双写机制同时维护新表和老表的增量。

对比InnoDB实现逻辑

虽然X-Engine与InnoDB的OnlineDDL都是采用基线+增量的方式实现,但具体逻辑是不同的,主要是因为InnoDB采用的的是原地更新操作并且通过row-log机制来维护增量,而X-Engine是一个append-only的存储引擎,天然地支持数据的多版本存储,可以实时维护增量数据,在基线建立完成后只需要将基线与增量数据合并,即使基线中的数据在增量中被修改,但增量中数据的版本比基线数据版本更新,从而在合并时会覆盖基线中老版本的数据。下图是InnoDB引擎OnlineDDL过程。

可以看到InnoDB引擎的OnlineDDL也包括3个关键时间点,与X-Engine引擎的区别在于,t1–>t2 是InnoDB追row-log过程,而对应X-Engine是唯一约束检查的过程。当然对于X-Engine来说,t1–>t2不是必需的,因为DDL变更可能并不涉及唯一索引操作。

Instant-DDL

与MySQL8.0(InnoDB)类似,X-Engine同样也支持Instant-DDL。在所有支持的OnlineDDL中,若DDL操作只涉及修改表的属性信息,或只是做了加列操作,不需要修改记录格式,也不需要新增索引,那么这些OnlineDDL操作可以优化成Instant-DDL。这些DDL操作可以“极速”完成,用户基本无感知。
由于Instant-DDL执行时,并没有真正涉及引擎数据的修改,为了后续查询结果和DDL操作的正确性,需要对于引擎的记录格式做一定的调整,加一些控制元信息。新增一个1字节来标示生成这个记录时,表是否执行过instant-ddl。同时,生成记录时,还需要记录有多少个列是已有的,以及有多少个null列等;在读取解析记录时,根据字典信息,就能知道有多少个列是需要根据instant列信息来补充,确保instant-DDL后,返回查询结果的正确性。

DDL原子性保证

从OnlineDDL的整体流程中我们了解到,OnlineDDL最后一个阶段是Post-ddl阶段。MySQL8.0以前,Server层的元数据都是通过文件来存储,比如frm文件,par文件以及trg文件等。一个DDL操作修改,涉及到文件修改,引擎数据修改以及引擎字典的修改,这些操作无法做成一个事务,必然导致整个DDL操作无法做到原子性。若DDL过程中出现异常,就可能会导致Server层和引擎层数据不一致,以及残余的垃圾没有清理等问题。MySQL8.0将Server层的所有字典信息统一存储在DD(DataDictionary)中,并且通过InnoDB引擎存储,那么DDL过程中,我们只要保证Server层数据字典的修改,以及引擎层数据字典的修改封装成一个事务即可。

对于InnoDB引擎而言,DD数据字典操作,InnoDB引擎数据字典操作都是通过InnoDB引擎存储,通过InnoDB事务特征来保证原子性。对于X-Engine引擎而言,DD数据字典操作,X-Engine引擎数据字典操作分别采用InnoDB引擎和X-Engine引擎,除了依赖于InnoDB和X-Engine自身是事务引擎特征,还需要借助于内部的2PC协议来保证整个事务的原子性。如果MySQL开启了binlog,那么就是binlog,X-Engine,InnoDB三者一起通过2PC协议保证事务的原子性。而Post-ddl阶段就是做善后和清理工作,如果最终整个事务提交,Post-ddl阶段负责真正清理old-table数据;如果最终整个事务回滚,那么Post-ddl阶段负责清理临时产生的new-table数据,确保DDL变更前后,数据库的状态是一致的。

使用体验

X-Engine作为MySQL的一个新引擎,在语法使用层面完全与MySQL(InnoDB)相同,通过algorithm_option指定Online类型,通过lock_option指定DDL过程中,是否允许其它并发的DML和SELECT操作。通常情况下,这两个选项都不用特别指定,采用默认值即可,MySQL内部会优先选择Instant类型和Inplace类型,对于不支持Online的DDL操作,选择Copy类型。在功能层面也与MySQL(InnoDB)相同,目前X-Engine暂时还不支持全文索引,虚拟列,外键等功能,因此与这些功能相关的DDL操作会不支持,其它DDL操作与MySQL(InnoDB)相同。常用的DDL操作分类如下:

后续工作

X-Engine作为一个新的数据库存储引擎,通过集团业务场景的打磨,已经体现了它的价值,我们希望通过云上RDS场景,让更多用户享受到新技术带来的红利。当然,目前X-Engine还有一些不足,尤其是相对于传统成熟的MySQL(InnoDB)和Oracle,所以X-Engine引擎在优化自身的稳定性和性能同时,会持续不断地丰富数据库功能,包括支持外键,全文索引,虚拟列等。除了公有云的RDS输出,基于X-Engine的一体化分布式数据库PolarDB-X也是一个重要方向,我们会以专有云形式输出,服务更多对分布式数据库有强需求的用户。

MySQL · 捉虫动态 · 弱序内存模型导致的死锁问题

$
0
0

背景

众所周知,基于X86架构的CPU瓜分了服务器领域90%领域以上的市场,而基于ARM架构的CPU则占据了移动芯片领域绝大部份的市场。MySQL作为流行的通用数据库,可能运行在任何架构的CPU上。然而,与X86不同,ARM架构的CPU往往是弱内存序模型,这对于基于原子操作+内存屏障实现锁机制的InnoDB而言,可能引入新的bug。

image.png

image.png

如上图所示,X86属于强序模型,仅会发生“写-读”乱序:即写操作后的读操作被乱序到写操作前执行。引入乱序机制的根本原因在于片上缓存/同步机制的设计机制(为了提高CPU流水线的执行效率)。介绍这方面资料的相关文章很多,读者可以自行搜索阅读,本文在此不表。而ARM架构的处理器核数往往更多,因此它采用了更加激进的弱序模型,除了依赖读操作,所有读/写操作都可能出现乱序的问题。基于这一背景,我们发现了MySQL 8.0.13代码在ARM上出现的死锁问题(目前官方已修复:link)。

问题分析

如上文所述,InnoDB基于原子操作+内存屏障实现了自己的一套锁机制。为了便于读者阅读和问题理解,我们简化了相关代码。对于读写锁rw_lock_t类型,我们主要介绍writer_thread和recursive这两个变量:writer_thread表示持有锁的写线程,recursive表示这个锁是否是递归锁和writer_thread值的合法性。我们假设两个线程A和B按照以下顺序执行锁操作:step1. A成功申请了写锁,并调用rw_lock_set_writer_id_and_recursion_flag()函数,修改了writer_thread=A和recursive=true这两个变量,recursive=true表示writer_thread的值是合法的;step2. A释放了写锁,将recursive变量修改为false,表示writer_thread是非法的;step3. B申请了写锁,并调用rw_lock_set_writer_id_and_recursion_flag()函数,修改了writer_thread=B和recursive=true这两个变量;step4. A申请写锁,发现写锁已经被某线程持有。然而因为rw_lock_t是递归锁,A需要检查持有该写锁的线程是否是自己,如果是就成功获得锁。如果多线程执行无法保证step3和step4两组操作之间的执行顺序,这里的判断逻辑就会在ARM架构上引入严重bug。

首先我们说明rw_lock_set_writer_id_and_recursion_flag()函数。由于os_compare_and_swap_thread_id原子操作包含了wmb屏障,这里的写操作逻辑在ARM上没有问题。writer_thread会先被设置,然后lock->recursive才被设置成true表示writer_thread是合法的。

/* rw_lock_set_writer_id_and_recursion_flag()函数 */
1.   local_thread = lock->writer_thread;
2.   /* 原子操作包含wmb,这块的顺序没问题 */
3.   success = os_compare_and_swap_thread_id( &lock->writer_thread, local_thread, curr_thread);
4.   lock->recursive = recursive;

其次,我们说明step4中的判断逻辑。首先,line-3的os_rmb对本问题毫无作用,我们来看line-5的问题。line-5主要包含了lock->recursive和os_thread_eq(lock->writer_thread, thread_id)的判断,包含lock->recursive和lock->writer_thread两个读操作。在X86上,我们保证先读lock->recursive,再读lock->writer_thread的顺序。如果lock->recursive为true,我们才会访问lock->writer_thread的值,这就和上面的rw_lock_set_writer_id_and_recursion_flag()函数相呼应,保证看到的lock->writer_thread一定是最新的。

/* 判断是否是本线程持有了这个rw_lock_t锁 */
1.   os_thread_id_t thread_id = os_thread_get_curr_id();
2.   if (!pass) {
3.       os_rmb;          /* 这个rmb有什么问题吗? */ 
4.   }
5.   if (!pass && lock->recursive && os_thread_eq(lock->writer_thread, thread_id)) {
6.       /* 判断是本线程持有了锁,开始执行后续逻辑 */

然而,ARM这类弱序模型可能打乱了lock->recursive和lock->writer_thread两个读操作的顺序。以step4为例,A先访问了lock->writer_thread,然后才访问lock->recursive。这时候如果step4和step3是交叉执行的,就会引入bug。例如A访问lock->writer_thread是在step3之前,这时候它获取到的lock->writer_thread=A(这时候lock->recursive=false,表明这个值是无效的)。然而如果这时候step3执行完成,A然后才访问了lock->recursive=true,这就导致A以为自己持有了这个写锁,就进入了后面的递归锁逻辑。这导致了临界区的混乱,两个线程可能进入了同一个临界区。这个问题导致的后果,轻则死锁,重则mysqld崩溃甚至数据写坏。

问题修复

基于上述分析,修复这个问题仅需要保证lock->recursive和lock->writer_thread两个读操作的顺序,因此我们的修复方案如下:

/* 判断是否是本线程持有了这个rw_lock_t锁 */
1.   bool recursive;
2.   os_thread_id_t writer_thread;
3.   if (!pass) {
4.       recursive = lock->recursive; 
5.       os_rmb;
6.        writer_thread = lock->writer_thread;
7.   }
8.   if (!pass && recursive && os_thread_eq(writer_thread, thread_id)) {
9.       /* 判断是本线程持有了锁,开始执行后续逻辑 */

通过这个bug,我们了解到:在ARM这类弱序模型上编写多线程程序的时候(尤其是lock-free算法),要特别注意内存屏障的使用,避免出现临界区混乱等问题

MySQL · 最佳实践 · 8.0 redo log写入性能问题分析

$
0
0

对比了MySQL 5.6和8.0在8核环境下oltp_write_only的性能,发现8.0写入性能(QPS 6-7万)反而低于5.6版本的(QPS 14万),所以进一步测试分析了下redo log这里可能导致性能降低的原因

1. 测试方法

sysbench –mysql-host=IP –mysql-port=PORT –mysql-user=mysql –mysql-password=PASSWD –tables=250 –table_size=25000 –db_driver=mysql –threads=128 –report-interval=5 –rand-type=uniform prepare

sysbench –mysql-host=IP –mysql-port=PORT –mysql-user=mysql –mysql-password=PASSWD –tables=250 –table_size=25000 –db_driver=mysql –threads=128 –report-interval=5 –rand-type=uniform –max-time=360 –max-requests=3000000 run

sysbench –mysql-host=IP –mysql-port=PORT –mysql-user=mysql –mysql-password=PASSWD –table_size=25000 –db_driver=mysql –threads=128 cleanup

2. 测试结果

1. upstream 8.0 (8核)

SQL statistics:
    queries performed:
        read:                            0
        write:                           12000000
        other:                           6000000
        total:                           18000000
    transactions:                        3000000 (10792.71 per sec.)
    queries:                             18000000 (64756.24 per sec.)
    ignored errors:                      0      (0.00 per sec.)
    reconnects:                          0      (0.00 per sec.)

General statistics:
    total time:                          277.9637s
    total number of events:              3000000

CPU:

1

2. upstream 8.0(8核,CPU 8专门跑log_writer,其余线程跑在1-7)

SQL statistics:
    queries performed:
        read:                            0
        write:                           12000000
        other:                           6000000
        total:                           18000000
    transactions:                        3000000 (10705.28 per sec.)
    queries:                             18000000 (64231.71 per sec.)
    ignored errors:                      0      (0.00 per sec.)
    reconnects:                          0      (0.00 per sec.)

General statistics:
    total time:                          280.2336s
    total number of events:              3000000

CPU:

2

3. upstream 8.0(8核,CPU 8专门跑log_flusher,其余线程跑1-7)

SQL statistics:
    queries performed:
        read:                            0
        write:                           12000000
        other:                           6000000
        total:                           18000000
    transactions:                        3000000 (12860.01 per sec.)
    queries:                             18000000 (77160.08 per sec.)
    ignored errors:                      0      (0.00 per sec.)
    reconnects:                          0      (0.00 per sec.)

General statistics:
    total time:                          233.2794s
    total number of events:              3000000

CPU:

3

4. upstream 8.0(8核,CPU 8专门用来跑log_writer和log_flusher,其余线程跑在1-7)

SQL statistics:
    queries performed:
        read:                            0
        write:                           12000000
        other:                           6000000
        total:                           18000000
    transactions:                        3000000 (15305.69 per sec.)
    queries:                             18000000 (91834.11 per sec.)
    ignored errors:                      0      (0.00 per sec.)
    reconnects:                          0      (0.00 per sec.)

General statistics:
    total time:                          196.0038s
    total number of events:              3000000

CPU:

4

3. 结果分析

  1. 不做任何隔离时,8核均无法跑不到100%,性能较低,QPS 64756.24
  2. 只隔离log_writer时,单独跑log_writer的核可以接近100%,但剩余7核不行,性能没有提升, QPS 64231.71
  3. 只隔离log_flusher时8核可以都大于90%,性能有所提升,QPS 77599.04
  4. 同时隔离log_writer和log_flusher时8核可以都接近100%,性能进一步提升,QPS 91834.11

从上面的结果可以看出来,

  1. log_flusher是否隔离对8核CPU利用率的影响较大,分析原因应该是在不隔离log_flusher时,由于和很多用户线程共享CPU,所以log_flusher得不到有效的CPU调度无法充分执行,flush_to_disk_lsn更新滞后反过来又影响到用户线程的推进(innodb_flush_log_at_trx_commit = 1),所以整体CPU利用率上不去。隔离log_flusher之后,CPU和性能都有所提升,但相比同时隔离log_writer和log_flusher又有低一些
  2. 单独隔离log_writer性能没有提升,分析原因应该还是同上,瓶颈在log_flusher上

综上,关于upstream 8.0写入性能较低的原因推测是log_writer和log_flusher由于和大量用户线程共享CPU核心,得不到充分调度成为瓶颈,影响整体写入性能,并且log_flusher更为严重。

4. 进一步验证

为了进一步验证上面的推测,我使用sysbench –max-requests=3000000分别对如下4中场景:

a.不隔离
b.同时隔离log_writer及log_flusher
c.单独隔离log_writer
d.单独隔离log_flusher

进行等量压测,每次都是300万次请求,另外为了LSN的可比较性,关掉了undo purge,基于此,我在sysbench run阶段对InnoDB log module进行打点记录,下面分组来看具体性能和打点信息:

性能

 a.不隔离b.隔离writer&flusherc.隔离writerd.隔离flusher
QPS78739.8393705.4784082.0881546.58
TIME228.5992s192.0894s214.0747s220.7310s

由于关掉了undo purge,除场景b外,其他场景QPS相较于之前的测试有所提升是符合预期的

打点信息

1. LSN

 a.不隔离b.隔离writer&flusherc.隔离writerd.隔离flusher
log_lsn_current5736265204573625480957365439345736474256

同样由于关掉了undo purge,对于sysbench –max-requests=3000000,lsn差不多

2. Buffer Wait

 a.不隔离b.隔离writer&flusherc.隔离writerd.隔离flusher
log_waits0000
log_on_buffer_space_no_waits0000
log_on_buffer_space_waits0000
log_on_buffer_space_waits_loops0000

上面的这4个监控信息都是

mtr_t::commit()->
mtr_t::Command::execute()->
log_buffer_reserve()->
log_wait_for_space_after_reserving()->
log_wait_for_space_in_log_buf()->
log_write_up_to(flush_to_disk=false)->
log_wait_for_write()

里的打点,它们全为0,也就是说明配置使用的64M大小log buffer是够用的,无论隔离与否,都不会出现因为log buffer不够用而进行回收时的wait

3. recent_XXX

 a.不隔离b.隔离writer&flusherc.隔离writerd.隔离flusher
log_on_recent_written_wait_loops0000
log_on_recent_closed_wait_loops0000

1.log_on_recent_written_wait_loops是在

mtr_t::commit()->
mtr_t::Command::execute()->
mtr_write_log_t::()->
log_buffer_write_completed()

里如果由于recent_written不足等待回收则进行的打点

2.log_on_recent_closed_wait_loops是在

mtr_t::commit()->
mtr_t::Command::execute()->
mtr_write_log_t::()->
log_wait_for_space_in_log_recent_closed()

里如果由于recent_closed不足等待回收则进行的打点

它们都为0说明recent_written和recent_closed当前配置够用,并且log_writer和log_closer对其回收及时。

重点关注下面的打点信息,首先先说下打点位置

4. Log Writes

 a.不隔离b.隔离writer&flusherc.隔离writerd.隔离flusher
log_write_requests53837519537722945398789254287129
log_writes2489114622661060406534914036257

1.log_write_requests是

mtr_t::commit()->
mtr_t::Command::execute()->
log_buffer_reserve()

里打点,可以看到mtr的个数也差不多

2.log_writes是

log_writer()->
log_writer_write_buffer()->
log_files_write_buffer()

里log_writer将log写进table cache并更新log_sys->write_lsn之后的打点

5. log_writer

 a.不隔离b.隔离writer&flusherc.隔离writerd.隔离flusher
log_writer_no_waits2411019821737096389159953395741
log_writer_waits706525341233
log_writer_wait_loops4505411030841467
log_writer_on_file_space_waits0000
log_write_notifier_no_waits9665011758614100142684
log_write_notifier_waits6364591218441824056562168416
log_write_notifier_wait_loops6702652252035224393462846572
log_on_write_no_waits0000
log_on_write_waits0000
log_on_write_wait_loops0000

1.log_writer_no_waits和log_writer_waits都是在

log_writer()->
os_event_wait_for(log.writer_event)

后的打点,log_writer_no_waits是在os_event_wait_for()时发现有连续可写的内容不需wait的次数,log_writer_waits是在os_event_wait_for()时发现没有连续可写的内容需要wait的次数

2.log_write_notifier_no_waits和log_write_notifier_waits都是在

log_write_notifier()->
os_event_wait_for(log.write_notifier_event)

后的打点,log_writer_no_waits是在os_event_wait_for()时发现log.write_lsn向前推进所以不需wait的次数,log_write_notifier_waits是os_event_wait_for()时发现log.write_lsn没有向前推进所以需要wait的次数

3.log_on_write_no_waits和log_on_write_waits都是在

XXX->
log_write_up_to()->
log_wait_for_write()->
os_event_wait_for(log.write_events[])

后的打点,可以看到都为0,说明并不存在用户线程在等待log_writer更新log.write_lsn到指定lsn的时候。

6. log_flusher

 a.不隔离b.隔离writer&flusherc.隔离writerd.隔离flusher
log_flusher_no_waits2198984639077353433843696
log_flusher_waits1112620141521559647
log_flusher_wait_loops20594822442320832797221
log_flush_notifier_no_waits7049121679294462289
log_flush_notifier_waits128905950814231335218895
log_flush_notifier_wait_loops98017310911321059829958259
log_on_flush_no_waits76089349721506
log_on_flush_waits2993605297519329939352991368
log_on_flush_wait_loops8616066301106060910196954663
log_flush_lsn_avg_rate3586521139523423894717
log_flush_total_time181411112us (181s)137893867us (138s)209647279us (210s)29879924us (30s)
log_flush_avg_time683us27us642us25us

1.log_flusher_no_waits、log_flusher_waits和log_flusher_wait_loops都是在

log_flusher()->
os_event_wait_for(log.flusher_event)

后的打点,log_flusher_no_waits是在os_event_wait_for()时发现log.write_lsn向前推进所以不需要wait的次数,

log_flusher_waits是在os_event_wait_for()时发现log.write_lsn没有向前推进所以需要wait的次数,log_flusher_wait_loops则是在wait里被唤醒或timeout之后发现condition还不满足而继续wait的loop次数

2.log_flush_notifier_no_waits和log_flush_notifier_waits都是在

log_flush_notifier()->
os_event_wait_for(log.flush_notifier_event)

后的打点,log_flush_notifier_no_waits是在os_event_wait_for()时发现log.flushed_to_disk_lsn更新所以不需要wait的次数,log_flush_notifier_waits是在os_event_wait_for()时发现log.flushed_to_disk_lsn没更新所以需要wait的次数

3.log_on_flush_no_waits、log_on_flush_waits和log_on_flush_wait_loops都是在

XXX->
log_write_up_to(flush_to_disk=true)->
log_wait_for_flush()

后的打点,这里是用户线程在(innodb_flush_log_at_trx_commit = 1)等待flush_to_disk_lsn更新到自己对应的LSN,log_on_flush_no_waits是当用户线程对应的LSN的redo log已被log_flusher刷盘,无需wait的次数,log_on_flush_waits是当用户线程对应LSN的暂未不刷盘,需要wait的次数,log_on_flush_wait_loops是在wait中被唤醒或timeout后发现condition还不满足继续wait的loop次数

4.log_flush_lsn_avg_rate是每30次flush的内容平均每秒的长度

5.log_flush_total_time是整体flush的用时

6.log_flush_avg_time单次flush的平均用时

基于以上信息,分析如下,以a.不隔离作为基准

  1. 隔离log_flusher(场景d),可以看到隔离log_flusher之后,由于log_flusher独占1个核心,此时log_flusher_no_waits从219898增长到843696(4倍),说明log_flusher被调度执行到的次数更多,刷盘更及时,甚至因为CPU太过充分导致log_flusher_waits、log_flusher_wait_loops出现大幅增长。而log_on_flush_no_waits和log_on_flush_waits没有太大变化说明即使刷盘效率有所提高,但用户线程在自己对应LSN刷盘时还是需要等待,不过log_on_flush_wait_loops有所减少,从8616066减少到6954663,虽然wait次数没变,但wait里的loop变少,说明一定程度上用户线程推进速度还是有所提升,这点也符合QPS和CPU利用率提升的现象。另外log_flusher大约100万次(log_flusher_no_waits+log_flusher_waits)的刷盘操作,总用时30s,单次刷盘25us,效率最高。不过为什么整体性能提升有限呢?因为此时log_writer成为了瓶颈,此时CPU利用率有所提升,log_writer与大量用户线程共享核心,得不到充分调度,更新write_lsn不及时,这点可以从log_writer_no_waits和log_writes看出来,log_writer_waits没变的基础上,log_writer_no_waits从24110198降到3395741(1/6),正是因为隔离log_flusher后,用户线程CPU利用率有所提高(不隔离时会在等待自己对应LSN刷盘上wait较久,这样还会让出CPU给log_writer来用),log_writer拿不到CPU导致,对应的log_writes也从24891146降到4036257(1/6),log_writer的写入效率变低,瓶颈。
  2. 隔离log_writer(场景c),可以看到隔离log_writer之后,由于log_writer独占1个核心,此时log_writer_no_waits从24110198增长到38915995(近2倍),说明log_writer被调度到的次数更多,write_lsn推进更及时。而log_writer_waits没什么变化说明即使log_writer独占1个核心,CPU也是刚刚够用,基本每次写完page cache后就有新的log要再写入。不过log_on_flush_no_waits和log_on_flush_waits没有太大变化说明即使写page cache效率有所提高,但用户线程在等自己对应LSN刷盘时还是需要等待,不过log_on_flush_wait_loops有所减少,从8616066减少到6091019,一定程度上用户线程推进速度有所提升。至于整体性能提升不明显的原因,是因为此时log_flusher是瓶颈。按理说log_writer执行频率和效率大幅提高,唤醒log_flusher的频率也会很高,但log_flusher_no_waits从219898仅增长至353433(1.7倍,还不如隔离log_flusher带来的提升),log_flusher_waits甚至降低,另外可以看到log_flusher大约刷盘35万次,总用时210s,单次平均用时642us,(即使刨除由于单次fsync的内容变多导致可能的fsync用时变长之外)远高于隔离log_flusher时的用时,都说明此时log_flusher得不到有效的CPU调度,执行效率低下,瓶颈。
  3. 同时隔离log_writer和log_flusher(场景b),二者共用1个核心,此时log_flusher_no_waits从219898提升至4639077(23倍),被调度的更多,执行更充分,另外log_writer_no_waits没有太大变化(应该是log_flusher被调度更多了,执行效率变高,所以与其共享一个核心的log_writer相比不隔离时被调度的次数稍少一些),这样在log_writer执行效率没有太大变化的情况下,log_flusher效率大幅度提升,一共刷盘500万次,总用时138s,单次平均用时27us。这种情况,在log_on_flush_waits不变的基础上,log_on_flush_no_loops从8616066减少到3011060(1/3),用户线程在等待自己LSN落盘的wait loops减少最多,所以整体性能也提升最多。

5. 其他发现

  1. 在不做任何隔离的情况下,打开undo purge的性能低于关闭时的QPS,(64756.24 : 78739.83),分析原因应该是打开undo purge时,由于undo purge truncate会写入更多的log,导致LSN变大(5736265204 :7692486960),在插入这么多新的redo log后,由于log_flusher效率并没有提升,log变多了,用户线程在等待自己对应LSN刷盘时会等更久(log_on_flush_wait_loops变化,13015383 : 8616066),所以性能低一些。
  2. 在32核,1024并发场景下,不隔离时QPS是274509.11,隔离时(log_writer和log_flusher同时绑定在CPU32)QPS反而降为254697.57,分析了下打点数据,隔离时log_writer_no_waits从8175104将为5261245,说明当在更多核心更高压力时,将log_writer、log_flusher同时放在一个核心会因为此时log_flusher相比不隔离时刷盘更快而导致log_writer被调度执行的机会更少,log_writer成为瓶颈
  3. 在2的场景下,进一步测试,保持用户使用的核心数不变(CPU 1-31),多加一个核心,将log_writer绑定CPU32,log_flusher绑定CPU33,发现此时性能和不隔离是差不太多(281093.45 :274509.11),分析打点数据,此时log_writer_no_waits(11153587)和log_flusher_no_waits(1600971)都大幅提升,说明二者并不是瓶颈(另外观察到此时log_flusher所在核心的CPU利用率也都有70%左右了),但性能为什么没上去呢?发现此时log_on_recent_closed_wait_loops从之前的0变成了17844432,证明用户线程在mtr::commit时等待recent_closed buffer回收空间次数变多,应该是此时log_writer写的更快,而log_closer由于与用户线程共享CPU,回收recent_closed不及时导致的,log_closer成为新的瓶颈
  4. 在3的场景下,进一步测试,保持用户使用的核心数(CPU 1-31)和log_writer(CPU 32)和log_flusher(CPU32)不变的基础上,再加一个核心,绑定log_closer(CPU33),此时性能提升较多310020.40,不过此时log_on_recent_closed_wait_loops并不为0(758048),因为log_closer并不是由谁唤醒而是周期性的执行,所以在这种情况下,默认的sleep时间(1000us)也显得太久,需要调小
  5. 在2的场景下,只是用32个核心,用户线程使用(1-29),log_flusher、log_writer、log_closer使用(30-32),这样即使用户线程使用的核心相比场景2少了2个,但整体性能是299089.22,相比场景2反而有所提升

6. 总结

MySQL 8.0将写redo log拆分成多个线程异步来做的方式,可能并不是理想的优化,

  1. 当并发比较低(用户线程少)时,用户线程与这些异步线程之间的交互等待反而不如直接像RocksDB那样全有用户线程来做更核实
  2. 在并发比较高(用户线程多)时,又会因为这些异步线程(log_writer、log_flusher、log_closer)得不到充分的CPU调度而成为写入瓶颈。这样的异步设计需要确保这些异步线程有足够的CPU时间才能提高性能

所以目前能想到的改进方案有如下:

  • 提高log异步线程的优先级

MySQL · 引擎特性 · InnoDB redo log 之 write ahead

$
0
0

1. 背景

现代文件系统对文件的buffer IO,一般是按照page为单位进行处理的。假设page的大小4096字节,当要将数据写入到文件偏移范围为[6144, 8192)的区域,会先在内存里,将对应page cache的[2048, 4096)这个区域的数据修改为新值,然后将对应的page cache,整个的从内存刷到磁盘上。但是如果要写入的文件区域,因为还没有被缓存或者被置换出去了等原因,在内存里不存在对应的page cache,则需要先将对应page的内容从磁盘上读到内存里,修改要写入的数据,然后在将整个page写回到磁盘;在这种情况下,会有一次额外的读IO开销,IO的性能会有一定的损失。

InnoDB内redo log采用的是buffer write,也会遇到这种问题,而且mysql的整体性能对redo log写IO的性能比较敏感,为此InnoDB对该问题做了优化,结合redo log写入是append write的特性,引入了write ahead方法,尝试解决这个问题。主要原理是当某次写文件系统IO满足这两个条件:

a. 该IO的目的地址是文件内的某个page的起始偏移地址;

b. 改IO的数据大小为page大小的整数倍

则该IO的执行,不需要先从磁盘中读出对应page的数据,再做修改和和写入,而是直接将该IO所带的数据作为修改后的数据,缓存在内存里(即page cache),等后续刷盘操作将该page cache写入到磁盘上。这样就避免了额外的读IO开销。

write ahead的原理比较简单,但是InnoDB内的实现比较精炼,不易理解,容易淡忘。所以,本文以MySQL 8.0.12版本代码为参考,注释分析redo log的write ahead的工作机制,以备后续查记;并简单验证该write ahead机制是否有效。

2. redo log write ahead的工作流程

在MySQL 8.0,innodb将redo log的相关操作,按照功能划分成不同的阶段,由不同的线程负责不同阶段的逻辑。在mini transaction的commit阶段,将该mini transaction产生的redo log拷贝到log_sys->buf内,这部分逻逻辑比较分散,可以发生在用户线程内; log_writer线程,负责将全局的log_sys->buf内的redo log写入文件系统,暂时保存在page cache中;log_flusher线程,负责刷盘,将还处在文件系统的redo log写到磁盘上;log_write_notifier和log_flush_notifier线程,负责触发事件,分别提醒log_writer线程和log_flusher线程从等待中开始工作。

redo log的write ahead逻辑发生在log_writer线程内。该线程逻辑的代码入口在log0write.cc:log_writer函数处;它的工作流程比较简单:

  1. 循环等待条件:log_sys->m_recent_written->m_tail 大于 log_sys->m_written_lsn,条件满足时,说明有新的redo log产生,需要被写入;
  2. 有新的redo log时,则写redo log到文件系统中。

主路径的调用路径如下:

log_writer
->log_writer_write_buffer
->log_files_write_buffer // redo log的write ahead逻辑发生在这个函数内

下面结合代码分析log_files_write_buffer函数的实现:

//@log_files_write_buffer函数
static void log_files_write_buffer(log_t &log, byte *buffer, size_t buffer_size,
                                   lsn_t start_lsn) {
  ......
  // 该变量为true,表示将redo log直接从log_sys->buf内写入到文件系统;
  // 否则,表示需要先将要写入的redo log拷贝到log_sys->write_ahead_buf,
  // 然后从log_sys->write_ahead_buf,将redo log写入到文件系统中, 对于后者
  // 有两种情况:a. 执行write ahead逻辑;b. 需要对最后一个完整的log block填0。
  bool write_from_log_buffer;
  // 计算本次redo log IO的大小和判断write_from_log_buffer的值。
  // 后面分析其实现。 
  auto write_size = compute_how_much_to_write(log, real_offset, buffer_size,
                                              write_from_log_buffer);

  if (write_size == 0) {
    // 如果本次IO大小的计算值为0,表示当前刚好处在正在写入的redo log文件的结尾,
    // 需要先切换到下一个redo log文件
    start_next_file(log, start_lsn);
    return;
  }

  // 以512个字节为单位,计算并填入每个完整的log block的元信息,
  // 如:该log block的有效数据长度、该log block当前对应的checkpoint no,
  // 该log block的checksum等。
  // note: 这里计算的是完整log block的元信息,不完整的log block后面再做处理。
  prepare_full_blocks(log, buffer, write_size, start_lsn, checkpoint_no);
  ......
  if (write_from_log_buffer) {
    // 从log_sys->buf,将redo log直接写入到文件系统
    // 只需将写入的源端指向log_sys->buf内的正确位置,即
    // buffer所指向的地址。
    ......
    write_buf = buffer;
    .......

  } else {
    // 从log_sys->write_ahead_buf将redo log写入到文件系统,
    // 同样要讲写入的源端指向log_sys->write_ahead_buf
    write_buf = log.write_ahead_buf;
    // 先将要写入的redo log从全局log_sys->buf内拷贝到log_sys->write_ahead_buf
    // 中, 拷完后,将最后一个不完整的block的结尾区域填0,并填上checkpoint no,
    // checksum等元信息。
    copy_to_write_ahead_buffer(log, buffer, write_size, start_lsn,
                               checkpoint_no);
    //执行到这里的逻辑,有两种情况:
    //a. 当前要写入文件偏移量刚好log_sys->write_ahead_buf当前可以覆盖区域的结尾处
    //.  (这个地址一般都是page对齐的), 需要做write ahead操作;
    //b. 本次IO要写入的redo log量太少,小于一个log block的大小,需要一块额外的buffer
    //.  空间,将这块不完整log block的后端区域填0,并计算和填入checksum值等信息。如果直
    //   接在log_sys->buf内原地填0,可能会把mtr刚刚拷贝到该区域的log覆盖掉。
    // 下面的这个分支判断筛选出情况a.
    if (!current_write_ahead_enough(log, real_offset, 1)) {
      // 在执行write ahead的情况下,将log_sys->write_ahead_buf内未被有效redo log
      // 填充的区域都填0;同时更新write_size的值, 即write ahead buffer的大小
      written_ahead = prepare_for_write_ahead(log, real_offset, write_size);
    }
  }
  ......
  // 当刚才完成的写IO的目标范围的结束偏移(不是有效redo log的结束偏移),不在
  // log_sys->write_ahead_buf的当前覆盖范围内,则往后滑动
  // log_sys->write_ahead_buf的覆盖范围,以便计算后续的redo log写IO,
  // 是否需要执行write ahead,和截断要写入的log数据等操作.
  update_current_write_ahead(log, real_offset, write_size);
}                                              

//@log_files_write_buffer->compute_how_much_to_write
// 该函数主要是为了判断当前的redo log写IO,是否需要write ahead,
// 和计算本次IO应该写入的数据大小。
static inline size_t compute_how_much_to_write(const log_t &log,
                                               uint64_t real_offset,
                                               size_t buffer_size,
                                               bool &write_from_log_buffer) {
  size_t write_size;
  .......
  // 如果需要跨文件,则当前IO只写入当前正在写入的redo log文件可以装下的数据;
  // 这很容易理解,一般的同步IO都是这么操作的。
  if (!current_file_has_space(log, real_offset, buffer_size)) {
    ......
    // 如果已经处在当前正在写入的redo log文件的结尾处,则需要先切换redo log文件;
    // 设置当前IO大小为0,来通知上层调用切换到新的redo log文件
    if (!current_file_has_space(log, real_offset, 1)) {
      .......
      write_from_log_buffer = false;
      return (0);
    } else {
      // 设定本次写入IO的数据量为当前redo log文件可以容纳的最大数据
      write_size =
          static_cast<size_t>(log.current_file_end_offset - real_offset);
      ......
    }
  } else {
    // 如果不需要跨文件(当前redo log文件可以容纳要写入的log), 则暂时设定
    // 写入IO的大小为要写入的log的大小,这个值在后面可能还要受到
    // log_sys->write_ahead_buf能够容纳的数据量的限制,而被截断。
    write_size = buffer_size;
  }
  ......

  // InnoDB的redo log是按照log block进行管理的,一个log block的大小为
  // OS_FILE_LOG_BLOCK_SIZE字节,每个log block都有独立的元信息,如log
  // block no, checksum等。当某次写redo log的IO要写入的log数据不足一个
  // OS_FILE_LOG_BLOCK_SIZE时,该IO准备逻辑需要将该写入数据的对应的log
  // block的后端区域填0,然后计算和填入该block填0后的checksum值;但是这
  // 不能在全局的log_sys->buf原地做,需要一块额外buffer,否则可能会覆盖后
  // 续填入其中的log数据;这里我们将log_sys->write_ahead_buf选为我们
  // 的"额外buffer",所以这里当write_size小于OS_FILE_LOG_BLOCK_SIZE时,
  // 令'write_from_log_buffer'为false,表示本次写IO数据最后要从
  // log_sys->write_ahead_buf写入到文件系统中。
  write_from_log_buffer = write_size >= OS_FILE_LOG_BLOCK_SIZE;
  ......
  // 判断当前的write ahead区域,是否可以装得下我们这log IO要写入的数据,如
  // 果装不下,则需要截断本次IO要写入的数据;如果当前要写入的文件偏移,刚好处
  // 在log_sys->write_ahead_buf当前覆盖覆盖区域的结束位置(一般也是某个
  // page的起始或者结束地址处),这个时候,需要采用一次write ahead操作,具体
  // 逻辑为:将本次写IO要写入的数据从log_sys->buffer拷贝到
  // log_sys->write_ahead_buf内,将log_sys->write_ahead_buf后
  // 端未被有效数据填充的区域填0,然后将整个log_sys->write_ahead_buf的
  // 内容写入到文件系统中,避免可能出现的一次读IO开销
  // 
  // note. 这里有一个隐藏的假设:
  // a. 当某次写IO的目的偏移地址是与log_sys->write_ahead_buf当前覆盖范围
  //    的结束地址对齐时,则假定该次写IO目标区域在内存没有对应的page cache,需
  //.   要执行一次write ahead操作
  // b. 当执行一次write ahead逻辑后,在接下来的一段时间内,该区域对应的page cache
  //.   会保存在内存中,后续对当前write ahead buffer可以覆盖的文件区域的
  //.   写IO,都可以命中这些page cache, 从而避免额外的读IO开销。
  // 上面的假设a和b,真实情况下并不是百分百成立的。
  if (!current_write_ahead_enough(log, real_offset, write_size)) {
    if (!current_write_ahead_enough(log, real_offset, 1)) {
      // 本次写IO的目的地址不在write ahead buffer当前可以覆盖区域内
        
      // 计算write ahead buffer下一个覆盖区域的结尾偏移地址
      const auto next_wa = compute_next_write_ahead_end(real_offset);

      if (!write_ahead_enough(next_wa, real_offset, write_size)) {
        // log_sys->write_ahead buffer的下一个完整的覆盖区域都容纳不了本次
        // 写IO的log数据,则将本次IO要写入的数据截断到write ahead buffer的
        // 大小;并且不需要再从log_sys->write_ahead_buf写,可以直接从
        // log_sys->buf写入到文件系统,减少了一次内存拷贝的开销。
        ......
        write_size = next_wa - real_offset;
        ......
      } else {
        // 本次写IO执行write ahead逻辑
        write_from_log_buffer = false;
      }
    } else {
      // log_sys->write_ahead_buf的当前覆盖范围容纳不了本次IO要写入的log
      // 数据,将本次IO要写入的log数据按照可以容纳的量阶段。
      write_size =
          static_cast<size_t>(log.write_ahead_end_offset - real_offset);
      ......
    }
  } else {
    if (write_from_log_buffer) {
      // 走到这里,根据上面write_from_log_buffer的赋值逻辑,说明本次IO要写入的log数
      // 据是大于一个OS_FILE_LOG_BLOCK_SIZE的,在这种情况下,将写入的log数据按照向下
      // 对齐OS_FILE_LOG_BLOCK_SIZE进行截断,这样可以一定概率的避免对最后一个不完整
      // block的后面区域填0操作(填0操作,有拷贝到另外一块额外buffer内的开销),因为等下
      // 一次IO的时候,这个不完整的block可能又有新的log数据填入,变得完整了。
      write_size = ut_uint64_align_down(write_size, OS_FILE_LOG_BLOCK_SIZE);
    }
  }

  return (write_size);
}    

总的来说,某次写redo log的IO可能会有以下这四种情况:

a. 该IO的目标偏移量刚好是log_sys->write_ahead_buf当前可以覆盖区域的结尾处,并且该IO要写入的log数据量小于srv_log_write_ahead_size, 则利用log_sys->write_ahead_buf,执行write ahead逻辑:将要写入的log数据拷贝到log_sys->write_ahead_buf内,对log_sys->write_ahead_buf后端未被有效数据填充的区域填0,然后将整个log_sys->write_ahead_buf写入到文件系统中;

b. 该IO的目标偏移量刚好是log_sys->write_ahead_buf当前可以覆盖区域的结尾处,并且该IO要写入的log数据大于srv_log_write_ahead_size, 则不需要执行write ahead操作。将本次写入IO的数据量截断为srv_log_write_ahead_size大小,直接从log_sys->buf将这srv_log_write_ahead_size大小的数据写入到文件系统中,这样既起到了write ahead操作的作用,也避免了write ahead操作所产生的额外内存拷贝的开销。

c. 该IO的目标偏移量不在log_sys->write_ahead_buf当前可以覆盖区域的结尾处,并且该IO要写入的数据小于一个log block的大小,则不需要执行write ahead 操作,但是需要利用log_sys->write_ahead_buf对这个不完整的log block的后端未填入有效log数据的区域填0,并计算checksum等信息,然后将整个log block从log_sys->write_ahead_buf处写入到文件系统中,这个过程会有一次额外的内存拷贝,从log_sys->buf将要写入的log数据拷贝到log_sys->write_ahead_buf内。

d. 该IO的目标偏移量不在log_sys->write_ahead_buf当前可以覆盖区域的结尾处,并且该IO要写入的数据大于一个log block的大小,则也不需要执行write ahead操作。将本次写入IO的数据大小按下截断到OS_FILE_LOG_BLOCK_SIZE的整数倍,然后从log_sys->buf直接写入到文件系统,这样可以较大概率的避免对最后一个不完整log block的填0操作所引入的开销。

下图可以简要的是示意上面介绍的InnoDB内redo log写IO的情况:

tmp

3. 主要的数据结构和参数

a. log_sys->write_ahead_buf该buffer主要有两个作用:a. 用于redo log的write ahead,先将要写入的redo log从log_sys->buf拷贝到log_sys->write_ahead_buf, 再对log_sys->write_ahead_buf后端未被有效数据填充的区域填0;b. 用于对不完整block的后端区域填0。因为原地填0等操作,可能会覆盖后续填入的有效log数据。

b. 参数innodb_log_write_ahead_size

​ 用于控制log_sys->write_ahead_buf的大小,默认为8092;一般需要设置为内存页大小的整数倍,linux下内存页的大小可通过‘getconf PAGE_SIZE’命令获取,内存页的大小一般为4096字节。

4. 验证write ahead是否有效

附录里有测试的代码,大致的思路是在清空page cache的情况下,按照append write的方式,单线程同步的对一个文件写入1G数据,按两种方式进行对比:a. 普通写入方式,每次写入的数据为512B,直至写完1GB;b. 采用write ahead的方式进行写入,当写入的地址为一个page的起始地址时,则写入一个后端填0的完整page,否则写入512B数据,也是直至写完1GB数据。

对比测试是在同一个物理机的同一块磁盘上进行的(这里就不给出软硬件型号参数了),磁盘采用的是nvme盘;测试前清空缓存。

分别执行如下命令,进行对比

// 命令说明:
//     a. 先给tmp.txt写入1G的数据,在进行测试,是为了避免文件第一次写入时,元信息修改产生的影响;
//.    b. echo 3 >/proc/sys/vm/drop_caches 用于清空缓存。


// write ahead写入方式, 
> dd if=/dev/zero of=./tmp.txt bs=1048576 count=1024 && g++ -O3 -DWRITE_AHEAD append_write.cc -o append_write && echo 3 >/proc/sys/vm/drop_caches && time ./append_write ./tmp.txt
    
// 普通写入方式
> dd if=/dev/zero of=./tmp.txt bs=1048576 count=1024 && g++ -O3 -DNORMAL_WRITE append_write.cc -o append_write && echo 3 >/proc/sys/vm/drop_caches && time ./append_write ./tmp.txt

跑3次取平均值,结果为:

不同的写入方式write ahead写入方式普通写入方式
耗时/second2.87811.515

可以看到在这种方式,write ahead的收益还是很明显的,有差不多4倍的收益。

结论: 在page cache不命中的情况下,采用write ahead的方式进行写入的优化效果还是很明显的。

##附:

#include <stdio.h>
#include <sys/types.h>
#include <sys/stat.h>
#include <unistd.h>
#include <errno.h>
#include <stdint.h>
#include <stdlib.h>
#include <fcntl.h>
#include <string.h>

char filepath[128];
uint32_t len_per = 512;
char buf[4096];
char buf2[4096];
const uint64_t file_size = 1024*1024*1024*1ul;
const uint64_t block_size = 4096;

void usage() {
  fprintf(stderr, "usage:\n\t./append_write filepath [len_per]\n");
}
int main(int argc, char* argv[]) {
  if (argc > 3) {
    usage();
    return -1;
  }
  strcpy(filepath, argv[1]);

  if (argc == 3) {
    len_per = atoi(argv[2]);
  }
  int32_t fd = open(filepath, O_RDWR);
  if (fd == -1) {
    fprintf(stderr, "create new file failed, errno: %d\n", errno);
    return -1;
  }
  fprintf(stderr, "start writing...\n");
  for (uint64_t sum = 0; sum < file_size; sum += len_per) {
#ifdef WRITE_AHEAD
    if (sum % block_size == 0) {
      memcpy(buf2, buf, len_per);
      memset(buf2+len_per, 0, block_size - len_per);
      if (pwrite(fd, buf2, block_size, sum) != block_size) {
        fprintf(stderr, "write failed, errno: %d\n", errno);
        close(fd);
        return -1;
      }
    } else if (pwrite(fd, buf, len_per, sum) != len_per) {
      fprintf(stderr, "write failed, errno: %d\n", errno);
      close(fd);
      return -1;
    }
#elif defined(NORMAL_WRITE)
    if (pwrite(fd, buf, len_per, sum) != len_per) {
      fprintf(stderr, "write failed, errno: %d\n", errno);
      close(fd);
      return -1;
    }
#endif
  }
  fprintf(stderr, "finish writing...\n");

  close(fd);
  return 0;
}
Viewing all 687 articles
Browse latest View live