-- Author: fruit.com.cn
-- FruitShorts 短剧服务 · 分区维护(PostgreSQL 18)
--
-- 为什么要单独一个脚本:
-- 声明式分区只定义了"分片规则",不会自动建分区。如果只建了 default 分区,
-- 所有数据都会堆在 default 里,分区等于白做,而且越晚拆越贵。
-- 所以这里提供三个东西:建分区的函数、清理过期分区的函数、以及现有库的转换脚本。
--
-- 应用侧也有兜底:com.fruitshorts.framework.partition.PartitionMaintainer
-- 启动时 + 每天凌晨各跑一次 ensure,保证不需要人工介入也不会漏建月份。
--
-- 幂等:可重复执行。
-- ============================================================
-- 一、分区管理函数
-- ============================================================
/**
* 为按月分区的表补齐「从 p_from 所在月开始、连续 p_months 个月」的分区。
* 分区命名
_YYYYMM,已存在则跳过,返回实际新建的个数。
*/
CREATE OR REPLACE FUNCTION ensure_monthly_partitions(
p_table text,
p_from date DEFAULT CURRENT_DATE,
p_months int DEFAULT 3
) RETURNS int AS $$
DECLARE
i int;
v_start date;
v_end date;
v_name text;
v_created int := 0;
BEGIN
IF NOT EXISTS (SELECT 1 FROM pg_partitioned_table pt
JOIN pg_class c ON c.oid = pt.partrelid
WHERE c.relname = p_table) THEN
RAISE EXCEPTION '% 不是分区表,无法维护分区', p_table;
END IF;
FOR i IN 0 .. (p_months - 1) LOOP
v_start := (date_trunc('month', p_from) + (i || ' month')::interval)::date;
v_end := (v_start + interval '1 month')::date;
v_name := format('%s_%s', p_table, to_char(v_start, 'YYYYMM'));
IF NOT EXISTS (SELECT 1 FROM pg_class WHERE relname = v_name) THEN
EXECUTE format('CREATE TABLE %I PARTITION OF %I FOR VALUES FROM (%L) TO (%L)',
v_name, p_table, v_start, v_end);
v_created := v_created + 1;
END IF;
END LOOP;
RETURN v_created;
END;
$$ LANGUAGE plpgsql;
/**
* 删除 p_before 之前的月分区,返回删除个数。
* 只认 _YYYYMM 这种命名的分区,default 分区不动。
*/
CREATE OR REPLACE FUNCTION drop_partitions_before(
p_table text,
p_before date
) RETURNS int AS $$
DECLARE
rec record;
dropped int := 0;
cutoff text := to_char(p_before, 'YYYYMM');
BEGIN
FOR rec IN
SELECT c.relname
FROM pg_inherits i
JOIN pg_class c ON c.oid = i.inhrelid
JOIN pg_class p ON p.oid = i.inhparent
WHERE p.relname = p_table
AND c.relname ~ ('^' || p_table || '_[0-9]{6}$')
LOOP
IF substring(rec.relname from '([0-9]{6})$') < cutoff THEN
EXECUTE format('DROP TABLE IF EXISTS %I', rec.relname);
dropped := dropped + 1;
END IF;
END LOOP;
RETURN dropped;
END;
$$ LANGUAGE plpgsql;
-- ============================================================
-- 二、把两张高写入表转成按月分区(只处理空表,有数据直接中止)
-- ============================================================
DO $$
BEGIN
-- playback_ticket:每次签发播放票据一行,1 万并发下量级最大
IF EXISTS (SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'playback_ticket')
AND NOT EXISTS (SELECT 1 FROM pg_partitioned_table pt
JOIN pg_class c ON c.oid = pt.partrelid
WHERE c.relname = 'playback_ticket') THEN
IF (SELECT count(*) FROM playback_ticket) > 0 THEN
RAISE EXCEPTION 'playback_ticket 已有数据,请先归档再转分区(勿直接删表)';
END IF;
DROP TABLE playback_ticket;
END IF;
CREATE TABLE IF NOT EXISTS playback_ticket (
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
ticket_no VARCHAR(40) NOT NULL,
user_id BIGINT NOT NULL DEFAULT 0,
drama_id BIGINT NOT NULL,
episode_id BIGINT NOT NULL,
quality VARCHAR(16) NOT NULL DEFAULT 'AUTO',
play_url VARCHAR(1024) NOT NULL DEFAULT '',
expire_at TIMESTAMP(3) NOT NULL,
ip VARCHAR(64) NOT NULL DEFAULT '',
device_id VARCHAR(128) NOT NULL DEFAULT '',
status VARCHAR(16) NOT NULL DEFAULT 'ISSUED',
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);
-- 分区表的唯一索引必须包含分区键,所以带上 created_at;
-- ticket_no 是 UUID,实际唯一性不受影响。
CREATE UNIQUE INDEX IF NOT EXISTS uk_playback_ticket_no
ON playback_ticket (ticket_no, created_at);
CREATE INDEX IF NOT EXISTS idx_playback_ticket_user
ON playback_ticket (user_id, created_at DESC);
-- order_event:订单状态流转流水,支付争议的证据链
IF EXISTS (SELECT 1 FROM information_schema.tables
WHERE table_schema = 'public' AND table_name = 'order_event')
AND NOT EXISTS (SELECT 1 FROM pg_partitioned_table pt
JOIN pg_class c ON c.oid = pt.partrelid
WHERE c.relname = 'order_event') THEN
IF (SELECT count(*) FROM order_event) > 0 THEN
RAISE EXCEPTION 'order_event 已有数据,请先归档再转分区(勿直接删表)';
END IF;
DROP TABLE order_event;
END IF;
CREATE TABLE IF NOT EXISTS order_event (
id BIGINT GENERATED BY DEFAULT AS IDENTITY,
order_no VARCHAR(40) NOT NULL,
event VARCHAR(32) NOT NULL,
from_status VARCHAR(16) NOT NULL DEFAULT '',
to_status VARCHAR(16) NOT NULL DEFAULT '',
source VARCHAR(16) NOT NULL DEFAULT 'SERVER',
payload TEXT,
operator_id BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (created_at);
CREATE INDEX IF NOT EXISTS idx_order_event_no
ON order_event (order_no, created_at);
END $$;
-- ============================================================
-- 三、预建当前月 + 未来 3 个月的分区
-- ============================================================
SELECT ensure_monthly_partitions('watch_history', CURRENT_DATE, 4);
SELECT ensure_monthly_partitions('playback_ticket', CURRENT_DATE, 4);
SELECT ensure_monthly_partitions('order_event', CURRENT_DATE, 4);
-- ============================================================
-- 四、保留策略(默认只演示,不自动删;上线后由应用定时任务按配置执行)
-- ============================================================
-- 需要手工清理时执行:
-- SELECT drop_partitions_before('watch_history', CURRENT_DATE - INTERVAL '24 months');
-- SELECT drop_partitions_before('playback_ticket', CURRENT_DATE - INTERVAL '6 months');
-- SELECT drop_partitions_before('order_event', CURRENT_DATE - INTERVAL '36 months');