How to change primary key column types online, such as upgrading from INT to BIGINT, without affecting business operations?
Suppose you have a table in PostgreSQL where you initially chose an INT primary key without much thought, but now your business is thriving and you’re running out of sequence numbers, so you want to upgrade to BIGINT type. What should you do?
The obvious approach would be to directly use DDL to modify the type:
But this approach is not feasible for frequently accessed large production tables.
TL;DR
Let’s use pgbench’s built-in scenario as an example:
-- Goal: upgrade pgbench_accounts table regular column abalance type: INT -> BIGINT
-- Add new column: abalance_tmp BIGINT
ALTERTABLEpgbench_accountsADDCOLUMNabalance_tmpBIGINT;-- Create trigger function: keep new column data synchronized with old column
CREATEORREPLACEFUNCTIONpublic.sync_pgbench_accounts_abalance()RETURNSTRIGGERAS$$BEGINNEW.abalance_tmp=NEW.abalance;RETURNNEW;END;$$LANGUAGE'plpgsql';-- Complete full table update, see batch update method below
UPDATEpgbench_accountsSETabalance_tmp=abalance;-- don't run this on large tables
-- Create trigger
CREATETRIGGERtg_sync_pgbench_accounts_abalanceBEFOREINSERTORUPDATEONpgbench_accountsFOREACHROWEXECUTEFUNCTIONsync_pgbench_accounts_abalance();-- Complete column switch, data sync direction changes - old column data syncs with new column
BEGIN;LOCKTABLEpgbench_accountsINEXCLUSIVEMODE;ALTERTABLEpgbench_accountsDISABLETRIGGERtg_sync_pgbench_accounts_abalance;ALTERTABLEpgbench_accountsRENAMECOLUMNabalanceTOabalance_old;ALTERTABLEpgbench_accountsRENAMECOLUMNabalance_tmpTOabalance;ALTERTABLEpgbench_accountsRENAMECOLUMNabalance_oldTOabalance_tmp;ALTERTABLEpgbench_accountsENABLETRIGGERtg_sync_pgbench_accounts_abalance;COMMIT;-- Verify data integrity
SELECTcount(*)FROMpgbench_accountsWHEREabalance_new!=abalance;-- Clean up trigger and function
DROPFUNCTIONIFEXISTSsync_pgbench_accounts_abalance();DROPTRIGGERtg_sync_pgbench_accounts_abalanceONpgbench_accounts;
Sometimes you need to add a non-null column with default values to large tables. Therefore, you need to perform a full table update once, which can be done using the method below to split one huge update into 100 or more smaller updates.
Get primary key bucket information from statistics:
Generate SQL statements directly from statistical bucket information - change the SQL here to the update statement needed:
SELECT 'UPDATE signup_users SET app_type = '''' WHERE id BETWEEN '|| lo::TEXT ||' AND '|| hi::TEXT ||';'FROM ( SELECT lo, lead(lo) OVER (ORDER BY lo) as hi
FROM ( SELECT unnest(histogram_bounds::TEXT::BIGINT[]) lo
FROM pg_stats
WHERE tablename='signup_users' and attname='id' ORDER BY 1) t1
) t2;
Use shell script to print update statements directly:
DATNAME=""RELNAME="pgbench_accounts"IDENTITY="aid"UPDATE_CLAUSE="abalance_new = abalance"SQL=$(cat <<-EOF
SELECT 'UPDATE ${RELNAME} SET ${UPDATE_CLAUSE} WHERE ${IDENTITY} BETWEEN ' || lo::TEXT || ' AND ' || hi::TEXT || ';'
FROM (
SELECT lo, lead(lo) OVER (ORDER BY lo) as hi
FROM (
SELECT unnest(histogram_bounds::TEXT::BIGINT[]) lo
FROM pg_stats
WHERE tablename = '${RELNAME}'
and attname = '${IDENTITY}'
ORDER BY 1
) t1
) t2;
EOF)# echo $SQLpsql ${DATNAME} -qAXwtc "ANALYZE ${RELNAME};"psql ${DATNAME} -qAXwtc "${SQL}"
Handle boundary cases:
UPDATE signup_users SET app_type='' WHERE app_type !='';
Optimization and Improvement
Can also add transaction statements and sleep intervals:
DATNAME="test"RELNAME="pgbench_accounts"COLNAME="aid"UPDATE_CLAUSE="abalance_tmp = abalance"SLEEP_INTERVAL=0.1SQL=$(cat<<-EOFSELECT'BEGIN;UPDATE ${RELNAME} SET ${UPDATE_CLAUSE} WHERE ${COLNAME} BETWEEN '||lo::TEXT||' AND '||hi::TEXT||';COMMIT;SELECT pg_sleep(${SLEEP_INTERVAL});VACUUM ${RELNAME};'FROM(SELECTlo,lead(lo)OVER(ORDERBYlo)ashiFROM(SELECTunnest(histogram_bounds::TEXT::BIGINT[])loFROMpg_statsWHEREtablename='${RELNAME}'andattname='${COLNAME}'ORDERBY1)t1)t2;EOF)#echo$SQLpsql${DATNAME}-qAXwtc"ANALYZE ${RELNAME};"psql${DATNAME}-qAXwtc"${SQL}"