In business scenarios such as finance, logistics, and IoT, systems generate massive time series data — transaction records, trajectory data, monitoring logs. Analyzing this terabyte-scale data in real time is a common performance challenge. PolarDB for PostgreSQL uses features such as partitioned tables and hot-cold tiering to provide a cost-effective solution for storing massive time series data. On top of that, the columnstore index (IMCI) feature lets you perform real-time, high-performance analytics on massive time series data without complex data preprocessing, so that you can effectively unlock data value.
Solution overview
Workflow
-
Data writing: Your business application writes time series data (for example, transaction records) into a PolarDB for PostgreSQL cluster.
-
Columnstore index: Create a columnstore index on the base table. PolarDB for PostgreSQL automatically maintains the columnar data alongside the row store. Compared with row storage, column storage organizes data by column, which delivers a higher compression ratio and reads only the relevant columns during aggregate queries, reducing I/O.
-
Query acceleration: Analytical queries (such as candlestick aggregations) are routed to the columnstore index preferentially, either through the optimizer or through an explicit
Hint. The query engine uses columnar storage and parallel processing to scan and aggregate the data, and then returns the result.
Solution advantages
-
Simple to use: No business refactoring or complex ETL is required. Create a columnstore index on the base table and analytical queries are transparently accelerated.
-
Feature-rich: Natively supports partitioned tables and ships with a rich set of time series analysis functions such as
time_bucket,first, andlast, simplifying your SQL development.
Results
-
Data volume: 100 million rows spanning 2 days (approximately 50 million rows per day).
-
Candlestick aggregation query: 5 metrics within a specified time window — highest price, lowest price, opening price, closing price, and total transaction volume.
-
Columnstore index degree of parallelism: 8.
-
Query time (in seconds):
Scenario
Second-level candlestick aggregation
Minute-level candlestick aggregation
Hour-level candlestick aggregation
Day-level candlestick aggregation
Full data aggregation (100 million rows)
3.41
0.95
0.93
0.91
1-day data aggregation (~50 million rows)
1.88
0.82
0.81
0.76
12-hour data aggregation (~25 million rows)
0.89
0.55
0.53
N/A
1-hour data aggregation (~6 million rows)
0.41
0.39
0.37
N/A
Procedure
Step 1: Prepare the environment
-
Verify that your cluster version and configuration meet the following requirements:
-
Cluster versions:
-
PostgreSQL 14 (minor engine version 2.0.14.10.20.0 or later)
-
PostgreSQL 15 (minor engine version 2.0.15.15.7.0 or later)
-
PostgreSQL 16 (minor engine version 2.0.16.8.3.0 or later)
-
PostgreSQL 17 (minor engine version 2.0.17.7.5.0 or later)
NoteYou can view the minor engine version in the console or by running the
SHOW polardb_version;statement. If the minor engine version does not meet the requirements, upgrade the minor engine version。 -
-
The
wal_levelparameter must be set tological. This setting adds the required information for logical decoding to the write-ahead logging (WAL).NoteYou can set the wal_level parameter in the console. Modifying this parameter restarts the cluster. Plan your business operations accordingly and proceed with caution.
-
The source table must have a primary key, and the primary key column must be included when you create the columnstore index. Using a
SERIALorBIGSERIALdata type for the primary key is recommended, as it significantly improves data synchronization efficiency. -
You can create only one columnstore index for each table.
-
-
Enable the columnstore index feature.
The method for enabling IMCI varies depending on the minor engine version of your PolarDB for PostgreSQL cluster:
Step 2: Prepare the data
-
This solution uses a transaction records table and simulates generating 100 million transaction records spanning approximately 2 days. Trading hours are 08:00–16:00 each day, producing about 40 million records per day.
-- Transaction records table CREATE TABLE market_trades ( trade_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, -- Auto-increment primary key trade_ts TIMESTAMP, -- Transaction timestamp market_id VARCHAR, -- Market ID price DECIMAL, -- Transaction price amount DECIMAL, -- Transaction amount insert_ts TIMESTAMP -- System write timestamp ); INSERT INTO market_trades(trade_ts, market_id, price, amount, insert_ts) SELECT trade_ts, market_id, price, amount, trade_ts + (random() * 500)::INT * INTERVAL '1 millisecond' AS insert_ts FROM ( -- ======================== -- 1. Day 1 peak: 2025-06-01 8:00 - 16:00, 40 million rows -- ======================== SELECT '2025-06-01 08:00:00'::TIMESTAMP + (random() * 28800)::INT * INTERVAL '1 second' + -- 28800 seconds = 8 hours (random() * 1000)::INT * INTERVAL '1 millisecond' AS trade_ts, CASE WHEN random() < 0.6 THEN 'BTC-USDT' ELSE 'ETH-USDT' END AS market_id, CASE WHEN random() < 0.6 THEN 30000 + (random() * 1000) ELSE 2000 + (random() * 100) END AS price, random() * 10 + 0.1 AS amount FROM generate_series(1, 40000000) UNION ALL -- ======================== -- 2. Day 1 off-peak: 2025-06-01 16:00 - 2025-06-02 08:00, 10 million rows -- ======================== SELECT CASE WHEN random() < 0.5 THEN -- 16:00 - 24:00 '2025-06-01 16:00:00'::TIMESTAMP + (random() * 28800)::INT * INTERVAL '1 second' ELSE -- 00:00 - 08:00 (early morning of day 2) '2025-06-02 00:00:00'::TIMESTAMP + (random() * 28800)::INT * INTERVAL '1 second' END + (random() * 1000)::INT * INTERVAL '1 millisecond' AS trade_ts, CASE WHEN random() < 0.6 THEN 'BTC-USDT' ELSE 'ETH-USDT' END AS market_id, CASE WHEN random() < 0.6 THEN 30000 + (random() * 1000) ELSE 2000 + (random() * 100) END AS price, random() * 10 + 0.1 AS amount FROM generate_series(1, 10000000) UNION ALL -- ======================== -- 3. Day 2 peak: 2025-06-02 8:00 - 16:00, 40 million rows -- ======================== SELECT '2025-06-02 08:00:00'::TIMESTAMP + (random() * 28800)::INT * INTERVAL '1 second' + (random() * 1000)::INT * INTERVAL '1 millisecond' AS trade_ts, CASE WHEN random() < 0.6 THEN 'BTC-USDT' ELSE 'ETH-USDT' END AS market_id, CASE WHEN random() < 0.6 THEN 30000 + (random() * 1000) ELSE 2000 + (random() * 100) END AS price, random() * 10 + 0.1 AS amount FROM generate_series(1, 40000000) UNION ALL -- ======================== -- 4. Day 2 off-peak: 2025-06-02 16:00 - 2025-06-03 08:00, 10 million rows -- ======================== SELECT CASE WHEN random() < 0.5 THEN -- 16:00 - 24:00 '2025-06-02 16:00:00'::TIMESTAMP + (random() * 28800)::INT * INTERVAL '1 second' ELSE -- 00:00 - 08:00 (early morning of day 3) '2025-06-03 00:00:00'::TIMESTAMP + (random() * 28800)::INT * INTERVAL '1 second' END + (random() * 1000)::INT * INTERVAL '1 millisecond' AS trade_ts, CASE WHEN random() < 0.6 THEN 'BTC-USDT' ELSE 'ETH-USDT' END AS market_id, CASE WHEN random() < 0.6 THEN 30000 + (random() * 1000) ELSE 2000 + (random() * 100) END AS price, random() * 10 + 0.1 AS amount FROM generate_series(1, 10000000) ) AS data; -
Create a columnstore index on the transaction records table.
CREATE INDEX idx_csi_market_trades ON market_trades USING CSI;
Step 3: Run candlestick aggregation queries
Use case: compute candlesticks over fixed time windows.
Application example: compute per-second highest price, lowest price, opening price, closing price, and total transaction volume.
The following examples compute per-second, per-minute, per-hour, and per-day candlestick data respectively.
Second-level candlestick aggregation
-- Second-level candlestick aggregation
/*+ SET (polar_csi.enable_query on) */
SELECT
time_bucket('1 second', trade_ts) AS candle_ts, -- Data within 1 second
market_id,
MIN(price) AS low, -- Lowest price within 1 second
MAX(price) AS high, -- Highest price within 1 second
FIRST(price ORDER BY trade_ts) AS open, -- Opening price within 1 second
LAST(price ORDER BY trade_ts) AS close, -- Closing price within 1 second
SUM(amount) AS vol -- Total transaction volume within 1 second
FROM market_trades
WHERE trade_ts >= '2025-06-01 00:00:00' AND trade_ts <= '2025-06-02 00:00:00'
GROUP BY candle_ts, market_id
ORDER BY candle_ts, market_id;
Minute-level candlestick aggregation
-- Minute-level candlestick aggregation
/*+ SET (polar_csi.enable_query on) */
SELECT
time_bucket('1 minute', trade_ts) AS candle_ts, -- Data within 1 minute
market_id,
MIN(price) AS low, -- Lowest price within 1 minute
MAX(price) AS high, -- Highest price within 1 minute
FIRST(price ORDER BY trade_ts) AS open, -- Opening price within 1 minute
LAST(price ORDER BY trade_ts) AS close, -- Closing price within 1 minute
SUM(amount) AS vol -- Total transaction volume within 1 minute
FROM market_trades
WHERE trade_ts >= '2025-06-01 00:00:00' AND trade_ts <= '2025-06-02 00:00:00'
GROUP BY candle_ts, market_id
ORDER BY candle_ts, market_id;
Hour-level candlestick aggregation
-- Hour-level candlestick aggregation
/*+ SET (polar_csi.enable_query on) */
SELECT
time_bucket('1 hour', trade_ts) AS candle_ts, -- Data within 1 hour
market_id,
MIN(price) AS low, -- Lowest price within 1 hour
MAX(price) AS high, -- Highest price within 1 hour
FIRST(price ORDER BY trade_ts) AS open, -- Opening price within 1 hour
LAST(price ORDER BY trade_ts) AS close, -- Closing price within 1 hour
SUM(amount) AS vol -- Total transaction volume within 1 hour
FROM market_trades
WHERE trade_ts >= '2025-06-01 00:00:00' AND trade_ts <= '2025-06-02 00:00:00'
GROUP BY candle_ts, market_id
ORDER BY candle_ts, market_id;
Day-level candlestick aggregation
-- Day-level candlestick aggregation
/*+ SET (polar_csi.enable_query on) */
SELECT
time_bucket('1 day', trade_ts) AS candle_ts, -- Data within 1 day
market_id,
MIN(price) AS low, -- Lowest price within 1 day
MAX(price) AS high, -- Highest price within 1 day
FIRST(price ORDER BY trade_ts) AS open, -- Opening price within 1 day
LAST(price ORDER BY trade_ts) AS close, -- Closing price within 1 day
SUM(amount) AS vol -- Total transaction volume within 1 day
FROM market_trades
WHERE trade_ts >= '2025-06-01 00:00:00' AND trade_ts <= '2025-06-02 00:00:00'
GROUP BY candle_ts, market_id
ORDER BY candle_ts, market_id;
SQL notes
-
/*+ SET (polar_csi.enable_query on) */: forces the query to use the columnstore index execution plan. In some scenarios, the optimizer may incorrectly estimate that the row store is more efficient. Use thisHintto ensure the query uses the columnstore path. -
time_bucket(bucket_width, ts): a function provided by Time Series Database that groups the timestamptsby the specified intervalbucket_width.