OLTP and OLAP
Introduction
- In
Postgres,OLTPandOLAPare two completely different database system design philosophies serving distinct purposes. - Postgres itself is an extremely powerful relational database management system (RDBMS). By default, it is highly optimized for
OLTPand thanks to its rich ecosystem of extensions,Postgrescan also fully supportOLAPworkloads.
Here is the detailed difference between these two concepts:
OLTP
Online Transaction Processing: focuses on fast, accurate and secure processing of a large number of continuous financial or operational transactions from end users.
- Data characteristics: Data changes constantly (
Insert, Update, Deletecontinuously). Query Pattern: Read/write statements acting on one or a few specific data rows (for example, finding info of a specific customer withWHERE id = 123).- Advantages
- Data integrity (
ACID):Postgresguarantees absolute transaction integrity without errors or data loss, thanks to its locking mechanisms andMVCC(Multi-Version Concurrency Control). Indexing: Supports multiple index types for rapid data lookups.
- Data integrity (
- Examples: E-commerce platforms, banking systems, Enterprise Resource Planning, etc.
OLAP
Online Analytical Processing: focuses on analyzing and aggregating historical data to generate reports and statistics that help businesses make decisions.
- Data characteristics: Historical data that rarely changes (mainly bulk
ReadandAppend/Insert). Extremely large data volumes (millions to billions of rows). Query Pattern: Heavy read queries that often scan multiple columns across entire tables to calculate aggregates (for example, calculating total revenue over several years usingSUM, GROUP BY, JOIN).- Processing Methods
- By default,
Postgresuses row-oriented storage, which is not optimal for heavyOLAPbecause when it needs to sum a single column, it still reads all other columns in that row intoRAM. - However, Postgres can be optimized for
OLAPthrough the following methods: - Using
Partitioningto split tables by time, region, etc. - Using
Materialized Viewto calculate periodic data statistics.
- By default,
- Real-world examples: Plotting quarterly revenue charts, analyzing customer shopping behavior to deploy targeted advertising campaigns.
Detail
- In practice,
OLTPandOLAPare two critical pillars in enterprise systems. One serves daily operations, while the other supports analytical insights for strategic decision-making. - Almost every major business domain must use both systems simultaneously, separating them across departments to solve different problems.
- Let us look at the Banking and Fintech domain, where the data accuracy (
ACID) ofOLTPis paramount and the historical data ofOLAPis utilized for risk management. OLTPfor transaction systems:- Handles QR code scans, interbank transfers and ATM withdrawals.
- Updates account balances immediately when transactions occur.
OLAPfor risk management & fraud detection:- The system runs in the background analyzing transaction histories. If it detects a transaction in City A followed by another transaction in City B just 5 minutes later, the system will flag this anomaly using
OLAPto alert that the account has unusual activity. - Compiles transaction amounts by category and timeframe.
- The system runs in the background analyzing transaction histories. If it detects a transaction in City A followed by another transaction in City B just 5 minutes later, the system will flag this anomaly using
Create the tables as follows:
CREATE TABLE users (
user_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
cif_number VARCHAR(10) UNIQUE NOT NULL,
full_name VARCHAR(100) NOT NULL,
phone_number VARCHAR(15) UNIQUE NOT NULL,
email VARCHAR(100) UNIQUE,
identity_card_num VARCHAR(20) UNIQUE NOT NULL,
status VARCHAR(20) DEFAULT 'ACTIVE',
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE accounts (
account_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(user_id) ON DELETE RESTRICT,
account_number VARCHAR(20) UNIQUE NOT NULL,
balance NUMERIC(15, 2) NOT NULL DEFAULT 0.00 CHECK (balance >= 0),
currency VARCHAR(3) DEFAULT 'USD',
account_type VARCHAR(20) DEFAULT 'PAYMENT',
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_user FOREIGN KEY(user_id) REFERENCES users(user_id)
);
CREATE TABLE qr_merchants (
merchant_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
merchant_name VARCHAR(150) NOT NULL,
merchant_code VARCHAR(50) UNIQUE NOT NULL,
bank_account_number VARCHAR(20) NOT NULL,
bank_code VARCHAR(20) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE transactions (
transaction_id UUID NOT NULL DEFAULT gen_random_uuid(),
reference_number VARCHAR(50) NOT NULL,
from_account_id UUID REFERENCES accounts(account_id),
to_account_id UUID REFERENCES accounts(account_id),
merchant_id UUID REFERENCES qr_merchants(merchant_id),
amount NUMERIC(15, 2) NOT NULL CHECK (amount > 0),
transaction_type VARCHAR(20) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
description TEXT,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP NOT NULL,
PRIMARY KEY (transaction_id, created_at),
CONSTRAINT uq_ref_num_partition UNIQUE (reference_number, created_at)
) PARTITION BY RANGE (created_at);
CREATE INDEX idx_accounts_number ON accounts(account_number);
CREATE INDEX idx_transactions_from_acc ON transactions(from_account_id);
CREATE INDEX idx_transactions_to_acc ON transactions(to_account_id);
CREATE INDEX idx_transactions_created_at ON transactions(created_at DESC);
- The
userstable holds bank customer profiles.user_id(UUID) is designed for the system and programming logic to link tables, optimize APIs and serve as the primary key.cif_number(Customer Information File) is the customer identifier, used by tellers, accountants and core business systems for quick lookups and reconciliations.
- The
accountstable stores account type information. - The
qr_merchantstable manages merchants supporting QR code scanning. - The
transactionstable logs specific details for each transaction.- The
reference_numberis a unique transaction code generated for interbank transfers. - Utilizing
PRIMARY KEY (transaction_id, created_at)withPARTITION BY RANGE (created_at)creates table partitions over time.
- The
Next, we create the partition tables split by quarters:
CREATE TABLE transactions_2026_q1 PARTITION OF transactions
FOR VALUES FROM ('2026-01-01 00:00:00+00') TO ('2026-04-01 00:00:00+00');
CREATE TABLE transactions_2026_q2 PARTITION OF transactions
FOR VALUES FROM ('2026-04-01 00:00:00+00') TO ('2026-07-01 00:00:00+00');
CREATE TABLE transactions_2026_q3 PARTITION OF transactions
FOR VALUES FROM ('2026-07-01 00:00:00+00') TO ('2026-10-01 00:00:00+00');
CREATE TABLE transactions_2026_q4 PARTITION OF transactions
FOR VALUES FROM ('2026-10-01 00:00:00+00') TO ('2027-01-01 00:00:00+00');
CREATE TABLE transactions_default PARTITION OF transactions DEFAULT;
OLTP
This section handles a money transfer transaction:
BEGIN;
SELECT account_id, balance
FROM accounts
WHERE account_id IN ('68d3f4e7-5ccf-4c44-8e31-03cc9285e063', 'ef7e9581-2387-492c-9955-2a064ba3051e')
FOR UPDATE;
UPDATE accounts SET balance = balance - 10 WHERE account_id = '68d3f4e7-5ccf-4c44-8e31-03cc9285e063';
UPDATE accounts SET balance = balance + 10 WHERE account_id = 'ef7e9581-2387-492c-9955-2a064ba3051e';
INSERT INTO transactions (from_account_id, to_account_id, amount, transaction_type, status, reference_number, merchant_id, description)
VALUES ('68d3f4e7-5ccf-4c44-8e31-03cc9285e063', 'ef7e9581-2387-492c-9955-2a064ba3051e', 500000, 'QR_PAY', 'SUCCESS', 'FT2026' || upper(substring(md5(random()::text) from 1 for 8)), '5bec8b62-72fd-4338-970a-590f8f522fed', 'Scan QR');
COMMIT;
The workflow is as follows:
- First, use the
Row level lockFOR UPDATEto lock the rows of both accounts. - Next, update the
balanceby deducting from the source account and adding to the target account. - Then, save the transaction history into the
transactionstable. - The
reference_numberfield is a unique identifier generated randomly for the transaction using the following functions:random()::text: Generates a random decimal number (e.g., 0.35418...) and casts it to text format.md5: Encrypts that string into a 32-characterMD5hash string (containing characters0-9anda-f). This cleans up decimal points and ensures high randomness.substring(... from 1 for 8): Extracts the first 8 characters from thatMD5string.upper: Converts those 8 characters to UPPERCASE.'FT2026' || ...: Used to designate a Financial Transaction; the||operator concatenates strings, prependingFT2026to the 8 uppercase characters.
- As you can see, I used
FOR UPDATEto lock both rows to prevent any otherTransaction (Tx)from deleting or modifying the data of these two accounts while this transaction is executing. - If another Tx attempts to update data in these two rows concurrently, it will be blocked and must wait until the first Tx finishes before it can execute.
- To deploy this in high-traffic systems with continuous high load, additional tools and strategies need to be integrated. However, this solution currently guarantees transaction accuracy and prevents Deadlocks.
If you lock only 1 account or use a lower-level lock (such as FOR SHARE), it can cause a Deadlock when 2 Txs run concurrently and block each other from transferring funds. When this happens, Postgres will throw an error and KILL one of the two Txs, as shown below:
-- Tx1
BEGIN;
SELECT account_id, balance
FROM accounts
WHERE account_id IN ('68d3f4e7-5ccf-4c44-8e31-03cc9285e063')
FOR UPDATE;
-- execute to here
UPDATE accounts SET balance = balance - 10 WHERE account_id = '68d3f4e7-5ccf-4c44-8e31-03cc9285e063';
-- after execute Tx2
UPDATE accounts SET balance = balance + 10 WHERE account_id = 'ef7e9581-2387-492c-9955-2a064ba3051e';
-- Tx2
BEGIN;
SELECT account_id, balance
FROM accounts
WHERE account_id IN ('ef7e9581-2387-492c-9955-2a064ba3051e')
FOR UPDATE;
-- execute to here
UPDATE accounts SET balance = balance - 10 WHERE account_id = 'ef7e9581-2387-492c-9955-2a064ba3051e';
-- after execute Tx1
UPDATE accounts SET balance = balance + 10 WHERE account_id = '68d3f4e7-5ccf-4c44-8e31-03cc9285e063';
If you create 2 Txs and execute them following the flow above, you will encounter a Deadlock and the following errors:
Here is a Deadlock caused by using FOR SHARE:
-- Tx1
BEGIN;
SELECT account_id, balance
FROM accounts
WHERE account_id IN ('68d3f4e7-5ccf-4c44-8e31-03cc9285e063', 'ef7e9581-2387-492c-9955-2a064ba3051e')
FOR SHARE;
-- after execute Tx2
UPDATE accounts SET balance = balance - 10 WHERE account_id = '68d3f4e7-5ccf-4c44-8e31-03cc9285e063';
-- Tx2
BEGIN;
SELECT account_id, balance
FROM accounts
WHERE account_id IN ('68d3f4e7-5ccf-4c44-8e31-03cc9285e063', 'ef7e9581-2387-492c-9955-2a064ba3051e')
FOR SHARE;
-- after execute Tx1
UPDATE accounts SET balance = balance - 10 WHERE account_id = 'ef7e9581-2387-492c-9955-2a064ba3051e';
And here is when you do not use any Row-level lock at all, it can still cause a Deadlock like this:
BEGIN;
SELECT account_id, balance
FROM accounts
WHERE account_id IN ('68d3f4e7-5ccf-4c44-8e31-03cc9285e063', 'ef7e9581-2387-492c-9955-2a064ba3051e');
UPDATE accounts SET balance = balance - 10 WHERE account_id = '68d3f4e7-5ccf-4c44-8e31-03cc9285e063';
UPDATE accounts SET balance = balance + 10 WHERE account_id = 'ef7e9581-2387-492c-9955-2a064ba3051e';
BEGIN;
SELECT account_id, balance
FROM accounts
WHERE account_id IN ('68d3f4e7-5ccf-4c44-8e31-03cc9285e063', 'ef7e9581-2387-492c-9955-2a064ba3051e');
UPDATE accounts SET balance = balance - 10 WHERE account_id = 'ef7e9581-2387-492c-9955-2a064ba3051e';
UPDATE accounts SET balance = balance + 10 WHERE account_id = '68d3f4e7-5ccf-4c44-8e31-03cc9285e063';
When each Transaction executes an UPDATE statement, it creates an ExclusiveLock as a Transaction Lock:
- When
Tx1runs, it locks the row with account68d3f4e7-5ccf-4c44-8e31-03cc9285e063. - When
Tx2runs, it locks the row with accountef7e9581-2387-492c-9955-2a064ba3051e. - Therefore, when
Tx1continues and attempts to update accountef7e9581-2387-492c-9955-2a064ba3051e, it is blocked because it needs to request aShareLockonTx2. - Similarly, when
Tx2runs its update on account68d3f4e7-5ccf-4c44-8e31-03cc9285e063, it is also blocked and needs to request aShareLockonTx1. - Postgres checks and detects the
Deadlock, then automaticallyKILLs oneTransaction.
Using PROCEDURE
You can also create a Procedure to integrate custom data validation logic as needed:
CREATE OR REPLACE PROCEDURE transfer_money(
p_from_account_id UUID,
p_to_account_id UUID,
p_amount NUMERIC,
p_merchant_id UUID,
p_description TEXT,
INOUT p_status TEXT DEFAULT NULL,
INOUT p_message TEXT DEFAULT NULL
)
LANGUAGE plpgsql
AS $$
DECLARE
v_first_lock UUID;
v_second_lock UUID;
v_from_balance NUMERIC;
v_ref_number TEXT;
BEGIN
IF p_from_account_id = p_to_account_id THEN
p_status := 'FAILED';
p_message := 'Source and destination accounts cannot be the same.';
RETURN;
END IF;
IF p_amount <= 0 THEN
p_status := 'FAILED';
p_message := 'Transfer amount must be greater than 0.';
RETURN;
END IF;
IF p_from_account_id < p_to_account_id THEN
v_first_lock := p_from_account_id;
v_second_lock := p_to_account_id;
ELSE
v_first_lock := p_to_account_id;
v_second_lock := p_from_account_id;
END IF;
PERFORM balance FROM accounts WHERE account_id = v_first_lock FOR UPDATE;
PERFORM balance FROM accounts WHERE account_id = v_second_lock FOR UPDATE;
SELECT balance INTO v_from_balance
FROM accounts
WHERE account_id = p_from_account_id;
IF v_from_balance IS NULL THEN
p_status := 'FAILED';
p_message := 'Source account does not exist.';
RETURN;
END IF;
IF v_from_balance < p_amount THEN
p_status := 'FAILED';
p_message := 'Insufficient balance to complete the transaction.';
RETURN;
END IF;
UPDATE accounts
SET balance = balance - p_amount
WHERE account_id = p_from_account_id;
UPDATE accounts
SET balance = balance + p_amount
WHERE account_id = p_to_account_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'Destination account does not exist.';
END IF;
v_ref_number := 'FT2026' || upper(substring(md5(random()::text) from 1 for 8));
INSERT INTO transactions (
from_account_id,
to_account_id,
amount,
transaction_type,
status,
reference_number,
merchant_id,
description
) VALUES (
p_from_account_id,
p_to_account_id,
p_amount,
'QR_PAY',
'SUCCESS',
v_ref_number,
p_merchant_id,
p_description
);
p_status := 'SUCCESS';
p_message := 'Transfer successful. Transaction Ref: ' || v_ref_number;
EXCEPTION
WHEN OTHERS THEN
p_status := 'ERROR';
p_message := 'System error: ' || SQLERRM;
END;
$$;
CALL transfer_money(
'68d3f4e7-5ccf-4c44-8e31-03cc9285e063',
'ef7e9581-2387-492c-9955-2a064ba3051e',
10,
'5bec8b62-72fd-4338-970a-590f8f522fed',
'Scan QR'
);
As you can see, the Procedure performs several checks, including:
- The source and destination accounts must exist and cannot be identical.
- The
balanceof the source account must be greater than 0 and cannot be less than the transfer amount. - I used the comparison
IF p_from_account_id < p_to_account_idto compare and order theaccount_idvalues so that the smalleraccount_idis always locked first in everyTransaction.- This technique is called
Resource Orderingand is used to completely eliminate the possibility of deadlocks. - Suppose you have 2 Txs running concurrently:
Tx1 (A->B)andTx2 (B->A). - If you lock using the conventional approach:
Tx1locks A then B, whileTx2locks B then A.- This leads to a
DeadlockwhenTx1locks A andTx2locks B. - Consequently,
Tx1cannot update the balance for B becauseTx2holds the lock on B.
- If you lock in deterministic order, both
Tx1andTx2will lock A first and then B. Whichever Tx runs second will wait sequentially for the first Tx to finish, avoiding anyDeadlock.
- This technique is called
- The
PERFORM balancestatement works similarly toSELECT:- A
SELECTquery returns a result set that we don't need in this case. - Using
PERFORMsimply executes the query without returning any values.
- A
- I validate the Destination Account at the very end to take advantage of the preceding
UPDATE accountsstatement, which returns the number of updated rows:- If the Destination Account does not exist, the updated row count is 0, triggering
IF NOT FOUND THENtoRAISE EXCEPTION. - If the Destination Account exists,
IF NOT FOUND THENis skipped, and the update completes correctly.
- If the Destination Account does not exist, the updated row count is 0, triggering
OLAP
Next, we create a MATERIALIZED VIEW:
CREATE MATERIALIZED VIEW mv_fraud_and_risk_analytics AS
SELECT
t.transaction_id,
t.reference_number,
t.amount,
t.transaction_type,
t.status,
t.created_at AS transaction_time,
EXTRACT(YEAR FROM t.created_at) AS trans_year,
EXTRACT(QUARTER FROM t.created_at) AS trans_quarter,
EXTRACT(MONTH FROM t.created_at) AS trans_month,
EXTRACT(DAY FROM t.created_at) AS trans_day,
fa.account_number AS source_account,
fu.full_name,
fu.cif_number,
ta.account_number AS destination_account,
tu.full_name AS receiver_name,
m.merchant_name,
m.merchant_code
FROM
transactions t
LEFT JOIN accounts fa ON t.from_account_id = fa.account_id
LEFT JOIN users fu ON fa.user_id = fu.user_id
LEFT JOIN accounts ta ON t.to_account_id = ta.account_id
LEFT JOIN users tu ON ta.user_id = tu.user_id
LEFT JOIN qr_merchants m ON t.merchant_id = m.merchant_id
WHERE t.status = 'SUCCESS';
CREATE UNIQUE INDEX idx_mv_fraud_unique_id ON mv_fraud_and_risk_analytics (transaction_id);
CREATE INDEX idx_mv_fraud_time_type ON mv_fraud_and_risk_analytics (trans_year, transaction_type, trans_month);
CREATE INDEX idx_mv_fraud_time_real ON mv_fraud_and_risk_analytics (transaction_time);
CREATE INDEX idx_mv_fraud_merchant ON mv_fraud_and_risk_analytics (merchant_code);
REFRESH MATERIALIZED VIEW CONCURRENTLY mv_fraud_and_risk_analytics;
- To run
REFRESH MATERIALIZED VIEW CONCURRENTLY, you must create a unique index likeidx_mv_fraud_unique_id. - This allows you to run the refresh command daily to update data and perform analytics without impacting active transactional operations.
Analytical use cases for statistics and risk prevention are as follows:
-- Query 1
SELECT
trans_month,
COUNT(transaction_id) AS total_transactions,
SUM(amount) AS total_volume
FROM
mv_fraud_and_risk_analytics
WHERE
trans_year = 2026
AND transaction_type = 'QR_PAY'
GROUP BY
trans_month
ORDER BY
trans_month;
-- Query 2
SELECT
cif_number,
full_name,
COUNT(transaction_id) AS total_transactions_in_hour,
SUM(amount) AS total_amount_transferred
FROM
mv_fraud_and_risk_analytics
WHERE
transaction_time > NOW() - INTERVAL '1 hour'
AND transaction_type IN ('TRANSFER', 'QR_PAY')
GROUP BY
cif_number, full_name
HAVING
COUNT(transaction_id) >= 50
ORDER BY total_transactions_in_hour DESC;
-- Query 3
SELECT
cif_number,
full_name,
SUM(amount) AS total_money_to_blacklist_merchants
FROM
mv_fraud_and_risk_analytics
WHERE
merchant_code IN ('MERCHANT_GAMBLING_01', 'MERCHANT_FRAUD_SCAM_02')
GROUP BY
cif_number, full_name;
- Query 1: Analyzes total transaction count and volume by month for the year.
- Query 2: Identifies users with 50 or more transactions within a single hour for analysis.
- Useful for detecting Smurfing behavior.
- Bad actors often avoid transferring large sums at once to evade detection, opting to break the funds down and execute numerous small transactions in a short window.
- Query 3: Analyzes transactions made by customers to blacklisted merchant accounts, such as fraud or gambling, recorded by the bank.
- Essential for credit risk assessment and flagging anomalous customers.
- The risk analysis system checks history to update credit scoring.
Happy coding!
Comments
Post a Comment