对象关系数据库-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是解决异构数据关系存储的很好选择。

发布了127 篇原创文章 · 获赞 330 · 访问量 48万+

猜你喜欢

转载自blog.csdn.net/goldenhawking/article/details/87946291
今日推荐