Stored procedures, handlers and deadlock retry
Procedures with IN and OUT parameters, SIGNAL for your own errors, handlers that catch them, cursors for row-by-row work, the sql_mode a routine keeps, and a retry loop for error 1213.
After this lesson you can
- Write a procedure with parameters, local variables and control flow
- Raise an application error with SIGNAL and catch errors with DECLARE ... HANDLER
- Retry a transaction that was chosen as a deadlock victim
- Explain why a routine ignores the caller's sql_mode
A stored procedure is a named program stored in the database and run
with CALL. In MySQL its body is a BEGIN ... END block of SQL plus
variables, IF, CASE, WHILE, LOOP, cursors and error handlers. (In
the mysql command-line client you change the statement DELIMITER while
defining one, so the semicolons inside the body do not end the
CREATE PROCEDURE; that is a client feature, not part of SQL.)
Parameters and SIGNAL
Parameters are IN (the default), OUT or INOUT. SIGNAL raises an
error of your own; SQLSTATE '45000' means "unhandled user-defined
exception", and MySQL reports it as error 1644 unless you set
MYSQL_ERRNO.
CREATE TABLE stock (sku VARCHAR(5) PRIMARY KEY, qty INT NOT NULL);INSERT INTO stock VALUES ('A', 10), ('B', 1);CREATE PROCEDURE reserve(IN p_sku VARCHAR(5), IN p_qty INT, OUT p_left INT)BEGIN IF p_qty <= 0 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'quantity must be positive'; END IF; UPDATE stock SET qty = qty - p_qty WHERE sku = p_sku AND qty >= p_qty; IF ROW_COUNT() = 0 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'not enough stock'; END IF; SELECT qty INTO p_left FROM stock WHERE sku = p_sku;END;-- A helper that runs a statement and records the error instead of stopping.CREATE TABLE attempts (n INT AUTO_INCREMENT PRIMARY KEY, statement TEXT, outcome TEXT);CREATE PROCEDURE try_sql(IN stmt TEXT)BEGIN DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN GET DIAGNOSTICS CONDITION 1 @err_no = MYSQL_ERRNO, @err_msg = MESSAGE_TEXT; INSERT INTO attempts (statement, outcome) VALUES (stmt, CONCAT('error ', @err_no, ': ', REPLACE(@err_msg, CONCAT(DATABASE(), '.'), ''))); END; SET @sql = stmt; PREPARE s FROM @sql; EXECUTE s; DEALLOCATE PREPARE s; INSERT INTO attempts (statement, outcome) VALUES (stmt, 'ok');END;CALL try_sql('CALL reserve(''A'', 3, @left)');CALL try_sql('CALL reserve(''B'', 5, @left_b)');CALL try_sql('CALL reserve(''A'', 0, @left_zero)'); SELECT a.n, a.statement, a.outcome, IF(a.n = 1, @left, NULL) AS out_leftFROM attempts aORDER BY a.n;The first call reserved 3 units and returned the remainder, 7, through the
OUT parameter. The other two stopped at a SIGNAL with a message the
application can show. Because the stock check is part of the UPDATE
(WHERE qty >= p_qty) and ROW_COUNT() tells whether a row matched, two
sessions cannot both take the last units.
Handlers
DECLARE CONTINUE HANDLER FOR <condition> runs its statement and carries
on after the failing one; EXIT HANDLER runs it and leaves the enclosing
BEGIN ... END. The condition can be SQLEXCEPTION, SQLWARNING,
NOT FOUND, a SQLSTATE or a MySQL error number such as 1213. Inside a
handler, GET DIAGNOSTICS CONDITION 1 ... = MESSAGE_TEXT reads the error.
The transactions lesson used an EXIT HANDLER FOR SQLEXCEPTION ROLLBACK to
keep a failed transfer from committing half its work.
Retrying a deadlock victim
Error 1213 means InnoDB rolled your transaction back to break a deadlock.
The right response is to run the whole transaction again, a bounded number
of times. One session cannot produce a real deadlock, so this example
simulates one: the first two attempts raise error 1213 with SIGNAL, the
third succeeds.
CREATE TABLE stock (sku VARCHAR(5) PRIMARY KEY, qty INT NOT NULL);INSERT INTO stock VALUES ('A', 10);CREATE TABLE retry_log (n INT AUTO_INCREMENT PRIMARY KEY, attempt INT, outcome VARCHAR(60));-- One attempt at the transaction. Attempts 1 and 2 pretend to lose a deadlock.CREATE PROCEDURE reserve_once(IN p_sku VARCHAR(5), IN p_qty INT, IN p_attempt INT)BEGIN START TRANSACTION; UPDATE stock SET qty = qty - p_qty WHERE sku = p_sku; IF p_attempt < 3 THEN SIGNAL SQLSTATE '40001' SET MYSQL_ERRNO = 1213, MESSAGE_TEXT = 'Deadlock (simulated)'; END IF; COMMIT;END;CREATE PROCEDURE reserve_with_retry(IN p_sku VARCHAR(5), IN p_qty INT)BEGIN DECLARE attempt INT DEFAULT 0; DECLARE done BOOL DEFAULT FALSE; WHILE NOT done AND attempt < 5 DO SET attempt = attempt + 1; BEGIN DECLARE EXIT HANDLER FOR 1213 BEGIN ROLLBACK; INSERT INTO retry_log (attempt, outcome) VALUES (attempt, 'deadlock: rolled back, retrying'); END; CALL reserve_once(p_sku, p_qty, attempt); SET done = TRUE; INSERT INTO retry_log (attempt, outcome) VALUES (attempt, 'committed'); END; END WHILE;END; CALL reserve_with_retry('A', 4); SELECT r.attempt, r.outcome, (SELECT qty FROM stock WHERE sku = 'A') AS qty_nowFROM retry_log rORDER BY r.n;Each failed attempt was rolled back by the handler and logged; the third
committed, and the stock dropped by 4 once, not three times. One
difference from the real thing: after a genuine deadlock InnoDB has
already rolled the transaction back when 1213 arrives, while a SIGNAL
undoes nothing, so here the handler's ROLLBACK does that work (after a
real deadlock it is simply harmless). The inner
BEGIN ... END matters: an EXIT handler leaves only that block, so the
WHILE loop goes round again.
Cursors
A cursor walks a query's result one row at a time, which you need when each row's outcome depends on the previous ones:
DECLARE done BOOL DEFAULT FALSE;
DECLARE cur CURSOR FOR SELECT ... ORDER BY id;
DECLARE CONTINUE HANDLER FOR NOT FOUND SET done = TRUE;
OPEN cur;
read_loop: LOOP
FETCH cur INTO v_a, v_b;
IF done THEN LEAVE read_loop; END IF;
-- work on one row
END LOOP;
CLOSE cur;
Declarations must come in this order: variables, then cursors, then handlers. Prefer a single set-based statement whenever the rows are independent; the exercise is a case where they are not.
A routine keeps the sql_mode it was created with
CREATE TABLE t (n TINYINT);CREATE TABLE results (n INT AUTO_INCREMENT PRIMARY KEY, how VARCHAR(30), outcome VARCHAR(100));-- Created while strict mode is on (the default).CREATE PROCEDURE add_n(IN v INT)BEGIN DECLARE EXIT HANDLER FOR SQLEXCEPTION BEGIN GET DIAGNOSTICS CONDITION 1 @m = MESSAGE_TEXT; INSERT INTO results (how, outcome) VALUES ('procedure', CONCAT('error: ', @m)); END; INSERT INTO t VALUES (v); INSERT INTO results (how, outcome) VALUES ('procedure', 'ok');END;SET SESSION sql_mode = ''; INSERT INTO t VALUES (300);INSERT INTO results (how, outcome)SELECT 'direct INSERT', CONCAT('ok, stored ', n) FROM t; CALL add_n(300); SELECT r.how, r.outcome, IF(r.how = 'procedure', (SELECT FIND_IN_SET('STRICT_TRANS_TABLES', SQL_MODE) > 0 FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA = DATABASE() AND ROUTINE_NAME = 'add_n'), @@SESSION.sql_mode LIKE '%STRICT%') AS strict_in_effectFROM results rORDER BY r.n;The session turned strict mode off, and its own INSERT of 300 into a
TINYINT was clamped to 127. The procedure still failed: MySQL stores the
sql_mode in force when a routine is created and runs it under that
mode, whatever the caller has set. Recreate the routine to change it.
Common mistakes
- Retrying only the failed statement after 1213. The whole transaction was rolled back; rerun all of it.
- A
CONTINUEhandler forSQLEXCEPTIONaround a transaction. It hides the error and letsCOMMITrun, as in the transactions lesson. - Checking stock with a
SELECT, then updating. Two sessions can both pass the check; put the condition in theUPDATEand testROW_COUNT(). - Expecting
SET sql_modeto change how an existing procedure behaves. It runs with its creation-time mode.
What interviewers ask
- How do you raise a custom error in MySQL?
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '...', optionally withMYSQL_ERRNO. - What should an application do on error 1213? Treat the transaction as rolled back and retry it from the start, a limited number of times.
Exercise · Pro
Apply transfers in order
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade