Faiz Faiz - 7 months ago 25
SQL Question

Inserting running total

Problem to insert running total in MySQL transactional database. need your help for solutions and opinion. Table structure of my table is,

create table `wtacct` (
`ID` int(11) NOT NULL AUTO_INCREMENT,
`ACCOUNT_NO` varchar(16),
`AMOUNT` float(16,2),
`BALANCE` float(16,2)
);


[Please note other fields have been removed to make it simple example]

I am doing Transaction as,


  • Dr 10 USD from account 1001 and

  • Cr 10 USD to account 2002



Insert query

INSERT INTO wtacct (ID, ACCOUNT_NO, AMOUNT, BALANCE)
VALUES ('', 1001, -10, 100), ('', 2002, 10, 5000);


I want the Balance as,

BALANCE of Account no 1001 = Last transaction Balance of account 1001 - 10.

My solutions and limitations



Solution 1



In insert statement put sub query in balance field:

select balance from wtacct where account_no=1001 and id in(select max(id) from wtacct where account_no=1001)


Limitation: Mysql does not support same table select query (wtacct) where inserting the data (wtacct).

Solution 2



Using insert into select statement

insert into wtacct select '' ID, 1001 ACCOUNT_NO, -10 AMOUNT, (BALANCE-10) BALANCE where account_no=1001 and id in(select max(id) from wtacct where account_no=1001)


Limitation: For first transaction there is no record in wtacct for the account 1001 so select query will not return any record for first transaction.

Solution 3



Taking balance in variable and use it in insert statement.

select @balance1001 :=balance from wtacct
where account_no=1001 and id in(select max(id) from wtacct where account_no=1001)

select @balance2002 :=balance from wtacct
where account_no=2002 and id in(select max(id) from wtacct where account_no=2002)

INSERT INTO wtacct (ID, ACCOUNT_NO, AMOUNT, BALANCE)
VALUES ('', 1001, -10, @balance1001-10), ('', 2002, 10, @balance2002+10);


Limitation: there is a chance to be change the balance in time between select and insert query execution. also its costly, 3 query execution required.

Solution 4



Insert and then update Balance

INSERT INTO wtacct (ID, ACCOUNT_NO, AMOUNT, BALANCE)
VALUES ('', 1001, -10, 0);

UPDATE wtacct set balance = (ifnull(Select balance from wtacct where account_no=1001 and id in(select max(id) from wtacct where id <last_insert_id() and account_no=1001),0) -10)
where id =last_insert_id() and account_no=1001

........


Limitation: query is costly. its required 4 (two insert and 2 update) query execution. note last_insert_id() is php function

Solution 5



Using a trigger on insert statement. In the trigger, the balance will be updated calculating last transaction value and insert amount.

Limitation: Trigger not support transaction behavior and may fail.

Please give your solution and opinion on the above solutions. Please note in the above example their may be some syntax error/error. Please ignore them.

Answer

A big limitation I didn't see listed is a potential race condition, where two rows are being inserted into the table at the same time. There's a chance that the two inserts will both get the current "balance" from the same previous row.

One question: do you also have a separate "current balance" table that keeps a single value of the current "balance" for each account? Or are you only relying on the "balance" from the previous transaction.

Personally, I would track the current balance on a separate "account balance" table. And I would use BEFORE INSERT/UPDATE triggers to maintain the value in that row, and use that to return the current balance for the account.

For example, I would define a trigger like this which gets fired when a row is inserted into `wtacct` table:

CREATE TRIGGER wtacct_bi
BEFORE INSERT ON wtacct
FOR EACH ROW
BEGIN
  IF NEW.amount IS NULL THEN
     SET NEW.amount = 0;
  END IF
  ;
  UPDATE acct a
     SET a.balance = (@new_balance := a.balance + NEW.amount)
   WHERE a.account_no = NEW.account_no
  ; 
  SET NEW.balance = @new_balance
  ;
END$$

The setup for that trigger...

CREATE TABLE acct 
( account_no  VARCHAR(16) NOT NULL PRIMARY KEY
, balance     DECIMAL(20,2) NOT NULL DEFAULT 0
) ENGINE=InnoDB
;

CREATE TABLE wtacct 
( id          BIGINT NOT NULL PRIMARY KEY AUTO_INCREMENT
, account_no  VARCHAR(16) NOT NULL COMMENT 'FK ref acct.account_no'
, amount      DECIMAL(20,2) NOT NULL
, balance     DECIMAL(20,2) NOT NULL 
, FOREIGN KEY FK_wtacct_acct (account_no) REFERENCES acct (account_no)
    ON UPDATE CASCADE ON DELETE RESTRICT
) ENGINE=InnoDB
;

My reason for using a separate "current balance" table is that there is only one row for the given account_no, and that row retains the current balance of the account.

The UPDATE statement in the trigger should obtain an exclusive lock on the row being updated. And that exclusive lock prevents any other UPDATE statement from simultaneously updating the same row. The execution of the UPDATE statement will add the `amount` from the current transaction row being inserted to the current balance.

If we were using Oracle or PostgreSQL, we could use a RETURNING clause to get the value that was assigned to the \'balance\' column.

In MySQL we can do a wonky workaround, using a user-defined variable. The new value we are going to assign to the column is first assigned to the user_defined variable, and then that is assigned to the column.

And we can assign the value of the user-defined variable to the `balance` column of the row being inserted into `wtacct`.

The purpose of this approach is to make the retrieval and update of the current balance in a single statement, to avoid any race conditions.

The UPDATE statement locates the row, obtains an exclusive (X) lock on the row, retrieves the current balance (value from the \'balance\' column), calculates the new current balance, and assigns it back to the \'balance\' column. Then continues to hold the lock until the transaction completes.

Once the trigger completes, the INSERT statement (which initially fired the trigger) proceeds, attempting to insert the new row into `wtacct`. If that fails, then all of the changes made by the INSERT statement and execution of the trigger are rolled back, keeping everything consistent.

Once a COMMIT or ROLLBACK is issued by the session, the exclusive (X) lock held on the row(s) in `acct` are released, and other sessions can obtain locks on that row in `acct`.