Item/instance database performance suggestions
High level suggestions
Partitioning
A table can be partitioned along one or more columns with a variety of strategies. A good rule of thumb is to target approximately 1-2M rows per partition. Ideally there will be roughly an even distribution between tables. For evenly partitioning a table with randomly distributed keys, the ideal strategy is to use hash partitioning.
Hash partitioning is implemented in terms of a modulus and a remainder. A simple scalable strategy for managing partitions is to base the modulus on a power of 2 and create a partition for each value less than the modulus.
This allows for a reasonable scaling potentially up to billions of rows and simplifies the process of scaling the number of partitions up or down. Ideally, there would be an independent mechanism for configuring the exponent for each tenant and possibly each table.
For example, the trivial case is if the exponent of the modulus for an instance table is 0, it will have a modulus of 1, which means there will be a single partition with a remainder of 0
Table creation
The DDL for the `instance` table will be consistent regardless of partition count and might look like:
create table instance ( id uuid not null primary key, jsonb jsonb not null, creation_date timestamp, created_by text, instancestatusid uuid constraint instancestatusid_instance_status_fkey references instance_status, modeofissuanceid uuid constraint modeofissuanceid_mode_of_issuance_fkey references mode_of_issuance, instancetypeid uuid constraint instancetypeid_instance_type_fkey references instance_type ) partition by hash(id);
As a convention the name of the partition might be of the form
<parent_table_name>_<modulus>_<remainder.
For the trivial case of modulus 0, the partition would look like
create table instance_1_0 partition of instance for values with (modulus 1, remainder 0);
In the sample dataset, there are a number of tables that would benefit from partitioning. For example
- item has 23901856 rows
- instance has 22618027 rows
- holdings_record has 23901856 rows
Based on a target of 1-2M rows per partition table and a power of 2 partitioning strategy, the ideal number of partitions for each table would be based on an exponent of 4, yielding 16 partitions. This would result in ~1.4M rows for each partition table for all three tables.
Presumably, these 3 tables are joined together frequently, so it makes sense to partition along the same value in order to allow the planner to pick the correct partitions on common joins. Unfortunately, there is not a common value between these three tables.
As the holdings_record and item derive from instance, it may make sense to use instance.id as the partitioning key. This would mean partitioning by holdings_record.instanceid. In order to partition item it would make sense to create a new (redundant) foreign key column to the instance table and partition on that.
Indexes and constraints
Any indexes and constraints created on the parent table will automatically be applied to the partitions, so they can be created as normal. Re-indexing or analyzing is handled automatically.
Resizing
If the size of the partition grows beyond a reasonable size, it is possible to resize it up to the next exponent.
alter table instance detach partition instance_1_0; create table instance_2_0 partition of instance for values with (modulus 2, remainder 0); create table instance_2_1 partition of instance for values with (modulus 2, remainder 1); insert into instance select * from instance_1_0; drop table instance_1_0;
Resizing is potentially complicated by the existence of foreign keys pointing to the id. Ideally those tables would also be partitioned by the same criteria. In that case, the corresponding partions on the dependent table
should be detached before the instance table. In the event that the dependent tables are not partitioned, the corresponding constraints would need to be dropped and re-created after the fact.
Ideally, tables with those dependent relationships would also be partitioned based on the same exponent, as the planner will only parallelize matching partionwise joins in Postgresql 12. This is improved in PG 13.
Partitions could theoretically be migrated one by one if each dependent table only has a single foreign key pointing at the parent table. In the case of multiple foreign keys, they would presumably point to different partitions.
Unfortunately, this appears to be the case with the instance_relationship and preceding_suceeding_title tables in relation to instance.
Partitions can themselves be further partitioned, so this might be solved by, for example, partitioning instance_relationship on superinstanceid then on subinstanceid. I'm not sure how the planner would behave in this case.
Partition Formula
For partitioning schemes based on hashed UUIDs, the destination partition can be determined with
select mod(<modulus> + mod(uuid_hash(<uuid>), <modulus>), <modulus>);
Relational Refactor
Break the existing JSONB up into relational columns and tables. Any indexed property should presumably be broken out from the JSONB document. For example, metadata.updatedDate would presumably be a column of type datetime,
while identifiers would migrate to a join table and possibly a separate identifiers table.
Lookup tables
There are a number of lookup tables that index values embedded in the JSONB document. It would make sense to break these values into columns on the table and create hash indexes presuming that the values would be looked up by equivalence. Create lookup functions for lookup values with the correct markers for query parallelism to avoid a join or subquery.
Separate indexed JSONB values
There are a number of gin indexes on JSONB properties that presumably are intended to index an array. These would be candidates for breaking out into separate tables.
Functions
Consider adding functions to convert rows to and from JSONB.
Partial indexes
Modify any existing indexes on JSONB values that do not apply to every row in the table to be partial indexes. This should reduce the overhead of those indexes.
The percentage of rows in a particular table that are included in each index for a particular schema can be determined with the following query (in this case for instance indexes in fs09000000_mod_inventory_storage):
select tablename, 100 - (null_frac * 100) as indexed_percentage from pg_stats where schemaname='fs09000000_mod_inventory_storage' and tablename like 'instance%idx%' order by null_frac desc;
The following results were run on a single tenant in a test database, so mileage will vary, but should be illustrative of the potential for partial indexing.
instance
tablename | indexed_percentage |
---|---|
instance_matchkey_idx_unique | 0 |
instance_invalidisbn_idx_ft | 0 |
instance_isbn_idx_ft | 0 |
instance_staffsuppress_idx | 0.043332576751708984 |
instance_discoverysuppress_idx | 99.80666666524485 |
instance_indextitle_idx | 99.96333333256189 |
instance_publication_idx | 100 |
instance_contributors_idx | 100 |
instance_title_idx | 100 |
instance_identifiers_idx_ft | 100 |
instance_hrid_idx_unique | 100 |
instance_pmh_metadata_updateddate_idx | 100 |
instance_identifiers_idx_gin | 100 |
instance_source_idx | 100 |
instance_statisticalcodeids_idx | 100 |
instance_metadata_updateddate_idx | 100 |
- instance_matchkey_idx_unique should be a partial index or deleted
- instance_invalidisbn_idx_ft should be a partial index or deleted
- instance_isbn_idx_ft should be a partial index or deleted
- instance_staffsuppress_idx should be either broken into a separate table or be a partial index
holdings_record
tablename | index_percentage |
---|---|
holdings_record_discoverysuppress_idx | 0.11333227157593 |
holdings_record_callnumber_idx | 34.40444604812622 |
holdings_record_hrid_idx_unique | 100 |
holdings_record_callnumberandsuffix_idx | 100 |
holdings_record_fullcallnumber_idx | 100 |
holdings_record_pmh_metadata_updateddate_idx | 100 |
- holdings_record_discoverysuppress_idx should either have a partial index or be broken out into a separate table
- holdings_record_callnumber_idx should be a partial index
item
tablename | indexed_percentage |
---|---|
item_discoverysuppress_idx | 0 |
item_purchaseorderlineidentifier_idx | 0.2966642379760742 |
item_accessionnumber_idx | 8.78000259399414 |
item_barcode_idx_unique | 38.066667318344116 |
item_effectivecallnumbercomponents_callnumber_idx | 39.55000042915344 |
item_pmh_metadata_updateddate_idx | 100 |
item_hrid_idx_unique | 100 |
item_fullcallnumber_idx | 100 |
item_callnumberandsuffix_idx | 100 |
item_status_name_idx | 100 |
- item_discoverysuppress_idx should be a partial index or deleted.
- item_purchaseorderlineidentifier_idx should either have a partial index or be broken out into a separate table
- item_accessionnumber_idx should be a partial index
- item_effectivecallnumbercomponents_callnumber_idx should be a partial index
General suggestions
Replace deletes with a tombstone status
Deletes are very expensive, so it may make sense to replace deletes with a mechanism for indicating whether a particular entity is active or not. Some approaches to this may include
- a boolean is_active flag on each table
- a status column that includes a tombstone as well as statuses already set via the JSONB document. If the set of statuses are well defined, they could be represented by an enumerated type.
- A reference to an event log. This might consist of a sequential ID or a timestamp, with a minimum valid event tracked somewhere. This allows for expiring old entities on bulk import and also allows for tombstoning by explicitly setting this value to null on delete.
There might be a view for a table implementing this strategy that filters out inactive entities. Actual deletes might occur in a periodic maintenance window.
Of the noted options, the status column option seems to fit this data model the best.
Function Markers
In order to encourage query parallelism and improved planning, mark any user defined functions that are called by slow queries with the
appropriate parallel and volatility markers.
Any read-only functions could presumably be marked with parallel safe and stable or immutable.
Triggers
There are a large number of triggers on these large tables. Some of them appear to be functionally redundant. Consider reducing these.