Projetando o banco de dados de um sistema de compra e venda online: 100 exemplos do zero ao profissional com MySQL 8
Uma loja online é, em grande parte, um problema de banco de dados. Clientes fazem login, produtos têm tamanhos e cores, o estoque nunca pode ficar negativo, um pagamento pode chegar duas vezes e dois compradores podem clicar em Comprar pela última peça no mesmo segundo. Este artigo projeta o banco de dados de uma loja assim, passo a passo. Você verá as tabelas que uma loja online precisa, com chaves primárias, chaves estrangeiras, índices e o motivo de cada um. Depois verá dados de exemplo para cada tabela e o que acontece dentro das transações importantes quando o sistema roda: fazer um pedido, reservar estoque, pagamento aprovado e recusado, cancelamento, reembolso e dois compradores disputando o último item, junto com o que dá errado quando a transação não existe. Maquetes da lista de produtos, da página do produto, do carrinho, do checkout, do histórico de pedidos e dos pedidos do administrador mostram como as telas leem essas tabelas. O artigo termina com 100 exemplos numerados na sintaxe do MySQL 8: DDL, dados iniciais, consultas, índices, transações, bloqueios, views, particionamento, relatórios e práticas profissionais. Nota honesta: o MySQL 8 não estava instalado no computador que preparou este artigo, por isso nenhum dos 100 exemplos foi executado aqui. Execute-os você mesmo em um banco de treino e confira os resultados antes de confiar neles.
O que você precisa
Um computador com MySQL 8.0 ou mais recente (MySQL Community Server, ou a imagem Docker mysql:8.0), um cliente como o programa de linha de comando mysql ou o MySQL Workbench, e conhecimento básico de SQL: SELECT, INSERT, UPDATE e JOIN. Enquanto aprende, use um banco de treino, nunca o banco de uma loja real. Use o motor InnoDB, que é o padrão do MySQL 8, porque apenas o InnoDB oferece transações, bloqueios de linha e chaves estrangeiras.
Como usar um exemplo
Os exemplos em SQL são para MySQL 8 com InnoDB. Execute-os em ordem em um banco de treino (por exemplo, shop_practice). O sinal ? marca um parâmetro que a aplicação envia. Os exemplos em bash são executados no terminal.
Tópico 1: Comece pelas perguntas que a loja precisa responder
Antes de escrever qualquer CREATE TABLE, liste as perguntas que a loja precisa responder: o que este cliente possui? Quantas unidades posso vender agora? Quais pedidos estão esperando embalagem? Cada tabela existe para responder a uma dessas perguntas sem contradizer as outras. Este tópico configura o MySQL 8 com segurança e mostra as regras que todas as tabelas do resto do artigo seguem.
As tabelas e o que guardam
Uma loja precisa de clientes, endereços, categorias, produtos, variantes de produto, estoque, carrinhos, itens de carrinho, pedidos, itens de pedido, pagamentos, reembolsos, envios, itens de envio, avaliações, cupons e, em um marketplace, vendedores e repasses. O produto descreve o que se vende; a variante é a unidade vendável, com SKU e preço. O item de pedido copia o nome do produto e o preço no momento da compra, de modo que uma mudança de preço posterior não reescreve o histórico.
Chaves, restrições e índices
Toda tabela tem uma chave primária. Chaves estrangeiras impedem linhas órfãs, por exemplo um pedido sem cliente. Chaves UNIQUE protegem valores como e-mail ou SKU contra duplicação. Restrições CHECK recusam valores impossíveis, como estoque negativo. Índices aceleram as consultas que as telas executam, como o histórico de pedidos de um cliente, e cada índice deste artigo tem o motivo escrito ao lado.
Configure o MySQL 8 com segurança
Crie o banco com utf8mb4 para que textos em tailandês, japonês, chinês, árabe e emojis sejam armazenados corretamente. Crie um usuário de aplicação que leia e grave dados, mas não altere o esquema, e ative o modo SQL estrito para que valores inválidos sejam recusados em vez de alterados em silêncio. Guarde a senha em um cofre de segredos, nunca no artigo nem no código-fonte.
Ilustrações
Exemplo 1: Verificar a versão do servidor e os padrões
Confirme MySQL 8, utf8mb4 e o nível de isolamento.
SELECT VERSION() AS server_version,
@@character_set_server AS charset,
@@collation_server AS collation,
@@transaction_isolation AS isolation_level;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 2: Criar o banco de dados da loja
utf8mb4 e uma só collation para toda a loja.
CREATE DATABASE IF NOT EXISTS shop
CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
USE shop;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 3: Criar um usuário da aplicação com privilégio mínimo
O app lê e grava dados, mas não altera o esquema.
CREATE USER IF NOT EXISTS 'shop_app'@'localhost' IDENTIFIED BY '<strong-password>';
GRANT SELECT, INSERT, UPDATE, DELETE ON shop.* TO 'shop_app'@'localhost';
-- No DROP, ALTER or GRANT: the application cannot change the schema.
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 4: Ativar o modo SQL estrito
Rejeitar dados inválidos em vez de alterá-los em silêncio.
SET SESSION sql_mode = 'STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ONLY_FULL_GROUP_BY';
SELECT @@SESSION.sql_mode AS sql_mode;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 5: Confirmar o InnoDB
Transações e chaves estrangeiras precisam do motor InnoDB.
SELECT ENGINE, SUPPORT, TRANSACTIONS, XA
FROM information_schema.ENGINES
WHERE ENGINE = 'InnoDB';
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 6: Reiniciar um banco de treino
Praticar sem tocar nos dados da loja.
-- Practice only: this removes the practice copy and nothing else.
DROP DATABASE IF EXISTS shop_practice;
CREATE DATABASE shop_practice CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Tópico 2: Clientes e endereços
Um cliente é uma conta com e-mail único e um status. Os endereços pertencem ao cliente, mas o pedido guarda a própria cópia do endereço de entrega, porque o cliente pode editar ou apagar um endereço depois. Este tópico cobre as tabelas, os dados de exemplo e a regra de que cada cliente tem no máximo um endereço padrão.
Uma linha por conta
A tabela customers tem customer_id com incremento automático como chave primária e uma chave UNIQUE em email. Com a collation utf8mb4_0900_ai_ci do MySQL 8, a comparação de e-mails não diferencia maiúsculas de minúsculas, então [email protected] e [email protected] são a mesma conta. Guarde apenas o hash da senha, nunca a senha. A coluna status permite bloquear uma conta sem apagar o seu histórico.
Os endereços pertencem ao cliente
A tabela addresses referencia customers com chave estrangeira e ON DELETE CASCADE, então apagar o cliente também apaga os endereços salvos. A tabela orders não depende disso, porque cada pedido copia o destinatário, a linha de endereço e o CEP. Uma coluna gerada pode garantir um endereço padrão por cliente, já que uma chave UNIQUE aceita vários valores NULL.
Privacidade: não guarde dados de cartão no banco
A loja não precisa do número completo do cartão. Guarde apenas o token do provedor de pagamento e os quatro últimos dígitos, e deixe o provedor cuidar do cartão. Mantenha os dados pessoais no menor número possível de tabelas, e lembre-se de que backups, logs e relatórios também os contêm, por isso precisam da mesma proteção.
Exemplo 7: Criar a tabela de clientes
Contas com e-mail único e status.
CREATE TABLE customers (
customer_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
email VARCHAR(254) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
full_name VARCHAR(120) NOT NULL,
phone VARCHAR(20) NULL,
status ENUM('active','blocked') NOT NULL DEFAULT 'active',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (customer_id),
UNIQUE KEY uq_customers_email (email)
) ENGINE=InnoDB;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 8: Criar a tabela de endereços
Vários endereços por cliente, com chave estrangeira.
CREATE TABLE addresses (
address_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
customer_id BIGINT UNSIGNED NOT NULL,
label VARCHAR(40) NOT NULL DEFAULT 'Home',
recipient_name VARCHAR(120) NOT NULL,
line1 VARCHAR(200) NOT NULL,
line2 VARCHAR(200) NULL,
city VARCHAR(100) NOT NULL,
postal_code VARCHAR(20) NOT NULL,
country_code CHAR(2) NOT NULL,
is_default TINYINT(1) NOT NULL DEFAULT 0,
PRIMARY KEY (address_id),
KEY idx_addresses_customer (customer_id),
CONSTRAINT fk_addresses_customer FOREIGN KEY (customer_id)
REFERENCES customers (customer_id) ON DELETE CASCADE
) ENGINE=InnoDB;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 9: Inserir clientes de exemplo
Contas de exemplo para testes.
INSERT INTO customers (email, password_hash, full_name, phone) VALUES
('[email protected]', '<bcrypt-hash-1>', 'Mali Sun', '0812345678'),
('[email protected]', '<bcrypt-hash-2>', 'Niran Chai', '0898765432'),
('[email protected]', '<bcrypt-hash-3>', 'Ploy Wong', NULL);
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 10: Inserir endereços de exemplo
Endereços de exemplo, um padrão por cliente.
INSERT INTO addresses
(customer_id, label, recipient_name, line1, city, postal_code, country_code, is_default) VALUES
(1, 'Home', 'Mali Sun', '12/3 Sukhumvit Road', 'Bangkok', '10110', 'TH', 1),
(1, 'Office', 'Mali Sun', '99 Rama IX Road', 'Bangkok', '10310', 'TH', 0),
(2, 'Home', 'Niran Chai', '45 Moo 2', 'Chiang Mai', '50000', 'TH', 1);
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 11: Buscar o endereço padrão
Consulta que usa o índice composto.
SELECT address_id, recipient_name, line1, city
FROM addresses
WHERE customer_id = 1 AND is_default = 1;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 12: Um endereço padrão por cliente
Uma coluna gerada torna possível a regra de unicidade.
-- A generated column is NULL unless is_default = 1, and UNIQUE allows many NULLs.
ALTER TABLE addresses
ADD COLUMN default_owner BIGINT UNSIGNED
GENERATED ALWAYS AS (CASE WHEN is_default = 1 THEN customer_id END) STORED,
ADD UNIQUE KEY uq_one_default_address (default_owner);
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 13: Contar endereços por cliente
LEFT JOIN mantém os clientes sem endereço.
SELECT c.customer_id, c.full_name, COUNT(a.address_id) AS address_count
FROM customers AS c
LEFT JOIN addresses AS a ON a.customer_id = c.customer_id
GROUP BY c.customer_id, c.full_name
ORDER BY address_count DESC, c.customer_id;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 14: Chaves estrangeiras e bloqueio
Uma linha órfã é recusada, e bloquear é um UPDATE.
-- Fails with error 1452 (foreign key constraint) because customer 999 does not exist.
INSERT INTO addresses (customer_id, recipient_name, line1, city, postal_code, country_code)
VALUES (999, 'Nobody', 'Unknown', 'Nowhere', '00000', 'TH');
UPDATE customers SET status = 'blocked' WHERE customer_id = 3;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Tópico 3: Produtos, categorias e variantes
O catálogo tem três níveis. A categoria agrupa produtos, o produto é o que a loja vende, e a variante é o item exato, com SKU, tamanho, cor e preço. O estoque e o carrinho sempre apontam para a variante, nunca para o produto, porque uma camiseta preta tamanho M e uma camiseta branca tamanho L são coisas diferentes para contar.
Produtos e variantes
A tabela products guarda nome, slug, descrição, preço-base e status (draft, active ou archived). A tabela product_variants guarda o SKU, o rótulo da opção, como M / Black, o preço e a marca is_active. Uma chave UNIQUE em (product_id, option_label) impede duas variantes idênticas do mesmo produto. Um produto em rascunho continua oculto da listagem até o status mudar para active.
Categorias como árvore
A tabela categories referencia a si mesma por parent_id, então Roupas pode conter Camisetas. A chave estrangeira usa ON DELETE SET NULL, que transforma a subcategoria em categoria de primeiro nível se a categoria-pai for removida. Para listar todos os produtos de uma categoria, incluindo as subcategorias, use um common table expression recursivo.
Consultas de listagem e busca
A listagem mostra o menor preço de variante ativa de cada produto. A paginação keyset, que usa o product_id da última linha da página anterior, continua rápida em catálogos grandes, enquanto o OFFSET fica mais lento a cada página. Um índice FULLTEXT com o analisador ngram permite buscar palavras em tailandês, japonês e chinês, onde as palavras não são separadas por espaços.
Ilustrações
Exemplo 15: Categorias com categoria-pai
Chave estrangeira para a própria tabela forma uma árvore.
CREATE TABLE categories (
category_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
parent_id INT UNSIGNED NULL,
name VARCHAR(100) NOT NULL,
slug VARCHAR(120) NOT NULL,
PRIMARY KEY (category_id),
UNIQUE KEY uq_categories_slug (slug),
KEY idx_categories_parent (parent_id),
CONSTRAINT fk_categories_parent FOREIGN KEY (parent_id)
REFERENCES categories (category_id) ON DELETE SET NULL
) ENGINE=InnoDB;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 16: Criar a tabela de produtos
Nome, slug, preço-base e status.
CREATE TABLE products (
product_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
category_id INT UNSIGNED NOT NULL,
name VARCHAR(200) NOT NULL,
slug VARCHAR(220) NOT NULL,
description TEXT NULL,
base_price DECIMAL(10,2) NOT NULL,
status ENUM('draft','active','archived') NOT NULL DEFAULT 'draft',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (product_id),
UNIQUE KEY uq_products_slug (slug),
KEY idx_products_category_status (category_id, status),
CONSTRAINT fk_products_category FOREIGN KEY (category_id)
REFERENCES categories (category_id) ON DELETE RESTRICT,
CONSTRAINT chk_products_price CHECK (base_price >= 0)
) ENGINE=InnoDB;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 17: Criar a tabela de variantes
O item vendável: SKU, opção e preço.
CREATE TABLE product_variants (
variant_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
product_id BIGINT UNSIGNED NOT NULL,
sku VARCHAR(40) NOT NULL,
option_label VARCHAR(80) NOT NULL,
price DECIMAL(10,2) NOT NULL,
weight_grams INT UNSIGNED NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
PRIMARY KEY (variant_id),
UNIQUE KEY uq_variants_sku (sku),
UNIQUE KEY uq_variants_option (product_id, option_label),
CONSTRAINT fk_variants_product FOREIGN KEY (product_id)
REFERENCES products (product_id) ON DELETE RESTRICT,
CONSTRAINT chk_variants_price CHECK (price >= 0)
) ENGINE=InnoDB;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 18: Inserir categorias e produtos de exemplo
Dados de catálogo de exemplo.
INSERT INTO categories (category_id, parent_id, name, slug) VALUES
(1, NULL, 'Clothing', 'clothing'),
(2, 1, 'T-shirts', 't-shirts'),
(3, NULL, 'Accessories', 'accessories');
INSERT INTO products (product_id, category_id, name, slug, description, base_price, status) VALUES
(1, 2, 'Cotton Crew T-Shirt', 'cotton-crew-tshirt', 'Soft combed cotton, regular fit.', 390.00, 'active'),
(2, 3, 'Canvas Tote Bag', 'canvas-tote-bag', 'Heavy canvas with an inner pocket.', 590.00, 'active'),
(3, 2, 'Linen Henley Shirt', 'linen-henley', 'Summer linen, long sleeve.', 890.00, 'draft');
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 19: Inserir variantes de exemplo
Cinco variantes, uma delas inativa.
INSERT INTO product_variants
(variant_id, product_id, sku, option_label, price, weight_grams, is_active) VALUES
(1, 1, 'TS-CREW-M-BLK', 'M / Black', 390.00, 180, 1),
(2, 1, 'TS-CREW-L-BLK', 'L / Black', 390.00, 190, 1),
(3, 1, 'TS-CREW-M-WHT', 'M / White', 390.00, 180, 1),
(4, 2, 'TOTE-NAT', 'Natural', 590.00, 420, 1),
(5, 3, 'LIN-HEN-M-BLU', 'M / Blue', 890.00, 200, 0);
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 20: Listagem de produtos com paginação keyset
Consultas rápidas da próxima página sem OFFSET.
SELECT p.product_id, p.name, MIN(v.price) AS from_price
FROM products AS p
JOIN product_variants AS v
ON v.product_id = p.product_id AND v.is_active = 1
WHERE p.status = 'active'
AND p.category_id = 2
AND p.product_id > ? -- last product_id from the previous page
GROUP BY p.product_id, p.name
ORDER BY p.product_id
LIMIT 20;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 21: Variantes na página do produto
Somente as variantes ativas de um produto.
SELECT v.variant_id, v.sku, v.option_label, v.price, v.weight_grams
FROM product_variants AS v
WHERE v.product_id = 1
AND v.is_active = 1
ORDER BY v.option_label;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 22: Busca de texto completo
Índice FULLTEXT com o analisador ngram.
ALTER TABLE products
ADD FULLTEXT KEY ft_products_name_desc (name, description) WITH PARSER ngram;
SELECT product_id, name
FROM products
WHERE status = 'active'
AND MATCH (name, description) AGAINST ('cotton' IN NATURAL LANGUAGE MODE);
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Tópico 4: Estoque e livro-razão de estoque
O estoque é a parte da loja em que um erro custa dinheiro diretamente. Este projeto guarda três números por variante: on_hand, as unidades fisicamente presentes; reserved, as unidades prometidas a pedidos ainda não pagos ou não enviados; e available, que é on_hand menos reserved. Uma tabela de razão registra cada alteração, então os contadores podem sempre ser conferidos com o histórico.
Contadores com regras CHECK
A tabela inventory tem uma linha por variante. As restrições CHECK exigem que on_hand seja zero ou maior, e que reserved esteja entre zero e on_hand. Desde o MySQL 8.0.16 o servidor aplica as restrições CHECK, então um UPDATE que deixaria o estoque negativo falha com o erro 3819 em vez de gravar um valor errado em silêncio.
O razão
A tabela inventory_movements registra cada alteração com uma variação com sinal para on_hand e para reserved, um motivo como reserve, release, sale, cancel, return, restock ou adjustment, e o pedido a que pertence. O razão só recebe inserções: um erro se corrige com uma nova linha, não editando uma antiga. Isso dá uma trilha de auditoria para cada unidade que saiu da loja.
Conferir os contadores com o razão
Um job noturno soma o razão de cada variante e compara o resultado com o contador. Uma divergência indica um bug ou uma edição manual, e deve gerar um alerta. Os dados iniciais entram no razão como saldo de abertura, então a conferência começa do zero.
Exemplo 23: Criar a tabela de estoque
Quantidade em mãos e reservada, com regras CHECK.
CREATE TABLE inventory (
variant_id BIGINT UNSIGNED NOT NULL,
on_hand INT NOT NULL DEFAULT 0,
reserved INT NOT NULL DEFAULT 0,
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (variant_id),
CONSTRAINT fk_inventory_variant FOREIGN KEY (variant_id)
REFERENCES product_variants (variant_id) ON DELETE RESTRICT,
CONSTRAINT chk_inventory_on_hand CHECK (on_hand >= 0),
CONSTRAINT chk_inventory_reserved CHECK (reserved >= 0 AND reserved <= on_hand)
) ENGINE=InnoDB;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 24: Criar o livro-razão de estoque
Cada alteração de estoque gera uma linha.
CREATE TABLE inventory_movements (
movement_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
variant_id BIGINT UNSIGNED NOT NULL,
delta_on_hand INT NOT NULL DEFAULT 0,
delta_reserved INT NOT NULL DEFAULT 0,
reason ENUM('adjustment','restock','reserve','release','sale','cancel','return') NOT NULL,
order_id BIGINT UNSIGNED NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (movement_id),
KEY idx_movements_variant_time (variant_id, created_at),
KEY idx_movements_order (order_id),
CONSTRAINT fk_movements_variant FOREIGN KEY (variant_id)
REFERENCES product_variants (variant_id) ON DELETE RESTRICT
) ENGINE=InnoDB;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 25: Inserir estoque e saldo inicial
Quantidades de estoque com linhas do razão correspondentes.
INSERT INTO inventory (variant_id, on_hand, reserved) VALUES
(1, 20, 0),
(2, 8, 0),
(3, 0, 0), -- sold out
(4, 50, 0);
INSERT INTO inventory_movements (variant_id, delta_on_hand, delta_reserved, reason) VALUES
(1, 20, 0, 'adjustment'),
(2, 8, 0, 'adjustment'),
(4, 50, 0, 'adjustment');
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 26: Quantidade disponível para venda
Em mãos menos reservada.
SELECT variant_id, on_hand - reserved AS available
FROM inventory
WHERE on_hand - reserved > 0
ORDER BY variant_id;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 27: Relatório de estoque baixo
Variantes que precisam de reposição.
SELECT v.sku, i.on_hand - i.reserved AS available
FROM inventory AS i
JOIN product_variants AS v ON v.variant_id = i.variant_id
WHERE i.on_hand - i.reserved BETWEEN 0 AND 5
ORDER BY available, v.sku;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 28: Repor estoque com linha no razão
Uma transação para o estoque e seu registro.
START TRANSACTION;
UPDATE inventory
SET on_hand = on_hand + 30
WHERE variant_id = 3;
INSERT INTO inventory_movements (variant_id, delta_on_hand, delta_reserved, reason)
VALUES (3, 30, 0, 'restock');
COMMIT;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 29: Recusar estoque negativo
Uma restrição CHECK impede valores impossíveis.
-- Fails with error 3819 (check constraint violated) on MySQL 8.0.16 and later.
UPDATE inventory SET on_hand = -1 WHERE variant_id = 2;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 30: Conciliar o estoque com o razão
Encontrar contadores que não batem com o histórico.
SELECT i.variant_id, i.on_hand, COALESCE(SUM(m.delta_on_hand), 0) AS ledger_on_hand
FROM inventory AS i
LEFT JOIN inventory_movements AS m ON m.variant_id = i.variant_id
GROUP BY i.variant_id, i.on_hand
HAVING i.on_hand <> COALESCE(SUM(m.delta_on_hand), 0);
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Tópico 5: Carrinhos
O carrinho guarda os itens que o comprador pretende comprar. Um visitante tem um carrinho identificado por um token de sessão, e um cliente tem um carrinho identificado por customer_id. Carrinho não é pedido: nada é reservado, e o preço mostrado no carrinho é lido de novo no checkout. Este tópico cobre as tabelas de carrinho, como adicionar itens com segurança e como unir o carrinho do visitante depois do login.
Carrinhos de visitante e de cliente
A tabela carts tem customer_id e session_token opcionais. Quando o visitante faz login, o customer_id do carrinho é definido e os itens do carrinho do visitante são unidos ao carrinho aberto do cliente. A tabela cart_items tem uma chave UNIQUE em (cart_id, variant_id), então uma variante aparece uma vez por carrinho e a quantidade é atualizada em vez de duplicada.
Adicionar ao carrinho em uma instrução
INSERT ... AS new ON DUPLICATE KEY UPDATE soma a quantidade em uma única instrução atômica. Dois toques no mesmo botão não criam duas linhas, e a quantidade é limitada a 99 por uma restrição CHECK e pelo LEAST na atualização.
Carrinho não é pedido
Colocar um item no carrinho não reserva estoque, porque milhares de carrinhos são abandonados todos os dias. A reserva começa só quando o cliente faz o pedido. Por isso a página do carrinho precisa verificar a disponibilidade de novo e mostrar uma mensagem clara quando o item esgotou.
Ilustrações
Exemplo 31: Criar a tabela de carrinhos
Carrinhos de visitantes usam token de sessão; clientes usam o ID.
CREATE TABLE carts (
cart_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
customer_id BIGINT UNSIGNED NULL,
session_token CHAR(36) NULL,
status ENUM('open','converted','abandoned') NOT NULL DEFAULT 'open',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (cart_id),
UNIQUE KEY uq_carts_session (session_token),
KEY idx_carts_customer_status (customer_id, status),
KEY idx_carts_status_updated (status, updated_at),
CONSTRAINT fk_carts_customer FOREIGN KEY (customer_id)
REFERENCES customers (customer_id) ON DELETE CASCADE
) ENGINE=InnoDB;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 32: Criar a tabela de itens do carrinho
Uma linha por variante em cada carrinho.
CREATE TABLE cart_items (
cart_item_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
cart_id BIGINT UNSIGNED NOT NULL,
variant_id BIGINT UNSIGNED NOT NULL,
quantity INT NOT NULL,
added_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (cart_item_id),
UNIQUE KEY uq_cart_variant (cart_id, variant_id),
KEY idx_cart_items_variant (variant_id),
CONSTRAINT fk_cart_items_cart FOREIGN KEY (cart_id)
REFERENCES carts (cart_id) ON DELETE CASCADE,
CONSTRAINT fk_cart_items_variant FOREIGN KEY (variant_id)
REFERENCES product_variants (variant_id) ON DELETE RESTRICT,
CONSTRAINT chk_cart_quantity CHECK (quantity BETWEEN 1 AND 99)
) ENGINE=InnoDB;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 33: Inserir carrinhos de exemplo
Um carrinho de cliente e um carrinho de visitante.
INSERT INTO carts (cart_id, customer_id, session_token, status) VALUES
(1, 1, NULL, 'open'),
(2, NULL, 'b0a1c2d3-0000-4000-8000-000000000002', 'open');
INSERT INTO cart_items (cart_id, variant_id, quantity) VALUES
(1, 1, 1),
(1, 4, 2),
(2, 2, 1);
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 34: Adicionar ao carrinho
Inserir ou aumentar a quantidade em uma instrução.
INSERT INTO cart_items (cart_id, variant_id, quantity)
VALUES (1, 1, 2) AS new
ON DUPLICATE KEY UPDATE
quantity = LEAST(cart_items.quantity + new.quantity, 99);
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 35: Unir o carrinho de visitante após o login
Mover os itens em uma transação.
START TRANSACTION;
INSERT INTO cart_items (cart_id, variant_id, quantity)
SELECT 1, variant_id, quantity
FROM cart_items
WHERE cart_id = 2
ON DUPLICATE KEY UPDATE quantity = LEAST(quantity + VALUES(quantity), 99);
UPDATE carts SET status = 'abandoned' WHERE cart_id = 2;
COMMIT;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 36: Carrinho com preços e estoque atuais
Preço e disponibilidade são lidos ao exibir o carrinho.
SELECT ci.variant_id, v.sku, p.name, v.price, ci.quantity,
v.price * ci.quantity AS line_total,
COALESCE(i.on_hand - i.reserved, 0) AS available
FROM cart_items AS ci
JOIN product_variants AS v ON v.variant_id = ci.variant_id
JOIN products AS p ON p.product_id = v.product_id
LEFT JOIN inventory AS i ON i.variant_id = ci.variant_id
WHERE ci.cart_id = 1;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 37: Marcar carrinhos parados como abandonados
Tarefa periódica que usa o índice de status.
UPDATE carts
SET status = 'abandoned'
WHERE status = 'open'
AND updated_at < NOW(3) - INTERVAL 7 DAY;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 38: Verificar o plano da consulta do carrinho
O EXPLAIN mostra se um índice é usado.
EXPLAIN SELECT cart_id
FROM carts
WHERE status = 'open'
AND updated_at < NOW(3) - INTERVAL 7 DAY;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Tópico 6: Pedidos e itens de pedido
Um pedido é o registro de uma compra. Ele guarda os totais, o status e uma cópia do endereço de entrega. Cada linha do pedido copia o SKU, o nome do produto e o preço unitário, para que o pedido continue tendo sentido mesmo quando o catálogo mudar depois. Este tópico também cobre a regra de que os totais precisam sempre bater, e como percorrer o histórico de um cliente rapidamente, página a página.
Guarde o pedido como era no momento
A tabela order_items guarda sku_snapshot, product_name_snapshot e unit_price, copiados de product_variants quando o pedido é criado. A coluna line_total é gerada: unit_price vezes quantity, então nunca pode contradizer os valores que a formam. A chave estrangeira variant_id usa ON DELETE RESTRICT, então uma variante que já foi vendida não pode ser apagada; desative-a.
Os totais precisam bater
Uma restrição CHECK em orders exige grand_total = subtotal - discount_total + shipping_fee. Qualquer código que altere um desses números precisa alterar os outros na mesma transação, ou a instrução falha. Os totais são recalculados a partir dos itens antes de o pedido ser confirmado, então o cabeçalho sempre coincide com as linhas.
Paginação do histórico de pedidos
O índice composto (customer_id, created_at) atende a consulta dos pedidos mais recentes de um cliente. A paginação keyset com (created_at, order_id) devolve a próxima página sem varrer as linhas anteriores. Use EXPLAIN para confirmar que o índice é usado, e faça a ordenação corresponder ao índice.
Ilustrações
Exemplo 39: Criar a tabela de pedidos
Cabeçalho da compra, com totais e endereço copiado.
CREATE TABLE orders (
order_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
order_number VARCHAR(20) NOT NULL,
customer_id BIGINT UNSIGNED NOT NULL,
status ENUM('pending_payment','paid','packed','shipped','delivered','cancelled','refunded')
NOT NULL DEFAULT 'pending_payment',
currency CHAR(3) NOT NULL DEFAULT 'THB',
subtotal DECIMAL(12,2) NOT NULL,
discount_total DECIMAL(12,2) NOT NULL DEFAULT 0.00,
shipping_fee DECIMAL(12,2) NOT NULL DEFAULT 0.00,
grand_total DECIMAL(12,2) NOT NULL,
coupon_id INT UNSIGNED NULL,
ship_recipient VARCHAR(120) NOT NULL,
ship_line1 VARCHAR(200) NOT NULL,
ship_city VARCHAR(100) NOT NULL,
ship_postal_code VARCHAR(20) NOT NULL,
ship_country_code CHAR(2) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (order_id),
UNIQUE KEY uq_orders_number (order_number),
KEY idx_orders_customer_created (customer_id, created_at),
KEY idx_orders_status_created (status, created_at),
CONSTRAINT fk_orders_customer FOREIGN KEY (customer_id)
REFERENCES customers (customer_id) ON DELETE RESTRICT,
CONSTRAINT chk_orders_total CHECK (grand_total = subtotal - discount_total + shipping_fee)
) ENGINE=InnoDB;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 40: Criar a tabela de itens do pedido
Preços copiados na compra, com total de linha gerado.
CREATE TABLE order_items (
order_item_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
order_id BIGINT UNSIGNED NOT NULL,
variant_id BIGINT UNSIGNED NOT NULL,
sku_snapshot VARCHAR(40) NOT NULL,
product_name_snapshot VARCHAR(200) NOT NULL,
unit_price DECIMAL(10,2) NOT NULL,
quantity INT UNSIGNED NOT NULL,
line_total DECIMAL(12,2) GENERATED ALWAYS AS (unit_price * quantity) STORED,
PRIMARY KEY (order_item_id),
UNIQUE KEY uq_order_variant (order_id, variant_id),
KEY idx_order_items_variant (variant_id),
CONSTRAINT fk_order_items_order FOREIGN KEY (order_id)
REFERENCES orders (order_id) ON DELETE RESTRICT,
CONSTRAINT fk_order_items_variant FOREIGN KEY (variant_id)
REFERENCES product_variants (variant_id) ON DELETE RESTRICT,
CONSTRAINT chk_order_items_qty CHECK (quantity > 0)
) ENGINE=InnoDB;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 41: Copiar o carrinho para um pedido
O preço é guardado no checkout.
-- @order_id is the id of the order created just before this statement.
INSERT INTO order_items
(order_id, variant_id, sku_snapshot, product_name_snapshot, unit_price, quantity)
SELECT @order_id, v.variant_id, v.sku, p.name, v.price, ci.quantity
FROM cart_items AS ci
JOIN product_variants AS v ON v.variant_id = ci.variant_id
JOIN products AS p ON p.product_id = v.product_id
WHERE ci.cart_id = 1;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 42: Inserir três pedidos de exemplo
Pedidos em três situações diferentes.
INSERT INTO orders
(order_id, order_number, customer_id, status, subtotal, discount_total, shipping_fee, grand_total,
ship_recipient, ship_line1, ship_city, ship_postal_code, ship_country_code, created_at) VALUES
(1, 'SH-20261001-0001', 1, 'delivered', 780.00, 0.00, 50.00, 830.00, 'Mali Sun', '12/3 Sukhumvit Road', 'Bangkok', '10110', 'TH', '2026-10-01 10:12:00'),
(2, 'SH-20261020-0002', 2, 'paid', 590.00, 0.00, 50.00, 640.00, 'Niran Chai', '45 Moo 2', 'Chiang Mai', '50000', 'TH', '2026-10-20 18:40:00'),
(3, 'SH-20261029-0003', 1, 'pending_payment', 390.00, 39.00, 50.00, 401.00, 'Mali Sun', '12/3 Sukhumvit Road', 'Bangkok', '10110', 'TH', '2026-10-29 09:05:00');
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 43: Inserir os itens dos pedidos de exemplo
Os itens por trás dos três pedidos de exemplo.
INSERT INTO order_items
(order_id, variant_id, sku_snapshot, product_name_snapshot, unit_price, quantity) VALUES
(1, 1, 'TS-CREW-M-BLK', 'Cotton Crew T-Shirt', 390.00, 2),
(2, 4, 'TOTE-NAT', 'Canvas Tote Bag', 590.00, 1),
(3, 2, 'TS-CREW-L-BLK', 'Cotton Crew T-Shirt', 390.00, 1);
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 44: Histórico de pedidos com paginação keyset
Do mais recente para o mais antigo, para um cliente.
SELECT order_id, order_number, status, grand_total, created_at
FROM orders
WHERE customer_id = 1
AND (created_at, order_id) < (?, ?) -- last row of the previous page
ORDER BY created_at DESC, order_id DESC
LIMIT 20;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 45: Detalhe do pedido
Cabeçalho e itens em uma consulta.
SELECT o.order_number, o.status, oi.sku_snapshot, oi.product_name_snapshot,
oi.unit_price, oi.quantity, oi.line_total
FROM orders AS o
JOIN order_items AS oi ON oi.order_id = o.order_id
WHERE o.order_id = 1
ORDER BY oi.order_item_id;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 46: Fila do administrador: pedidos pagos
O que o depósito precisa embalar em seguida.
SELECT order_id, order_number, created_at
FROM orders
WHERE status = 'paid'
ORDER BY created_at
LIMIT 50;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Tópico 7: Pagamentos e reembolsos
Um pagamento é uma tentativa de cobrar um pedido. Um pedido pode ter várias tentativas: uma falha porque o cartão foi recusado, e a seguinte tem sucesso. Os provedores de pagamento também enviam webhooks, que podem chegar duas vezes ou fora de ordem. As tabelas payments e refunds são desenhadas para que novas tentativas e mensagens duplicadas não cobrem o cliente duas vezes nem registrem a mesma venda duas vezes.
Chaves de idempotência
Cada tentativa de pagamento tem um idempotency_key único. Se a aplicação repetir uma requisição por causa de um timeout de rede, o segundo INSERT encontra a linha existente em vez de criar uma nova cobrança. A coluna provider_ref, única por provedor, liga a linha ao ID da transação no próprio provedor.
Linhas de pagamento e webhooks
Um webhook informa que um pagamento foi aprovado ou recusado. O manipulador encontra o pagamento por provider e provider_ref e só altera o status se ele ainda for pending. Se o status já for final, a atualização altera zero linhas e o manipulador não faz mais nada. É isso que torna um webhook repetido inofensivo.
Regras de reembolso
Um reembolso referencia um pagamento e tem status próprio. O valor reembolsável é o pagamento menos todos os reembolsos que não falharam. A linha do reembolso é gravada antes de o provedor ser chamado, e o status é atualizado depois. O provedor nunca é chamado enquanto uma transação do banco mantém bloqueios de linha, porque uma chamada de rede lenta bloquearia outros compradores.
Exemplo 47: Criar a tabela de pagamentos
Uma linha por tentativa de cobrança, com chave de idempotência.
CREATE TABLE payments (
payment_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
order_id BIGINT UNSIGNED NOT NULL,
provider VARCHAR(30) NOT NULL,
provider_ref VARCHAR(100) NULL,
idempotency_key VARCHAR(64) NOT NULL,
amount DECIMAL(12,2) NOT NULL,
status ENUM('pending','succeeded','failed') NOT NULL DEFAULT 'pending',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (payment_id),
UNIQUE KEY uq_payments_idempotency (idempotency_key),
UNIQUE KEY uq_payments_provider_ref (provider, provider_ref),
KEY idx_payments_order (order_id),
CONSTRAINT fk_payments_order FOREIGN KEY (order_id)
REFERENCES orders (order_id) ON DELETE RESTRICT,
CONSTRAINT chk_payments_amount CHECK (amount > 0)
) ENGINE=InnoDB;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 48: Criar a tabela de reembolsos
Cada reembolso aponta para um pagamento e tem status próprio.
CREATE TABLE refunds (
refund_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
payment_id BIGINT UNSIGNED NOT NULL,
amount DECIMAL(12,2) NOT NULL,
status ENUM('pending','succeeded','failed') NOT NULL DEFAULT 'pending',
reason VARCHAR(200) NULL,
provider_ref VARCHAR(100) NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
PRIMARY KEY (refund_id),
KEY idx_refunds_payment (payment_id),
CONSTRAINT fk_refunds_payment FOREIGN KEY (payment_id)
REFERENCES payments (payment_id) ON DELETE RESTRICT,
CONSTRAINT chk_refunds_amount CHECK (amount > 0)
) ENGINE=InnoDB;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 49: Registrar uma tentativa de pagamento com segurança
Repetir com a mesma chave devolve a mesma linha.
INSERT INTO payments
(order_id, provider, provider_ref, idempotency_key, amount, status)
VALUES
(3, 'sample-gateway', NULL, 'order-3-attempt-1', 401.00, 'pending')
ON DUPLICATE KEY UPDATE updated_at = updated_at;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 50: Inserir pagamentos bem-sucedidos de exemplo
Pagamentos dos pedidos 1 e 2.
-- No explicit payment_id: example 49 may already have used the first auto-increment value.
INSERT INTO payments
(order_id, provider, provider_ref, idempotency_key, amount, status) VALUES
(1, 'sample-gateway', 'test-ch-0001', 'order-1-attempt-1', 830.00, 'succeeded'),
(2, 'sample-gateway', 'test-ch-0002', 'order-2-attempt-1', 640.00, 'succeeded');
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 51: Valor reembolsável
O pagamento menos os reembolsos que não falharam.
SELECT p.payment_id,
p.amount - COALESCE(SUM(CASE WHEN r.status <> 'failed' THEN r.amount END), 0) AS refundable
FROM payments AS p
LEFT JOIN refunds AS r ON r.payment_id = p.payment_id
WHERE p.payment_id = ?
GROUP BY p.payment_id, p.amount;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 52: Pagamento e pedido divergentes
Encontre pagamentos aprovados enquanto o pedido ainda aguarda.
SELECT p.payment_id, p.order_id, o.status
FROM payments AS p
JOIN orders AS o ON o.order_id = p.order_id
WHERE p.status = 'succeeded'
AND o.status = 'pending_payment';
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Tópico 8: Envios e atendimento
Atendimento é o trabalho de embalar e despachar as mercadorias. Um pedido pode ser enviado em vários pacotes, por exemplo quando um dos itens está em falta. A tabela shipments tem uma linha por pacote, e shipment_items diz quais linhas do pedido estão em cada pacote e em que quantidade. A coluna de status só anda em uma direção, então um pacote entregue não pode voltar a preparing.
Pacotes e envios parciais
shipments referencia orders, e shipment_items referencia shipments e order_items. A chave UNIQUE em (shipment_id, order_item_id) impede que a mesma linha apareça duas vezes no mesmo pacote. As duas tabelas juntas permitem dividir um pedido em pacotes de qualquer forma.
O status só avança
Mudar um pacote de preparing para in_transit, e de in_transit para delivered, usa um UPDATE com o status antigo na cláusula WHERE. Uma atualização fora de ordem altera zero linhas, e a aplicação informa o conflito em vez de sobrescrever o status.
Acompanhe as quantidades
A quantidade que ainda falta enviar de uma linha é a quantidade pedida menos a soma das linhas de shipment_items dessa linha. Uma cláusula HAVING encontra as linhas que não estão completas, e a tela do administrador usa isso para mostrar pedidos enviados parcialmente.
Ilustrações
Exemplo 53: Criar a tabela de envios
Um pacote de um pedido, com transportadora e rastreio.
CREATE TABLE shipments (
shipment_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
order_id BIGINT UNSIGNED NOT NULL,
carrier VARCHAR(60) NOT NULL,
tracking_number VARCHAR(60) NULL,
status ENUM('preparing','in_transit','delivered','returned') NOT NULL DEFAULT 'preparing',
shipped_at DATETIME(3) NULL,
delivered_at DATETIME(3) NULL,
PRIMARY KEY (shipment_id),
UNIQUE KEY uq_shipments_tracking (tracking_number),
KEY idx_shipments_order (order_id),
CONSTRAINT fk_shipments_order FOREIGN KEY (order_id)
REFERENCES orders (order_id) ON DELETE RESTRICT
) ENGINE=InnoDB;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 54: Criar a tabela de itens do envio
Quais itens do pedido estão em cada pacote, para envios parciais.
CREATE TABLE shipment_items (
shipment_item_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
shipment_id BIGINT UNSIGNED NOT NULL,
order_item_id BIGINT UNSIGNED NOT NULL,
quantity INT UNSIGNED NOT NULL,
PRIMARY KEY (shipment_item_id),
UNIQUE KEY uq_shipment_line (shipment_id, order_item_id),
KEY idx_shipment_items_line (order_item_id),
CONSTRAINT fk_shipment_items_shipment FOREIGN KEY (shipment_id)
REFERENCES shipments (shipment_id) ON DELETE CASCADE,
CONSTRAINT fk_shipment_items_line FOREIGN KEY (order_item_id)
REFERENCES order_items (order_item_id) ON DELETE RESTRICT,
CONSTRAINT chk_shipment_items_qty CHECK (quantity > 0)
) ENGINE=InnoDB;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 55: Inserir um envio entregue
Dados de envio de exemplo do pedido 1.
INSERT INTO shipments
(shipment_id, order_id, carrier, tracking_number, status, shipped_at, delivered_at) VALUES
(1, 1, 'Sample Courier', 'SC0000000001', 'delivered', '2026-10-02 09:00:00', '2026-10-04 15:20:00');
INSERT INTO shipment_items (shipment_id, order_item_id, quantity) VALUES (1, 1, 2);
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 56: Embalar e criar um envio
Uma transação para o status do pedido e o pacote.
START TRANSACTION;
UPDATE orders SET status = 'packed' WHERE order_id = 2 AND status = 'paid';
-- ROW_COUNT() = 0 means the order was not in the paid state: ROLLBACK.
INSERT INTO shipments (order_id, carrier, status) VALUES (2, 'Sample Courier', 'preparing');
SET @shipment_id = LAST_INSERT_ID();
INSERT INTO shipment_items (shipment_id, order_item_id, quantity)
SELECT @shipment_id, order_item_id, quantity
FROM order_items
WHERE order_id = 2;
COMMIT;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 57: Entregar à transportadora e registrar a entrega
O status do envio só avança.
UPDATE shipments
SET status = 'in_transit', tracking_number = 'SC0000000002', shipped_at = NOW(3)
WHERE shipment_id = 2 AND status = 'preparing';
UPDATE shipments
SET status = 'delivered', delivered_at = NOW(3)
WHERE shipment_id = 2 AND status = 'in_transit';
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 58: Quantidade que ainda falta enviar
Quantidade pedida menos a enviada, por item.
SELECT oi.order_item_id, oi.quantity AS ordered,
COALESCE(SUM(si.quantity), 0) AS shipped
FROM order_items AS oi
LEFT JOIN shipment_items AS si ON si.order_item_id = oi.order_item_id
WHERE oi.order_id = 2
GROUP BY oi.order_item_id, oi.quantity
HAVING shipped < ordered;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Tópico 9: Avaliações e cupons
As avaliações criam confiança, e os cupons impulsionam vendas, mas ambos podem ser usados de forma abusiva. Uma avaliação deve vir apenas de uma compra real entregue, e um cupom deve ser usado apenas dentro das suas datas e do seu limite de uso. As restrições deste tópico protegem essas duas regras dentro do banco, para que um erro no código da tela não as quebre.
Avaliações verificadas
A tabela reviews referencia order_items, não apenas products. A chave UNIQUE em order_item_id permite uma avaliação por linha comprada. A instrução de inserção seleciona a linha somente quando o status do pedido é delivered, então não é possível avaliar um pedido cancelado ou não pago.
Cupons com limites
Um cupom tem código, desconto percentual ou fixo, subtotal mínimo, data de início e de fim, e um número máximo de usos opcional. Restrições CHECK exigem que a data de fim seja posterior à de início, que o desconto seja positivo e que used_count não ultrapasse max_uses.
Resgate protegido
Dois compradores podem usar a última utilização de um cupom no mesmo instante. Um UPDATE condicional que verifica as datas e used_count na cláusula WHERE resolve isso com segurança. A aplicação só aplica o desconto quando ROW_COUNT é igual a 1.
Exemplo 59: Criar a tabela de avaliações
Uma avaliação por item do pedido, nota de 1 a 5.
CREATE TABLE reviews (
review_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
product_id BIGINT UNSIGNED NOT NULL,
customer_id BIGINT UNSIGNED NOT NULL,
order_item_id BIGINT UNSIGNED NOT NULL,
rating TINYINT UNSIGNED NOT NULL,
body TEXT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (review_id),
UNIQUE KEY uq_reviews_order_item (order_item_id),
KEY idx_reviews_product_created (product_id, created_at),
CONSTRAINT fk_reviews_product FOREIGN KEY (product_id)
REFERENCES products (product_id) ON DELETE RESTRICT,
CONSTRAINT fk_reviews_customer FOREIGN KEY (customer_id)
REFERENCES customers (customer_id) ON DELETE CASCADE,
CONSTRAINT fk_reviews_order_item FOREIGN KEY (order_item_id)
REFERENCES order_items (order_item_id) ON DELETE RESTRICT,
CONSTRAINT chk_reviews_rating CHECK (rating BETWEEN 1 AND 5)
) ENGINE=InnoDB;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 60: Criar a tabela de cupons
Datas, limite de uso e verificações de sanidade.
CREATE TABLE coupons (
coupon_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
code VARCHAR(40) NOT NULL,
discount_type ENUM('percent','fixed') NOT NULL,
discount_value DECIMAL(10,2) NOT NULL,
min_subtotal DECIMAL(10,2) NOT NULL DEFAULT 0.00,
starts_at DATETIME(3) NOT NULL,
ends_at DATETIME(3) NOT NULL,
max_uses INT UNSIGNED NULL,
used_count INT UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (coupon_id),
UNIQUE KEY uq_coupons_code (code),
CONSTRAINT chk_coupons_value CHECK (discount_value > 0),
CONSTRAINT chk_coupons_dates CHECK (ends_at > starts_at),
CONSTRAINT chk_coupons_uses CHECK (max_uses IS NULL OR used_count <= max_uses)
) ENGINE=InnoDB;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 61: Vincular pedidos a cupons
Chave estrangeira adicionada depois que as duas tabelas existem.
ALTER TABLE orders
ADD CONSTRAINT fk_orders_coupon FOREIGN KEY (coupon_id)
REFERENCES coupons (coupon_id) ON DELETE SET NULL;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 62: Inserir cupons de exemplo
Um cupom percentual e um cupom de valor fixo.
INSERT INTO coupons
(coupon_id, code, discount_type, discount_value, min_subtotal, starts_at, ends_at, max_uses, used_count) VALUES
(1, 'WELCOME10', 'percent', 10.00, 300.00, '2026-01-01 00:00:00', '2027-01-01 00:00:00', NULL, 0),
(2, 'SHIP50', 'fixed', 50.00, 0.00, '2026-10-01 00:00:00', '2026-12-31 23:59:59', 100, 0);
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 63: Usar um cupom com segurança
Um UPDATE protegido impede o uso acima do limite.
UPDATE coupons
SET used_count = used_count + 1
WHERE coupon_id = ?
AND starts_at <= NOW(3)
AND ends_at > NOW(3)
AND (max_uses IS NULL OR used_count < max_uses);
-- The application applies the discount only when ROW_COUNT() = 1.
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 64: Avaliação de compra verificada
Só um item de pedido entregue pode ser avaliado.
INSERT INTO reviews (product_id, customer_id, order_item_id, rating, body)
SELECT v.product_id, o.customer_id, oi.order_item_id, 5, 'Fits well and the cotton feels soft.'
FROM order_items AS oi
JOIN orders AS o ON o.order_id = oi.order_id
JOIN product_variants AS v ON v.variant_id = oi.variant_id
WHERE oi.order_item_id = 1
AND o.status = 'delivered';
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Tópico 10: Vendedores e marketplace
Um marketplace permite que muitos vendedores vendam em uma mesma loja. O banco precisa saber qual vendedor é dono de cada produto, qual vendedor recebeu a receita de cada linha de pedido e quanto de comissão a plataforma retém. O projeto copia o vendedor para cada linha de pedido, para que, se um produto passar a outro vendedor depois, o histórico de vendas do vendedor anterior não mude.
Vendedores são donos dos produtos
A tabela sellers guarda nome, e-mail, status e percentual de comissão. products.seller_id é uma chave estrangeira que aceita nulo, adicionada com ALTER TABLE, para que as tabelas de loja única dos tópicos anteriores continuem funcionando. O status pending mantém os produtos de um vendedor novo ocultos até que um administrador aprove o vendedor.
Copie o vendedor para cada linha
order_items.seller_id é copiado quando o pedido é criado, e não consultado depois por meio de products. Isso mantém o histórico de pedidos correto quando o produto muda de dono e torna o relatório de comissão um JOIN simples.
Repasses e comissão
A tabela payouts guarda uma linha por vendedor por período, com valor bruto, comissão e valor líquido. Uma restrição CHECK exige que net = gross - commission, e uma chave UNIQUE em (seller_id, period_start) impede que um repasse seja criado duas vezes para o mesmo período.
Exemplo 65: Criar a tabela de vendedores
Vendedores do marketplace com taxa de comissão.
CREATE TABLE sellers (
seller_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
name VARCHAR(120) NOT NULL,
email VARCHAR(254) NOT NULL,
status ENUM('pending','active','suspended') NOT NULL DEFAULT 'pending',
commission_percent DECIMAL(5,2) NOT NULL DEFAULT 10.00,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (seller_id),
UNIQUE KEY uq_sellers_email (email),
CONSTRAINT chk_sellers_commission CHECK (commission_percent BETWEEN 0 AND 100)
) ENGINE=InnoDB;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 66: Produtos pertencem a vendedores
Chave estrangeira que aceita nulo, adicionada com ALTER.
ALTER TABLE products
ADD COLUMN seller_id INT UNSIGNED NULL,
ADD KEY idx_products_seller (seller_id),
ADD CONSTRAINT fk_products_seller FOREIGN KEY (seller_id)
REFERENCES sellers (seller_id) ON DELETE RESTRICT;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 67: Copiar o vendedor para os itens do pedido
O histórico continua correto quando o produto muda de dono.
ALTER TABLE order_items
ADD COLUMN seller_id INT UNSIGNED NULL,
ADD KEY idx_order_items_seller (seller_id),
ADD CONSTRAINT fk_order_items_seller FOREIGN KEY (seller_id)
REFERENCES sellers (seller_id) ON DELETE RESTRICT;
UPDATE order_items AS oi
JOIN product_variants AS v ON v.variant_id = oi.variant_id
JOIN products AS p ON p.product_id = v.product_id
SET oi.seller_id = p.seller_id
WHERE oi.seller_id IS NULL;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 68: Criar a tabela de repasses
Um repasse por vendedor por período, com verificação.
CREATE TABLE payouts (
payout_id INT UNSIGNED NOT NULL AUTO_INCREMENT,
seller_id INT UNSIGNED NOT NULL,
period_start DATE NOT NULL,
period_end DATE NOT NULL,
gross_amount DECIMAL(12,2) NOT NULL,
commission_amount DECIMAL(12,2) NOT NULL,
net_amount DECIMAL(12,2) NOT NULL,
status ENUM('pending','paid') NOT NULL DEFAULT 'pending',
PRIMARY KEY (payout_id),
UNIQUE KEY uq_payouts_period (seller_id, period_start),
CONSTRAINT fk_payouts_seller FOREIGN KEY (seller_id)
REFERENCES sellers (seller_id) ON DELETE RESTRICT,
CONSTRAINT chk_payouts_net CHECK (net_amount = gross_amount - commission_amount)
) ENGINE=InnoDB;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 69: Inserir vendedores e atribuições
Dois vendedores e os seus produtos.
INSERT INTO sellers (seller_id, name, email, status, commission_percent) VALUES
(1, 'Lanna Threads', '[email protected]', 'active', 12.00),
(2, 'Sample Bags Co', '[email protected]', 'active', 10.00);
UPDATE products SET seller_id = 1 WHERE product_id IN (1, 3);
UPDATE products SET seller_id = 2 WHERE product_id = 2;
UPDATE order_items SET seller_id = 1 WHERE variant_id IN (1, 2, 3);
UPDATE order_items SET seller_id = 2 WHERE variant_id = 4;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 70: Relatório de comissão de um mês
Vendas brutas e comissão por vendedor.
SELECT s.name,
COUNT(DISTINCT oi.order_id) AS orders,
SUM(oi.line_total) AS gross,
ROUND(SUM(oi.line_total) * s.commission_percent / 100, 2) AS commission
FROM order_items AS oi
JOIN orders AS o ON o.order_id = oi.order_id
JOIN sellers AS s ON s.seller_id = oi.seller_id
WHERE o.status IN ('paid','packed','shipped','delivered')
AND o.created_at >= '2026-10-01'
AND o.created_at < '2026-11-01'
GROUP BY s.seller_id, s.name, s.commission_percent
ORDER BY gross DESC;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Tópico 11: Transações: fazer o pedido e reservar estoque
Fazer o pedido é a transação mais importante da loja. Ela cria o pedido, copia as linhas, reserva o estoque e fecha o carrinho. Todas essas etapas precisam dar certo juntas ou falhar juntas. Este tópico percorre cada etapa na ordem em que ocorre, mostra o SQL e explica o que dá errado quando as etapas não estão envolvidas em uma única transação.
Dentro da transação de checkout, passo a passo
1. START TRANSACTION. 2. Bloqueie as linhas de inventory do carrinho com SELECT ... FOR UPDATE, em ordem crescente de variant_id. 3. Calcule o subtotal com os preços atuais. 4. Insira o cabeçalho do pedido com o endereço padrão copiado. 5. Insira as linhas do pedido com a cópia do preço. 6. Reserve o estoque com um único UPDATE que faz JOIN com order_items. 7. Grave uma linha de razão por linha do pedido, com motivo reserve. 8. Mude o status do carrinho para converted. 9. COMMIT. O procedimento armazenado do exemplo 76 mostra as nove etapas em um só lugar.
O que dá errado sem transação
Sem transação, uma queda do sistema depois da etapa 4 deixa um pedido sem estoque reservado, e a mesma unidade pode ser vendida duas vezes. Uma queda depois da etapa 6 e antes da etapa 7 deixa um contador que o razão não consegue explicar. Uma falha depois de criar o pedido e antes de fechar o carrinho pode fazer o cliente pagar duas vezes pelo mesmo carrinho. Todas essas são falhas comuns, não casos exóticos.
Ordem de bloqueio e transações curtas
Bloqueie as linhas na mesma ordem em todo lugar. Se um checkout bloqueia a variante 4 e depois a 1, e outro bloqueia a 1 e depois a 4, os dois podem entrar em deadlock, e o MySQL desfaz um deles com o erro 1213. Mantenha a transação curta: não chame o provedor de pagamento, não envie e-mail e não espere o usuário dentro dela. A reserva é uma promessa com prazo, e o resultado do pagamento decide se ela vira venda.
Ilustrações
Exemplo 71: Checkout: travar as linhas de estoque
SELECT FOR UPDATE em ordem crescente.
-- Cart 1 holds three of variant 1 and two of variant 4 (after example 34).
START TRANSACTION;
SELECT variant_id, on_hand, reserved
FROM inventory
WHERE variant_id IN (1, 4)
ORDER BY variant_id
FOR UPDATE;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 72: Checkout: criar o cabeçalho do pedido
Totais e uma cópia do endereço padrão.
-- Subtotal: 3 x 390.00 + 2 x 590.00 = 2350.00. Shipping 50.00. No discount.
INSERT INTO orders
(order_number, customer_id, status, subtotal, discount_total, shipping_fee, grand_total,
ship_recipient, ship_line1, ship_city, ship_postal_code, ship_country_code)
SELECT 'SH-20261030-0004', 1, 'pending_payment', 2350.00, 0.00, 50.00, 2400.00,
a.recipient_name, a.line1, a.city, a.postal_code, a.country_code
FROM addresses AS a
WHERE a.customer_id = 1 AND a.is_default = 1;
SET @order_id = LAST_INSERT_ID();
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 73: Checkout: copiar os itens do carrinho
Preços no momento da compra.
INSERT INTO order_items
(order_id, variant_id, sku_snapshot, product_name_snapshot, unit_price, quantity)
SELECT @order_id, v.variant_id, v.sku, p.name, v.price, ci.quantity
FROM cart_items AS ci
JOIN product_variants AS v ON v.variant_id = ci.variant_id
JOIN products AS p ON p.product_id = v.product_id
WHERE ci.cart_id = 1;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 74: Checkout: recalcular os totais
Os totais vêm dos itens, não do carrinho.
UPDATE orders AS o
JOIN (
SELECT order_id, SUM(line_total) AS lines_total
FROM order_items
WHERE order_id = @order_id
GROUP BY order_id
) AS t ON t.order_id = o.order_id
SET o.subtotal = t.lines_total,
o.grand_total = t.lines_total - o.discount_total + o.shipping_fee;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 75: Checkout: reservar estoque e gravar no razão
A regra CHECK faz a reserva falhar se faltar estoque.
-- If a line asks for more than is on hand, the CHECK constraint fails this statement with error 3819.
-- The whole statement is undone, and the transaction is rolled back by the caller.
UPDATE inventory AS i
JOIN order_items AS oi ON oi.variant_id = i.variant_id
SET i.reserved = i.reserved + oi.quantity
WHERE oi.order_id = @order_id;
INSERT INTO inventory_movements (variant_id, delta_on_hand, delta_reserved, reason, order_id)
SELECT variant_id, 0, quantity, 'reserve', @order_id
FROM order_items
WHERE order_id = @order_id;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 76: Fazer um pedido em procedimento armazenado
O checkout completo, com rollback em qualquer erro.
DELIMITER $$
CREATE PROCEDURE place_order(
IN p_customer_id BIGINT UNSIGNED,
IN p_cart_id BIGINT UNSIGNED,
IN p_order_number VARCHAR(20),
OUT p_order_id BIGINT UNSIGNED)
BEGIN
DECLARE v_subtotal DECIMAL(12,2) DEFAULT 0;
DECLARE EXIT HANDLER FOR SQLEXCEPTION
BEGIN
ROLLBACK;
RESIGNAL;
END;
START TRANSACTION;
-- 1 lock the stock rows of the cart in ascending variant_id order
SELECT i.variant_id
FROM inventory AS i
JOIN cart_items AS ci ON ci.variant_id = i.variant_id
WHERE ci.cart_id = p_cart_id
ORDER BY i.variant_id
FOR UPDATE;
-- 2 the cart must be open and belong to this customer
IF NOT EXISTS (SELECT 1 FROM carts
WHERE cart_id = p_cart_id AND customer_id = p_customer_id AND status = 'open') THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'cart is not open for this customer';
END IF;
-- 3 subtotal from the current prices
SELECT COALESCE(SUM(v.price * ci.quantity), 0) INTO v_subtotal
FROM cart_items AS ci
JOIN product_variants AS v ON v.variant_id = ci.variant_id
WHERE ci.cart_id = p_cart_id;
IF v_subtotal = 0 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'cart is empty';
END IF;
-- 4 order header with the default address
INSERT INTO orders
(order_number, customer_id, status, subtotal, discount_total, shipping_fee, grand_total,
ship_recipient, ship_line1, ship_city, ship_postal_code, ship_country_code)
SELECT p_order_number, p_customer_id, 'pending_payment', v_subtotal, 0.00, 50.00, v_subtotal + 50.00,
a.recipient_name, a.line1, a.city, a.postal_code, a.country_code
FROM addresses AS a
WHERE a.customer_id = p_customer_id AND a.is_default = 1;
IF ROW_COUNT() = 0 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'customer has no default address';
END IF;
SET p_order_id = LAST_INSERT_ID();
-- 5 order lines with the price snapshot
INSERT INTO order_items
(order_id, variant_id, sku_snapshot, product_name_snapshot, unit_price, quantity)
SELECT p_order_id, v.variant_id, v.sku, p.name, v.price, ci.quantity
FROM cart_items AS ci
JOIN product_variants AS v ON v.variant_id = ci.variant_id
JOIN products AS p ON p.product_id = v.product_id
WHERE ci.cart_id = p_cart_id;
-- 6 reserve stock; the CHECK constraint stops oversell
UPDATE inventory AS i
JOIN order_items AS oi ON oi.variant_id = i.variant_id
SET i.reserved = i.reserved + oi.quantity
WHERE oi.order_id = p_order_id;
-- 7 ledger and cart status
INSERT INTO inventory_movements (variant_id, delta_on_hand, delta_reserved, reason, order_id)
SELECT variant_id, 0, quantity, 'reserve', p_order_id
FROM order_items
WHERE order_id = p_order_id;
UPDATE carts SET status = 'converted' WHERE cart_id = p_cart_id;
COMMIT;
END$$
DELIMITER ;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Tópico 12: Transações: pagamento aprovado, recusado, cancelamento e reembolso
Depois que o pedido é feito, o resultado do pagamento decide o que acontece com a reserva. Uma aprovação transforma a reserva em venda, uma recusa a libera, e um cancelamento ou reembolso devolve a mercadoria. Cada uma dessas transações é escrita de modo que uma mensagem repetida do provedor de pagamento não altere nada na segunda vez.
Pagamento aprovado
O manipulador do webhook inicia uma transação e atualiza o pagamento de pending para succeeded. Se nenhuma linha mudar, é um webhook duplicado: o manipulador faz ROLLBACK e responde 200. Caso contrário, marca o pedido como paid, move a reserva para on_hand subtraindo de on_hand e de reserved ao mesmo tempo, e grava uma linha sale por item. O exemplo 77 mostra a sequência completa.
Pagamento recusado e cancelamento
Um pagamento recusado marca o pagamento como failed, cancela o pedido em pending_payment e libera a reserva subtraindo a quantidade reservada de reserved. A liberação só acontece se a atualização do pedido alterar exatamente uma linha, então a reserva nunca é liberada duas vezes. Cancelar um pedido pago antes da embalagem soma a quantidade de volta a on_hand e grava uma linha cancel, como mostra o exemplo 79.
Reembolso sem segurar bloqueios
O reembolso acontece em duas transações curtas em torno da chamada ao provedor. A primeira registra o reembolso pendente e faz commit. O provedor é chamado sem nenhum bloqueio mantido. A segunda guarda a referência do provedor, marca o reembolso como bem-sucedido e marca o pedido como refunded somente quando os reembolsos bem-sucedidos cobrem o pagamento. O exemplo 81 bloqueia a linha do pagamento para que dois pedidos de reembolso não passem juntos pela verificação do valor reembolsável.
Ilustrações
Exemplo 77: Webhook de pagamento aprovado
Passos protegidos: um webhook repetido não altera nada.
START TRANSACTION;
UPDATE payments
SET status = 'succeeded'
WHERE provider = 'sample-gateway' AND provider_ref = ? AND status = 'pending';
-- ROW_COUNT() = 0: this webhook was already handled. ROLLBACK and answer 200 to the gateway.
UPDATE orders
SET status = 'paid'
WHERE order_id = ? AND status = 'pending_payment';
UPDATE inventory AS i
JOIN order_items AS oi ON oi.variant_id = i.variant_id
SET i.on_hand = i.on_hand - oi.quantity,
i.reserved = i.reserved - oi.quantity
WHERE oi.order_id = ?;
INSERT INTO inventory_movements (variant_id, delta_on_hand, delta_reserved, reason, order_id)
SELECT variant_id, -quantity, -quantity, 'sale', order_id
FROM order_items
WHERE order_id = ?;
COMMIT;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 78: Falha de pagamento libera o estoque
Libere a reserva exatamente uma vez.
START TRANSACTION;
UPDATE payments
SET status = 'failed'
WHERE provider = 'sample-gateway' AND provider_ref = ? AND status = 'pending';
-- ROW_COUNT() = 0: already handled, ROLLBACK.
UPDATE orders
SET status = 'cancelled'
WHERE order_id = ? AND status = 'pending_payment';
-- ROW_COUNT() = 0 here too: ROLLBACK, so the reservation is never released twice.
UPDATE inventory AS i
JOIN order_items AS oi ON oi.variant_id = i.variant_id
SET i.reserved = i.reserved - oi.quantity
WHERE oi.order_id = ?;
INSERT INTO inventory_movements (variant_id, delta_on_hand, delta_reserved, reason, order_id)
SELECT variant_id, 0, -quantity, 'release', order_id
FROM order_items
WHERE order_id = ?;
COMMIT;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 79: Cancelar um pedido pago antes de embalar
O estoque volta a ficar disponível.
START TRANSACTION;
UPDATE orders
SET status = 'cancelled'
WHERE order_id = ? AND status = 'paid';
-- ROW_COUNT() = 0: the order was packed or shipped already. ROLLBACK and show a return flow instead.
UPDATE inventory AS i
JOIN order_items AS oi ON oi.variant_id = i.variant_id
SET i.on_hand = i.on_hand + oi.quantity
WHERE oi.order_id = ?;
INSERT INTO inventory_movements (variant_id, delta_on_hand, delta_reserved, reason, order_id)
SELECT variant_id, quantity, 0, 'cancel', order_id
FROM order_items
WHERE order_id = ?;
COMMIT;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 80: Reembolso ao redor da chamada ao provedor
Nenhum bloqueio de linha fica ativo durante a chamada de rede.
-- Transaction 1: record the intent, then commit before calling the provider.
START TRANSACTION;
SELECT payment_id FROM payments WHERE payment_id = ? AND status = 'succeeded' FOR UPDATE;
INSERT INTO refunds (payment_id, amount, status, reason)
VALUES (?, ?, 'pending', 'Customer returned the item');
COMMIT;
-- The provider call happens here, outside any database transaction, so no row lock is held.
-- Transaction 2: store the provider result and update the order.
START TRANSACTION;
UPDATE refunds
SET status = 'succeeded', provider_ref = ?
WHERE refund_id = ? AND status = 'pending';
UPDATE orders AS o
JOIN payments AS p ON p.order_id = o.order_id AND p.status = 'succeeded'
SET o.status = 'refunded'
WHERE o.order_id = ?
AND o.status IN ('paid','packed','shipped','delivered')
AND (SELECT COALESCE(SUM(r.amount), 0) FROM refunds AS r
WHERE r.payment_id = p.payment_id AND r.status = 'succeeded') >= p.amount;
COMMIT;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 81: Limite de reembolso com bloqueio de linha
Dois pedidos de reembolso não passam juntos na verificação.
START TRANSACTION;
SELECT amount FROM payments WHERE payment_id = ? FOR UPDATE;
SELECT COALESCE(SUM(CASE WHEN status <> 'failed' THEN amount END), 0) AS already_refunded
FROM refunds
WHERE payment_id = ?;
-- The application compares the requested amount with (amount - already_refunded). ROLLBACK when it is too large.
INSERT INTO refunds (payment_id, amount, status, reason)
VALUES (?, ?, 'pending', ?);
COMMIT;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 82: Devolver mercadoria ao estoque
Somente depois de inspecionada e quando puder ser revendida.
START TRANSACTION;
UPDATE inventory
SET on_hand = on_hand + 1
WHERE variant_id = 1;
INSERT INTO inventory_movements (variant_id, delta_on_hand, delta_reserved, reason, order_id)
VALUES (1, 1, 0, 'return', 1);
COMMIT;
-- Restock only when the goods are inspected and resaleable. Otherwise write an adjustment instead.
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Tópico 13: Concorrência: dois compradores e o último item
Muitos compradores podem ler o mesmo estoque no mesmo momento. Se o código lê um número, decide e só depois grava, dois compradores podem ver uma única unidade e comprá-la os dois. Este tópico mostra o erro, duas formas de corrigi-lo, os tipos de bloqueio que o MySQL 8 oferece e o deadlock que aparece quando os bloqueios são tomados em ordens diferentes.
Dois compradores, um item
O comprador A lê available = 1, e o comprador B também lê available = 1. Os dois aumentam reserved em um. Sem proteção, reserved vira 2 enquanto on_hand continua 1, o que é uma venda acima do estoque. A restrição CHECK interrompe a segunda gravação com o erro 3819, mas isso é uma rede de segurança, não a correção completa. A correção de verdade está na instrução que verifica e grava ao mesmo tempo.
Bloqueios: FOR UPDATE, NOWAIT e SKIP LOCKED
SELECT ... FOR UPDATE faz as outras transações esperarem até esta fazer commit. NOWAIT falha imediatamente com o erro 3572 em vez de esperar, o que combina com uma tela que o usuário vê. SKIP LOCKED permite que um worker pegue as próximas tarefas que nenhum outro worker está segurando, o que combina com uma fila de verificação de pagamentos. Use SELECT ... FOR SHARE apenas quando precisar ler uma linha e garantir que ela não mudará.
Deadlock e bloqueio otimista
Um deadlock acontece quando duas transações seguram, cada uma, um bloqueio que a outra precisa. O InnoDB detecta isso e desfaz uma das duas com o erro 1213, então a aplicação precisa repetir a transação inteira. O remédio é a mesma ordem de bloqueio em todo lugar. Para a edição de produtos, que raramente disputa, o bloqueio otimista com uma coluna version evita segurar bloqueios: o UPDATE inclui a versão lida, e se nenhuma linha mudar é porque outra pessoa salvou antes.
Exemplo 83: Reproduzir a venda acima do estoque
Dois compradores, uma unidade e nenhuma proteção.
-- Setup: one unit of variant 2 is left.
UPDATE inventory SET on_hand = 1, reserved = 0 WHERE variant_id = 2;
-- Buyer A and buyer B run these two statements at nearly the same time (no locking, no transaction):
SELECT on_hand - reserved AS available FROM inventory WHERE variant_id = 2; -- both read 1
UPDATE inventory SET reserved = reserved + 1 WHERE variant_id = 2; -- both write
-- Without the CHECK constraint, reserved would become 2 while on_hand is 1: an oversell.
-- With it, the second write fails with error 3819.
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 84: O último item: UPDATE atômico
Uma instrução condicional entrega o item a um só comprador.
UPDATE inventory
SET reserved = reserved + 1
WHERE variant_id = 2
AND on_hand - reserved >= 1;
-- ROW_COUNT() = 1: this buyer got the item.
-- ROW_COUNT() = 0: sold out. Show "sold out" and do not create an order.
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 85: NOWAIT e SKIP LOCKED
Falhar logo, ou deixar os workers pularem linhas bloqueadas.
-- NOWAIT: error 3572 at once if another transaction holds the row.
START TRANSACTION;
SELECT on_hand, reserved FROM inventory WHERE variant_id = 2 FOR UPDATE NOWAIT;
ROLLBACK;
-- SKIP LOCKED: a worker takes up to 10 pending payment checks that no other worker holds.
START TRANSACTION;
SELECT payment_id FROM payments
WHERE status = 'pending'
ORDER BY payment_id
LIMIT 10
FOR UPDATE SKIP LOCKED;
-- ...check each payment with the provider, update its row, then:
COMMIT;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 86: Inspecionar os bloqueios
O performance_schema mostra quem segura e quem espera o quê.
SELECT engine_transaction_id, object_name, index_name, lock_type, lock_mode, lock_status, lock_data
FROM performance_schema.data_locks
WHERE object_name = 'inventory';
SELECT requesting_engine_transaction_id, blocking_engine_transaction_id
FROM performance_schema.data_lock_waits;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 87: Deadlock e a correção
Bloquear as linhas na mesma ordem em todo lugar.
-- Session A -- Session B
START TRANSACTION; START TRANSACTION;
UPDATE inventory SET reserved = reserved + 1 UPDATE inventory SET reserved = reserved + 1
WHERE variant_id = 1; WHERE variant_id = 4;
UPDATE inventory SET reserved = reserved + 1 UPDATE inventory SET reserved = reserved + 1
WHERE variant_id = 4; -- waits for B WHERE variant_id = 1; -- deadlock
-- One session gets error 1213 and is rolled back. The fix: both sessions lock in the same order (1, then 4).
-- Inspect the last deadlock with: SHOW ENGINE INNODB STATUS;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 88: Bloqueio otimista com coluna de versão
Detectar atualização perdida sem segurar bloqueios.
ALTER TABLE products ADD COLUMN version INT UNSIGNED NOT NULL DEFAULT 0;
UPDATE products
SET base_price = 420.00, version = version + 1
WHERE product_id = 1 AND version = 0;
-- ROW_COUNT() = 0: someone else changed the product first. Reload it and try again.
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Tópico 14: Índices e planos de consulta
Um índice é uma cópia ordenada de algumas colunas que permite ao MySQL encontrar linhas sem ler a tabela inteira. Mas todo índice também deixa as gravações mais lentas e ocupa espaço, então cada um precisa de um motivo. Este tópico explica como escolher a ordem das colunas, como ler EXPLAIN e EXPLAIN ANALYZE e como encontrar índices que ninguém usa.
Escolha a ordem do índice composto
Um índice composto funciona da esquerda para a direita. O índice (customer_id, created_at) atende a uma consulta que filtra por customer_id e ordena por created_at. Ele não atende a uma consulta que filtra só por created_at. Coloque as colunas de igualdade primeiro e a coluna de ordenação depois. Use um índice descendente, como (created_at DESC, order_id DESC), quando as linhas mais novas são lidas com mais frequência.
Leia o EXPLAIN
EXPLAIN mostra o tipo de acesso, o índice escolhido, o número de linhas estimado e se é preciso uma tabela temporária ou um filesort. EXPLAIN ANALYZE, disponível desde o MySQL 8.0.18, executa a consulta de fato e mostra o tempo e o número real de linhas de cada etapa. Compare a estimativa com o número real. Uma diferença grande costuma indicar estatísticas desatualizadas, e ANALYZE TABLE resolve isso.
Encontre índices não usados e redundantes
As views do schema sys, sys.schema_unused_indexes e sys.schema_redundant_indexes, listam candidatos. Um índice (status) é redundante quando (status, created_at) já existe. Os contadores são zerados quando o servidor reinicia, então verifique ao longo de um ciclo completo do negócio, como o fechamento do mês, antes de remover um índice. Um índice invisível permite testar a remoção sem perder o índice.
Exemplo 89: Testar um índice invisível
Verifique o plano antes de remover um índice.
ALTER TABLE orders ALTER INDEX idx_orders_status_created INVISIBLE;
EXPLAIN SELECT order_id FROM orders WHERE status = 'paid' ORDER BY created_at LIMIT 50;
-- Bring it back if the plans get worse:
ALTER TABLE orders ALTER INDEX idx_orders_status_created VISIBLE;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 90: Índice de cobertura para o menor preço
Responder à consulta só com o índice.
CREATE INDEX idx_variants_product_active_price
ON product_variants (product_id, is_active, price);
EXPLAIN SELECT product_id, MIN(price)
FROM product_variants
WHERE is_active = 1
GROUP BY product_id;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 91: EXPLAIN ANALYZE e histogramas
Medir o trabalho real e ajudar o otimizador.
EXPLAIN ANALYZE
SELECT order_id, order_number, created_at
FROM orders
WHERE customer_id = 1
ORDER BY created_at DESC
LIMIT 20;
ANALYZE TABLE orders UPDATE HISTOGRAM ON status;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 92: Índices não usados e redundantes
O esquema sys encontra candidatos a remoção.
SELECT * FROM sys.schema_unused_indexes WHERE object_schema = 'shop';
SELECT * FROM sys.schema_redundant_indexes WHERE table_schema = 'shop';
-- Usage counters restart when the server restarts. Check over a full business cycle before you drop an index.
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Tópico 15: Views, relatórios e particionamento
As telas e os relatórios fazem as mesmas perguntas repetidamente. As views dão nome a essas perguntas, os relatórios resumem as vendas, e o particionamento torna gerenciáveis as tabelas de histórico muito grandes. Este tópico cobre as views da tela de estoque e da lista do administrador, um relatório mensal com totais e um log de eventos particionado.
Views escondem os JOINs
Uma view como v_available_stock junta inventory, product_variants e products uma única vez, então cada tela não precisa repetir o JOIN. A view não é um cache: o MySQL executa o SELECT da view toda vez, e uma view com JOIN de várias tabelas não pode ser atualizada. Dê ao papel de relatórios permissão de SELECT na view, não nas tabelas base.
Relatórios
Um relatório mensal de vendas soma grand_total dos pedidos pagos ou em status posterior, agrupados por mês. GROUP BY ... WITH ROLLUP acrescenta uma linha de total. Os relatórios leem as tabelas ativas, então rode os pesados em uma réplica de leitura ou à noite, e nunca deixe um relatório segurar bloqueios que travem o checkout.
Partições para o histórico
PARTITION BY RANGE COLUMNS divide a tabela em períodos, aqui por created_at. A chave primária precisa incluir a coluna de partição, então fica (event_id, created_at). Apagar uma partição antiga remove um mês inteiro de uma vez e evita um DELETE longo que enche o undo log. O particionamento ajuda em tabelas de histórico grandes, mas não substitui um bom índice.
Exemplo 93: Visão de estoque
Uma visão que esconde o JOIN do código da tela.
CREATE OR REPLACE VIEW v_available_stock AS
SELECT v.variant_id, v.sku, p.name AS product_name,
i.on_hand, i.reserved, i.on_hand - i.reserved AS available
FROM inventory AS i
JOIN product_variants AS v ON v.variant_id = i.variant_id
JOIN products AS p ON p.product_id = v.product_id;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 94: Visão de resumo dos pedidos
Uma linha por pedido para a tela do administrador.
CREATE OR REPLACE VIEW v_order_summary AS
SELECT o.order_id, o.order_number, o.status, c.full_name AS customer_name,
o.grand_total, o.created_at,
COUNT(oi.order_item_id) AS line_count,
COALESCE(SUM(oi.quantity), 0) AS unit_count
FROM orders AS o
JOIN customers AS c ON c.customer_id = o.customer_id
LEFT JOIN order_items AS oi ON oi.order_id = o.order_id
GROUP BY o.order_id, o.order_number, o.status, c.full_name, o.grand_total, o.created_at;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 95: Vendas mensais com linha de total
GROUP BY WITH ROLLUP.
SELECT DATE_FORMAT(created_at, '%Y-%m') AS month,
COUNT(*) AS orders,
SUM(grand_total) AS revenue
FROM orders
WHERE status IN ('paid','packed','shipped','delivered')
GROUP BY month WITH ROLLUP;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 96: Log de eventos particionado
Partições por intervalo, e remoção barata de meses antigos.
CREATE TABLE payment_events (
event_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
payment_id BIGINT UNSIGNED NOT NULL,
event_type VARCHAR(40) NOT NULL,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (event_id, created_at)
) ENGINE=InnoDB
PARTITION BY RANGE COLUMNS (created_at) (
PARTITION p2026h1 VALUES LESS THAN ('2026-07-01'),
PARTITION p2026h2 VALUES LESS THAN ('2027-01-01'),
PARTITION pmax VALUES LESS THAN (MAXVALUE)
);
-- Later: remove a whole old period at once.
-- ALTER TABLE payment_events DROP PARTITION p2026h1;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Tópico 16: Prática profissional: migrações, auditoria, backups e privilégios
Um banco em produção muda com o tempo, pode ser danificado e guarda dados pessoais. Prática profissional significa que as mudanças são versionadas e revisadas, que toda mudança de status é registrada, que os backups são testados com uma restauração, e que cada programa tem apenas os direitos de que precisa. Este tópico cobre os hábitos que separam um projeto de aprendizado de uma loja em que as pessoas confiam.
Migrações versionadas
Cada mudança de esquema é um arquivo numerado que roda uma vez, em ordem. A tabela schema_migrations registra a versão e um checksum do arquivo, então uma migração editada é detectada. Nunca edite uma migração que já rodou em produção; escreva uma nova que a corrija.
Trilhas de auditoria e backups
Um gatilho AFTER UPDATE em orders grava cada mudança de status, com o usuário e a hora, em order_audit. Um backup com mysqldump --single-transaction dá um retrato consistente das tabelas InnoDB sem bloqueá-las. Um backup que nunca foi restaurado não é um backup: restaure-o em um banco de teste vazio e compare a quantidade de linhas.
Privilégio mínimo
O usuário da aplicação tem SELECT, INSERT, UPDATE e DELETE na loja, mas não tem DROP nem ALTER. Os relatórios usam um papel que lê apenas duas views. Mantenha contas de administrador para as migrações, guarde todas as senhas em um cofre de segredos e revise os privilégios com SHOW GRANTS a cada trimestre.
Exemplo 97: Migrações versionadas
Cada mudança roda uma vez, em ordem, com checksum.
CREATE TABLE schema_migrations (
version VARCHAR(20) NOT NULL,
checksum CHAR(64) NOT NULL,
applied_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (version)
) ENGINE=InnoDB;
-- File V003__add_order_audit.sql: each migration runs once, in order, and the checksum detects edits.
-- The migration tool inserts ('V003', '<sha256 of the file>') after it succeeds.
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 98: Gatilho de auditoria do status do pedido
Cada mudança de status é registrada com quem a fez.
CREATE TABLE order_audit (
audit_id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
order_id BIGINT UNSIGNED NOT NULL,
old_status VARCHAR(30) NOT NULL,
new_status VARCHAR(30) NOT NULL,
changed_by VARCHAR(100) NOT NULL,
changed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
PRIMARY KEY (audit_id),
KEY idx_order_audit_order (order_id, changed_at)
) ENGINE=InnoDB;
DELIMITER $$
CREATE TRIGGER trg_orders_status_audit
AFTER UPDATE ON orders
FOR EACH ROW
BEGIN
IF OLD.status <> NEW.status THEN
INSERT INTO order_audit (order_id, old_status, new_status, changed_by)
VALUES (OLD.order_id, OLD.status, NEW.status, CURRENT_USER());
END IF;
END$$
DELIMITER ;
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 99: Backup e restauração
mysqldump com --single-transaction e depois uma restauração de teste.
# Back up: --single-transaction gives a consistent view of InnoDB tables without locking them.
mysqldump --single-transaction --routines --triggers --events --set-gtid-purged=OFF \
-u shop_admin -p shop > shop-2026-10-30.sql
# Restore into an empty test database first, then check row counts before using it.
mysql -u shop_admin -p shop_restore < shop-2026-10-30.sql
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Exemplo 100: Papel de relatórios com visões somente leitura
Privilégio mínimo para os relatórios.
CREATE ROLE IF NOT EXISTS 'shop_reporting';
GRANT SELECT ON shop.v_order_summary TO 'shop_reporting';
GRANT SELECT ON shop.v_available_stock TO 'shop_reporting';
CREATE USER IF NOT EXISTS 'report_user'@'localhost' IDENTIFIED BY '<strong-password>';
GRANT 'shop_reporting' TO 'report_user'@'localhost';
SET DEFAULT ROLE 'shop_reporting' TO 'report_user'@'localhost';
SHOW GRANTS FOR 'report_user'@'localhost' USING 'shop_reporting';
*Este exemplo não foi executado aqui, porque o MySQL 8 não estava instalado no computador que preparou este artigo.*
Laboratório: monte o banco da loja e execute um checkout
Reúna o artigo em um banco de treino. Você vai criar o esquema, carregar os dados de exemplo, executar um checkout com pagamento aprovado e tentar quebrar as regras de estoque de propósito. Cada passo abaixo é uma verificação manual que você executa, porque nenhum dos exemplos foi executado durante a preparação deste artigo.
1. Crie o banco shop_practice com utf8mb4 e, em seguida, crie um usuário que tenha apenas SELECT, INSERT, UPDATE e DELETE. Execute os exemplos de 1 a 6.
2. Crie as tabelas customers, addresses, categories, products, product_variants, inventory, inventory_movements, carts e cart_items com os exemplos de 7 a 32, depois carregue os dados iniciais dos exemplos 9, 10, 18, 19, 25 e 33.
3. Crie as tabelas orders, order_items, payments e refunds com os exemplos 39, 40, 47 e 48, depois execute as consultas de histórico de pedidos dos exemplos 44 a 46.
4. Execute o checkout do exemplo 76 para o cliente 1 e o carrinho 1, depois confira ROW_COUNT e as linhas de inventory com os exemplos 26 e 30.
5. Execute o exemplo 77 duas vezes com o mesmo provider_ref e confirme que a segunda execução não muda nada. Este é o teste de idempotência.
6. Execute os exemplos 83 e 84 em duas sessões do MySQL, uma para cada comprador, e confirme que apenas um comprador fica com a última unidade.
7. Anote o que a sua execução mostrou para cada exemplo e compare com o comportamento esperado neste artigo antes de usar qualquer código em uma loja real.
Próximos passos
Depois deste artigo, estude a camada de aplicação que chama essas transações: uma API REST, validação de entradas e novas tentativas após um deadlock. Depois leia a documentação de webhooks do seu provedor de pagamento, porque é no manipulador de webhooks que aparecem a maioria dos bugs reais de uma loja. Por fim, estude a replicação do MySQL e as réplicas de leitura, que tiram os relatórios do banco do checkout.
Resumo
O banco de uma loja se apoia em algumas regras: copie preços e endereços para os pedidos, guarde o estoque como contadores com restrições CHECK e um razão que só recebe inserções, e faça de cada pagamento e de cada mudança de estoque uma transação segura para repetir. Bloqueie as linhas sempre na mesma ordem, mantenha as transações curtas e nunca chame o provedor de pagamento enquanto segura bloqueios. Escolha os índices pelas consultas que as telas realmente executam, verifique com EXPLAIN e versione cada mudança de esquema. Nenhum dos 100 exemplos foi executado enquanto este artigo era escrito, então teste cada um em um banco de treino antes de confiar nele.