Extensão — MySQL: Driver, Dialeto e as Diferenças que Importam

Extensão — MySQL: Driver, Dialeto e as Diferenças que Importam

As Trilhas da Ilha no MySQL 9.7, com ou sem Docker: colação que ignora acentos e muda o UNIQUE, o "utf8" que recusa emojis, JSON com índice de múltiplos valores no lugar de arrays, DDL que confirma a transação sozinho, modo estrito e chaves geradas pelo driver oficial.
Java

• • 28 min de leitura

Nesta aula o projeto das Trilhas da Ilha roda no MySQL. O foco são as diferenças que causam problemas na prática: comparações que ignoram acentos e maiúsculas, a codificação "utf8" que não guarda emojis, arrays substituídos por JSON com índices de múltiplos valores, comandos de esquema que confirmam transações sozinhos e um modo antigo que aceita dados errados em silêncio.

"PostgreSQL" levou o projeto para um banco servidor e mostrou o que ele oferece além do SQL comum. O MySQL é o outro grande banco relacional de código aberto, e em muitos lugares é o mais comum. Ele está por trás de hospedagens compartilhadas, do WordPress e de uma quantidade enorme de sistemas construídos nos anos 2000 e 2010. Um desenvolvedor Java vai encontrá-lo cedo ou tarde, muitas vezes num sistema que já existe. Esta aula não repete o que é igual nos dois bancos. Ela se concentra no que é diferente, porque é aí que moram os defeitos: um código que funciona no PostgreSQL pode se comportar de outro jeito no MySQL sem nenhum erro de compilação.

Um pouco de história

O MySQL foi criado em 1995 na Suécia por Michael "Monty" Widenius, David Axmark e Allan Larsson, da empresa MySQL AB. O nome vem de My, a filha de Widenius. Rápido, simples de instalar e gratuito, ele se tornou o "M" da combinação LAMP (Linux, Apache, MySQL, PHP), que dominou a Web na década de 2000. A Sun Microsystems comprou a MySQL AB em 2008 por um bilhão de dólares, e em 2010 a própria Sun foi comprada pela Oracle. Temendo pelo futuro do projeto sob a empresa dona do maior banco comercial do mundo, Widenius criou em 2009 um derivado, o MariaDB, batizado em homenagem à outra filha. Os dois ainda são compatíveis na maior parte do uso comum, mas vêm se afastando a cada versão.

O MySQL hoje segue dois trilhos de versões: as de suporte de longo prazo (LTS), das quais a atual é a 9.7, de abril de 2026, e as de inovação, com recursos novos e vida curta, que desde julho de 2026 são numeradas pelo ano e pelo mês (a primeira foi a 26.7). A LTS anterior, a 8.4, de abril de 2024, continua com suporte, e a série 8.0, usada por muitos sistemas, chegou ao fim do suporte em abril de 2026. O projeto das Trilhas da Ilha usa a 9.7, e os exemplos desta aula foram executados num MySQL 9.7.0.

Subindo o MySQL

Como em "PostgreSQL", há dois caminhos, e os dois terminam num banco trilhas, de um usuário trilhas com a senha trilhas, na porta 3306 da sua máquina.

Com o MySQL instalado. No Windows, o instalador do site oficial já traz o cliente mysql; no Linux, basta o pacote mysql-server da distribuição; no macOS, o Homebrew. O usuário e o banco são criados uma única vez, com o cliente conectado como administrador:

# Linux (pacote da distribuição): o root do MySQL entra pelo root do sistema
sudo mysql

# Windows e macOS: com a senha do root definida na instalação (no Homebrew, vazia)
mysql -u root -p
mysql> CREATE DATABASE trilhas;
Query OK, 1 row affected (0.000 sec)

mysql> CREATE USER trilhas IDENTIFIED BY 'trilhas';
Query OK, 0 rows affected (0.002 sec)

mysql> GRANT ALL ON trilhas.* TO trilhas;
Query OK, 0 rows affected (0.000 sec)

mysql> exit

Daí em diante, a conexão usa o usuário novo, e o cliente pede a senha: mysql -h 127.0.0.1 -u trilhas -p trilhas. Os exemplos desta aula foram executados assim, sem contêiner.

Com Docker. O serviço entra no mesmo compose.yaml de "PostgreSQL":

mysql:
  image: mysql:9.7
  environment:
    MYSQL_DATABASE: trilhas
    MYSQL_USER: trilhas
    MYSQL_PASSWORD: trilhas
    MYSQL_ROOT_PASSWORD: raiz
  ports:
    - "3306:3306"
  volumes:
    - dados-mysql:/var/lib/mysql

E o volume dados-mysql é declarado ao lado do dados-postgres. O cliente de linha de comando, dentro do contêiner: docker compose exec mysql mysql -utrilhas -ptrilhas trilhas.

O esquema no dialeto do MySQL

A migração inicial, em db/migration/mysql/V1__esquema_inicial.sql:

CREATE TABLE trilha (
    id              BIGINT       NOT NULL AUTO_INCREMENT PRIMARY KEY,
    nome            VARCHAR(200) NOT NULL UNIQUE,
    regiao          VARCHAR(50)  NOT NULL,
    distancia_km    DOUBLE       NOT NULL CHECK (distancia_km > 0),
    dificuldade     VARCHAR(10)  NOT NULL CHECK (dificuldade IN ('FACIL', 'MODERADA', 'DIFICIL')),
    duracao_min     INT          NOT NULL CHECK (duracao_min > 0),
    altitude_max_m  INT          CHECK (altitude_max_m > 0),
    etiquetas       JSON         NOT NULL,   -- sem DEFAULT: ver o texto
    detalhes        JSON         NOT NULL DEFAULT (JSON_OBJECT()),
    descricao       TEXT         NOT NULL,
    cadastrada_em   TIMESTAMP    NOT NULL DEFAULT CURRENT_TIMESTAMP,
    INDEX idx_trilha_etiquetas ((CAST(etiquetas AS CHAR(40) ARRAY)))
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4 COLLATE = utf8mb4_0900_ai_ci;

CREATE TABLE ponto_de_interesse (
    trilha_id  BIGINT       NOT NULL,
    ordem      INT          NOT NULL,
    nome       VARCHAR(100) NOT NULL,
    PRIMARY KEY (trilha_id, ordem),
    FOREIGN KEY (trilha_id) REFERENCES trilha (id) ON DELETE CASCADE
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;

CREATE TABLE avaliacao (
    id          BIGINT   NOT NULL AUTO_INCREMENT PRIMARY KEY,
    trilha_id   BIGINT   NOT NULL,
    autor       VARCHAR(100) NOT NULL,
    nota        INT      NOT NULL CHECK (nota BETWEEN 1 AND 5),
    comentario  TEXT,
    data        DATE     NOT NULL,
    FOREIGN KEY (trilha_id) REFERENCES trilha (id) ON DELETE CASCADE
) ENGINE = InnoDB DEFAULT CHARSET = utf8mb4;

As consultas desta aula usam as três primeiras trilhas de "PostgreSQL", com as etiquetas agora num array JSON:

INSERT INTO trilha (nome, regiao, distancia_km, dificuldade, duracao_min, altitude_max_m, etiquetas, detalhes, descricao) VALUES
  ('Lagoinha do Leste', 'Sul', 4.5, 'MODERADA', 120, 310, '["praia", "mirante", "camping"]',
   '{"estacionamento": true, "agua": false, "cachorros": false, "guia": {"obrigatorio": false}}',
   'Travessia pela mata até uma praia isolada, com uma lagoa de água doce atrás da areia.'),
  ('Costão do Santinho', 'Norte', 3.2, 'FACIL', 90, NULL, '["praia", "arqueologia"]',
   '{"estacionamento": true, "agua": true, "cachorros": true}',
   'Caminho curto pelo costão, com inscrições rupestres gravadas nas rochas.'),
  ('Morro da Coroa', 'Leste', 6.8, 'DIFICIL', 210, 480, '["mata", "cachoeira"]',
   '{"estacionamento": false, "agua": true, "guia": {"obrigatorio": true, "contato": "Associação de Guias"}}',
   'Trilha longa e íngreme na mata fechada, com uma cachoeira no topo.');

As diferenças visíveis contam a história do MySQL. A chave usa AUTO_INCREMENT, a forma própria do MySQL, anterior ao IDENTITY do padrão. O nome da trilha é VARCHAR(200), e não TEXT: o MySQL não permite um índice comum, como o que o UNIQUE cria, sobre uma coluna TEXT inteira, sem um tamanho de prefixo, então colunas únicas e indexadas precisam de um tamanho máximo declarado. O ENGINE = InnoDB escolhe o mecanismo de armazenamento, uma ideia própria do MySQL, em que cada tabela pode usar um mecanismo diferente. O InnoDB é o padrão desde 2010 e o único que oferece transações e chaves estrangeiras. O antigo MyISAM, que não tinha nenhuma das duas coisas, ainda aparece em sistemas antigos e é a origem de muitas histórias de dados inconsistentes.

As restrições CHECK merecem uma nota histórica. Até a versão 8.0.16, de 2019, o MySQL aceitava a sintaxe do CHECK e simplesmente a ignorava, sem aviso. Um esquema antigo com CHECK pode, portanto, conter dados que violam as próprias restrições, gravados antes da atualização.

Sem arrays: JSON e índices de múltiplos valores

O MySQL não tem um tipo array. As etiquetas vão para uma coluna JSON, que o MySQL guarda num formato binário otimizado, na mesma linha do JSONB do PostgreSQL. Para consultar "trilhas com a etiqueta praia" com índice, o MySQL oferece desde a versão 8.0.17 os índices de múltiplos valores, declarados na última linha da tabela: CAST(etiquetas AS CHAR(40) ARRAY) cria uma entrada de índice para cada elemento do array JSON, como o GIN faz. A consulta usa o operador MEMBER OF:

mysql> SELECT nome, etiquetas FROM trilha WHERE 'praia' MEMBER OF (etiquetas) ORDER BY nome;
+---------------------+---------------------------------+
| nome                | etiquetas                       |
+---------------------+---------------------------------+
| Costão do Santinho  | ["praia", "arqueologia"]        |
| Lagoinha do Leste   | ["praia", "mirante", "camping"] |
+---------------------+---------------------------------+

mysql> EXPLAIN SELECT nome FROM trilha WHERE 'praia' MEMBER OF (etiquetas)\G
*************************** 1. row ***************************
EXPLAIN: -> Filter: json'"praia"' member of (cast(etiquetas as char(40) array))  (cost=0.7 rows=2)
    -> Index lookup on trilha using idx_trilha_etiquetas (cast(etiquetas as char(40) array) = json'"praia"')  (cost=0.7 rows=2)

O Index lookup ... using idx_trilha_etiquetas mostra o índice em uso. No 9.7, o formato padrão do EXPLAIN é essa árvore; com EXPLAIN FORMAT=TRADITIONAL sai a tabela antiga, em que o índice aparece na coluna key. Os outros operadores de "PostgreSQL" têm equivalentes: o JSON_CONTAINS faz o papel do @>, e os operadores -> e ->>, com caminhos no formato $.campo, extraem valores:

mysql> SELECT nome, detalhes ->> '$.agua' AS agua, detalhes -> '$.guia.obrigatorio' AS guia FROM trilha ORDER BY id;

+---------------------+-------+-------+

| nome                | agua  | guia  |
+---------------------+-------+-------+
| Lagoinha do Leste   | false | false |
| Costão do Santinho  | true  | NULL  |
| Morro da Coroa      | true  | true  |
+---------------------+-------+-------+

mysql> SELECT nome FROM trilha WHERE JSON_CONTAINS(detalhes, '{"estacionamento": true, "cachorros": true}');
+---------------------+
| nome                |
+---------------------+
| Costão do Santinho  |
+---------------------+

A coluna etiquetas ficou sem DEFAULT, e o motivo é uma armadilha encontrada ao testar esta aula. Com DEFAULT (JSON_ARRAY()) e o índice de múltiplos valores juntos, um INSERT que omite as etiquetas falha no 9.7.0 com ERROR 3903 (22018): Invalid JSON value for CAST for functional index 'idx_trilha_etiquetas', enquanto o mesmo INSERT com '[]' explícito funciona. A solução mais simples é não ter valor padrão e sempre informar as etiquetas, o que a aplicação faz de qualquer forma. É um lembrete de que as combinações menos comuns de recursos são as menos testadas, em qualquer banco.

Comparações que ignoram acentos

A diferença mais surpreendente para quem vem do PostgreSQL é a colação (collation), a regra que decide como textos são comparados e ordenados. A colação padrão do MySQL 8, utf8mb4_0900_ai_ci, tem dois sufixos importantes: ai (accent insensitive) ignora acentos, e ci (case insensitive) ignora maiúsculas e minúsculas:

mysql> SELECT 'Joao' = 'João' AS sem_acento, 'LAGOINHA' = 'lagoinha' AS sem_caixa;
+------------+-----------+
| sem_acento | sem_caixa |
+------------+-----------+
|          1 |         1 |
+------------+-----------+

mysql> SELECT nome FROM trilha WHERE nome = 'costao do santinho';
+---------------------+
| nome                |
+---------------------+
| Costão do Santinho  |
+---------------------+

mysql> INSERT INTO trilha (nome, regiao, distancia_km, dificuldade, duracao_min, etiquetas, descricao)
    -> VALUES ('LAGOINHA DO LESTE', 'Sul', 4, 'FACIL', 30, '[]', '');
ERROR 1062 (23000): Duplicate entry 'LAGOINHA DO LESTE' for key 'trilha.nome'

Para uma busca feita por pessoas, isso é conveniente: quem digita "costao" encontra o "Costão". Mas a mesma regra vale para o UNIQUE: "LAGOINHA DO LESTE" foi recusada como duplicata de "Lagoinha do Leste". No PostgreSQL, com a colação padrão, os dois nomes seriam diferentes, e as duas trilhas seriam aceitas. A mesma migração e o mesmo código produzem regras de negócio diferentes em cada banco. Nenhum dos dois comportamentos é errado, mas o sistema precisa escolher um, e o escolhido precisa ser testado no banco real. Para comparações exatas numa coluna específica, o MySQL oferece colações binárias ou sensíveis a acentos, como utf8mb4_0900_as_cs, e o PostgreSQL oferece colações não determinísticas ou a extensão citext para o caso oposto.

O "utf8" que não é UTF-8

A segunda armadilha clássica do MySQL é a codificação chamada utf8. Por razões históricas, ela guarda no máximo três bytes por caractere, e o UTF-8 de verdade usa até quatro. Os caracteres de quatro bytes, que incluem os emojis, não cabem:

mysql> CREATE TABLE emoji_mb3 (texto VARCHAR(50)) CHARSET = utf8mb3;
mysql> INSERT INTO emoji_mb3 VALUES ('Vista do mar 🌊');
ERROR 1366 (HY000): Incorrect string value: '\xF0\x9F\x8C\x8A' for column 'texto' at row 1

A codificação completa se chama utf8mb4, e a antiga foi renomeada para utf8mb3, com o nome utf8 mantido como apelido descontinuado. Desde o MySQL 8.0, o padrão do servidor é o utf8mb4, mas bancos criados em versões antigas, e esquemas copiados de tutoriais antigos, ainda usam o utf8 curto, e o primeiro visitante que escrever um comentário com um emoji vai receber um erro 500. As migrações desta aula declaram utf8mb4 em todas as tabelas para não depender do padrão do servidor.

A codificação também vale para a conexão. O cliente mysql do 9.7 conversa em utf8mb4, mas um arquivo de configuração antigo, ou a opção --default-character-set=latin1, faz o cliente declarar latin1 enquanto o terminal envia UTF-8. O servidor então converte cada byte do "ã" como se fosse um caractere latin1, e o que fica gravado é "Costão do Santinho": a consulta por "costao do santinho" não encontra mais nada. Os dados precisam ser recriados com o cliente em utf8mb4. O driver JDBC oficial usa utf8mb4 por padrão nas versões atuais, e o problema aparece mais com ferramentas e scripts do que com a aplicação Java, mas os dados corrompidos por eles ficam no mesmo banco.

O modo estrito e o GROUP BY

O MySQL tem uma configuração que muda o comportamento de todo o servidor, o sql_mode. O padrão atual é estrito, e o exemplo abaixo mostra o que acontece sem ele. Um ponto de interesse com um nome de 132 caracteres, numa coluna VARCHAR(100):

mysql> SET SESSION sql_mode = '';

mysql> INSERT INTO ponto_de_interesse (trilha_id, ordem, nome)
    -> VALUES (1, 1, CONCAT('Mirante ', REPEAT('muito ', 20), 'alto'));
Query OK, 1 row affected, 1 warning
mysql> SHOW WARNINGS;
+---------+------+-------------------------------------------+
| Level   | Code | Message                                   |
+---------+------+-------------------------------------------+
| Warning | 1265 | Data truncated for column 'nome' at row 1 |
+---------+------+-------------------------------------------+
mysql> SELECT CHAR_LENGTH(nome) AS tamanho FROM ponto_de_interesse WHERE trilha_id = 1;
+---------+
| tamanho |
+---------+
|     100 |
+---------+

mysql> SET SESSION sql_mode = DEFAULT;
mysql> INSERT INTO ponto_de_interesse (trilha_id, ordem, nome)
    -> VALUES (1, 1, CONCAT('Mirante ', REPEAT('muito ', 20), 'alto'));
ERROR 1406 (22001): Data too long for column 'nome' at row 1

Sem o modo estrito, o MySQL cortou o texto em 100 caracteres, gravou e emitiu só um aviso, que o JDBC não transforma em exceção. O dado perdido nunca mais volta. No modo estrito, o padrão desde o MySQL 5.7, o mesmo comando falha com um erro claro. Muitos sistemas antigos rodam com sql_mode vazio por compatibilidade, e migrar um sistema desses para o modo estrito costuma revelar anos de dados cortados em silêncio.

Outro componente do modo padrão é o ONLY_FULL_GROUP_BY:

mysql> SELECT regiao, nome, AVG(distancia_km) FROM trilha GROUP BY regiao;
ERROR 1055 (42000): Expression #2 of SELECT list is not in GROUP BY clause and contains nonaggregated
column 'trilhas.trilha.nome' which is not functionally dependent on columns in GROUP BY clause;
this is incompatible with sql_mode=only_full_group_by

A consulta pede o nome de cada região, mas uma região tem várias trilhas, e não há "o" nome dela. As versões antigas do MySQL aceitavam a consulta e devolviam o nome de uma trilha qualquer do grupo, um resultado arbitrário que parecia correto. O PostgreSQL sempre recusou essa consulta, e o MySQL moderno também recusa.

Esquema fora das transações

Em "O Banco que Evolui", a migração V5 falhou no terceiro comando, e a coluna criada pelo primeiro desapareceu, porque a migração inteira rodou numa transação. No MySQL, isso não acontece:

mysql> START TRANSACTION;

mysql> INSERT INTO avaliacao (trilha_id, autor, nota, data) VALUES (1, 'Ana', 5, '2026-08-02');
mysql> CREATE TABLE foto (id BIGINT AUTO_INCREMENT PRIMARY KEY, url VARCHAR(500));
mysql> ROLLBACK;
mysql> SELECT COUNT(*) AS avaliacoes_depois_do_rollback FROM avaliacao;
+-------------------------------+
| avaliacoes_depois_do_rollback |
+-------------------------------+
|                             1 |
+-------------------------------+
mysql> SHOW TABLES LIKE 'foto';
+--------------------------+
| Tables_in_trilhas (foto) |
+--------------------------+
| foto                     |
+--------------------------+

O ROLLBACK não desfez nem a tabela nem a avaliação. No MySQL, os comandos de esquema, como CREATE TABLE e ALTER TABLE, causam uma confirmação implícita (implicit commit): antes de executá-los, o MySQL confirma a transação em andamento, e eles próprios não podem ser desfeitos. A avaliação, inserida antes do CREATE TABLE, foi confirmada junto.

A consequência prática está nas migrações: uma migração do Flyway com três comandos de esquema que falha no terceiro deixa os dois primeiros aplicados, e o banco fica num estado intermediário que não corresponde a nenhuma versão. O Flyway registra a falha no histórico, e a correção exige desfazer à mão o que foi aplicado e usar o comando repair. A regra prática para o MySQL é uma mudança de esquema por migração, sempre que possível, e nunca misturar comandos de esquema com alterações de dados que precisariam ser desfeitas juntas.

Sem RETURNING: as chaves geradas do JDBC

O RETURNING, usado no SQLite e no PostgreSQL para obter o id gerado, não existe no MySQL:

mysql> INSERT INTO trilha (...) VALUES (...) RETURNING id;
ERROR 1064 (42000): You have an error in your SQL syntax; ... near 'RETURNING id' at line 1

O MariaDB, por sua vez, tem RETURNING: mais um lugar onde os dois se afastaram. No MySQL, o caminho é o mecanismo do próprio JDBC, Statement.RETURN_GENERATED_KEYS com getGeneratedKeys(), que o driver do MySQL implementa lendo o último AUTO_INCREMENT gerado pela conexão:

import java.sql.*;
import java.util.*;

public class MysqlJdbc {
    public static void main(String[] args) throws Exception {
        String url = args[0];
        try (Connection c = DriverManager.getConnection(url, "trilhas", "trilhas")) {
            System.out.println(c.getMetaData().getDatabaseProductName() + " " + c.getMetaData().getDatabaseProductVersion()
                    + " via " + c.getMetaData().getDriverName() + " " + c.getMetaData().getDriverVersion());
            long id;
            try (PreparedStatement ps = c.prepareStatement("""
                    INSERT INTO trilha (nome, regiao, distancia_km, dificuldade, duracao_min, etiquetas, detalhes, descricao)
                    VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", Statement.RETURN_GENERATED_KEYS)) {
                ps.setString(1, "Pico do Ribeirão 🌄");
                ps.setString(2, "Sul");
                ps.setDouble(3, 5.4);
                ps.setString(4, "DIFICIL");
                ps.setInt(5, 180);
                ps.setString(6, "[\"mata\", \"mirante\"]");          // JSON viaja como texto
                ps.setString(7, "{\"agua\": true}");
                ps.setString(8, "Subida forte até o pico.");
                ps.executeUpdate();
                try (ResultSet chaves = ps.getGeneratedKeys()) {
                    chaves.next();
                    id = chaves.getLong(1);
                }
            }
            try (PreparedStatement ps = c.prepareStatement("SELECT nome, etiquetas FROM trilha WHERE id = ?")) {
                ps.setLong(1, id);
                try (ResultSet rs = ps.executeQuery()) {
                    rs.next();
                    System.out.println("id " + id + ": " + rs.getString("nome") + " " + rs.getString("etiquetas"));
                }
            }
            try (PreparedStatement ps = c.prepareStatement("SELECT nome FROM trilha WHERE ? MEMBER OF (etiquetas) ORDER BY nome")) {
                ps.setString(1, "mirante");
                try (ResultSet rs = ps.executeQuery()) {
                    List<String> nomes = new ArrayList<>();
                    while (rs.next()) nomes.add(rs.getString(1));
                    System.out.println("com mirante: " + nomes);
                }
            }
            try (Statement s = c.createStatement()) {
                s.executeUpdate("INSERT INTO trilha (nome, regiao, distancia_km, dificuldade, duracao_min, etiquetas, descricao) VALUES ('pico do ribeirao 🌄', 'Sul', 1, 'FACIL', 1, '[]', '')");
            } catch (SQLException e) {
                System.out.println("SQLState " + e.getSQLState() + ", código " + e.getErrorCode());
            }
        }
    }
}
MySQL 9.7.0 via MySQL Connector/J mysql-connector-j-9.7.0 (Revision: 0aade1f13bcc98faf7dda5c02e782481eb291f62)
id 5: Pico do Ribeirão 🌄 ["mata", "mirante"]
com mirante: [Lagoinha do Leste, Pico do Ribeirão 🌄]
SQLState 23000, código 1062

O programa foi executado com o driver oficial, com.mysql:mysql-connector-j 9.7.0, a versão que o Spring Boot 4.1.1 gerencia: java -cp .:mysql-connector-j-9.7.0.jar MysqlJdbc jdbc:mysql://localhost:3306/trilhas. O emoji do nome foi gravado e lido sem problemas, graças ao utf8mb4 de ponta a ponta. A última linha mostra outra diferença para quem trata erros pelo código: o MySQL informa o SQLState 23000, a classe genérica de violação de restrição, e distingue o tipo pelo código próprio 1062, "entrada duplicada", enquanto o PostgreSQL usa o 23505, mais específico. E, pela colação, "pico do ribeirao 🌄", sem acento e em minúsculas, foi recusado como duplicata.

O id 5, com quatro trilhas na tabela, mostra que o AUTO_INCREMENT também deixa buracos, pelas mesmas razões das sequências do PostgreSQL: a "LAGOINHA DO LESTE" recusada pelo UNIQUE, mais acima, consumiu o 4. Cada inserção recusada gasta um valor: a do próprio programa levou o 6, e o ON DUPLICATE KEY a seguir, mesmo quando só atualiza, leva o 7.

Inserir ou atualizar

Um padrão comum é "insira a trilha, ou, se ela já existir, atualize-a", chamado de upsert. Os dois bancos o oferecem com sintaxes diferentes. No MySQL:

mysql> INSERT INTO trilha (nome, regiao, distancia_km, dificuldade, duracao_min, etiquetas, descricao)
    -> VALUES ('Morro da Coroa', 'Leste', 7.5, 'DIFICIL', 230, '["mata"]', '') AS nova
    -> ON DUPLICATE KEY UPDATE distancia_km = nova.distancia_km, duracao_min = nova.duracao_min;
mysql> SELECT nome, distancia_km, duracao_min FROM trilha WHERE nome = 'Morro da Coroa';
+----------------+--------------+-------------+
| nome           | distancia_km | duracao_min |
+----------------+--------------+-------------+
| Morro da Coroa |          7.5 |         230 |
+----------------+--------------+-------------+

O AS nova dá um nome à linha que se tentou inserir, e o ON DUPLICATE KEY UPDATE diz o que fazer se ela colidir com qualquer chave única da tabela. A forma antiga, com a função VALUES(coluna), ainda funciona, mas está descontinuada desde o MySQL 8.0.20. No PostgreSQL, o equivalente é INSERT ... ON CONFLICT (nome) DO UPDATE SET distancia_km = EXCLUDED.distancia_km, que exige indicar qual restrição de unicidade tratar, uma diferença sutil mas importante quando a tabela tem mais de uma.

O projeto das Trilhas da Ilha no MySQL

No pom.xml, entram o driver oficial e o módulo do Flyway:

<dependency>
    <groupId>com.mysql</groupId>
    <artifactId>mysql-connector-j</artifactId>
    <scope>runtime</scope>
</dependency>
<dependency>
    <groupId>org.flywaydb</groupId>
    <artifactId>flyway-mysql</artifactId>
</dependency>

Repare no nome do módulo do Flyway, flyway-mysql, e não flyway-database-mysql: os nomes dos módulos não seguem um padrão único, e vale conferir na documentação. O application-mysql.properties:

spring.datasource.url=jdbc:mysql://localhost:3306/trilhas
spring.datasource.username=trilhas
spring.datasource.password=trilhas

E as migrações vão para db/migration/mysql, encontradas pelo mesmo marcador {vendor} de "PostgreSQL". Para rodar com o MySQL, --spring.profiles.active=mysql.

A entidade é o ponto em que a portabilidade cobra seu preço. O @JdbcTypeCode(SqlTypes.ARRAY) das etiquetas, pensado para o text[] do PostgreSQL, não combina com a coluna JSON do MySQL, e a validação do Hibernate recusaria o esquema. Há três caminhos. O primeiro é mapear as etiquetas como @JdbcTypeCode(SqlTypes.JSON) nos dois bancos, trocando o text[] do PostgreSQL por um jsonb com um array: a entidade fica igual para os dois, e o PostgreSQL continua indexando o conteúdo com GIN. O segundo é voltar ao modelo relacional puro, com uma tabela etiqueta_da_trilha e uma @ElementCollection, como os pontos de interesse: funciona em qualquer banco, ao custo de uma tabela e de consultas com JOIN. O terceiro é aceitar que o sistema é de um banco só e usar tudo o que ele oferece. As consultas nativas de busca textual de "PostgreSQL" já fazem essa escolha.

O projeto das Trilhas da Ilha adota o terceiro caminho: o PostgreSQL é o banco do projeto, e o perfil mysql existe para estudo e comparação, com as migrações próprias e uma versão da entidade com as etiquetas em JSON. É a decisão mais comum na prática. A portabilidade entre bancos parece valiosa no início de um projeto, e raramente é exercida: sistemas trocam de banco muito menos do que se imagina, e pagar a portabilidade em cada consulta, abrindo mão de recursos como arrays, JSONB e busca textual, costuma sair mais caro do que uma eventual migração futura. O que se deve preservar é a camada de repositórios, que isola o resto do código dessas escolhas.

Com o PostgreSQL e o MySQL, o projeto passou pelos dois grandes bancos relacionais de código aberto e pelas diferenças que importam entre eles.

Fontes e leituras recomendadas

Exercícios

Exercício 1

Um sistema de cadastro de usuários roda no MySQL com a colação padrão. Um usuário se cadastra como "Joao" e, depois, outro tenta se cadastrar como "João". O que acontece se a coluna de nome de usuário for UNIQUE? E se o mesmo sistema migrar para o PostgreSQL? Proponha uma política explícita.

Ver resposta

✓ Resposta: No MySQL com utf8mb4_0900_ai_ci, "Joao" e "João" são iguais, porque a colação ignora acentos. O segundo cadastro é recusado com o erro 1062, "entrada duplicada", como a "LAGOINHA DO LESTE" da aula. No PostgreSQL, com a colação padrão, os dois textos são diferentes, e o segundo cadastro é aceito: o sistema passa a ter dois usuários que, para uma pessoa, têm o mesmo nome, uma porta aberta para golpes de personificação. A migração muda uma regra de negócio sem mudar uma linha de código, e isso só aparece se houver um teste contra o banco real. A política explícita é decidir como nomes de usuário são comparados e aplicar a decisão na aplicação e no banco. Por exemplo: normalizar o nome antes de gravar, com minúsculas e sem acentos, usando o java.text.Normalizer com a forma NFD e removendo as marcas diacríticas, guardar essa forma normalizada numa coluna própria com UNIQUE, e manter o nome como o usuário digitou numa coluna de exibição. Assim, a regra é a mesma em qualquer banco e em qualquer colação, e fica testável num teste de unidade. O teste com o banco real, como "Bancos de Verdade nos Testes" vai mostrar, confirma que a restrição funciona.

Exercício 2

Uma tabela de comentários foi criada há anos com CHARSET = utf8. Os visitantes começaram a receber erros ao comentar com emojis. Escreva a migração que corrige a tabela e explique o cuidado com índices que ela exige.

Ver resposta

✓ Resposta: A migração converte a tabela e todas as colunas de texto para a codificação completa:

-- V8__comentarios_utf8mb4.sql
ALTER TABLE comentario CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;

O CONVERT TO reescreve os dados existentes, o que é seguro porque todo texto válido em utf8mb3 é válido em utf8mb4. O cuidado é com os índices. Com utf8mb4, cada caractere pode ocupar até quatro bytes em vez de três, e o espaço máximo reservado por coluna no índice cresce na mesma proporção. Com o formato de linha padrão do InnoDB, o limite de uma chave de índice é de 3072 bytes, e uma coluna VARCHAR(1000) indexada, que cabia com três bytes por caractere, com quatro exigiria 4000 bytes, e a conversão falha. A solução é reduzir o tamanho da coluna, se os dados permitirem, ou indexar só um prefixo, INDEX (texto(250)). Em tabelas grandes, a conversão reescreve a tabela inteira e pode demorar e travar escritas, então deve ser planejada, e ferramentas de alteração de esquema sem bloqueio são usadas em produção. Pela regra desta aula, a migração tem um único comando de esquema, porque no MySQL ele não pode ser desfeito numa transação.

Exercício 3

Explique por que uma migração do Flyway com os comandos abaixo é perigosa no MySQL e segura no PostgreSQL, e reescreva-a para o MySQL.

ALTER TABLE trilha ADD COLUMN acessivel BOOLEAN NOT NULL DEFAULT FALSE;
UPDATE trilha SET acessivel = TRUE WHERE dificuldade = 'FACIL';
CREATE INDEX idx_trilha_acessivel ON trilha (acessivel, regiao);
Ver resposta

✓ Resposta: No PostgreSQL, o Flyway executa a migração numa transação, e os comandos de esquema participam dela. Se o CREATE INDEX falhar, por exemplo por falta de espaço, a coluna e a atualização são desfeitas, e o banco continua na versão anterior, pronto para uma nova tentativa. No MySQL, o ALTER TABLE confirma implicitamente o que veio antes e é confirmado na hora; o UPDATE roda e é confirmado pelo CREATE INDEX, que também causa uma confirmação implícita. Se o índice falhar, a coluna e os dados atualizados ficam no banco, o Flyway marca a V9 como falha, e uma nova execução tenta o ALTER TABLE de novo e falha, porque a coluna já existe. O banco fica num estado intermediário que exige intervenção manual. A reescrita divide a mudança em migrações que, sozinhas, podem falhar sem deixar lixo:

-- V9__coluna_acessivel.sql
ALTER TABLE trilha ADD COLUMN acessivel BOOLEAN NOT NULL DEFAULT FALSE;
-- V10__preenche_acessivel.sql
UPDATE trilha SET acessivel = TRUE WHERE dificuldade = 'FACIL';
-- V11__indice_acessivel.sql
CREATE INDEX idx_trilha_acessivel ON trilha (acessivel, regiao);

Cada uma é atômica por si: o ALTER TABLE e o CREATE INDEX são comandos únicos, e o UPDATE sozinho roda numa transação normal. Uma falha deixa o banco numa versão bem definida, e uma nova execução continua de onde parou.

Exercício 4

Um sistema antigo em MySQL roda com sql_mode vazio há anos, e a equipe quer ativar o modo estrito. Que problemas esperar, e como fazer a mudança com segurança?

Ver resposta

✓ Resposta: Sem o modo estrito, o MySQL vinha aceitando em silêncio dados inválidos: textos cortados no tamanho da coluna, números fora do intervalo ajustados para o limite, datas inválidas como "0000-00-00", valores não numéricos gravados como zero, e consultas com GROUP BY que devolviam valores arbitrários. Ao ativar o modo estrito, as operações que produziam esses dados passam a falhar com erro. Caminhos do código que "funcionavam" começam a lançar exceções, geralmente os menos testados, como importações e integrações. Os dados já corrompidos continuam no banco e podem fazer falhar operações que os leem e regravam. E consultas agrupadas escritas de forma incorreta passam a ser recusadas. A mudança segura é gradual. Primeiro, medir: ativar o modo estrito num ambiente de testes com uma cópia dos dados de produção e rodar a suíte completa, e em produção examinar os avisos com SHOW WARNINGS ou pelo registro do servidor. Depois, corrigir o código que gera dados inválidos e as consultas agrupadas. Em seguida, limpar os dados existentes com migrações. Por fim, ativar o modo estrito por sessão, na URL de conexão da aplicação, antes de mudá-lo no servidor, o que permite voltar atrás rapidamente. Os testes contra o banco real, com o mesmo sql_mode da produção, evitam que o problema volte.

Exercício 5

O projeto decidiu que o PostgreSQL é o seu banco. Um colega argumenta que isso é um erro, porque "se um dia precisarmos trocar de banco, será impossível". Construa a resposta, com o que a arquitetura do projeto já faz para limitar esse risco.

Ver resposta

✓ Resposta: O argumento pesa uma possibilidade rara contra um custo certo. Sistemas trocam de banco raramente, e quando trocam, costuma ser por mudanças grandes de escala ou de modelo de dados, que exigiriam reescritas de qualquer forma. Manter a portabilidade, por outro lado, custa algo em toda consulta: abrir mão dos arrays, do JSONB com índices GIN, da busca textual em português, do RETURNING e dos níveis de isolamento bem definidos do PostgreSQL, e escrever para o menor denominador comum entre bancos que, como a aula mostrou, diferem até em como comparam textos e em quais comandos podem ser desfeitos. Mesmo com código "portável", uma troca de banco exigiria revalidar colações, codificações, transações e desempenho, porque as diferenças que importam estão justamente onde o SQL parece igual. A arquitetura do projeto limita o risco de outra forma: o acesso a dados está concentrado nos repositórios, e o resto do sistema conhece só as interfaces deles e as entidades. As consultas próprias do PostgreSQL estão identificadas, como consultas nativas nos repositórios. As migrações ficam numa pasta por banco. E os testes, com "Bancos de Verdade nos Testes", rodam contra o banco real. Se a troca um dia for necessária, o trabalho está delimitado: reescrever as migrações, as consultas nativas e os mapeamentos específicos, e deixar os testes dizerem o que mais mudou. É a mesma lição de "Lições de Quem Veio Antes" sobre o YAGNI: não pagar hoje por uma flexibilidade que talvez nunca seja usada, e manter o código organizado para que ela seja barata se um dia for.

Comentários

Mais em Java

A Terceira Pedra — Mil Conexões ao Mesmo Tempo: Virtual Threads e Keep-Alive
A Terceira Pedra — Mil Conexões ao Mesmo Tempo: Virtual Threads e Keep-Alive

Nesta aula o Farol passa a atender cada conexão numa thread virtual própria…

O Projeto Final Começa — Arquitetando um Servidor HTTP sobre Sockets
O Projeto Final Começa — Arquitetando um Servidor HTTP sobre Sockets

Começa o projeto final: o que trafega entre navegador e servidor no HTTP/1.1…

Uma Linguagem para Torradeiras que Conquistou o Mundo — a História do Java
Uma Linguagem para Torradeiras que Conquistou o Mundo — a História do Java

Como um projeto da Sun para eletrodomésticos fracassou, foi salvo pela web e…