对象关系数据库-PostgreSQL的非关系字段测试
PostgreSQL是一种含有面向对象技术的高性能数据库,并在很多重要场合发挥基础设施作用。 其通过扩展hstore支持单级键值索引, 获得了有限的非关系数据存取能力,直到 JSON 字段的出现,使其真正意义上可以高效管理、访问完备的非关系集合,从而成为一种混合具备非关系能力的关系数据库。本文通过一个案例,介绍混合对象关系数据库PostgreSQL在中大规模异质数据管理中的应用。
文章目录
1 PostgreSQL的演进
传统的关系数据库,在应对异构数据管理方面存在不足。如管理一个元器件仓库,对元器件除了入库日期、生产厂、名称等公共字段外,不同类的器件还有大量异质的参数。如电容器有耐压以及材质,晶体管有开关类型,而加热陶瓷片有等效功率、额定电压、温度等属性。若在数据库中存储所有属性的并集,往往会生成上百个字段。而对于一个器件而言,列的填充率非常稀疏。
1.1 从关系数据库到对象关系数据库
早期PostgreSQL采用了表继承来解决该问题。在文章《PostgreSQL 强大的多层表继承–及其在海量数据分类按月分区存储中的应用》中,我曾介绍了这个技术。在加入继承后,PostgreSQL成为了一种带有OO味道(伪OO)的关系数据库。使用表继承,可以在主表中安排公共字段,子表中存储专有字段。然而,这种技术仅仅部分的解决了数据的存储,却没有解决一致性访问问题。
以本文的电子器件仓库为例:
- 在主表中执行查询,可以搜索到所有器件的公共属性
- 当需要请求独特的特有参数时,需要到子表再次检索
- 当类别很多时,表数目太大,分区过散。
- 需要根据某种零件的个数,评估是否需要进行进一步分区。维护多级分区的触发器函数非常复杂。
采用继承的更多意义,是管理复杂规则的分区。很多无法用简单partition by 描述的分区规则,可以采用表继承+触发器完成。
1.2 高效但不完备的hstore
对简单类型的单层扩展,PostgreSQL中具备一种哈希存储解决方案,即hstore。它可以使用字符串键值索引的形式,有效的在一个字段中存储很多信息。
使用hstore的典型应用,比如openStreetMap的渲染数据库,把几乎所有的属性塞进了单个hstore字段。不仅如此,hstore还支持包含等计算,以及对其中某类字段建立一般性的索引。然而,hstore 也有不足,比如:
- 不完备的单层键值索引。无法hstore嵌套hstore,从而与json不兼容。
- 只有字符串类型。
而与此同时,非关系数据库却得到了迅猛发展,如芒果等数据库,可以完善的支持JSON数据的高效检索,在非关系数据的管理、访问技术上做出了宝贵的探索。
1.3 非关系数据库的成功与局限
在PostgreSQL提供继承、hstore的时候,非关系数据库得到了突飞猛进的发展。对于一种器件,只要把所有的属性放在文档中,建立一定的索引,便可以方便的查询。
{
"mod": "模式3",
"name": "QpUxSfn",
"type": "RM01",
"uuid": "nBI TQDbZ uxZKiXoGTcNKhqtYhJXvlT",
"props": {
"unit": 1,
"rec_date": "2017-06-29 20:07:38"
},
"tempc": 186.82999139679742,
"report_date": "2017-06-30 04:15:26"
}
理论上,每个文档中的各个记录,可以拥有任意的字段(当然一般应用要遵循一定的元数据标准)。如此一来,异质数据就不成问题了。但是,专门针对特定场景优化设计的非关系数据库,在数据的完整性与有效性方面却支持不够,如表现在:
- 不完整的关系代数支持
- 跨文档(表)联合 (join)支持不够
- 事务完整性
很多复杂业务逻辑,都需要完整的关系代数支撑。把范式交给后台程序完成,是舍近求远;引入额外的关系数据库,则人为割裂了数据,不利于关联维护。
1.4 PostgreSQL的解决方案
经过多个主要版本的演化,PostgreSQL吸收非关系数据库优势,提供了较为完善稳定的JSON字段支持。此JSON字段可采用插入时解析,生成二进制JSONB格式,进而大大提高访问效率。其主要优势:
- 完整、先进的关系数据支持
- 完整、高效的非关系字段支持
- 非关系字段元素参与关系SQL运算
- 输出常见脚本JSON格式
为了测试这种技术,我们搞一个案例,来实际测试分区技术、一千万条记录情况下的综合查询性能。
2. 一千万级别案例
测试版本:postgreSQL 11.1 msys2 版(包含在msys2运行环境中)
操作系统:windows 7 x64(虚拟机,4核心i7,8GB内存,固态硬盘)
基本配置:SHARED_MEM 2GB, Maintain_mem 1GB
2.1 建立测试数据库
测试数据为test。
CREATE DATABASE test
WITH
ENCODING = 'UTF8';--很重要,亚洲字符的JSON兼容性UTF8最好
2.1.1 建立表单
创建一个表main,存储仓库的零件。只包含三列
CREATE TABLE main
(
id bigint NOT NULL DEFAULT 0,
main_time timestamp with time zone NOT NULL DEFAULT now(),
dict jsonb,
CONSTRAINT pk_main PRIMARY KEY (id)
)
WITH (OIDS = FALSE);
分别是id、主登记时间、非关系字典(JSON字典)。为ID建立序列(可以支持自增长)
CREATE SEQUENCE public.main_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1;
2.1.2 建立索引
有很多文章介绍了JSON字段的索引。对JSON字段的操作,包括对结构的判断(是否具备某个字段),以及对键值的匹配(值、范围)。
对结构的判断计算,直接看官网文档。对更生动的例子,可以查看这个文章https://yq.aliyun.com/articles/647475。可以从官方文档hstore的例子,衍生学习json也是不错的选择。
需要注意的是,对json字段用->取子元素,返回的还是json; 用->>取子元素的值,返回字符串。
我们直接按照网上的方法,依据例子中的JSON结构建立索引。
首先,要建立非易变(IMMUTABLE)的JSON到日期转换函数,以方便对JSON日期进行范围比较。
CREATE FUNCTION json_to_timestamp(j jsonb) RETURNS timestamp with time zone
LANGUAGE sql IMMUTABLE
AS $$
select j::text::timestamp with time zone as RESULT
$$;
而后,创建索引:
CREATE INDEX idx_jsonb ON main
USING gin (dict jsonb_path_ops);
CREATE INDEX idx_mod ON main
USING hash ((dict ->> 'mod') COLLATE pg_catalog."default");
CREATE INDEX idx_name ON main
USING btree ((dict ->> 'name') COLLATE pg_catalog."default");
CREATE INDEX idx_prop_unit ON main
USING btree (((dict ->> 'props')::double precision));
CREATE INDEX idx_record_date ON main
USING btree (json_to_timestamp((dict -> 'props') -> 'rec_date'));
CREATE INDEX idx_report_date ON main
USING btree (json_to_timestamp(dict -> 'report_date'));
CREATE INDEX idx_tempc ON main
USING btree (((dict ->> 'tempc')::double precision));
CREATE INDEX idx_type ON main
USING hash ((dict ->> 'type') COLLATE pg_catalog."default");
小测试
插入100万条数据,平均速度 4800行/秒。这是在有索引的情况下测试的。之所以在有索引下测试,为的是把索引时间算进去。** 有网上各种测试,为了显示优势,故意drop索引、插入、而后新建,得到的成绩很好,但不符合日常的场景。**
2.1.3 进行分区
由于我们要插入千万级的数据,一般来说,都会进行分区,以降低单个索引的深度与规模。这里以最常见的按登记日期分区来做。
分区方法,采用动态的触发器+表继承,可以参见《PostgreSQL 强大的多层表继承–及其在海量数据分类按月分区存储中的应用》,这里直接给出触发器函数。有了这个触发器,会自动新增子分区表,把向主表插入的记录引导到对应的字表。
(1) 触发器
CREATE FUNCTION public.on_insert_base_table_main()
RETURNS trigger
LANGUAGE 'plpgsql'
COST 100
VOLATILE NOT LEAKPROOF
AS $BODY$DECLARE
--Variable Hold subtable name
str_sub_tablename varchar;
--Variable Hold year\month info with timestamle
str_sub_report_time varchar;
str_sql_cmd varchar;
dtm_next_month timestamp with time zone;
str_sub_datebegin varchar;
str_sub_dateend varchar;
BEGIN
--The triggle func will be exectued only when BEFORE INSERT
IF TG_OP <> 'INSERT' OR TG_TABLE_NAME <>'main' OR TG_WHEN <> 'BEFORE' THEN
RETURN NULL;
END IF;
--Generate Table Name
str_sub_report_time = date_part('year',NEW.main_time)::varchar || '_' || date_part('month',NEW.main_time)::varchar;
str_sub_tablename = 'subtable_' || str_sub_report_time;
--Check if table not created
select * from pg_tables where schemaname = 'public' and tablename=str_sub_tablename
into str_sql_cmd;
IF NOT FOUND THEN
dtm_next_month = NEW.main_time + interval '1 month';
--Create table Cmd
str_sub_datebegin = date_part('year',NEW.main_time)::varchar ||'-'||date_part('month',NEW.main_time)::varchar || '-1 0:0:0';
str_sub_dateend = date_part('year',dtm_next_month)::varchar ||'-'||date_part('month',dtm_next_month)::varchar || '-1 0:0:0';
str_sql_cmd = '
CREATE TABLE '||str_sub_tablename||'
(
CONSTRAINT pk_'|| str_sub_tablename||' PRIMARY KEY (id ),
CONSTRAINT chk_'|| str_sub_tablename||'
CHECK(main_time >='''||
str_sub_datebegin||
'''::timestamp with time zone AND
main_time < '''||
str_sub_dateend||'''::timestamp with time zone
)
)
INHERITS (main)
WITH ( OIDS=FALSE );
ALTER TABLE '||str_sub_tablename||' OWNER TO postgres;
CREATE INDEX idx_'||str_sub_tablename||'_main_time ON '|| str_sub_tablename||' USING btree (main_time );
CREATE INDEX idx_'||str_sub_tablename||'_jsonb ON ' || str_sub_tablename || ' USING gin (dict jsonb_path_ops);
CREATE INDEX idx_'||str_sub_tablename||'_mod ON ' || str_sub_tablename || ' USING hash ((dict ->> ''mod''::text) COLLATE pg_catalog."default");
CREATE INDEX idx_'||str_sub_tablename||'_name ON ' || str_sub_tablename || ' USING btree ((dict ->> ''name''::text) COLLATE pg_catalog."default");
CREATE INDEX idx_'||str_sub_tablename||'_prop_unit ON ' || str_sub_tablename || ' USING btree (((dict -> ''props'' ->>''unit'')::double precision));
CREATE INDEX idx_'||str_sub_tablename||'_record_date ON ' || str_sub_tablename || ' USING btree (json_to_timestamp((dict -> ''props''::text) -> ''rec_date''::text));
CREATE INDEX idx_'||str_sub_tablename||'_report_date ON ' || str_sub_tablename || ' USING btree (json_to_timestamp(dict -> ''report_date''::text));
CREATE INDEX idx_'||str_sub_tablename||'_tempc ON ' || str_sub_tablename || ' USING btree (((dict ->> ''tempc''::text)::double precision));
CREATE INDEX idx_'||str_sub_tablename||'_type ON ' || str_sub_tablename || ' USING hash ((dict ->> ''type''::text) COLLATE pg_catalog."default");
';
EXECUTE str_sql_cmd;
END IF;
--insert Data
str_sql_cmd = 'INSERT INTO '||str_sub_tablename||'
( id,dict,main_time) VALUES (
nextval(''main_id_seq''),$1,$2);
';
EXECUTE str_sql_cmd USING
NEW.dict,
NEW.main_time;
--return null because main table does not really contain data
return NULL;
END;
$BODY$;
(2) 建立触发器关联
CREATE TRIGGER on_insert_main
BEFORE INSERT ON main FOR EACH ROW
EXECUTE PROCEDURE on_insert_base_table_main();
值得注意的是,我们对子表采用了日期范围判断,而不是日期提取年月的判断,以便允许查询优化器根据main_time分派查询任务。
2.2 插入1000万条数据
我们用Qt开发一个小程序,用于插入测试数据并计时性能。
2.2.1 Qt 插入计时程序
注意:请在UTF-8编码下存取代码,使用QtCreator编辑的代码一般满足UTF-8,用VC不行。Linux自行支持,妥妥的。
//main.cpp,Force UTF-8,Qt 控制台程序(黑屏幕)with sql
#include <QCoreApplication>
#include <QSqlDatabase>
#include <QSqlQuery>
#include <QSqlError>
#include <QJsonDocument>
#include <QJsonObject>
#include <QJsonArray>
#include <QTextStream>
#include <QVector>
#include <QDateTime>
#include <QRandomGenerator>
QString rand_string(int minn, int maxn)
{
static const char vc[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz ";
static const size_t length = sizeof(vc);
const int n = QRandomGenerator::global()->bounded(minn, maxn);
QString ret;
for (int i=0;i<n;++i)
{
const int idx = QRandomGenerator::global()->bounded(0, length-1);
ret += vc[idx];
}
return ret;
}
template<class T>
T rand_chioce(const QVector<T> & v)
{
const int length = v.size();
const int idx = QRandomGenerator::global()->bounded(0, length-1);
return v[idx];
}
int main(int argc, char *argv[])
{
QCoreApplication a(argc, argv);
QTextStream st(stdout);
QSqlDatabase db = QSqlDatabase::addDatabase("QPSQL");
db.setHostName("localhost");
db.setUserName("user");
db.setPassword("user");
db.setPort(5432);
db.setDatabaseName("test");
try {
if (db.open()==false)
throw db.lastError().text();
QSqlQuery qu(db);
const QVector<QString> v_types {QString("RM01"),QString("RM02"),
QString("RM03"),QString("RM041"),
QString("TM_187"),QString("TM82")};
const QVector<QString> v_recs {QString("模式1"),QString("模式1"),
QString("模式3"),QString("一般模式")};
const QVector<int> units {1,8478,22,449,494,10,3945,28378};
clock_t ckb = 0,cke = 0,ckall = 0;
double seconds = 0;
double items = 0;
for (int g=0;g<1000;++g)
{
QVariantList batchJson,batch_maintime;
ckb = clock();
if (db.transaction()==false)
throw db.lastError().text();
cke = clock();
ckall += cke - ckb;
for (int i=0;i<10000;++i,++items)
{
QDateTime dtm_main = QDateTime(QDate(2016,01,01),QTime(0,0,0))
.addSecs(QRandomGenerator::global()->bounded(60*60*24*365*2));
QJsonObject root;
root["name"] = rand_string(5,20);
root["type"] = rand_chioce(v_types);
root["uuid"] = rand_string(32,32);
root["mod"] = rand_chioce(v_recs);
root["report_date"] = dtm_main.addMSecs(
-QRandomGenerator::global()->bounded(100000000)+50000000
).toString("yyyy-MM-dd HH:mm:ss");
QJsonObject comment;
if (root["type"].toString().startsWith("TM"))
{
root["max_range"] = QRandomGenerator::global()->generateDouble();
root["max_path"] = QRandomGenerator::global()->bounded(100.0);
comment["tag"] = rand_string(16,32);
comment["magic"] = rand_string(1,8);
}
else
{
root["tempc"] = QRandomGenerator::global()->bounded(256.0);
comment["unit"] = rand_chioce(units);
comment["rec_date"] = dtm_main.addMSecs(
-QRandomGenerator::global()->bounded(100000000)-50000000
).toString("yyyy-MM-dd HH:mm:ss");
}
root["props"] = comment;
QJsonDocument doc(root);
batchJson<<QString::fromStdString(doc.toJson(QJsonDocument::Compact).toStdString());
batch_maintime<<dtm_main;
}//end prepare batch rows
ckb = clock();
if (qu.prepare("insert into main(dict,main_time) values (?,?);")==false)
throw qu.lastError().text();
cke = clock();
ckall += cke - ckb;
qu.addBindValue(batchJson);
qu.addBindValue(batch_maintime);
ckb = clock();
if (qu.execBatch()==false )
throw qu.lastError().text();
if (db.commit()==false)
throw db.lastError().text();
cke = clock();
ckall += cke - ckb;
seconds += ckall *1.0/ CLOCKS_PER_SEC;
ckall = 0;
st<< QString().sprintf("total: %.3lf K rows , speed: %.3lf K rows / sec\r"
,items/1000
,items/1000/seconds
);
st.flush();
}//end one group
} catch (QString str) {
db.rollback();
st<<str;
}
db.close();
a.processEvents();
return 0;
}
2.2.2 自动分区效果
可以随时查看自动分区的效果,并执行查询语句。插入后,数据库类似下图,很多月份表自动创建:
3 性能记录
3.1 平均插入速度
从空数据库开始,在有索引的情况下,进行测试。
- 初始插入时,速率:2504条/秒。
- 插入一千万条记录后,速率下降为:1519条/秒。
total: 10000.000 K rows , speed: 1.519 K rows / sec
若删除索引、约束、检查进行copy,速度可以提高5-10倍。但这种删除索引、约束进行copy的测试方法,不具备参考价值。因为大多数情况下库都不可能锁定很久,拆除索引的行为也是不可接受的。
3.2 磁盘消耗
1000万条记录,共占用10GB空间。包含数据3GB、索引7GB。
3.2 查询性能测试
我们通过一些语句来测试数据查询的性能, 用一个语句从1000万条记录中,联合多个条件查询:
select * from main where
main_time >= '2017-06-01' and
main_time <'2017-07-01' and
dict->>'mod' = '模式3' and
dict->>'type' = 'RM01' and
dict->>'name' like 'Q%' and
json_to_timestamp(dict->'report_date') >= '2017-06-13' and
json_to_timestamp(dict->'props'->'rec_date') <= '2017-07-12' and
(dict->'props'->>'unit')::double precision <20 ;
可以发现性能棒极了,耗时77毫秒,返回86条记录。原因是我们建立的索引发挥了作用,直接explain得到优化方案:
explain select * from main where
main_time >= '2017-06-01' and
main_time <'2017-07-01' and
dict->>'mod' = '模式3' and
dict->>'type' = 'RM01' and
dict->>'name' like 'Q%' and
json_to_timestamp(dict->'report_date') >= '2017-06-13' and
json_to_timestamp(dict->'props'->'rec_date') <= '2017-07-12' and
(dict->'props'->>'unit')::double precision <20 ;
得到:
QUERY PLAN
--------------------------------------------------------------------------
Append (cost=0.00..6416.47 rows=62 width=270)
-> Seq Scan on main (cost=0.00..0.00 rows=1 width=48)
Filter: ((main_time >= '2017-06-01 00:00:00+08'::timestamp with time zone) AND (main_time < '2017-07-01 00:00:00+08'::timestamp with time zone) AND ((dict ->> 'name'::text) ~~ 'Q%'::text) AND ((dict ->> 'mod'::text) = 'ģʽ3'::text) AND ((dict ->> 'type'::text) =
'RM01'::text) AND ((((dict -> 'props'::text) ->> 'unit'::text))::double precision < '20'::double precision) AND (json_to_timestamp((dict -> 'report_date'::text)) >= '2017-06-13 00:00:00+08'::timestamp with time zone) AND (json_to_timestamp(((dict -> 'props'::text) -> 'rec_date'::text)) <= '2017-07-12 00:00:00+08'::timestamp with time zone))
-> Bitmap Heap Scan on subtable_2017_6 (cost=4930.12..6416.16 rows=61 width=274)
Recheck Cond: (((((dict -> 'props'::text) ->> 'unit'::text))::double precision < '20'::double precision) AND ((dict ->> 'type'::text) = 'RM01'::text))
Filter: ((main_time >= '2017-06-01 00:00:00+08'::timestamp with time zone) AND (main_time < '2017-07-01 00:00:00+08'::timestamp with time zone) AND ((dict ->> 'name'::text) ~~ 'Q%'::text) AND ((dict ->> 'mod'::text) = 'ģʽ3'::text) AND (json_to_timestamp((dict -> 'report_date'::text)) >= '2017-06-13 00:00:00+08'::timestamp with time zone) AND (json_to_timestamp(((dict -> 'props'::text) -> 'rec_date'::text)) <= '2017-07-12 00:00:00+08'::timestamp with time zone))
-> BitmapAnd (cost=4930.12..4930.12 rows=366 width=0)
-> Bitmap Index Scan on idx_subtable_2017_6_name (cost=0.00..247.59 rows=7917 width=0)
Index Cond: (((dict ->> 'name'::text) >= 'Q'::text) AND ((dict ->> 'name'::text) < 'R'::text))
-> Bitmap Index Scan on idx_subtable_2017_6_prop_unit (cost=0.00..1997.78 rows=92981 width=0)
Index Cond: ((((dict -> 'props'::text) ->> 'unit'::text))::double precision < '20'::double precision)
-> Bitmap Index Scan on idx_subtable_2017_6_type (cost=0.00..2684.20 rows=82693 width=0)
Index Cond: ((dict ->> 'type'::text) = 'RM01'::text)
(13 rows)
放宽条件,计时执行:
select count(id),avg(id) from main where
main_time >= '2017-06-01' and
main_time <'2017-07-01' and
json_to_timestamp(dict->'report_date') >= '2017-06-13' and
json_to_timestamp(dict->'props'->'rec_date') <= '2017-07-12' and
(dict->'props'->>'unit')::double precision <20 ;
耗时430毫秒,返回
“56218” “7456551.642676722758”,同样explain得到索引情况:
QUERY PLAN
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Aggregate (cost=53357.88..53357.89 rows=1 width=40)
-> Append (cost=0.00..53136.71 rows=44233 width=8)
-> Seq Scan on main (cost=0.00..0.00 rows=1 width=8)
Filter: ((main_time >= '2017-06-01 00:00:00+08'::timestamp with time zone) AND (main_time < '2017-07-01 00:00:00+08'::timestamp with time zone) AND ((((dict -> 'props'::text) ->> 'unit'::text))::double precision < '20'::double precision) AND (json_to_timestamp((dict -> 'report_date'::text)) >= '2017-06-13 00:00:00+08'::timestamp with time zone) AND (json_to_timestamp(((dict -> 'props'::text) -> 'rec_date'::text)) <= '2017-07-12 00:00:00+08'::timestamp with time zone))
-> Bitmap Heap Scan on subtable_2017_6 (cost=7277.84..52915.54 rows=44232 width=8)
Recheck Cond: (((((dict -> 'props'::text) ->> 'unit'::text))::double precision < '20'::double precision) AND (json_to_timestamp((dict -> 'report_date'::text)) >= '2017-06-13 00:00:00+08'::timestamp with time zone))
Filter: ((main_time >= '2017-06-01 00:00:00+08'::timestamp with time zone) AND (main_time < '2017-07-01 00:00:00+08'::timestamp with time zone) AND (json_to_timestamp(((dict -> 'props'::text) -> 'rec_date'::text)) <= '2017-07-12 00:00:00+08'::timestamp with time zone))
-> BitmapAnd (cost=7277.84..7277.84 rows=55505 width=0)
-> Bitmap Index Scan on idx_subtable_2017_6_prop_unit (cost=0.00..1997.78 rows=92981 width=0)
Index Cond: ((((dict -> 'props'::text) ->> 'unit'::text))::double precision < '20'::double precision)
-> Bitmap Index Scan on idx_subtable_2017_6_report_date (cost=0.00..5257.70 rows=243370 width=0)
Index Cond: (json_to_timestamp((dict -> 'report_date'::text)) >= '2017-06-13 00:00:00+08'::timestamp with time zone)
(12 rows)
4 后记
PostgreSQL的JSONB字段,能够很好得满足关系数据库的灵活扩展、非关系数据存储检索功能。可以说,PG是解决异构数据关系存储的很好选择。