CREATE TABLE profiles (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
attributes JSON NOT NULL,
email VARCHAR(120) AS (JSON_UNQUOTE(JSON_EXTRACT(attributes, '$.email'))) STORED,
INDEX idx_email (email)
);
空間型(GIS)
- POINT、LINESTRING、POLYGON、GEOMETRY、MULTIPOINT、MULTILINESTRING、MULTIPOLYGON、GEOMETRYCOLLECTION。
- 効率よく検索するにはSPATIALインデックスが必要です(MBRContains、ST_Distance、ST_Within)。
- MySQL 8.0では、空間インデックスを使うにはSRIDの指定が必須です。
CREATE TABLE locations (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(120),
point POINT NOT NULL SRID 4326,
SPATIAL INDEX (point)
) ENGINE = InnoDB;
-- 良い例:最も選択性の高い列を先頭に
CREATE INDEX idx_invoices
ON invoices (customer_id, status, date)
-- customer_id(選択性が高い)→ status → date
;
-- アンチパターン:冗長なインデックス
-- (customer_id)は(customer_id, status, date)ですでにカバーされている
DROP INDEX idx_invoices_customer ON invoices;
-- クエリーが読むのはcustomer_idとtotalだけ → インデックスがカバーする
CREATE INDEX idx_orders_cover
ON orders (customer_id, total);
SELECT customer_id, SUM(total)
FROM orders
WHERE customer_id IN (1, 2, 3)
GROUP BY customer_id;
ALTER TABLE orders ALTER INDEX idx_legacy INVISIBLE;
-- 数時間パフォーマンスを監視する
ALTER TABLE orders ALTER INDEX idx_legacy VISIBLE; -- 元に戻す
-- または
DROP INDEX idx_legacy ON orders; -- 削除を確定する
-- 診断
EXPLAIN SELECT * FROM orders
WHERE customer_id = 42 AND date >= '2024-01-01';
-- インデックスの使用統計
SELECT object_schema, object_name, index_name, count_star
FROM performance_schema.table_io_waits_summary_by_index_usage
WHERE object_schema = DATABASE()
ORDER BY count_star DESC;
例では、どのエンジンでもそのまま実行できるように、自然キー(customer_id, phone)を使っています。PostgreSQLでは代理キーをid BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEYと宣言します(BIGSERIALは古い書き方で、今も使えます)。決まった値の集合には2つの方法があります。ALTER TABLEで変更するCHECK (kind IN ('mobile', 'home', 'office'))か、CREATE TYPE tipo_tel AS ENUM (…)による独自の型です。2つ目には注意してください。PostgreSQL 17.6で測定すると、ALTER TYPE … ADD VALUEは使えますが、ALTER TYPE … DROP VALUEは0A000(dropping an enum value is not implemented)を返します。PostgreSQLの列挙型に入った値は、二度と取り除けません。
-- 例:読み取りのたびのSUMを避けるため、注文の合計をキャッシュする
ALTER TABLE orders
ADD COLUMN total DECIMAL(12, 2) NOT NULL DEFAULT 0;
MySQL 5.7+MariaDB 10.5+Aurora
DELIMITERはSQLではありません。クライアントは理解しますが、サーバは理解しません。
-- トリガーで保守する
DELIMITER //
CREATE TRIGGER order_line_after_insert
AFTER INSERT ON order_line
FOR EACH ROW
BEGIN
UPDATE orders
SET total = (SELECT COALESCE(SUM(quantity * unit_price), 0)
FROM order_line
WHERE order_id = NEW.order_id)
WHERE id = NEW.order_id;
END//
DELIMITER ;
CREATE FUNCTION recalculate_total() RETURNS trigger
LANGUAGE plpgsql AS $$
BEGIN
UPDATE orders
SET total = (SELECT COALESCE(SUM(quantity * unit_price), 0)
FROM order_line
WHERE order_id = NEW.order_id)
WHERE id = NEW.order_id;
RETURN NULL;
END;
$$;
CREATE TRIGGER order_line_after_insert
AFTER INSERT ON order_line
FOR EACH ROW EXECUTE FUNCTION recalculate_total();
SQLiteのトリガーは、MySQLと同じく本体を中に持ちますが、DELIMITERは使いません(MySQLのクライアントの機能で、ここでは構文エラーになります)。BEGIN … ENDがすでに本体を区切っているからです。別の関数はなく、FOR EACH ROWが唯一のモードで、ENDのあとの;は常に必要です。
CREATE TRIGGER order_line_after_insert
AFTER INSERT ON order_line
FOR EACH ROW
BEGIN
UPDATE orders
SET total = (SELECT COALESCE(SUM(quantity * unit_price), 0)
FROM order_line
WHERE order_id = NEW.order_id)
WHERE id = NEW.order_id;
END;
ALTER TABLE orders
ADD total DECIMAL(12, 2) NOT NULL DEFAULT 0;
さらにトリガーの形も変わります。ここにはFOR EACH ROWがありません。トリガーは文ごとに1回実行され、新しい行はinsertedテーブルにまとめて届きます。NEWもありません。そのため、行の集合を前提に書きます:
CREATE TRIGGER order_line_after_insert
ON order_line
AFTER INSERT
AS
BEGIN
SET NOCOUNT ON;
UPDATE p
SET total = (SELECT COALESCE(SUM(d.quantity * d.unit_price), 0)
FROM order_line d
WHERE d.order_id = p.id)
FROM orders p
WHERE p.id IN (SELECT order_id FROM inserted);
END;
SQL Server 2025で測定すると、例の2行(3 × 25.50と2 × 10.00)を1つのINSERTで入れたらorders.totalは96.50になり、2つの別々の注文の行を含むINSERTでは両方が更新されました。罠は、MySQLのように「行ごと」に書き、order_idを変数に入れることです。SELECT @order = order_id FROM insertedでは、2つの注文のINSERTで一方だけが更新され、もう一方は0.00のまま、エラーは一切出ませんでした。SET @order = (SELECT order_id FROM inserted)ではMsg 512で失敗し、INSERT全体が取り消されました。
CREATE VIEW dbo.orders_total WITH SCHEMABINDING AS
SELECT order_id, SUM(quantity * unit_price) AS total, COUNT_BIG(*) AS lines
FROM dbo.order_line
GROUP BY order_id;
CREATE UNIQUE CLUSTERED INDEX ux_orders_total ON dbo.orders_total (order_id);
-- レポート用に(顧客, 月)のすべての組み合わせを生成
SELECT c.id, m.month
FROM customers c
CROSS JOIN (
SELECT 1 AS month UNION ALL SELECT 2 UNION ALL SELECT 3
-- ...12まで
) m;
SELF JOIN
同じテーブルが、別のエイリアスで2回現れます。階層や、同じテーブルの行どうしの比較に便利です。
CREATE TABLE employees (
id BIGINT PRIMARY KEY,
name VARCHAR(120),
manager_id BIGINT NULL,
FOREIGN KEY (manager_id) REFERENCES employees(id)
);
-- 各従業員と、その上司の名前
SELECT e.name AS employee, m.name AS manager
FROM employees e
LEFT JOIN employees m ON m.id = e.manager_id;
-- MySQLでの代用
SELECT c.name, o.id
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
UNION
SELECT c.name, o.id
FROM customers c
RIGHT JOIN orders o ON o.customer_id = c.id
WHERE c.id IS NULL;
PostgreSQL 13+
PostgreSQLはネイティブに対応しており、OUTERは省略できます。FULL JOINも同じ意味です。PostgreSQL 17.6で、5人の顧客と、うち3件は顧客のない6件の注文で測定すると、8行すべてを返し、実行計画はHash Full Joinになります。
-- PostgreSQLのネイティブな書き方
SELECT c.name, o.id
FROM customers c
FULL OUTER JOIN orders o ON o.customer_id = c.id;
SQLiteにもSTRAIGHT_JOINはありません。書くと構文エラーになります。その代わり、クエリーの中で順序を固定する方法があります。CROSS JOINは結果を変えませんが、プランナがテーブルを並べ替えることを禁じます。さらに、テーブルごとのヒントが2つあります:INDEXED BY <index>とNOT INDEXEDです。SQLite 3.51で、顧客50,000人と注文200,000件で測定すると、JOINではプランナが先にcustomersを読み、CROSS JOINでは書いたとおりに先にordersを読みます。ヒントには注意してください。存在しないインデックスをINDEXED BYで指定すると、無視されるのではなくクエリーが失敗します。
-- SQLite:書いた順序が優先される
SELECT c.name, o.id
FROM orders o
CROSS JOIN customers c ON o.customer_id = c.id;
SQL Server
SQL ServerにもSTRAIGHT_JOINはありません。書くと構文エラー(Msg 102)になります。あるのはクエリー内のヒントです。OPTION (FORCE ORDER)はクエリー全体で書かれた順序を固定し、LOOP、HASH、MERGEは方式を固定します。OPTION (HASH JOIN)ならすべての結合に、結合の語の間(INNER HASH JOIN)なら1つの結合だけに効きます。2つ目の書き方には注意してください。結合に付けたヒントは順序も固定し、サーバはそれをWarning: The join order has been enforced because a local join hint is used.と知らせます。SQL Server 2025で、顧客50,000人と注文200,000件で測定すると、FROM orders o JOIN customers cと書いても、オプティマイザは先にcustomersを読みます。OPTION (FORCE ORDER)を付けると書いたとおりに先にordersを読み、Merge JoinはMANY-TO-MANYになります。ほかのエンジンと同じく、自動の実行計画のほうが悪いと測定で確かめた場合にだけ使ってください。
-- SQL Server:書いた順序が優先される
SELECT c.name, o.id
FROM orders o
JOIN customers c ON o.customer_id = c.id
OPTION (FORCE ORDER);
-- 各顧客の最も高額な注文
SELECT c.name, x.total
FROM customers c
CROSS APPLY (
SELECT TOP (1) o.total
FROM orders o
WHERE o.customer_id = c.id
ORDER BY o.total DESC
) x;
- セミジョイン(一致が少なくとも1つある)→ EXISTSまたはIN。
- アンチジョイン(一致が1つもない)→ NOT EXISTSまたはLEFT JOIN ... WHERE ... IS NULL。
-- セミジョイン:注文が1件以上ある顧客
SELECT c.id, c.name
FROM customers c
WHERE EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);
-- アンチジョイン:注文のない顧客
SELECT c.id, c.name
FROM customers c
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);
NOT INはアンチジョインではありません。サブクエリーがNULLを1つでも返すと、比較が真になることはなく、結果は0行になります。5人の顧客と6件の注文(うち3件はcustomer_idがNULL)で測定すると、NOT EXISTSとLEFT JOIN … IS NULLは2行を返し、NOT INは0行を返します。PostgreSQL 17.6、MySQL 8.0.46、SQLite 3.51、SQL Server 2025のどれでも同じです。
Nested Loop、Hash Join、Merge Joinに加えて、いくつかのパターンには専用のノードがあります。FULL OUTER JOINにはHash Full Join、NOT EXISTSにはHash Anti Joinです。
アンチジョインでは、ほかの方法では見えない違いが出ます。PostgreSQL 17.6で、顧客50,000人と注文200,000件で測定すると、NOT EXISTSはParallel Hash Anti Joinになり、同じ意味のLEFT JOIN … WHERE o.id IS NULLは後ろにFilterの付いたHash Right Joinになります。つまり、プランナはこれをアンチジョインと認識していません。時間は同じでした(14.98 msと13.22 ms)。違いは実行計画にあって、時計にはありません。自分のクエリーを測定せずに、これを理由に書き換えないでください。
SQLite 3.35+
SQLiteにはHash JoinもMerge Joinもありません。すべての結合はネステッドループで、変わるのは内側のテーブルを丸ごと走査するか、インデックスで検索するかだけです。実行計画もEXPLAINからは得られません。EXPLAINは仮想マシンのバイトコードを返し、最も単純な結合でもaddr、opcode、p1…の19行になります。実行計画はEXPLAIN QUERY PLANで得られ、3つの語で表されます:SCAN(丸ごと読む)、SEARCH … USING INDEX(検索する)、USING COVERING INDEX(インデックスがすでに列を持っていて、テーブルには触れない)。
ここでは、アンチジョインにPostgreSQLのような違いは出ません。SQLite 3.51で、顧客50,000人と注文200,000件で測定すると、NOT EXISTSは中にSEARCH … USING COVERING INDEXを持つCORRELATED SCALAR SUBQUERYで5.53 ms、同じ意味のLEFT JOIN … WHERE o.id IS NULLはSEARCH … USING COVERING INDEX … LEFT-JOINで8.38 msでした。どちらも同じインデックスを使い、特別なノードはありません。
-- SQLiteで実行計画を求めるにはこう書く。EXPLAINだけではない
EXPLAIN QUERY PLAN
SELECT c.id, c.name
FROM customers c
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);
- 目安:MySQL専用のサーバでRAMの60%〜80%。
- 推奨される最小値:本番環境で1 GiB。
- MySQL 8.0以降とMariaDB 10.5以降では、オンラインで(再起動せずに)変更できます。
-- 現在のサイズを確認する
SHOW VARIABLES LIKE 'innodb_buffer_pool_size';
-- オンラインで変更する(8 GB)
SET GLOBAL innodb_buffer_pool_size = 8 * 1024 * 1024 * 1024;
max_connections
同時接続の最大数です。MySQLでもMariaDBでもデフォルトは151です。
- 接続ごとにメモリーを消費します(thread_stackとセッションごとのバッファで約256 KiB)。
- 値が大きすぎると、負荷がかかったときにパフォーマンスを損ないます(競合)。
- SHOW STATUS LIKE 'Max_used_connections'で測定します。上限に達したら、少しずつ増やします。
SHOW STATUS LIKE 'Max_used_connections';
SHOW STATUS LIKE 'Threads_connected';
SET GLOBAL max_connections = 500;
max_allowed_packet
プロトコルのパケットの最大サイズです(大きなINSERT、LOAD DATA、BLOB)。
- デフォルトはMySQL 8.0で64 MiB、それ以前のバージョンでは16 MiB。
- 操作がこれを超えると、Got a packet bigger than 'max_allowed_packet' bytesエラーになります。
- BLOBの多いワークロードでは、256 MiBや1 GiBに増やすのが一般的です。
SET GLOBAL max_allowed_packet = 256 * 1024 * 1024;
-- クライアントもパラメータを渡す必要がある
-- (コマンドライン:--max-allowed-packet=256M)
クエリーキャッシュ
SELECTクエリーの結果全体をキャッシュします。
MySQL 5.7+
MySQL 5.7で非推奨になり、MySQL 8.0で削除されました。ワークロードがクエリーキャッシュの恩恵を受けていた場合、いまはアプリケーション(Redis、Memcached)やマテリアライズドビューに任せます。
SHOW VARIABLES LIKE 'tmp_table_size';
SHOW VARIABLES LIKE 'max_heap_table_size';
SET GLOBAL tmp_table_size = 256 * 1024 * 1024;
SET GLOBAL max_heap_table_size = 256 * 1024 * 1024;
-- ディスクに書き出された一時テーブルの数
SHOW STATUS LIKE 'Created_tmp_disk_tables';
- 推奨する文字セット:utf8mb4(完全なUTF-8、4バイト)。別名のutf8は歴史的なもので、3バイトに限られます(絵文字を扱えません)。
- MySQL 8.0以降で推奨する照合順序:utf8mb4_0900_ai_ci(大文字と小文字を区別せず、アクセントも区別しない、Unicode 9ベース)。
- MariaDBでは:utf8mb4_unicode_ciまたはuca1400_ai_ci(10.10以降)。
ALTER DATABASE my_db
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
ALTER TABLE customers
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
ALTER DATABASE my_db SET RECOVERY FULL;
BACKUP DATABASE my_db TO DISK = N'/var/opt/mssql/data/my_db_full.bak'
WITH CHECKSUM, COMPRESSION, INIT;
BACKUP LOG my_db TO DISK = N'/var/opt/mssql/data/my_db_log.trn'
WITH CHECKSUM, INIT;
RESTORE VERIFYONLY FROM DISK = N'/var/opt/mssql/data/my_db_full.bak'
WITH CHECKSUM;
-- 権限を絞ったアプリケーション用のユーザを作成
CREATE USER 'app_orders'@'10.0.%.%' IDENTIFIED BY 'strong_password';
GRANT SELECT, INSERT, UPDATE, DELETE
ON my_db.orders TO 'app_orders'@'10.0.%.%';
GRANT SELECT
ON my_db.customers TO 'app_orders'@'10.0.%.%';
-- 本番環境では決して使わない
-- GRANT ALL PRIVILEGES ON *.* TO 'app'@'%';
FLUSH PRIVILEGES;
SQL Serverでは2つに分かれます。ログインはサーバに入るためのもので、ユーザは各データベースの中に存在します。権限はユーザに付与し、テーブルごとよりスキーマ単位のほうが適しています。GRANT … ON SCHEMA::salesは、あとで作成されたテーブルにも及びます。
USE master;
CREATE LOGIN app_orders WITH PASSWORD = N'Strong-Password-2026',
CHECK_POLICY = ON, DEFAULT_DATABASE = my_db;
USE my_db;
CREATE USER app_orders FOR LOGIN app_orders WITH DEFAULT_SCHEMA = sales;
GRANT SELECT, INSERT, UPDATE, DELETE ON SCHEMA::sales TO app_orders;
-- 本番環境では決して使わない
-- ALTER SERVER ROLE sysadmin ADD MEMBER app_orders;
-- ALTER ROLE db_owner ADD MEMBER app_orders;
MySQL Enterpriseには監査プラグインがあります。Community Editionにはありません。通常は一般ログ(パフォーマンスへの負担が大きい)や外部のプラグインで補います。
MariaDB 10.5+
MariaDBには「server_audit」プラグインが付属しています:
INSTALL SONAME 'server_audit';
SET GLOBAL server_audit_logging = ON;
SET GLOBAL server_audit_events = 'CONNECT,QUERY,TABLE';
SET GLOBAL server_audit_file_path = '/var/log/mysql/audit.log';
SQL Server
SQL ServerにはSQL Server Auditが付属しています。ファイルに書き込むサーバ監査と、各データベースでどのオブジェクトに対するどの操作を記録するかを決める仕様です。ドキュメントによれば、データベースレベルの監査は2016 SP1以降のすべてのエディションにあります。読み出しにはsys.fn_get_audit_fileを使います。下の設定では、app_ordersによるSELECTとUPDATEが、ログイン付きでSLとUPの行として出てきました。
USE master;
CREATE SERVER AUDIT aud_sales TO FILE (FILEPATH = N'/var/opt/mssql/data/');
ALTER SERVER AUDIT aud_sales WITH (STATE = ON);
USE my_db;
CREATE DATABASE AUDIT SPECIFICATION das_sales FOR SERVER AUDIT aud_sales
ADD (SELECT, INSERT, UPDATE, DELETE ON SCHEMA::sales BY public)
WITH (STATE = ON);
SELECT event_time, action_id, server_principal_name, statement
FROM sys.fn_get_audit_file(N'/var/opt/mssql/data/aud_sales*.sqlaudit', DEFAULT, DEFAULT);
キーワード: ベストプラクティス、設計、命名規則、バックアップ、レプリケーション、GTID、バイナリログ、GRANT、監査、セキュリティ、InnoDB、外部キー、TLS、SQL Server、IDENTITY、SEQUENCE、スキーマ、ログイン、XACT_ABORT、SQL Server Audit
EXPLAIN SELECT c.name, COUNT(o.id) AS orders
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE c.active = 1
GROUP BY c.id;
重要な列:
- type — アクセス方法。良いものから悪いものへ:system → const → eq_ref → ref → range → index → ALL。ALLはテーブルのフルスキャンで、大きなテーブルでは良くありません。
- key — 選ばれたインデックス。NULLはインデックスを使っていないことを示します。
- rows — 調べると推定される行数。返される行数よりずっと多ければ、改善の余地があります。
- Extra — 役に立つヒント:
- Using index — カバリングインデックス(とても良い)。
- Using where — 行を読んだあとにフィルタを適用。
- Using temporary — 一時テーブルが必要(高コスト)。
- Using filesort — インデックスの外での並べ替え(大きなテーブルでは高コスト)。
SHOW VARIABLES LIKE 'slow_query%';
SHOW VARIABLES LIKE 'long_query_time';
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 1; -- 1秒
SET GLOBAL log_queries_not_using_indexes = 'ON';
SET GLOBAL slow_query_log_file = '/var/log/mysql/slow.log';
-- 累積時間の合計が多い上位10件のクエリー
SELECT digest_text,
count_star AS exec,
ROUND(sum_timer_wait/1e9, 2) AS total_ms,
ROUND(avg_timer_wait/1e9, 2) AS avg_ms,
sum_rows_examined AS rows_exam
FROM performance_schema.events_statements_summary_by_digest
ORDER BY sum_timer_wait DESC
LIMIT 10;
-- I/Oが最も多いテーブル
SELECT object_schema, object_name,
count_read, count_write,
ROUND(sum_timer_wait/1e9, 2) AS total_ms
FROM performance_schema.table_io_waits_summary_by_table
WHERE object_schema = DATABASE()
ORDER BY sum_timer_wait DESC
LIMIT 10;
SELECT ROUND((1 - r.v / q.v) * 100, 2) AS buffer_pool_hit_pct
FROM (SELECT VARIABLE_VALUE AS v FROM performance_schema.global_status
WHERE VARIABLE_NAME = 'Innodb_buffer_pool_reads') r,
(SELECT VARIABLE_VALUE AS v FROM performance_schema.global_status
WHERE VARIABLE_NAME = 'Innodb_buffer_pool_read_requests') q;
-- performance_schemaなしで(MariaDBではデフォルトでオフ)、2つのカウンタを手で:
SHOW STATUS LIKE 'Innodb_buffer_pool_read%';
-- A
START TRANSACTION;
UPDATE cuentas SET saldo = saldo - 10 WHERE id = 1;
UPDATE cuentas SET saldo = saldo + 10 WHERE id = 2;
-- B
START TRANSACTION;
UPDATE cuentas SET saldo = saldo - 10 WHERE id = 2;
UPDATE cuentas SET saldo = saldo + 10 WHERE id = 1;
InnoDBは自分でこれを検出し、ロールバックのコストが最も小さいトランザクションを強制終了します。そのトランザクションはエラー1213 Deadlock found when trying to get lockを受け取ります。サーバの障害でもデータの破損でもありません。正しい動作であり、アプリケーションはそのトランザクションを再試行しなければなりません。
診断 SHOW ENGINE INNODB STATUSの「LATEST DETECTED DEADLOCK」セクションには、最後のデッドロックが、両方のトランザクションと関係した文とともに残っています。今まさに保持されているロックを見るには、MySQL 8.0以降ではperformance_schemaを使います(MariaDBにはdata_locksがありません。そこではinformation_schema.INNODB_LOCK_WAITSとINNODB_TRXです):
SHOW ENGINE INNODB STATUS;
SELECT * FROM performance_schema.data_locks;
SELECT * FROM performance_schema.data_lock_waits;
SELECT @@innodb_lock_wait_timeout;
CREATE TABLE eventos (
id BIGINT NOT NULL AUTO_INCREMENT,
ocurrido DATE NOT NULL,
payload JSON,
PRIMARY KEY (id, ocurrido)
)
PARTITION BY RANGE (YEAR(ocurrido)) (
PARTITION p2023 VALUES LESS THAN (2024),
PARTITION p2024 VALUES LESS THAN (2025),
PARTITION p2025 VALUES LESS THAN (2026),
PARTITION pmax VALUES LESS THAN MAXVALUE
);
4つの種類
- RANGE — 順序付けできる値の区間ごと。ほとんどの場合は日付です。最も役に立ちます。
- LIST — 離散的な値の集合に属するかどうか。
- HASH — 整数式による均等な分散。書き込みを分散させるには良いですが、プルーニングには向きません。
- KEY — HASHと似ていますが、サーバ内部の関数を使います。整数以外の列も受け付けます。
RANGE COLUMNSとLIST COLUMNSという派生形は、関数で包まずに複数の列や整数以外の型を受け付けます:
PARTITION BY LIST (region_id) (
PARTITION europa VALUES IN (1, 2, 3),
PARTITION asia VALUES IN (4, 5)
);
PARTITION BY HASH (cliente_id) PARTITIONS 8;
PARTITION BY KEY (uuid) PARTITIONS 4;
PARTITION BY RANGE COLUMNS (pais, alta) (
PARTITION p_es_2024 VALUES LESS THAN ('ES', '2025-01-01')
);
EXPLAIN SELECT COUNT(*) FROM eventos
WHERE ocurrido BETWEEN '2025-03-01' AND '2025-03-31';
SELECT partition_name, table_rows
FROM information_schema.PARTITIONS
WHERE table_name = 'eventos';
ALTER TABLE eventos DROP PARTITION p2023;
ALTER TABLE eventos REORGANIZE PARTITION pmax INTO (
PARTITION p2026 VALUES LESS THAN (2027),
PARTITION pmax VALUES LESS THAN MAXVALUE
);
ALTER TABLE eventos REBUILD PARTITION p2025;
WITH ventas_mes AS (
SELECT vendedor_id, DATE_FORMAT(fecha, '%Y-%m') AS mes, SUM(total) AS total
FROM pedidos
GROUP BY vendedor_id, mes
)
SELECT * FROM ventas_mes WHERE total > 10000;
PostgreSQL 13+
PostgreSQLにDATE_FORMATはありません。42883(function date_format(date, unknown) does not exist)が返ります。同等なのはto_charで、月でグループ化するなら、テキストではなく日付を返すdate_truncのほうがたいてい向いています。出力のエイリアスでのグループ化は、MySQLと同じように使えます:
WITH ventas_mes AS (
SELECT vendedor_id, date_trunc('month', fecha) AS mes, SUM(total) AS total
FROM pedidos
GROUP BY vendedor_id, mes
)
SELECT * FROM ventas_mes WHERE total > 10000;
SQLite 3.35+
SQLiteにもDATE_FORMATはありません。3.51で測定すると、no such function: DATE_FORMATが返ります。同等なのはstrftimeで、同じ%Y-%mのコードを使います。出力のエイリアスでのグループ化は、ほかの2つのエンジンと同じように使えます。
WITH ventas_mes AS (
SELECT vendedor_id, strftime('%Y-%m', fecha) AS mes, SUM(total) AS total
FROM pedidos
GROUP BY vendedor_id, mes
)
SELECT * FROM ventas_mes WHERE total > 10000;
SQL Server
SQL ServerにもDATE_FORMATはありません。2025で測定すると、'DATE_FORMAT' is not a recognized built-in function name(エラー195)が返ります。月はFORMAT(fecha, 'yyyy-MM')で、2022以降は日付を返すDATETRUNC(month, fecha)で取り出します。そしてここでは出力のエイリアスでグループ化できません。GROUP BY vendedor_id, mesはInvalid column name 'mes'(エラー207)を返すので、式を繰り返して書きます:
WITH ventas_mes AS (
SELECT vendedor_id, DATETRUNC(month, fecha) AS mes, SUM(total) AS total
FROM pedidos
GROUP BY vendedor_id, DATETRUNC(month, fecha)
)
SELECT * FROM ventas_mes WHERE total > 10000;
再帰CTE:階層 WITH RECURSIVEは、組織図、入れ子のカテゴリー、部品表といった木構造を、アプリケーション側でループを書かずにたどります。最初の部分が基本ケースで、2つ目の部分は行を返さなくなるまで繰り返されます:
WITH RECURSIVE arbol AS (
SELECT id, nombre, jefe_id, 1 AS nivel
FROM empleados
WHERE jefe_id IS NULL
UNION ALL
SELECT e.id, e.nombre, e.jefe_id, a.nivel + 1
FROM empleados e
JOIN arbol a ON e.jefe_id = a.id
)
SELECT * FROM arbol ORDER BY nivel, nombre;
SQLiteは逆で、RECURSIVEは省略できます。3.51で測定すると、WITH arbol AS (…)と書いた同じクエリーは、WITH RECURSIVEのときとまったく同じ行を返します。それでも書いておけば、1語のコストで、それを必須とするPostgreSQLにそのまま使えるクエリーになります。一方、SQL Serverはそれを拒否します。
SQL Server
SQL ServerではRECURSIVEは書きません。2025で測定すると、WITH RECURSIVE arbol AS (…)は構文エラー(Incorrect syntax near 'arbol'、エラー102)になり、WITH arbol AS (…)は木を返します。再帰は100階層で止まります(エラー530)。もっと深く進むには、クエリーの末尾にOPTION (MAXRECURSION n)を付けます。0にすると上限がなくなります。
ウィンドウ関数:グループ化せずに計算する GROUP BYは行をまとめてしまいますが、ウィンドウ関数は関連する行の集合に対して計算し、すべての行を残します。そのおかげで、累計、移動平均、グループ内での順位を1回の処理で求められます:
SELECT
vendedor_id,
fecha,
total,
SUM(total) OVER (PARTITION BY vendedor_id ORDER BY fecha) AS acumulado,
AVG(total) OVER (PARTITION BY vendedor_id
ORDER BY fecha
ROWS BETWEEN 6 PRECEDING AND CURRENT ROW) AS media_7,
RANK() OVER (PARTITION BY vendedor_id ORDER BY total DESC) AS puesto,
LAG(total, 1) OVER (PARTITION BY vendedor_id ORDER BY fecha) AS anterior
FROM pedidos;
フレーム(ROWS BETWEEN ...)は、各行の計算にどの行を含めるかを決めます。デフォルトでは、ORDER BY付きの集計はパーティションの先頭から現在の行までを対象にします。まさにこれが累計になります。
PostgreSQL 13+
PostgreSQLには、MySQL 8.0.46にまだない機能もあります。両方で測定しました:
- FILTER (WHERE …):集計の中にCASEを詰め込まずに条件を付けます:count(*) FILTER (WHERE total > 1000)。MySQLでは構文エラーです。
- GROUPSフレームとEXCLUDE句(ROWSとRANGEに加えて)。MySQL 8.0.46はThis version of MySQL doesn't yet support 'GROUPS'(エラー1235)を返します。
- DISTINCT ON:ROW_NUMBER()もCTEも使わずに、グループごとにORDER BYで最初の1行を返します。PostgreSQLだけのものです。
名前付きのウィンドウ(OVER w … WINDOW w AS (…))は両方にあり、列ごとに定義を繰り返さずに済みます。
- FILTER (WHERE …):ありません。構文エラーになります。COUNT(CASE WHEN total > 1000 THEN 1 END)と書きます。
- GROUPSフレームとEXCLUDE句:どちらもありません。さらにRANGEはUNBOUNDEDとCURRENT ROWしか受け付けず、RANGE BETWEEN 100 PRECEDING AND CURRENT ROWはエラー4194を返します。
- 名前付きのウィンドウ:OVER w … WINDOW w AS (…)は2022から使えます。
- DISTINCT ON:ありません。グループごとの1行は、下のパターンのとおりROW_NUMBER()で求めます。
WITH ranking AS (
SELECT
p.*,
ROW_NUMBER() OVER (PARTITION BY categoria_id ORDER BY ventas DESC) AS rn
FROM productos p
)
SELECT * FROM ranking WHERE rn <= 3;
CTEについては逆で、だからMySQLの助言はそのまま当てはまりません。PostgreSQL 12以降、ちょうど1回だけ使われるCTEはクエリーに展開されます。17.6で20,000行に対して測定すると、WITH v AS (SELECT * FROM ventas) SELECT * FROM v WHERE vendedor_id = 3の実行計画にはCTE Scanが残らず、インデックスを使って0.297 msでした。同じクエリーにAS MATERIALIZEDを付けると、テーブル全体のSeq Scanの上にCTE Scanが現れ、1.662 msまで増えます。CTEを2回以上参照すると自動で実体化されます。12より前は、常にオプティマイザの壁でした。
SQLite 3.35+
SQLiteでは、その並べ替えは実行計画にUSE TEMP B-TREE FOR ORDER BYとして現れ、すでにPARTITION BYの順序で返すインデックスがあるとUSE TEMP B-TREE FOR LAST TERM OF ORDER BYに減ります。ウィンドウ全体はCO-ROUTINEの中で解決されます。
CTEについてはPostgreSQLと同じことをし、さらに一歩先を行きます。3.51で20,000行に対して測定すると、WITH v AS (SELECT * FROM ventas) SELECT * FROM v WHERE vendedor_id = 3の実行計画にはMATERIALIZEが残らず、インデックスを使って0.108 msでした。同じクエリーにAS MATERIALIZEDを付けると(SQLiteは3.35から、AS NOT MATERIALIZEDと同じくこれを理解します)、テーブル全体のSCANの上にMATERIALIZEが現れ、2.019 msまで増えます。そして一歩先というのはここです。PostgreSQLと違い、2回参照しても実体化しません。展開されたまま、それぞれの参照でインデックスを使うSEARCHになり、0.203 msでした。
MySQLから来ると最初に驚くこと
エラーはトランザクション全体を中断させます。それ以降は、ROLLBACKするまで、どの文も同じ応答を返します:current transaction is aborted, commands ignored until end of transaction block、SQLSTATE 25P02。これはアプリケーションのバグではなく設計で、思っていたのとはもう違う状態のままトランザクションが進むのを防ぎます。
抜け道はセーブポイントです SAVEPOINTは戻るための地点を記録し、ROLLBACK TO SAVEPOINTは、それより前の作業を失わずにトランザクションを救います:
BEGIN;
INSERT INTO cuentas (id, saldo) VALUES (3, 0);
SAVEPOINT tras_alta;
INSERT INTO cuentas (id, saldo) VALUES (3, 0); -- 失敗:23505
ROLLBACK TO SAVEPOINT tras_alta;
COMMIT;
ALTER TABLE pedidos SET (autovacuum_vacuum_scale_factor = 0.02);
動いているかを確かめる
SELECT relname, n_live_tup, n_dead_tup, last_autovacuum, autovacuum_count
FROM pg_stat_user_tables
WHERE n_dead_tup > 0
ORDER BY n_dead_tup DESC
LIMIT 10;
SELECT pid, relid::regclass AS tabla, phase, heap_blks_scanned, heap_blks_total
FROM pg_stat_progress_vacuum;
敵は開いたままのトランザクション VACUUMが片付けられるのは、もう誰からも見えないものだけです。開いたままのトランザクション(またはhot_standby_feedbackを使うレプリカ)はその境界を止めてしまい、そうなるとVACUUMは実行されて成功を報告しても、何も解放しません。だからこそ、idle in transactionのまま忘れられた1つのセッションが、触れてもいないテーブルを大きくするのです。
キーワード: VACUUM、autovacuum、肥大化、MVCC、n_dead_tup、VACUUM FULL、FREEZE、周回、pg_stat_user_tables、pg_stat_progress_vacuum、DATFROZENXID、hot_standby_feedback、IDLE IN TRANSACTION
-- セッションA
BEGIN;
UPDATE cuentas SET saldo = saldo - 10 WHERE id = 1;
UPDATE cuentas SET saldo = saldo + 10 WHERE id = 2;
-- セッションB
BEGIN;
UPDATE cuentas SET saldo = saldo - 10 WHERE id = 2;
UPDATE cuentas SET saldo = saldo + 10 WHERE id = 1;
SELECT pid, pg_blocking_pids(pid) AS bloqueado_por,
wait_event_type, wait_event, left(query, 40) AS consulta
FROM pg_stat_activity
WHERE cardinality(pg_blocking_pids(pid)) > 0;
SELECT l.pid, c.relname, l.locktype, l.mode, l.granted
FROM pg_locks l LEFT JOIN pg_class c ON c.oid = l.relation
WHERE NOT l.granted;
ここにはプレフィックスインデックスはない:代わりに式インデックス CREATE INDEX … ON paginas (url(64))は有効な構文ではありません。サーバはurl(64)を関数呼び出しと読み、42883 function url(integer) does not existと答えます。同等のものは式をインデックスすることです:
CREATE INDEX idx_url ON paginas (left(url, 64));
CREATE INDEX idx_email ON usuarios (lower(email));
CREATE INDEX idx_cobertura ON pedidos (cliente_id) INCLUDE (estado);
これでEXPLAINはIndex Only Scanを表示します。しかし「だけ」というのは半分の約束です:PostgreSQLはインデックスから行があなたのトランザクションに見えるかどうかを判断できないので、VACUUMが保守する可視性マップを参照します。測定結果:1,000行を更新した直後、同じプランはHeap Fetches: 2と表示し、VACUUMのあとはHeap Fetches: 0でした。書き込まれて掃除されないテーブルのカバリングインデックスは、それでもテーブルを読みに行きます。
左端プレフィックスのルールは厳密ではない
MySQLでは、(A, B)のインデックスはWHERE B = ?には役に立ちません。ここでは役立つことがあります:測定すると、2番目の列だけで絞り込むクエリーが、複合インデックスに対するIndex Only Scanで解決されました。魔法でも正しいインデックスの代わりでもありません(インデックスを下るのではなく全体をたどります)が、インデックスがテーブルよりずっと小さければ、それでも得になります。実際的な結論:「足りない」インデックスを作る前にプランを見てください。すでに使われているものがあるかもしれません。
フィールドの中にあるものにはGIN
CREATE INDEX idx_datos ON eventos USING gin (datos);
SELECT count(*) FROM eventos WHERE datos @> '{"tags":["t7"]}';
インデックスがなければそのクエリーはシーケンシャルスキャンで、あればBitmap Index Scanです。テストのGINは200,000行で864 kBでした。jsonbで@>でしか問い合わせないなら、jsonb_path_opsのほうが小さく済みます:同じデータで、通常のGINの864に対して640 kBです。
テーブルを止めずに作る
通常のCREATE INDEXはSHAREロックを取ります:読み取りは許しますが、書き込みを止めます。CREATE INDEX CONCURRENTLYはSHARE UPDATE EXCLUSIVEを取るので何も止めません。その代わりにテーブルを2回走査し、3つのルールがあります:
CREATE INDEX CONCURRENTLY idx_pedidos_cliente ON pedidos (cliente_id);
-- 作りかけのまま残ったものはないか
SELECT indexrelid::regclass AS indice, indisvalid
FROM pg_index WHERE NOT indisvalid;
1. トランザクションの中では実行できません:25001 CREATE INDEX CONCURRENTLY cannot run inside a transaction block。
2. 失敗すると無効なインデックスが残ります:誰も使いませんが、書き込みのたびに保守されます。上のクエリーで見つけて削除する必要があります。
3. PostgreSQL 12からはREINDEX INDEX CONCURRENTLYがあり、肥大化したインデックスをテーブルを止めずに再構築する方法です。
誰も使っていないインデックス
SELECT relname AS tabla, indexrelname AS indice, idx_scan,
pg_size_pretty(pg_relation_size(indexrelid)) AS tamano
FROM pg_stat_user_indexes
WHERE idx_scan = 0
ORDER BY pg_relation_size(indexrelid) DESC;
キーワード: インデックス、BTREE、BRIN、GIN、GIST、SPGIST、HASH、部分インデックス、式インデックス、INCLUDE、INDEX ONLY SCAN、HEAP FETCHES、CONCURRENTLY、REINDEX、pg_stat_user_indexes、INDISVALID、jsonb_path_ops、可視性マップ
SELECT calls, round(total_exec_time::numeric, 1) AS ms_total,
round(mean_exec_time::numeric, 2) AS ms_media, rows, query
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;
細かい注意点:CREATE EXTENSIONだけでは足りません。shared_preload_librariesに加えてサーバを再起動する必要があります。拡張機能を作成しただけだと、最初のクエリーは55000 pg_stat_statements must be loaded via "shared_preload_libraries"と答えます。mean_exec_timeではなくtotal_exec_timeでソートしてください:午後を食いつぶすクエリーは、たいてい100万回実行される速いクエリーです。
キャッシュヒット率
SELECT blks_hit, blks_read,
round(100.0 * blks_hit / nullif(blks_hit + blks_read, 0), 2) AS pct
FROM pg_stat_database
WHERE datname = current_database();
SELECT name, setting, unit, context, source, pending_restart
FROM pg_settings
WHERE name IN ('shared_buffers','work_mem','max_connections','max_wal_size');
パラメータの3つのクラスと、痛い目にあうもの context列が、変更に何が必要かを示します:
- user / superuser:セッションでのSETで足ります(work_mem、effective_cache_size)。
- sighup:再読み込みが必要です(checkpoint_timeout、max_wal_size、autovacuumのほぼすべて)。
- postmaster:サーバの再起動が必要です。このサーバでは378個中65個で、その中にshared_buffers、max_connections、wal_level、shared_preload_libraries、autovacuum_max_workersがあります。
ALTER SYSTEM SET max_wal_size = '4GB';
SELECT pg_reload_conf();
-- 再起動を待っているものはあるか
SELECT name, setting FROM pg_settings WHERE pending_restart;
推奨事項
触るのは少なく、1つずつ、測りながら。ファイルを編集する代わりにALTER SYSTEMを使い(記録が残り、ALTER SYSTEM RESETで元に戻せます)、全体よりも先にデータベースごと、ロールごとに設定します。夜間のレポートにしか要らない設定の代償を、ほかの時間帯に払わせるべきではありません。
本当の罠:付与は未来には届きません GRANT … ON ALL TABLES IN SCHEMAが権限を与えるのは、今日存在するテーブルです。測定結果:付与のあと、app_roは既存のテーブルは読めましたが、1分後に作成されたテーブルは読めませんでした。未来をカバーするのは別の文です:
GRANT USAGE ON SCHEMA public TO app_ro;
GRANT SELECT ON ALL TABLES IN SCHEMA public TO app_ro; -- 今日のテーブル
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT SELECT ON TABLES TO app_ro; -- 明日のテーブル
付与は、元に戻す必要のある痕跡も残します:ON ALL TABLESのあとでは、ロールの削除はDependentObjectsStillExistと、権限が残っているテーブルの一覧とともに失敗します(テストではPostGISのテーブルまで出てきました)。対になるのはREVOKE、またはDROP ROLEの前のDROP OWNED BY roleです。
時間には表れない注意が2つあります:
- DROP COLUMNが一瞬で終わるのは、列を削除済みと印を付けるだけだからです:テーブルが書き直されるまで容量は戻りません。
- SET NOT NULLと通常のCHECKは書き直しませんが、ロックを持ったままテーブル全体を走査します。大きなテーブルでは、それだけでもう停止です。
serialかIDENTITYか serialは型ではありません:シーケンスを作り、そのnextvalをデフォルト値に設定する糖衣構文です。GENERATED ALWAYS AS IDENTITYは標準のもので、しかも列を保護します:値を手で挿入しようとすると428C9 cannot insert a non-DEFAULT value into columnと答えました。新しいテーブルにはIDENTITYを。
- 23505:MESSAGE:duplicate key value violates unique constraint "er_d_pkey"。DETAIL:Key (id)=(1) already exists.
- 42883:MESSAGE:operator does not exist: text = integer。HINT:No operator matches the given name and argument types. You might need to add explicit type casts.
- 42703:MESSAGE:column "ids" does not exist。HINT:Perhaps you meant to reference the column "er_d.id".
ALTER SYSTEM SET log_error_verbosity = 'verbose';
SELECT pg_reload_conf();
これで、ログはFATAL: database "no_existe" does not existからFATAL: 3D000: database "no_existe" does not existになりました(存在しないロールなら28000)。誰かが「接続できない」と報告してきたときに、推測するか知っているかの違いです。
CREATE TABLE pt (id bigserial, creado date NOT NULL, importe numeric)
PARTITION BY RANGE (creado);
CREATE TABLE pt_2024 PARTITION OF pt
FOR VALUES FROM ('2024-01-01') TO ('2025-01-01');
CREATE TABLE pt_resto PARTITION OF pt DEFAULT;
肝心なのはプルーニング
3年分の300,000行で測定すると:WHERE creado BETWEEN '2024-03-01' AND '2024-03-31'のクエリーは「pt_2024だけ」を走査しました。同じテーブルで、キーではない列で絞り込むと、すべてのパーティションに対してParallel Appendを行いました。
ALTER TABLE pt ADD CONSTRAINT pt_uni UNIQUE (id, creado);
(id)だけのUNIQUEは0A000 unique constraint on partitioned table must include all partitioning columnsで拒否されます。宣言的パーティション分割では、識別子のグローバルな一意性は保証できません。それは制約ではなく、構造上一意であるシーケンスから得ます。
SELECT array(SELECT s FROM (VALUES ('a'),('B'),('á'),('b'),('A')) v(s) ORDER BY s COLLATE "C"),
array(SELECT s FROM (VALUES ('a'),('B'),('á'),('b'),('A')) v(s) ORDER BY s COLLATE "es-ES-x-icu");
測定すると、結果はまるで似ていません:
- C:A, B, a, b, á。文字の番号でソートします:大文字がすべて小文字より前で、アクセント付きは最後です。
- es-ES-x-icu:a, A, á, b, B。辞書のようにソートします。
- WHERE s = 'usuario42' → Index Only Scan。
- WHERE s LIKE 'usuario42%' → インデックスがすぐそこにあるのに「Seq Scan」。
- 正しい演算子クラスでインデックスを作り直すと、同じクエリーはBitmap Index Scanになりました。
CREATE INDEX idx_prefijo ON ch_like (s text_pattern_ops);
varcharとnvarcharは2つのエンコーディング
- nvarchar(とnchar)はUTF-16で保存します。照合順序が何であれ、あらゆるUnicode文字を保存できます。
- varchar(とchar)はその照合順序のコードページのバイトで保存します。Latin1_General_CI_ASのような従来の照合順序ではそれはWindows-1252で、その外にあるものは?として保存されます。
- _UTF8で終わる照合順序(SQL Server 2019以降)では、varcharはUTF-8で保存します。
テスト用サーバ(SQL Server 2025)で、データベースをLatin1_General_100_CI_AS_SC_UTF8にして測定した結果:
値
varcharのバイト数
nvarcharのバイト数
ñ
2
2
日本語
9
6
絵文字1つ
4
4
LEN()は文字を、DATALENGTH()はバイトを数えます。varchar(n)のnはバイト数で、nvarchar(n)ではバイトの組の数です。文字数ではありません。測定結果:UTF-8のvarchar(10)にはñが5つ入り、10個ではMsg 2628(String or binary data would be truncated)になります。nvarchar(10)にはñが10個入りますが、絵文字6つは入りません。
SELECT CASE WHEN 'a' = 'A' COLLATE Latin1_General_100_CI_AS THEN 1 ELSE 0 END AS ci,
CASE WHEN 'a' = 'A' COLLATE Latin1_General_100_CS_AS THEN 1 ELSE 0 END AS cs,
CASE WHEN 'e' = 'é' COLLATE Latin1_General_100_CI_AI THEN 1 ELSE 0 END AS ai,
CASE WHEN 'e' = 'é' COLLATE Latin1_General_100_CI_AS THEN 1 ELSE 0 END AS acc;
測定結果:1, 0, 1, 0。
- _CI / _CS — 大文字と小文字を区別しない/区別する。
- _AI / _AS — アクセントを区別しない/区別する。
- _SC — 絵文字のような基本面の外にある文字を1文字として数えます。測定結果:LEN(N'😀')は_SCありで1、なしで2になり、_SCがないとLIKE N'_'はそれに一致しません。
- _UTF8 — varcharをUTF-8にします。
- _BIN2 — コードポイントで比較します。A, B, a, b, n, o, á, ñの順に並べました。Latin1_General_100_CI_ASとModern_Spanish_100_CI_ASではA, a, á, b, B, n, ñ, oでした。
- 100はソート表のバージョンです。これのないもの(Latin1_General_CI_AS)はより古く、SQL_で始まるものはSQL Server 2000からのレガシーなものです。
- サーバ — masterとtempdbのもので、インストール時に選びます。
- データベース — CREATE DATABASE … COLLATE …。
- 列 — name varchar(40) COLLATE Latin1_General_100_CS_AS。
- 式 — WHERE a = b COLLATE Latin1_General_100_CI_AS、またはORDER BYの中。
ALTER DATABASE … COLLATEはすでにある列には触れません。測定すると、データベースは_UTF8に移りましたが、その列はLatin1_General_CI_ASのままでした。列はそれぞれ専用のALTER TABLE … ALTER COLUMNで変更します。
照合順序の競合
照合順序の異なる2つのテキストは、そのままでは比較できません:
- WHERE a = b → Msg 468、Cannot resolve the collation conflict。
- 両方の列のUNION ALL → Msg 457。カタログのテキスト(sys.*はLatin1_General_CI_AS)と組み合わせると → Msg 451。
- 片方にCOLLATEを付ければ解決します。
- WHERE s = 'usuario42' → Index Seek。列の側にCOLLATEを付けると → Index Scanで、インデックス全体を読みます。
- WHERE s LIKE 'usuario42%' → Index Seek。PostgreSQLとは違います。
- varchar列に対するN'…'のパラメータ:Windowsの照合順序(Latin1_General_100_CI_AS)では、インデックスは引き続き使われ、論理読み取りは7でした。SQL_の照合順序(SQL_Latin1_General_CP1_CI_AS)では、列全体がnvarcharに変換され、プランはIndex Scanになります:3に対して334の読み取りです。等価検索が遅く、パラメータがnvarcharで届いているなら、列の照合順序を確認してください。
datetimeoffsetはオフセットを保存する
PostgreSQLのtimestamptzとは違い、書き込まれたときのオフセットを保存し、時点で比較します。'2026-09-30T12:00:00+05:30'と'2026-09-30T06:30:00+00:00'は等しいという結果でした。SWITCHOFFSET(value, '+00:00')でUTCに移し、AT TIME ZONE 'Central European Standard Time'は夏時間を適用して08:30 +02:00を返しました。Calíopeはそれを値自身の時刻で、横にオフセットを付けて表示します。
Msg 2627, Level 14, State 1, Line 1: Violation of PRIMARY KEY constraint 'PK__padre__3213E83F9B0ABF57'. Cannot insert duplicate key in object 'dbo.padre'. The duplicate key value is (1).
テキストは言語で変わり、番号は変わらない SET LANGUAGE Spanishでは、8134はError de división entre cero.として届きました。同じ番号で、テキストが違います。言語はセッションのもので、Calíopeはログインのデフォルトの言語で接続します。だから、コードでも自分のログでも、比較するのは番号で、テキストでは決してありません。
18456:理由はサーバのログにある
失敗したログインは、クライアントには常に同じ形で届きます:Msg 18456, Level 14, State 1、Login failed for user '…'。3つの異なる原因で測定したところ、クライアントには3回ともState 1が見えました。本当の状態はサーバのログにしかありません:
EXEC xp_readerrorlog 0, 1, N'Login failed';
ログの状態
書かれる理由
5
その名前のログインがない
8
パスワードが一致しない
38
要求したデータベースを開けなかった(存在しないか、そのログインにそのデータベースのユーザがない)
接続時に要求したデータベースが存在しない場合は、18456の前に4060が届き(Cannot open database … requested by the login)、こちらは理由を示します。誰かが「入れない」とき、外からは同じ文に見えるものを、サーバのログなら一瞬で見分けられます。
CREATE INDEX ix_cliente ON dbo.pedidos (cliente_id);
SELECT cliente_id, total FROM dbo.pedidos WHERE cliente_id = 4242;
-- Index Seek (ix_cliente) + Clustered Index Seek … LOOKUP:論理読み取り29
CREATE INDEX ix_cliente_inc ON dbo.pedidos (cliente_id) INCLUDE (total);
-- Index Seek (ix_cliente_inc)だけ:論理読み取り3
CREATE INDEX ix_pend ON dbo.pedidos (cliente_id) WHERE estado = 'pendiente';
未処理の注文が5 %のとき、同じ列の完全なインデックスの2,952 kBに対して160 kBでした。落とし穴は、プランがどんな値にも通用しなければならないことです。リテラルのWHERE estado = 'pendiente'では使われ、変数(WHERE estado = @e)では使われず、WITH (INDEX(ix_pend))で強制するとMsg 8622になります。
そして、MySQLのユーザが驚く使い方がもう1つあります。SQL Serverの一意インデックスは「NULLを1つしか許しません」。メールアドレスがNULLの行が2つあると、CREATE UNIQUE INDEX ux_email ON dbo.pedidos (email)はMsg 1505(The duplicate key value is (<NULL>))になります。解決策はフィルタ付きインデックスです:
CREATE UNIQUE INDEX ux_email ON dbo.pedidos (email) WHERE email IS NOT NULL;
- WHERE YEAR(fecha) = 2023 AND MONTH(fecha) = 6:Index Scan、読み取り510。
- WHERE fecha >= '2023-06-01' AND fecha < '2023-07-01':Index Seek、読み取り40。
式を範囲に書き直せないときは、計算列にインデックスを作成します:
ALTER TABLE dbo.pedidos ADD email_lower AS LOWER(email);
CREATE INDEX ix_email_lower ON dbo.pedidos (email_lower);
SELECT id FROM dbo.pedidos WHERE LOWER(email) = 'u77@ejemplo.com';
-- Index Seek (ix_email_lower)、計算列の名前を書かずに
集計には列ストア
列ストアインデックスは、各列を別々に圧縮して保存します。同じテーブルが、通常のクラスタ化インデックスでは35,520 kB、CLUSTERED COLUMNSTOREでは7,104 kBでした。SELECT estado, SUM(total) … GROUP BY estadoは、前者では4,440ページを読んでCPUを19 ms使い、後者では147ページと2 msでした。キーで1行を探すには、今でも通常のインデックスのほうが優れています。業務用のテーブルでよくあるのは、集計する列でNONCLUSTERED COLUMNSTOREを追加することです(4列のもので3,128 kBでした)。
外部キーは自動ではインデックスにならない
InnoDBは外部キーごとにインデックスを作成しますが、SQL Serverは作成しません。測定結果:cliente_id int REFERENCES dbo.clientes(id)のあと、子テーブルには主キーのインデックスしかありませんでした。そのインデックスがないと、親テーブルでのDELETEのたびに、制約を確かめるために子テーブル全体がスキャンされます。
断片化とFILLFACTOR
SELECT i.name, p.avg_fragmentation_in_percent, p.page_count,
p.avg_page_space_used_in_percent
FROM sys.dm_db_index_physical_stats(DB_ID(), OBJECT_ID('dbo.pedidos'), NULL, NULL, 'DETAILED') p
JOIN sys.indexes i ON i.object_id = p.object_id AND i.index_id = p.index_id
WHERE p.index_level = 0;
不可視インデックスはない:代わりにDISABLE ALTER INDEX … DISABLEがいちばん近いものですが、同じではありません。インデックスのデータを削除して定義だけを残すので、元に戻すにはREBUILD全体が必要です。そしてクラスタ化インデックスを無効にすると、テーブルが読めなくなります。SELECT COUNT(*)はMsg 8655になり、ついでにすべての非クラスタ化インデックスも無効にして、それぞれに警告を1つずつ出します。
誰も使わないもの、そして足りないもの
SELECT OBJECT_NAME(i.object_id) AS tabla, i.name AS indice,
u.user_seeks, u.user_scans, u.user_lookups, u.user_updates
FROM sys.indexes i
LEFT JOIN sys.dm_db_index_usage_stats u
ON u.object_id = i.object_id AND u.index_id = i.index_id
AND u.database_id = DB_ID()
WHERE OBJECTPROPERTY(i.object_id, 'IsUserTable') = 1 AND i.index_id > 1
ORDER BY u.user_seeks + u.user_scans + u.user_lookups;
SELECT au.type_desc, SUM(au.used_pages) AS pages
FROM sys.allocation_units AS au
JOIN sys.partitions AS p ON au.container_id = p.partition_id
WHERE p.object_id = OBJECT_ID('dbo.my_table')
GROUP BY au.type_desc;
パラメータ:2,100個、実際には2,098個
プロシージャは2,100個のパラメータを受け付け、呼び出しではそれ以上渡せません。しかし、ドライバやORMはパラメータ化クエリーをsp_executesql経由で送り、そこでは文と宣言も数に入ります。2,098個のパラメータは動作し、2,099個でMsg 180になりました。ある日増えたリストから生成したWHERE id IN (@p1, @p2, …)の典型的なエラーです。解決策はリストを分割することではありません。テーブルとして渡し(テーブル値パラメータか一時テーブル)、JOINすることです。文の中に6万個のリテラルを書いたINは動作しました。
もっと多くの列:列セットとSPARSE
1,025列はMsg 1702になります。SPARSE列とCOLUMN_SET FOR ALL_SPARSE_COLUMNSを使って、2,002列のテーブルを作成できました。列セットのない同じ列では、同じMsg 1702になりました。ドキュメントはこれを「ワイドテーブル」と呼んで上限を30,000列に上げていますが、行はやはり8,060バイトに収まらなければなりません。
ALTER DATABASE my_db SET READ_COMMITTED_SNAPSHOT ON WITH ROLLBACK IMMEDIATE;
このオプションを使うと、READ COMMITTEDは行の最後にコミットされたバージョンを読み、同じ読み取りがすぐに古い値で返ってきました(コミットされていない50ではなく100)。オンにする前に3つのこと:
- これはセッションではなくデータベースのもので、オフの状態で来ます。新しく作成したデータベースでも、modelでも、私の検証環境のdemoでも、is_read_committed_snapshot_onは0です。
- WITH ROLLBACK IMMEDIATEがないと、ALTERは待ちます。データベースにほかのセッションが残っていなくなるまでです。1つ接続していると14.5秒待ちました。そのセッションが出ていくまでにかかった時間です。これを付けると、ほかのセッションを追い出し、そのトランザクションをロールバックします。
- 変わるのは読む側だけです。同じ行への2つの書き込みは、今でも互いを待ちます。セッションBのUPDATEは以前とまったく同じように待ちました。
誰が誰をブロックしているか
ブロックが続いている間に問い合わせます:
SELECT r.session_id, r.blocking_session_id, r.wait_type,
r.wait_time, r.wait_resource
FROM sys.dm_exec_requests AS r
WHERE r.blocking_session_id <> 0;
SELECT request_session_id, resource_type, resource_description,
request_mode, request_status
FROM sys.dm_tran_locks
WHERE resource_database_id = DB_ID();
-- セッションA
BEGIN TRAN;
UPDATE dbo.cuentas SET saldo = saldo - 10 WHERE id = 1;
UPDATE dbo.cuentas SET saldo = saldo + 10 WHERE id = 2;
-- セッションB
BEGIN TRAN;
UPDATE dbo.cuentas SET saldo = saldo - 10 WHERE id = 2;
UPDATE dbo.cuentas SET saldo = saldo + 10 WHERE id = 1;
2つのうち一方がMsg 1205(レベル13、… has been chosen as the deadlock victim. Rerun the transaction.)を受け取り、トランザクションとバッチの残りを失います。もう一方はコミットします。すぐには検出されません。6回実行したところ、循環が閉じてから犠牲者が倒れるまで0.1秒から4.8秒かかりました。ドキュメントがその幅を説明しています。モニタが5秒ごとに循環を探し、1つ見つけた直後はもっと頻繁に探すのです。
SELECT CAST(event_data AS xml) AS grafo
FROM sys.fn_xe_file_target_read_file('system_health*.xel', NULL, NULL, NULL)
WHERE object_name = 'xml_deadlock_report';
MySQLから来た人へ:まず、ここのDDLはPostgreSQLと同じくトランザクション対応です。PostgreSQLから来た人にとって変わるのは、ロックの名前と、PostgreSQLにはないいくつかのツールです。ここで説明することはすべて、200,000行のテーブルでSQL Server 2025に対して測定したものです。
BEGIN TRAN;
ALTER TABLE dbo.pedidos ADD moneda char(3) NULL;
CREATE INDEX ix_cliente ON dbo.pedidos (cliente);
CREATE TABLE dbo.monedas (codigo char(3) PRIMARY KEY);
EXEC sp_rename 'dbo.pedidos.nota', 'comentario', 'COLUMN';
ROLLBACK;
コストはSch-Mと、それが作る待ち行列 ALTER TABLEはどれも、スキーマ変更ロックSch-Mを要求します。これはSELECTを含めすべてと競合します。危険なのは数ミリ秒で終わるかもしれないALTERではなく、待機です。ほかのセッションが、1行だけに触れたトランザクションの途中にあるとき:
- ALTER TABLE … ADDは7秒待ちました。wait_typeはLCK_M_SCH_Mです。
- そのうしろで始まった別の行のSELECTはLCK_M_SCH_Sで6秒待ち、そのblocking_session_idはトランザクションではなくALTERでした。
こうして1ミリ秒の変更がアプリケーション全体を止めます。対策はPostgreSQLと同じです:
SET LOCK_TIMEOUT 3000;
ALTER TABLE dbo.pedidos ADD moneda char(3) NULL;
PostgreSQLにはない障害が2つあります:
- インデックスのある列は型を変えられません。estadoにインデックスがあると、varchar(100)への変更は書き直さないので動作しましたが、varchar(30)とnvarchar(100)はMsg 5074(The index 'ix_estado' is dependent on column 'estado')になりました。インデックスを削除し、列を変更して、インデックスをもう一度作成します。
- DEFAULTは名前の付いた制約で、それがある間は列を削除できません。同じMsg 5074になり、SQL Serverが自分で名前を付けたDF__pedidos__moneda__…という制約が示されます。先にALTER TABLE … DROP CONSTRAINTで削除します。だから、作成するときに名前を付けておく価値があります。
ドキュメントはエディションの条件を加えています。メタデータだけに触れるADD COLUMN … NOT NULL DEFAULTはEnterpriseの機能で、Standardではテーブルを書き直します。私の検証環境は、Enterpriseの機能をすべて持つDeveloperなので、それは測定できませんでした。
オンラインのインデックス:ONLINE = ON
ALTER INDEX ALL ON dbo.grande REBUILD WITH (ONLINE = ON);
CREATE INDEX ix_g ON dbo.grande (g) WITH (ONLINE = ON);
3,000,000行で、オンラインの再構築は47秒かかり、途中で始めたUPDATEは0.5秒で終わりました。ONLINEなしでは、その間ずっとテーブルがロックされたままです。ALTER COLUMNもこれを受け付けます(ALTER TABLE … ALTER COLUMN n bigint NOT NULL WITH (ONLINE = ON))。228 msではなく395 msかかりましたが、テーブルは2倍にならず1,483ページのままでした。
3つの制限:
- テーブルに空間インデックスがあると、ALTER INDEX ALL … WITH (ONLINE = ON)はMsg 153で失敗し、空間インデックス自体もオンラインでは再構築できません。主キーと通常のインデックスは、1つずつならできます。
- ONLINEはロックがないという意味ではありません。開始時にテーブルにSロックを要求し、ドキュメントによると終了時には短いSch-Mを要求します。テーブルにトランザクションが開いていると、オンラインのREBUILDはSを待ち続け、そのうしろに来たUPDATEは待ち行列で6.6秒待ちました(LCK_M_IX)。SELECTは通りました。
- ドキュメントによると、ONLINE = ONはEnterpriseのもので、Standardではエラーになります。
ALTER INDEX ALL ON dbo.pedidos REBUILD WITH (ONLINE = ON (
WAIT_AT_LOW_PRIORITY (MAX_DURATION = 1 MINUTES, ABORT_AFTER_WAIT = SELF)));
同じトランザクションが開いたままで、そのうしろのUPDATEは0.5秒で通り、REBUILDはLCK_M_S_LOW_PRIORITYで待機中と表示されました。MAX_DURATIONが過ぎると、ABORT_AFTER_WAITが決めます:
- SELF:REBUILDがMsg 1222であきらめ、トランザクションは続きます。慎重なのはこれです。
- BLOCKERS:1分後、ブロックしていたセッションが接続を切断され(… disconnected because of a high priority DDL operation)、そのトランザクションはロールバックされました。REBUILDは完了しました。
- NONE:低い優先度で無期限に待ちます。
ALTER INDEX … REBUILDはこれを受け付け、私の2025ではCREATE INDEX … WITH (ONLINE = ON (WAIT_AT_LOW_PRIORITY …))も受け付けます。ALTER TABLEは受け付けません。ADD、ALTER COLUMN、ADD CONSTRAINTでは構文エラー(Msg 155)になります。それらに残るのはLOCK_TIMEOUTだけです。
再開可能なインデックス:RESUMABLE = ON
CREATE INDEX ix_relleno ON dbo.grande (relleno, g) WITH (ONLINE = ON, RESUMABLE = ON);
-- 別のセッションから:
ALTER INDEX ix_relleno ON dbo.grande PAUSE;
SELECT name, state_desc, percent_complete FROM sys.index_resumable_operations;
ALTER INDEX ix_relleno ON dbo.grande RESUME;
SELECT name FROM sys.check_constraints WHERE is_not_trusted = 1;
SELECT name FROM sys.foreign_keys WHERE is_not_trusted = 1;
Calíopeでは
「テーブルメンテナンス」のOPTIMIZE操作は、SQL ServerではONLINEなしのALTER INDEX ALL … REBUILDです。再構築している間、テーブルをロックします。本番環境の大きなテーブルでは、エディタでREBUILD WITH (ONLINE = ON (WAIT_AT_LOW_PRIORITY …))を書いたほうがよいでしょう。
MySQLやPostgreSQLから来た人にとって変わるのは形です。ここではパーティションはテーブルではなくテーブルの一部で、その配置はテーブルより先に作成する2つのオブジェクトで記述します。ここで説明することはすべて、3年分に散らばる300,000件の売上でSQL Server 2025に対して測定したものです。
CREATE PARTITION FUNCTION pf_anio (date)
AS RANGE RIGHT FOR VALUES ('2024-01-01', '2025-01-01', '2026-01-01');
CREATE PARTITION SCHEME ps_anio
AS PARTITION pf_anio ALL TO ([PRIMARY]);
CREATE TABLE dbo.ventas (
id bigint IDENTITY NOT NULL,
creado date NOT NULL,
cliente int NOT NULL,
importe decimal(10,2) NOT NULL,
CONSTRAINT pk_ventas PRIMARY KEY CLUSTERED (creado, id)
) ON ps_anio (creado);
RANGE RIGHTかRANGE LEFTか:境界がどちら側に入るか RIGHTでは、境界はその右側のパーティションに属します。日付には自然な選択です。$PARTITION.pf_anio('2023-12-31')は1を返し、'2024-01-01'は2を返しました。同じ境界でLEFTにすると、1月1日は2023年のパーティションに入りました。何も失敗しません。ある日のデータが違う年に入るだけです。$PARTITIONは数えるのにも便利です:
SELECT $PARTITION.pf_anio(creado) AS particion, COUNT(*) AS filas
FROM dbo.ventas GROUP BY $PARTITION.pf_anio(creado);
肝心なのはパーティションの除外
実際のプラン(SET STATISTICS XML ON、RunTimePartitionSummaryの下)は、各クエリーがいくつのパーティションを読んだかを示します:
フィルタ
パーティション
読み取り
creado BETWEEN '2024-03-01' AND '2024-03-31'
1
38
同じ範囲を変数かパラメータで
1
38
cliente = 42
4つ中4つ
—
YEAR(creado) = 2024
4つ中4つ
1,238
2つの日付の間のCAST(creado AS datetime)
4つ中4つ
46
そこから読み取れることが2つあります:
- 変数やパラメータでも除外されます。コンパイル時ではなく実行時に決まるからです。だから推定プランには数が出ません。キーがフィルタにあれば[PtnId1000] >= RangePartitionNew(…)、なければ[PtnId1000] >= (1) AND [PtnId1000] <= (4)と表示されます。
- 列に関数をかけると、インデックスから隠れるのと同じように除外からも隠れます。YEAR(creado)は4つすべてを読みました。手で書いた範囲(>= '2024-01-01' AND < '2025-01-01')は1つだけ読みます。
ALTER TABLE dbo.ventas_2023 WITH CHECK
ADD CONSTRAINT ck_2023 CHECK (creado < '2024-01-01');
ALTER TABLE dbo.ventas_2023 SWITCH TO dbo.ventas PARTITION 1;
制限とエディション
1つの関数で15,000個のパーティションが可能です。15,000個の境界ではMsg 7719になります。ドキュメントによると、パーティション分割はSQL Server 2016 SP1から、Expressを含むすべてのエディションにあります。
Calíopeでは
パーティション分割されたテーブルの行数とサイズは、すべてのパーティションの合計です。Calíopeが書くCREATE TABLE(テーブルのDDLとして表示するものと、「バックアップ」が保存するもの)の前には、それぞれIF NOT EXISTS付きのパーティション関数とパーティション構成が置かれ、最後はON ps_anio (creado)で終わります。[PRIMARY]上の非クラスタ化主キーのようにアラインされていないインデックスは、自分のONを保ちます。そのバックアップを空のデータベースに復元すると、同じようにパーティション分割され、各行は自分のパーティションに入ります。復元先にすでに同じ名前の関数があれば、既存のものがその境界とともに使われます。「スキーマ比較」はパーティション分割も見ます(テーブルがどこにあるか、各構成の境界)が、そのスクリプトは、すでにあるテーブルをパーティション分割したり、パーティション分割を解除したりはしません。それにはデータの入ったクラスタ化インデックスを再構築する必要があるので、あなたが行うための注記としてスクリプトに書かれます。Calíopeが表示するプランは推定プランなので、パーティションについては上のPtnId1000の形になります。実際にいくつ読まれたかは、実際のプラン、SET STATISTICS XML ONにしかありません。
推奨事項 削除するものでパーティション分割してください。本当の利点は、ログをいっぱいにするDELETEの代わりに、SWITCHかTRUNCATE … WITH (PARTITIONS)で期間を削除することです。主キーと一意インデックスにはパーティションキーを含め、すべてをアラインしたままにします。日付にはRANGE RIGHT。最後には常に空のパーティションを置いて、ただで分割できるようにします。そして、クエリーがパーティションを除外しているかどうかを、思い込まずに実際のプランで確かめてください。
MySQLやPostgreSQLから来た人にとって、ここでは3つのことが変わります。比べる数値は時間ではなく論理読み取りであること。プランはキャッシュされてほかの値でも再利用され、それが「昨日は問題なかった」の半分の原因であること。そしてpg_stat_statementsに相当するものは何もオンにしなくても最初からありますが、再起動すると失われること。ここで説明することはすべて、200,000件の注文でSQL Server 2025に対して測定したものです。
クエリーを測る:論理読み取り
SET STATISTICS IO, TIME ON;
SELECT COUNT(*), SUM(importe) FROM dbo.pedidos WHERE cliente_id = 4243;
SET STATISTICS IO, TIME OFF;
SELECT TOP (5) qs.last_logical_reads, qs.last_elapsed_time AS micros, st.text
FROM sys.dm_exec_query_stats AS qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) AS st
WHERE st.text LIKE N'%cliente_id = 4243%'
ORDER BY qs.last_execution_time DESC;
結果は62で、STATISTICS IOと同じでした。
推定プランと実際のプラン SET SHOWPLAN_XML ONはクエリーを実行せずにプランを要求します。SET STATISTICS XML ONは実行して、起きたことを加えます。すべての演算子でEstimateRowsの横にActualRowsが付きます。文が書き込みをするなら、本当に実行されるので、BEGIN TRAN … ROLLBACKで囲んでください。
SELECT TOP (20) CONVERT(varchar(20), qs.query_hash, 1) AS forma,
SUM(qs.execution_count) AS ejecuciones,
SUM(qs.total_elapsed_time) / 1000 AS ms_total,
SUM(qs.total_logical_reads) AS lecturas
FROM sys.dm_exec_query_stats AS qs
GROUP BY qs.query_hash
ORDER BY SUM(qs.total_elapsed_time) DESC;
それがCalíopeの「スロークエリー」の「SQL文ごと」表示に出るものです。形ごとに1行で、いちばんコストの高いプランのテキストを付け、サーバのすべてのデータベースから集めます。VIEW SERVER STATE権限が必要です。そして履歴は残りません。サーバを再起動すると、ビューは23行から0行になりました。プランがキャッシュから外れたときも残りません。
すでにオンになっているクエリーストア
クエリーストアは永続するほうです。テキスト、プラン、数値を、間隔ごとにデータベース自体の中に保存します。SQL Server 2025では、誰も頼んでいないのに、demoでも新しく作成したデータベースでもオン(READ_WRITE)になっていました。ドキュメントによると、SQL Server 2022から新しいデータベースではそうなっています。
SELECT TOP (20) q.query_id, SUM(rs.count_executions) AS ejecuciones,
SUM(rs.avg_duration * rs.count_executions) / 1000 AS ms_total,
COUNT(DISTINCT p.plan_id) AS planes, qt.query_sql_text
FROM sys.query_store_query_text AS qt
JOIN sys.query_store_query AS q ON q.query_text_id = qt.query_text_id
JOIN sys.query_store_plan AS p ON p.query_id = q.query_id
JOIN sys.query_store_runtime_stats AS rs ON rs.plan_id = p.plan_id
GROUP BY q.query_id, qt.query_sql_text
ORDER BY ms_total DESC;
並列処理
最初の状態では、cost threshold for parallelismは5、max degree of parallelismは0(すべてのコア)です。しきい値がとても低いので、中くらいのスキャンでももう並列になり得ます。別の200,000行のテーブルでは、1つのスキャンが16スレッドを使い、実時間152 msに対してCPUを2,094 ms使いました。高いCXPACKETはエラーではありません。その2つの値が最初のままになっているしるしです。
推奨事項
うまくいく順番:「スロークエリー」で(履歴が必要ならクエリーストアで)クエリーを見つけ、SET STATISTICS XML ONでその実際のプランを要求し、EstimateRowsとActualRowsを比べます。ずれていて、コンパイル時の値が今の値でなければ、パラメータスニッフィングです。同じ値でずれているなら統計情報です。一致しているのにそれでも多く読んでいるなら、インデックスが足りません。プランを読む前にメモリーや並列処理に手を出すのは遠回りです。
キーワード: パフォーマンス、STATISTICS IO、STATISTICS TIME、論理読み取り、showplan_xml、STATISTICS XML、実行計画、実際のプラン、パラメータスニッフィング、RECOMPILE、OPTIMIZE FOR UNKNOWN、sp_recompile、dm_exec_query_stats、query_hash、プランキャッシュ、クエリーストア、dm_os_wait_stats、待機、並列処理
MySQLから来た人へ:ここにはmysqldumpも独立したバイナリログもありません:物理バックアップはT-SQLのSQL文で、そこから先へ進めるための履歴は、各データベースのトランザクションログです。PostgreSQLから来た人にとっての違いは単位です:バックアップと復元の単位はサーバ全体ではなく1つのデータベースです。ここで説明することはすべてSQL Server 2025で測定したものです。
復旧モデルがほとんどすべてを決める
これは各データベースのプロパティで、3つあります:
モデル
ログが保持するもの
特定の時点への復旧
SIMPLE
CHECKPOINTのたびに自動で空になる
できない:最後のバックアップまでだけ
FULL
BACKUP LOGでバックアップするまで、すべて
できる
BULK_LOGGED
一括読み込み以外のすべて(一括読み込みはページ単位で記録)
できる。ただし読み込みの途中は除く
新しいデータベースはmodelのモデルをコピーし、このサーバではそれがFULLです:
SELECT name, recovery_model_desc, log_reuse_wait_desc
FROM sys.databases;
BACKUP DATABASE tienda TO DISK = '/var/opt/mssql/backup/tienda_full.bak'
WITH CHECKSUM, INIT;
BACKUP DATABASE tienda TO DISK = '/var/opt/mssql/backup/tienda_dif.bak'
WITH DIFFERENTIAL, CHECKSUM, INIT;
BACKUP LOG tienda TO DISK = '/var/opt/mssql/backup/tienda_0930_1200.trn'
WITH CHECKSUM, INIT;
SELECT b.type, b.is_copy_only, b.first_lsn, b.last_lsn,
b.backup_finish_date, m.physical_device_name
FROM msdb.dbo.backupset b
JOIN msdb.dbo.backupmediafamily m ON m.media_set_id = b.media_set_id
WHERE b.database_name = 'tienda'
ORDER BY b.backup_start_date;
RESTORE DATABASE tienda_rec FROM DISK = '/var/opt/mssql/backup/tienda_full.bak'
WITH NORECOVERY,
MOVE 'tienda' TO '/var/opt/mssql/data/tienda_rec.mdf',
MOVE 'tienda_log' TO '/var/opt/mssql/data/tienda_rec_log.ldf';
RESTORE LOG tienda_rec FROM DISK = '/var/opt/mssql/backup/tienda_log1.trn'
WITH NORECOVERY;
RESTORE LOG tienda_rec FROM DISK = '/var/opt/mssql/backup/tienda_log2.trn'
WITH STOPAT = '2026-09-30T17:50:09.926', RECOVERY;
Calíopeがすること
「バックアップ」は.bakではなくテキストのスクリプトを書きます:バッチの間にGOを挟んだSQL文、1,000行ずつのINSERTの行、ISO 8601の日付、0x…のバイナリなので、別のサーバでも、別のバージョンでも、セッションが別の言語でも同じように戻ります。「ユーザのパスワードを含める」をオンにすると、ログインはCREATE LOGIN … WITH PASSWORD = 0x… HASHEDとして、各データベースのユーザはCREATE USER … FOR LOGINとして書かれます:スクリプトを復元しても、誰も孤立しません。パーティション分割されたテーブルはパーティション分割されたまま戻ります:スクリプトは、なければ先にそのパーティション関数とパーティション構成を作成します。
MySQLから来た人にとって、ここではアカウントがホストを中に持たず、しかも2つのものです:サーバに属し、接続に使う*ログイン*と、各データベースに属し、権限を受け取る*ユーザ*です。PostgreSQLから来た人にとっては、LOGINを持つすべてのロールが、使えるようになるには各データベースの中にもう一度存在しなければならないようなものです。ここで説明することはすべてSQL Server 2025に対して測定したものです。
ログインとユーザ
CREATE LOGIN ana WITH PASSWORD = 'Xq7#lunes_pw9'; -- サーバ:入る
USE tienda;
CREATE USER ana FOR LOGIN ana; -- データベース:中にいる
ログインがあってユーザがないと、サーバには入れてもデータベースには入れません:データベースを指定して接続するとCannot open database … The login failed(4060)になり、masterからのUSEはMsg 916になりました。ユーザは名前ではなく「sid」でログインに結び付いています:孤立はそこから生まれます。
SELECT dp.name, dp.type_desc
FROM sys.database_principals dp
LEFT JOIN sys.server_principals sp ON sp.sid = dp.sid
WHERE dp.type IN ('S', 'U', 'G')
AND dp.authentication_type_desc = 'INSTANCE'
AND sp.sid IS NULL;
そして、権限を失わずにつなぎ直すには:
ALTER USER ana WITH LOGIN = ana;
あと2つ。DROP LOGINは、ログインにユーザがあるかどうかを確認しません:警告なしに削除し、同じサーバの元のデータベースでユーザを孤立させました。そしてデータベースを別のマシンに持っていくときにそうならないよう、そこでは同じsidでログインを作成します(CREATE LOGIN ana WITH PASSWORD = …, SID = 0x1C2F…):そうすると、復元したデータベースのユーザは、手を加えなくても再び動きました。
ロール
固定サーバロールは18個あります:いつもの8つ(sysadmin、serveradmin、securityadmin、processadmin、setupadmin、bulkadmin、diskadmin、dbcreator)と、2022以降の、より細かい10個の##MS_…##(何にも触れずに状態を見るための##MS_ServerStateReader##など)です。各データベースには固定ロールが9個あります:db_owner、db_datareader、db_datawriter、db_ddladmin、db_securityadmin、db_accessadmin、db_backupoperatorと、拒否する2つ、db_denydatareaderとdb_denydatawriterです。独自のロールはCREATE ROLEで作成し、ALTER ROLE … ADD MEMBERでメンバーを追加します。
そして、全員が属するpublic。サーバではこれがVIEW ANY DATABASEを持っています:anaは、入れるのは4つだけなのに、サーバの10個のデータベースの名前が見えました。名前が情報になるなら、publicから取り上げてください。
スキーマ単位の付与は明日のテーブルにも及ぶ
GRANT SELECT ON SCHEMA::rrhh TO ana;
これでanaはrrhhのテーブルを読めるようになり、後から作成したテーブルも読めました。ON ALL TABLESが今日のテーブルにしか及ばないPostgreSQLとは逆です。そしてスキーマの外にあるものは閉じたままです:dbo.pedidosはMsg 229になりました。
CREATE FUNCTION seg.fn_lo_mio(@dueno sysname)
RETURNS TABLE WITH SCHEMABINDING
AS RETURN SELECT 1 AS ok WHERE @dueno = USER_NAME();
CREATE SECURITY POLICY seg.solo_lo_mio
ADD FILTER PREDICATE seg.fn_lo_mio(dueno) ON dbo.pedidos
WITH (STATE = ON);
sa saはsidが0x01のログインで、sysadminのメンバーであり、その名前は入ろうとする誰もが知っています。パスワードが間違っていると、クライアントでは常に状態1で18456になります。理由はサーバのログにしかなく、そこでは同じ試行が状態8、パスワードの誤りとして表示されます。よくあるやり方は、独自の名前で管理用のログインを作成してからALTER LOGIN sa DISABLEすることです。
キーワード: セキュリティ、ログイン、ユーザ、ロール、SYSADMIN、db_owner、db_datareader、PUBLIC、GRANT、DENY、REVOKE、スキーマ、fn_my_permissions、has_perms_by_name、孤立したユーザ、ALTER USER WITH LOGIN、SID、包含ユーザ、包含データベース、所有権の連鎖、行レベルセキュリティ、セキュリティポリシー、SA、check_policy、229、916、4060、15023、18456、33064
MySQLから来た人へ:ここにはmy.cnfもSET GLOBALもありません:サーバの設定は「sp_configure」で書き込んで「RECONFIGURE」で有効にし、データベースの設定はALTER DATABASE SCOPED CONFIGURATIONで行います。PostgreSQLから来た人にとっては、sys.configurationsがpg_settingsにあたり、重要な列はsourceではありません:「value」と「value_in_use」の違いです。ここで説明することはすべてSQL Server 2025に対して測定したものです。
107のオプション、そのうち74は隠れている
このサーバのsys.configurationsには107のオプションがあり、ただのsp_configureが表示するのは33です。残りの74は*詳細オプション*です:show advanced optionsをオンにするまで、一覧にも出ず、変更もできません。sp_configure 'max degree of parallelism', 4はMsg 15123、does not exist, or it may be an advanced optionになりました。
書いただけでは適用されない:valueとvalue_in_use sp_configureは値を書き込むだけで、サーバはRECONFIGUREまで古い値を保持します。sp_configure 'cost threshold for parallelism', 50の後、sys.configurationsはvalueが50、value_in_useが5で、サーバ自身がRun the RECONFIGURE statement to installと警告しました。
SELECT name, value, value_in_use, is_dynamic, is_advanced
FROM sys.configurations
WHERE value <> value_in_use;
RECONFIGUREの前には毎回そのクエリーを実行してください。測定した2つのことのためです:
- RECONFIGUREは、自分のものだけでなく保留中のものをすべて適用します:誰かが値を書いたままにしていれば、それも自分のものと一緒に入ります。
- 失敗したRECONFIGUREは、書き込まれたものを保留中のまま残します。min server memory (MB)を1,024、max server memory (MB)を128にするとMsg 5831になり、1,024はvalueに残って次の機会を待っていました。
例外が通るケースが1つあります:recovery interval (min)を120にするとMsg 5807(not recommended)になり、RECONFIGURE WITH OVERRIDEはそれでも適用しました。WITH OVERRIDEは、まさに今警告したチェックを飛ばすものなので、習慣で使うものではありません。
変更はすべて、時刻とセッションとともにサーバのログに記録されます:EXEC xp_readerrorlog 0, 1, N'Configuration option'で、そのchanged from 0 to 4とともに一覧にできます。
メモリー:max server memoryは上限なしで来る max server memory (MB)は最初から2,147,483,647、つまり上限なしです:SQL Serverは、システムから手放すよう求められるまで、キャッシュのためにメモリーを取ります。専用サーバでは、オペレーティングシステムの分を残す上限を設定します。受け付ける最小値は128で、100にするとMsg 15129になりました。
Linuxではその下にもう1つの上限、mssql-confのmemory.memorylimitmbがあり、ドキュメントによれば物理メモリーの80%がデフォルトです。このコンテナには6,347 MBが見え、max server memoryに手を付けない状態で、その目標値(sys.dm_os_sys_infoのcommitted_target_kb)は5,139 MBでした。
並列処理:3つのレベルがあり、いちばん近いものが優先される
最初の状態では、max degree of parallelismは0(すべてのプロセッサ)、cost threshold for parallelismは5です。300,000行に対して同じクエリーを実行し、実際のプランからDegreeOfParallelismを読みました:
- サーバが0のとき:15スレッド。見えるすべてのプロセッサです。
- サーバが4のとき:4。
- データベースでALTER DATABASE SCOPED CONFIGURATION SET MAXDOP = 2のとき:サーバが4でも2。
- クエリーにOPTION (MAXDOP 8)を付けたとき:データベースとサーバの両方を超えて8。
- cost threshold for parallelismが50のとき:直列。しきい値は並列プランではなく、直列プランのコスト(ここでは7.7)と比べられます。
sp_configureではないもの:Linuxのmssql-conf
データ、ログ、バックアップのデフォルトのパス、TLS証明書、メモリーの上限、言語は/var/opt/mssql/mssql.confにあり、/opt/mssql/bin/mssql-conf set …と再起動で変更します。このコンテナのファイルには2行しかありません:TLSの証明書と鍵で、SQL Server 2022以降が厳格なTLS(TDS 8.0)に必要とするものです。Windowsでは、同じことをSQL Server構成マネージャーで行います。
Calíopeが表示するもの
「サーバ情報」の「グローバル変数」は、sys.configurationsをそのvalue_in_use(書き込まれて保留中のものではなく、動いているもの)とともに一覧にし、SERVERPROPERTY.*のプロパティ(バージョン、エディション、照合順序)も表示します。「セッション変数」は、自分の接続のSETオプションです。Calíopeはそれらを読むだけで変更はしません:sp_configureは「SQLエディタ」で書き、RECONFIGUREの後、更新するとサーバ情報に表示されます。「スロークエリー」は、その設定にoptimize for ad hoc workloadsとmax server memory (MB)を表示します。そして、自分のパスワードを持つユーザを許可するオプションcontained database authenticationは「ログイン、ユーザ、権限(SQL Server)」で説明しています。
推奨事項
少しだけ、そして測定しながら。max server memoryは必ず、システムの分を残して。cost threshold for parallelismは5より上に。そしてMAXDOPは、サーバ全体ではなく、アプリケーションが求めるときにデータベース単位で。RECONFIGUREの前には毎回value <> value_in_useのクエリーを:そうすれば、ほかの誰かが中途半端に残したものではなく、自分が望むものを適用できます。
キーワード: 設定、sp_configure、RECONFIGURE、WITH OVERRIDE、sys.configurations、value_in_use、SHOW ADVANCED OPTIONS、is_dynamic、MAX SERVER MEMORY、MIN SERVER MEMORY、MEMORYLIMITMB、MSSQL-CONF、MAXDOP、MAX DEGREE OF PARALLELISM、COST THRESHOLD FOR PARALLELISM、DATABASE SCOPED CONFIGURATION、TEMPDB、USER CONNECTIONS、xp_readerrorlog、15123、15129、5807、5831
トランザクションは、複数のSQL文をまとめて、全体が適用されるかまったく適用されないかの単位にします。MySQLやPostgreSQLと同じく、明示的なトランザクションがなければ、SQL Serverは各SQL文をそれぞれ単独でコミットします(自動コミット)。違うのは細部で、ここで説明することはすべてSQL Server 2025に対して測定したものです。
暗黙モード SET IMPLICIT_TRANSACTIONS ONにすると、データに触れる最初のSQL文がトランザクションを開き、それはCOMMITまで開いたままになります。測定しました:@@TRANCOUNTは0で、ただのSELECTで1に上がり、COMMITでようやく0に戻りました。このモードでコミットを忘れたセッションは、ロックを持ち続け、ログを抱え込みます。これは後で説明します。
BEGIN TRAN;
INSERT INTO cuentas (id, saldo) VALUES (3, 0);
SAVE TRANSACTION antes;
BEGIN TRY
INSERT INTO cuentas (id, saldo) VALUES (1, 0); -- 失敗:2627
END TRY
BEGIN CATCH
ROLLBACK TRANSACTION antes; -- @@TRANCOUNTは1のまま
END CATCH
INSERT INTO cuentas (id, saldo) VALUES (5, 5);
COMMIT;
SAMPLE BY
データの一部をクエリーできます(SELECT … SAMPLE 0.1)。サンプリングの式は主キーに含まれていなければならず、そうでなければサーバはCREATEを拒否します。通常はハッシュを使います(ORDER BY intHash32(id) SAMPLE BY intHash32(id))。測定結果:10万行に対するSAMPLE 0.1は10,039行を数えました。
ソートキーは変更できない ALTER TABLE … MODIFY ORDER BYでは、その同じALTERで作成した列を末尾に追加することしかできません。並べ替えたり、既存の列を追加したりするとエラーになります(Existing column … is used in the expression that was added to the sorting key)。キーを本当に変えるには、別のテーブルを作成してデータをコピーします。
日単位のパーティショニングがよくある間違いである理由
パーティションが多いと小さなパートが多くなり、パートにはコストがあります。1つのパーティションでアクティブなパートが1,000個を超えると、サーバは挿入を遅らせ始め(parts_to_delay_insert)、3,000個になると拒否します(parts_to_throw_insert)。さらに、1回のINSERTで触れられるパーティションは100個までです(max_partitions_per_insert_block)。PARTITION BY fechaでの測定結果:200日分の異なる日付を含むINSERTはCode: 252 … Too many partitions for single INSERT block (more than 100)で失敗しました。よく使われるのは月単位(toYYYYMM)で、テーブルが小さければパーティショニングしません。
パーティション単位のマージと削除
OPTIMIZE TABLE ventas PARTITION 202603 FINAL;
ALTER TABLE ventas DROP PARTITION 202601;
CREATE TABLE ventas (
fecha Date, id UInt64,
nota String TTL fecha + INTERVAL 30 DAY
)
ENGINE = MergeTree
PARTITION BY toYYYYMM(fecha)
ORDER BY (fecha, id)
TTL fecha + INTERVAL 1 YEAR;
CREATE TABLE totales (k String, total UInt64)
ENGINE = SummingMergeTree ORDER BY k;
CREATE MATERIALIZED VIEW mv_totales TO totales AS
SELECT k, sum(v) AS total FROM origen GROUP BY k;
それ以前のデータは入らない(POPULATEを除く)
測定結果:ソースにすでに2行ある状態で、TO付きのビューと内部テーブルのビューは空で作成されました。POPULATE付きで作成したビューは、両方の行を持って作成されました。その後の新しい挿入は、3つすべてに届きました。ClickHouseのドキュメントは、POPULATEがビューを埋めている間にソースに挿入されたものは失われる可能性があると警告しています。トラフィックがある場合は、ビューを作成してから書き込み先をINSERT INTO … SELECTで手作業で埋めるほうが安全です。
ミューテーション:ビューは決して気づかない
ビューが反応するのは挿入だけです。測定結果:ALTER TABLE origen UPDATE v = 1000 WHERE k = 'a'の後、ソースではaの合計が2,000になりましたが、書き込み先は10のままでした。cの行をDELETEした後、ソースからはそれらが消えましたが、書き込み先は合計5を保ったままでした。ソースを修正したら、書き込み先は手作業で再計算します。
軽量DELETE DELETE FROM テーブル WHERE …はパートを書き直しません。行に削除済みの印を付けてクエリーから隠し、次のマージで実際に削除されます。測定結果:10万行のうち、DELETE FROM … WHERE id < 3の後には99,997行が見え、apply_deleted_mask = 0では10万行すべてがまだ残っていました。lightweight_deletes_syncはデフォルトで2で、印がすべてのレプリカに届くまで待ちます。
SELECT … SETTINGS log_comment = 'informe-diario';
SYSTEM FLUSH LOGS;
SELECT query_duration_ms, read_rows, formatReadableSize(read_bytes), formatReadableSize(memory_usage)
FROM system.query_log WHERE type = 'QueryFinish' AND log_comment = 'informe-diario';
SELECT partition, name, rows, active FROM system.parts WHERE table = 'eventos';
SELECT table, elapsed, progress, num_parts, result_part_name FROM system.merges;
しきい値を4に下げての測定結果:5回目のINSERTはCode: 252 … Too many parts … Merges are processing significantly slower than inserts (TOO_MANY_PARTS)で失敗しました。これは最もよくある運用上のエラーで、ほとんどの場合、1行ずつ、またはごく小さなバッチで挿入していること、あるいはパーティショニングが細かすぎることが原因です。解決策は、数千行単位のバッチで挿入することです。測定したサーバ(26.8)ではasync_insertがデフォルトでオンになっており、ドキュメントによると、同時に届いた小さなINSERTを1つのパートにまとめます。ただし、1つずつ順に実行した3回のINSERTは、やはり3つのパートになりました。
CREATE ROLE lectura;
GRANT SELECT ON demo.* TO lectura;
REVOKE SELECT ON demo.eventos FROM lectura;
GRANT lectura TO ana;
SET DEFAULT ROLE lectura TO ana;
データベース全体に付与した権限のうち1つのテーブルにREVOKEを実行しても、データベースへの付与は削除されず、部分的な取り消しが残ります。SHOW GRANTS FOR lecturaは両方の行を表示し、system.grantsは2行目にis_partial_revoke = 1の印を付けます(測定済み)。そのロールでは、demo.productosは読めますが、demo.eventosを読むとCode: 497 … ACCESS_DENIEDで失敗します。
クォータ、プロファイル、行ポリシー
- クォータ(CREATE QUOTA、system.quotas、system.quota_usage):一定の間隔ごとのクエリー数、行数、時間の上限。サーバのドキュメントによると、超えるとクエリーはQUOTA_EXCEEDEDで拒否されます。
- 設定プロファイル(CREATE SETTINGS PROFILE … SETTINGS max_result_rows = 5 TO ana):ユーザは接続のたびにこれを受け取り、system.settingsはchanged = 1で表示します(測定済み)。
- 行ポリシー(CREATE ROW POLICY … ON demo.eventos USING id <= 3 TO ana):サーバは、ユーザが読むものすべてにその絞り込みを加えます。測定結果:count()は、そのユーザでは3、管理者では90でした。
SELECT name, value, changed FROM system.settings WHERE changed;
SELECT name, value, changeable_without_restart FROM system.server_settings WHERE name = 'max_concurrent_queries';
1つのリクエストに1つの文、そしてセッションなし
HTTPでは、各リクエストは1つの文しか受け付けません。;で区切った2つの文はMulti-statements are not allowedで失敗します(測定済み)。インターフェイスにはセッション用のsession_idがあり、それを使えばSETはリクエストをまたいで続きます。しかし、セッションは一度に1つのクエリーしか受け付けません。最初のクエリーの実行中に同じsession_idで2つ目を送ると、Code: 373 … SESSION_IS_LOCKEDで失敗します(測定済み)。そのため、エディタはこれを使いません。同じ接続の2つのタブがこのエラーになってしまうからです。複数の文を持つレポートだけが、実行のたびに新しく、ほかの誰とも共有しない自分のセッションを開きます。どのリクエストにも、そのデータベースと設定が付いています。
62 · SYNTAX_ERROR
構文エラーです。メッセージには位置と、何が期待されていたかが示されます。1つのリクエストに2つの文がある場合(Multi-statements are not allowed)や、262,144バイトより長いクエリー(Max query size exceeded)でも表示されます。
53 · TYPE_MISMATCH CAST(1 AS Array(UInt8))のような、型の間で不可能な変換です。正しい変換関数(toString、toUInt8OrNull…)を使えば解決します。
497 · ACCESS_DENIED
権限が不足しています。メッセージには、どの権限かが示されます(it's necessary to have the grant SELECT ON demo.eventos)。データベース全体に権限が付与されていても、部分的な取り消しがあるとこのエラーになります。
CREATE TABLE n (a NUMBER(5,2), c NUMBER(3), e NUMBER(5,-2));
INSERT INTO n (a) VALUES (1.005); -- 1.01
INSERT INTO n (a) VALUES (123.456); -- 123.46
INSERT INTO n (a) VALUES (1000); -- ORA-01438
INSERT INTO n (c) VALUES (12.6); -- 13
INSERT INTO n (e) VALUES (12345); -- 12300
空文字列はNULL
ほかのエンジンから来たときにいちばん驚くのがこれです:Oracleでは''はまさにNULLです。測定では、'' IS NULLは真、LENGTH('')は0ではなくNULLで、''を挿入すると列はNULLになるため、COUNT(column)はそれを数えません。NOT NULLの列は''をORA-01400で拒否します。
SELECT COUNT(*) FROM f WHERE d = DATE '2026-10-16'; -- 0
SELECT COUNT(*) FROM f WHERE d >= DATE '2026-10-16'
AND d < DATE '2026-10-17'; -- 1
1つ目のクエリーは午前0時と比較しています。1日全体には、半開区間を使います。
TIMESTAMPと2つのタイムゾーン
- TIMESTAMP(3)は秒未満を格納し、丸めます:.98765は.988になりました。11バイトを使います。
- TIMESTAMP WITH TIME ZONEは、瞬間と、書き込まれたときのオフセットを格納します:14:30 +05:30は14:30 +05:30のまま返ってきます。13バイトを使います。
- TIMESTAMP WITH LOCAL TIME ZONEは瞬間を格納し、セッションのタイムゾーンで返します:同じ14:30 +05:30を−06:00のセッションで読むと03:00になりました。見えるものは、誰が尋ねるかで変わります。
CREATE INDEX ix_cli_email ON cli (email);
-- WHERE email = 'u77@x.es' → INDEX RANGE SCAN IX_CLI_EMAIL
-- WHERE email LIKE 'u77%' → INDEX RANGE SCAN IX_CLI_EMAIL
-- WHERE email LIKE '%77@x.es' → TABLE ACCESS FULL
-- WHERE UPPER(email) = '…' → TABLE ACCESS FULL
不可視インデックス ALTER INDEX ix_cli_email INVISIBLEは、インデックスを保守し続けながらオプティマイザの目から隠します:測定では、emailによるクエリーがINDEX RANGE SCANからTABLE ACCESS FULLになりました。削除する前に、そのインデックスが必要かどうかを試せます:何も悪くならなければ削除し、悪くなればVISIBLEで再構築なしにすぐ戻せます。
ビットマップインデックス CREATE BITMAP INDEX … (estado)は26ai Freeで作成できました。値ごとにビットマップを格納し、異なる値が少なければ非常にコンパクトで、分析クエリーでは複数の条件を組み合わせられます。しかし、書き込まれる行はどれも、多くの行をカバーするビットマップの一区間をロックします:同時に書き込みがあるテーブルにはBツリーを使います。
ALTER TABLE cli MOVE;
SELECT index_name, status FROM all_indexes
WHERE owner = 'DBA_MANUAL' AND table_name = 'CLI';
-- IX_CLI_BAJA UNUSABLE, IX_CLI_EMAIL UNUSABLE, … SYS_C… UNUSABLE
CREATE TABLE u (a NUMBER, CONSTRAINT uq_u UNIQUE (a));
INSERT INTO u VALUES (NULL); -- OK
INSERT INTO u VALUES (NULL); -- OK
INSERT INTO u VALUES (1); -- OK
INSERT INTO u VALUES (1); -- ORA-00001
CREATE TABLE ped (id NUMBER PRIMARY KEY,
cli_id NUMBER CONSTRAINT fk_ped_cli REFERENCES cli (id));
INSERT INTO ped VALUES (1, 999999); -- ORA-02291
DELETE FROM cli WHERE id = 6; -- ORA-02292
ORA-02291は親のない子、ORA-02292は子を持つ親の削除です。
インデックスは作成されない
測定では、外部キー付きでpedを作成したあと、ped.cli_idにインデックスはなく、ある顧客の注文の検索(WHERE cli_id = 5)はテーブル全体を読みました。Oracleは、親が削除されたりキーが変わったりするたびにそれを行います。ほとんどの場合、外部キーごとにCREATE INDEX ix_ped_cli ON ped (cli_id)が必要です。
ON UPDATEはない ON DELETE CASCADEとON DELETE SET NULLしかありません。ON UPDATE CASCADEはORA-02000(missing DELETE keyword)になります。Oracleでは主キーは変更しないもので、変更しなければならない場合は手作業か遅延制約で行います。
ON DELETE CASCADE
外部キーをそのように宣言すると、顧客5を削除したときに、その注文も確認なしに削除されました。便利で危険です:1行のDELETEが何段階も下まで及ぶことがあります。
削除せずに無効にする
ALTER TABLE ped DISABLE CONSTRAINT fk_ped_cli;
INSERT INTO ped VALUES (30000, 999999); -- OK
ALTER TABLE ped ENABLE CONSTRAINT fk_ped_cli; -- ORA-02298
ALTER TABLE ped ENABLE NOVALIDATE CONSTRAINT fk_ped_cli;
INSERT INTO ped VALUES (30001, 999999); -- ORA-02291
CREATE TABLE ia (id NUMBER GENERATED ALWAYS AS IDENTITY, v VARCHAR2(5));
INSERT INTO ia (id, v) VALUES (100, 'b'); -- ORA-32795
UPDATE ia SET id = 5; -- ORA-32796
CREATE TABLE ib (id NUMBER GENERATED BY DEFAULT ON NULL AS IDENTITY,
v VARCHAR2(5));
INSERT INTO ib (id, v) VALUES (100, 'b'); -- 100
INSERT INTO ib (id, v) VALUES (NULL, 'c'); -- 1
- ALWAYS:常にOracleが値を設定します。書き込むとORA-32795、変更するとORA-32796になります。
- BY DEFAULT:指定しなければOracleが設定します。ON NULL付きなら、NULLを与えた場合も設定します。
BY DEFAULTには注意が必要です:カウンタはあなたが100を書いたことを知らないので、そこに達すると主キーと衝突します。内部では、アイデンティティはシステムのシーケンスです(ISEQ$$_…、ALL_TAB_IDENTITY_COLSにあります)。
シーケンス
CREATE SEQUENCE sq START WITH 1 CACHE 20;
SELECT sq.CURRVAL FROM dual; -- ORA-08002
SELECT sq.NEXTVAL, sq.NEXTVAL FROM dual; -- 1, 1
SELECT sq.NEXTVAL FROM dual; -- 2
CREATE TABLE ventas (id NUMBER, fecha DATE, pais VARCHAR2(2),
importe NUMBER(10,2))
PARTITION BY RANGE (fecha)
INTERVAL (NUMTOYMINTERVAL(1, 'MONTH'))
(PARTITION p_ini VALUES LESS THAN (DATE '2026-01-01'));
インターバルがなければ、隙間はエラー
インターバルのない範囲やリストでは、どのパーティションにも入らない行はORA-14400(no partition found)になります。これはINTERVALで、VALUES LESS THAN (MAXVALUE)の最後のパーティションで、あるいはリストならDEFAULTで避けられます(「リスト、ハッシュ、自動のパーティション分割(Oracle)」)。
SELECT SUM(importe) FROM ventas
WHERE fecha >= DATE '2026-03-01' AND fecha < DATE '2026-04-01';
-- PARTITION RANGE SINGLE
SELECT SUM(importe) FROM ventas
WHERE TO_CHAR(fecha, 'YYYY-MM') = '2026-03';
-- PARTITION RANGE ALL
1か月分をまるごと削除する ALTER TABLE ventas DROP PARTITION FOR (DATE '2026-02-15')は、その日付を含むパーティションを、1行ずつのDELETEなしに一度に削除します。日付でパーティション分割するいちばんの理由です。TRUNCATE PARTITIONは空にして残します。
グローバルインデックスは使用不可になる
CREATE INDEX ix_ventas_pais ON ventas (pais); -- global
CREATE INDEX ix_ventas_id ON ventas (id) LOCAL;
ALTER TABLE ventas DROP PARTITION FOR (DATE '2026-02-15');
-- IX_VENTAS_PAIS: UNUSABLE
-- IX_VENTAS_ID: USABLE (10/10)
CREATE USER dba_sincuota IDENTIFIED BY "…";
GRANT CREATE SESSION, CREATE TABLE TO dba_sincuota;
CREATE TABLE dba_sincuota.x (id NUMBER); -- OK
INSERT INTO dba_sincuota.x VALUES (1); -- ORA-01950
テーブルはまだ何も使わないので作成され(「セグメントとエクステント(Oracle)」)、最初のINSERTがORA-01950で失敗します。ALTER USER dba_sincuota QUOTA 500M ON usersかQUOTA UNLIMITEDで直します。
どれだけ使っているか
SELECT tablespace_name, used_percent
FROM dba_tablespace_usage_metrics;
ゴミ箱 DROP TABLEは削除しません:テーブルの名前をBIN$…に変え、容量を持ったままゴミ箱に残します(DBA_RECYCLEBINがそれを一覧にしました)。FLASHBACK TABLE x TO BEFORE DROPは行ごとそれを戻しました。本当に解放するのはDROP TABLE x PURGEかPURGE RECYCLEBINです。
INSERT INTO cuenta VALUES (8, 80);
SAVEPOINT antes_del_9;
INSERT INTO cuenta VALUES (9, 90);
ROLLBACK TO SAVEPOINT antes_del_9;
COMMIT;
SELECT id FROM cuenta WHERE id IN (8, 9); -- 8
ROLLBACK TO SAVEPOINTはセーブポイントのあとに来たものを取り消し、トランザクションは生かしたままにします。
文が失敗すると、取り消されるのはその文だけ
INSERT INTO cuenta VALUES (10, 10); -- OK
INSERT INTO cuenta VALUES (10, 10); -- ORA-00001
COMMIT;
SELECT COUNT(*) FROM cuenta WHERE id = 10; -- 1
待つのは書き込みだけ
同じ行に書き込む2つのセッションは互いを待ちます:2つ目は、1つ目が確定するかロールバックするまで止まります(「行ロックと、誰が誰をブロックするか(Oracle)」)。ただのSELECTはブロックもしなければブロックもされません。SELECT … FOR UPDATEはブロックします。書き込むために行を確保するからです。
-- B
SET TRANSACTION ISOLATION LEVEL SERIALIZABLE;
SELECT saldo FROM cuenta WHERE id = 2; -- 100
-- A
UPDATE cuenta SET saldo = 999 WHERE id = 2;
COMMIT;
-- B
SELECT saldo FROM cuenta WHERE id = 2; -- 100
UPDATE cuenta SET saldo = saldo + 1 WHERE id = 2; -- ORA-08177
デフォルトでは、予約された行に書き込みたい側は無制限に待ちます。SELECT … FOR UPDATEなら選べます:
SELECT * FROM cuenta WHERE id = 3 FOR UPDATE NOWAIT; -- ORA-00054
SELECT * FROM cuenta WHERE id = 3 FOR UPDATE WAIT 2; -- ORA-00054 (2.01 s)
SELECT id FROM cuenta WHERE id IN (3, 4)
FOR UPDATE SKIP LOCKED; -- 4
-- A
UPDATE cuenta SET saldo = 1 WHERE id = 4;
-- B
UPDATE cuenta SET saldo = 1 WHERE id = 5;
-- A
UPDATE cuenta SET saldo = 2 WHERE id = 5;
-- B
UPDATE cuenta SET saldo = 2 WHERE id = 4;
-- A: ORA-00060 (5.95 s)
円環が閉じてから約5秒後、Aの文がORA-00060(deadlock detected while waiting for resource)で失敗しました。
EXPLAIN PLAN FOR SELECT * FROM ped WHERE cliente = 7;
SELECT * FROM TABLE(DBMS_XPLAN.DISPLAY(NULL, NULL, 'BASIC ROWS COST'));
-- 0 SELECT STATEMENT 10 11
-- 1 TABLE ACCESS BY INDEX ROWID BATCHED 10 11
-- 2 INDEX RANGE SCAN IX_PED_CLIENTE 10 1
SELECT * FROM ped ORDER BY id FETCH FIRST 10 ROWS ONLY;
-- COUNT STOPKEY, INDEX FULL SCAN (3)
SELECT * FROM ped ORDER BY total FETCH FIRST 10 ROWS ONLY;
-- COUNT STOPKEY, SORT ORDER BY STOPKEY, TABLE ACCESS FULL (139)
ORDER BYの列にインデックスがあれば、Oracleは最初の10エントリーを読んで止まります(COUNT STOPKEY)。インデックスがなければ、上位10件しか残さないのに20,000件すべてを読んでソートしなければなりません:コストは139対3です。
BEGIN
DBMS_STATS.GATHER_TABLE_STATS(USER, 'PED',
method_opt => 'FOR COLUMNS estado SIZE 254');
END;
-- ESTADO: FREQUENCY
-- WHERE estado = 'RARO' → INDEX RANGE SCAN IX_PED_ESTADO (199)
-- WHERE estado = 'COMUN' → TABLE ACCESS FULL (99800)
SELECT sql_text, executions FROM v$sql
WHERE sql_text LIKE '%manual69%';
-- … WHERE id = 1 1
-- … WHERE id = 2 1
-- … WHERE id = 3 1
-- … WHERE id = :id 3
SELECT COUNT(total) FROM ped WHERE estado = :e;
-- :e = 'RARO', 'RARO', 'COMUN', … (18)
SELECT child_number, executions, is_bind_sensitive, is_bind_aware
FROM v$sql WHERE sql_text LIKE '%estado = :e%';
-- 0, 18, Y, N → INDEX RANGE SCAN IX_PED_ESTADO
-- system
CREATE ROLE rol_lectura;
GRANT rol_lectura TO dba_lector;
-- dba_manual
GRANT SELECT ON cliente TO rol_lectura;
-- dba_lector
SELECT * FROM dba_manual.cliente; -- 1, Ana
INSERT INTO dba_manual.cliente VALUES (2, 'Luis');
-- ORA-41900: missing INSERT privilege on "DBA_MANUAL"."CLIENTE"
SELECT role FROM session_roles; -- ROL_LECTURA
SELECT privilege FROM session_privs; -- CREATE SESSION
-- system
ALTER USER dba_lector DEFAULT ROLE NONE;
-- dba_lector
SET ROLE rol_lectura;
CREATE PROFILE perfil_manual LIMIT
FAILED_LOGIN_ATTEMPTS 2 PASSWORD_LOCK_TIME 1;
ALTER USER dba_caduca PROFILE perfil_manual;
-- 2 × ORA-01017; 3: ORA-28000: The account is locked
SELECT account_status FROM dba_users
WHERE username = 'DBA_CADUCA'; -- LOCKED(TIMED)
ALTER USER dba_caduca ACCOUNT UNLOCK;
-- EXPIRED(GRACE):
-- ORA-28098: Profile limits PASSWORD_LIFE_TIME and
-- PASSWORD_GRACE_TIME are not unlimited, password will
-- expire within 0 days.
-- EXPIRED:
-- ORA-28001: The account has expired. The password must be changed.
ALTER USER dba_caduca IDENTIFIED BY "Caduca_pass2";
DELETE FROM cliente; -- 2
SELECT COUNT(*) FROM cliente; -- 0
SELECT COUNT(*) FROM cliente
AS OF TIMESTAMP (SYSTIMESTAMP - INTERVAL '2' SECOND); -- 2
INSERT INTO cliente
SELECT * FROM cliente
AS OF TIMESTAMP (SYSTIMESTAMP - INTERVAL '2' SECOND); -- 2
AS OF TIMESTAMPを使うと少し前の状態のテーブルを読めるので、誤って削除したものを取り戻せます。これは*UNDO*から来ていて、*UNDO*はおよそundo_retention秒(ノードでは900)保持されます:それを過ぎると、以前のデータはもうないかもしれません。
ゴミ箱
DROP TABLE borrable;
SELECT original_name FROM user_recyclebin; -- BORRABLE
FLASHBACK TABLE borrable TO BEFORE DROP;
SELECT * FROM borrable; -- 42
DROP TABLE borrable PURGE;
FLASHBACK TABLE borrable TO BEFORE DROP;
-- ORA-38305: object not in RECYCLE BIN
DROP TABLEはテーブルをゴミ箱に残し、FLASHBACK TABLE … TO BEFORE DROPはデータとともにそれを戻します。PURGEを付けるとゴミ箱を経由せず、元に戻す手段はありません。
-- dba_manual
ALTER SYSTEM SET open_cursors = 301;
-- ORA-01031: insufficient privileges
ALTER SYSTEMは管理作業です。Calíopeはサーバのパラメータを変更しません:読むだけです。
NLS:日付と数値
SELECT TO_CHAR(DATE '2026-10-16') FROM dual; -- 16-OCT-26
ALTER SESSION SET NLS_DATE_FORMAT = 'DD/MM/YYYY';
SELECT TO_CHAR(DATE '2026-10-16') FROM dual; -- 16/10/2026
ALTER SESSION SET NLS_NUMERIC_CHARACTERS = ',.';
SELECT TO_CHAR(1234.5) FROM dual; -- 1234,5
SELECT TO_NUMBER('1234.5') FROM dual; -- ORA-01722
SELECT name, bytes FROM v$sgainfo
WHERE name IN ('Maximum SGA Size', 'Buffer Cache Size',
'Shared Pool Size');
-- Buffer Cache Size 620756992
-- Shared Pool Size 939524096
-- Maximum SGA Size 1603398928
SELECT name, value FROM v$pgastat
WHERE name IN ('total PGA allocated', 'maximum PGA allocated');
-- total PGA allocated 10366976
-- maximum PGA allocated 755031040
Oracle AI Database 26ai Freeは無料のエディションで、Oracleが公表している使用上の上限があります:最大2 CPUスレッド、2 GBのメモリー、12 GBのユーザデータです。テストノードもそれを裏付けています:cpu_countは2で、SGAは1.5 GiBを超えません。それ以上が必要なら別のエディションが必要です。プロトコルは同じです。
データベース自体の上限(測定済み)
CREATE TABLE TTT…T (x NUMBER); -- 128 × T: OK
CREATE TABLE UUU…U (x NUMBER); -- 129 × U:
-- ORA-00972: The identifier … exceeds the maximum length of 128 bytes.
CREATE TABLE v (a VARCHAR2(4001)); -- ORA-00910
CREATE TABLE n (a NUMBER(39)); -- ORA-01727
CREATE TABLE ancha (c0 NUMBER, …, c1000 NUMBER);
-- ORA-01792: maximum number of columns in a table or view is 1000
SELECT RPAD('x', 4000, 'x') || 'y' FROM dual; -- ORA-01489
SELECT * FROM no_existe;
-- ORA-00942: table or view "DBA_MANUAL"."NO_EXISTE" does not exist
INSERT INTO cliente VALUES (90, 'X');
-- ORA-00001: unique constraint (DBA_MANUAL.SYS_C0010174) violated
-- on table DBA_MANUAL.CLIENTE columns (ID)
SELECT TO_DATE('16/10/2026', 'YYYY-MM-DD') FROM dual;
-- ORA-01830: Date format picture ends before converting
-- entire input string.
EXPLAIN SELECT * FROM cli
WHERE pais = 'ES';
-- IndexLookUp、推定10行、
-- stats:pseudo
ANALYZE TABLE cli;
EXPLAIN SELECT * FROM cli
WHERE pais = 'ES';
-- TableFullScan、推定2000行
SELECT SLEEP(0.5);
SELECT Query_time, Digest, Query
FROM information_schema.SLOW_QUERY
WHERE Is_internal = 0
ORDER BY Time DESC LIMIT 1;
-- 0.501377506 a0adeeb7…
-- SELECT SLEEP(0.5);
頼めば楽観的 BEGIN OPTIMISTICはCOMMITまで何もロックしません。そこで別のセッションが同じ行に書き込んだかを確かめ、書き込んでいれば、トランザクション全体が失敗します:
BEGIN OPTIMISTIC;
UPDATE cta SET saldo = 500 WHERE id = 1;
-- 別のセッション:UPDATE … WHERE id = 1
COMMIT;
-- ERROR 9007: Write conflict, …
-- reason=Optimistic [try again later]
分離レベルは2つ
デフォルトのREPEATABLE READとREAD COMMITTED。SERIALIZABLEとREAD UNCOMMITTEDはエラー8048になります。TiDBのREPEATABLE READはギャップロックのないスナップショットです:id BETWEEN 1 AND 10に対するSELECT … FOR UPDATEを開いたまま、別のセッションはid 5を4ミリ秒で挿入し、最初のセッションはCOMMITまで2行を数え続けました。MySQL 26.10では、同じINSERTはCOMMITまで3秒待ちました。
SET @t = NOW(6);
UPDATE cta SET saldo = 40 WHERE id = 1;
SELECT saldo FROM cta AS OF TIMESTAMP @t
WHERE id = 1; -- 100
SELECT saldo FROM cta
AS OF TIMESTAMP NOW() - INTERVAL 1 HOUR;
-- ERROR 9006: GC life time is shorter
-- than transaction duration, …
未来は読めず(エラー1105)、開いているトランザクションの中では使えず(8135)、その時点のスキーマを見ます:テーブルを作成する前の時点なら1146になります。トランザクション全体にはSTART TRANSACTION READ ONLY AS OF TIMESTAMP …があり、これはTiDBが受け付ける唯一の読み出し専用トランザクションです(ただのREAD ONLYは1235になります)。セッション全体にはSET @@tidb_snapshot = …があり、設定しているあいだはすべての書き込みを拒否します。
FLASHBACK:DROPやTRUNCATEを元に戻す
DROP TABLE cta;
FLASHBACK TABLE cta; -- 丸ごと戻る
TRUNCATE TABLE cta;
FLASHBACK TABLE cta TO cta_antes;
CREATE TABLE ventas (
id BIGINT NOT NULL,
dia DATE NOT NULL,
total DECIMAL(10,2),
PRIMARY KEY (id, dia))
PARTITION BY RANGE COLUMNS (dia) (
PARTITION p2025
VALUES LESS THAN ('2026-01-01'),
PARTITION p2026
VALUES LESS THAN ('2027-01-01'));
ALTER RESOURCE GROUP informes
RU_PER_SEC=500
QUERY_LIMIT=(EXEC_ELAPSED='2s',
ACTION=KILL);
-- ERROR 8253: Query execution was
-- interrupted, identified as
-- runaway query
CREATE ROLE 'lectura', 'escritura';
GRANT SELECT ON tienda.* TO 'lectura';
GRANT 'lectura', 'escritura'
TO 'app'@'%';
SET DEFAULT ROLE ALL TO 'app'@'%';
SHOW GRANTS FOR 'app'@'%'
USING 'lectura';
空のドキュメントは29バイトで、読み取り値1件ごとに28.9バイト増えます。540,000件で16,628,919バイトになり、次のまとまりはコード10334で失敗します:Resulting document after update is larger than 16777216。ドキュメントあたり16 MBという上限は交渉の余地がありません。しかも、上限に達する前から痛みます:スカラーのフィールドの$setは、そのドキュメントでは3.15 ms、228バイトのドキュメントでは0.50 msかかり、配列の$popは71.95 msで、単独の読み取り値を挿入する0.15 msとは比べものになりません。上限のわからないまま増える配列は、置き場所を間違えた参照です。
for (let i = 0; i < 70; i++) db.tope.createIndex({ ["c" + i]: 1 })
// 67 CannotCreateIndex · add index fails, too many indexes
const k = {}
for (let i = 0; i < 33; i++) k["g" + i] = 1
db.comp.createIndex(k) // 13103 · too many compound keys
トランザクションの寿命は60秒で、その線を越えるとコミットはhas been abortedとともに251 NoSuchTransactionを返します。中では、ロックの要求はそれぞれ5ミリ秒しか待ちません:トランザクションはロックで固まるくらいなら失敗するほうを選びます。そして、セットが満たせないwriteConcernは、試す前に失敗します。
db.tope.insertOne({ a: 1 })
for (let i = 0; i < 70; i++) db.tope.createIndex({ ["c" + i]: 1 })
// 63個作られ、64個目が失敗する:_id_も数に入る
// 67 CannotCreateIndex :: add index fails, too many indexes
const k = {}
for (let i = 0; i < 33; i++) k["g" + i] = 1
db.comp.createIndex(k) // 32なら通る
// 13103 :: too many compound keys
db.getSiblingDB("d".repeat(65)).c.insertOne({ a: 1 })
// 73 InvalidNamespace :: db name must be at most 63 characters, found: 65
db.createCollection("z".repeat(260))
// 73 InvalidNamespace :: Fully qualified namespace is too long
db.k.createIndex({ w: 1 })
db.k.insertOne({ w: "y".repeat(50000) }) // 入る:キーの上限はない
キーはふつうコロンを使って名付けられ(app:usuario:1、app:sesion:1001)、Calíopeは一覧をそのプレフィックスでまとめます。しかしサーバは階層を知りません。Redisにとってapp:usuario:1は、ほかと同じ13バイトの文字列です。一覧表示したり削除したりできるフォルダはなく、あるプレフィックスの下のキーを見つける唯一の方法は、SCAN … MATCH app:usuario:*で走査することです。区切り文字は自分で選びます(Calíopeでは、デフォルトで:)。テストベッドには5つのルートプレフィックスがあります:app(254キー)、metricas(9)、binario、clave、sinprefijo。
TTLには2つの負の応答があり、それぞれ違います
TTL sinprefijo # -1:存在し、期限切れにならない
TTL no:existe # -2:存在しない
SET k v EX 100
TTL k # 99
SET k v2 # KEEPTTLのないSETは有効期限を消す
TTL k # -1
SET k v EX 100
SET k v3 KEEPTTL # それまでの有効期限を保つ
TTL k # 99
PERSIST k # 1:有効期限を消す
EXPIRE k -1 # 負の時間はキーを削除する
EXISTS k # 0
上限を使用中の量のすぐ上に設定して測定しました(noevictionで300 KB、allkeys-lruで100 KB)。noevictionでは200バイトのキーが811個入り、次のキーはOOM command not allowed when used memory > 'maxmemory'と答え、GETは読み続けました。テストベッドのポリシーであるallkeys-lruでは、書き込みはひとつも失敗しませんでした。サーバは場所を空けるために1,309個のキーをどのデータベースからも(負荷ジェネレータのデータベース1も含めて)追い出し、直前に読まれた(TOUCH)テストベッドの266個のキーには触れませんでした。追い出しは誰にも警告しません。キーはただ消え、それを数えるのはevicted_keysだけです。Redisがあるデータの唯一のコピーなら、ポリシーはnoevictionです。キャッシュなら、allkeysのどれかです。
起動時、Redisは誰かに応答する前にAOFかRDBを読み込みます。テストベッドの266個のキーでは見る暇もありません。150万個のキーを足すと約6秒かかり、その間INFO persistenceはloading:1と答え、ほかのコマンドはすべてLOADING Redis is loading the dataset in memoryと答えました。Calíopeはこれを接続の切断ではなく、サーバの応答として表示します。接続は正常で、待ってからもう一度試すだけです。
Valkey
Valkeyは自分の名前で同じように答えます。LOADING Valkey is loading the dataset in memoryで、9.1.2で300万個のキーを持って起動する間に測定し、その間INFO persistenceにはloading:1がありました。AOFもappendonlydir/の中の複数のファイルで、appendonly.aof.1.base.rdb、appendonly.aof.1.incr.aof、appendonly.aof.manifestです。
8.1以降、ValkeyのSLOWLOGはCOMMANDLOGの3つの部分のひとつ、遅いものの部分です。COMMANDLOG GET <n> slowは同じエントリーを返し、slowlog-log-slower-thanはcommandlog-execution-slower-thanと同じ設定です(9.1.2で測定。一方を変えるともう一方も変わります)。残りの2つの部分は、要求か応答が1 MBより大きいコマンドを記録します(large-requestとlarge-reply、設定はcommandlog-request-larger-thanとcommandlog-reply-larger-than)。「スローログ」が読むのは遅いものだけで、Valkeyがまだ応答するSLOWLOGを通して読みます。COMMANDLOGのない8.0では、それがすべてです。
- SET a 1、LPUSH a x、SET b 2をキューに入れると、EXECはOK、WRONGTYPE、OKと答えました。LPUSHは失敗し、ほかの2つは書き込まれました。ロールバックはありません。実行中のエラーは何も取り消しません。
- キューに入れるときに見つかったエラー(引数の間違ったコマンド)は、トランザクション全体を取り消します。EXECはEXECABORTと答え、どれも実行しませんでした。
- DISCARDのあと、キーは存在しませんでした。
5秒(busy-reply-threshold、以前の名前はlua-time-limitで、その名前でも受け付けます)を過ぎても、スクリプトは止まりません。サーバは実行を続けますが、ほかのすべてにBUSY Redis is busy running a script. You can only call SCRIPT KILL or SHUTDOWN NOSAVE.と答えます。長いループで測定しました。6秒の時点でPINGはBUSYを受け取り、SCRIPT KILLはOKと答え、スクリプトのクライアントはERR Script killed by user with SCRIPT KILLを受け取り、次のPINGはPONGでした。
Valkey
Valkeyは自分の名前で答えます。BUSY Valkey is busy running a script. You can only call SCRIPT KILL or SHUTDOWN NOSAVE.で、強制終了されたスクリプトのクライアントはERR Script killed by user with SCRIPT KILL. script: on @user_script:1.を受け取ります。しきい値は同じ(busy-reply-threshold、5000ミリ秒、別名lua-time-limit)で、UNKILLABLEも同じです。9.1.2で測定しました。
min-replicas-to-writeを設定すると、min-replicas-max-lag秒(デフォルトは10)以内に確認応答するレプリカが少なくともその数だけないかぎり、プライマリは書き込みを拒否します。テストベッドのレプリカのないノードで1にして測定すると、SETはNOREPLICAS Not enough good replicas to write.と答え、読み取りは続きました。テストベッドでは、デフォルトの0です。
bindは、どのアドレスで接続を受け付けるかを指定します。テストベッドは、すべてのアドレスで待ち受けます(* -::*)。そのうえdefaultユーザにパスワードがなければ、保護モード(protected-mode yes、Redisの出荷時の設定)は、ローカルインターフェイスからの接続しか通しません。そのようなサーバで測定すると、127.0.0.1からのPINGはPONGと答え、コンテナのネットワークアドレスからはDENIED Redis is running in protected mode…と答えました。これは誰も設定していないサーバのための安全網で、設定そのものではありません。パスワードを設定すると働かなくなります。
Valkey
Valkey 9.1.2は別の文で答えます。DENIED Running in protected mode because protected mode is enabled and no password is set for the default user…です。ルールは同じで、同じ方法で測定しました。マシン自身からはPONG、コンテナのネットワークからはDENIEDです。
- GET app:xは答えましたが、キーが存在しなかったので空でした。
- SET app:x 1 → NOPERM User lector has no permissions to run the 'set' command。
- GET otra:x → NOPERM No permissions to access a key。~app:*のパターンがキーを制限します。
- PUBLISH otro 1 → NOPERM。そのコマンドは許可されていませんでした。
CONFIG GET <setting>はサーバが今使っている値を読み、CONFIG SET <setting> <value>は再起動せずにそれを変更します。実行中に変えられない設定もあります。測定結果:CONFIG SET databases 32はERR CONFIG SET failed (possibly related to argument 'databases') - can't set immutable configと答えました。
再起動のあとに残るもの
CONFIG SETは、サーバのメモリーの中にあります。ファイルのないテストベッドで測定しました。CONFIG SET timeout 300 → OK、CONFIG REWRITE → ERR The server is running without a config file、そしてコンテナを再起動すると、timeoutは引数どおりの0に戻っていました。
ファイルがあれば、CONFIG REWRITEはたしかに保存します。3行のファイルから起動したサーバで測定しました。CONFIG SET timeout 300とCONFIG REWRITEのあと、ファイルの末尾にはtimeout 300を含む# Generated by CONFIG REWRITEのセクションができましたが、コマンドラインで渡したもの(daemonize yes、logfile)やdefaultユーザも入っていました。もう一度起動すると、timeoutは300でした。CONFIG REWRITEは、今変えたものだけを保存するのではありません。サーバが動いている設定のすべてで、ファイルを書き直します。
INFO serverは、バージョン(redis_version)、モード(スタンドアロンのサーバではredis_mode:standalone)、ポート、稼働時間、設定ファイルを示します。INFO statsは起動時から起きたことを数えます。total_connections_received、total_commands_processed、instantaneous_ops_per_sec、rejected_connections(maxclientsを超えて拒否されたもの)、expired_keysとevicted_keys(期限切れになったキーと、メモリー不足で追い出されたキー)、keyspace_hitsとkeyspace_misses、total_error_repliesです。redisノードで任意の時点に測定すると、ヒット207,832回、ミス3,188回で、拒否された接続も追い出されたキーもありませんでした。「サーバ」はこの要点を表示し、読むだけです。
DEBUGには、サーバを止めたり(DEBUG SLEEP)クラッシュさせたり(DEBUG SEGFAULT)するテスト用のコマンドがあります。Redis 7.0以降、無効の状態で出荷されます。測定結果:DEBUG SLEEP 0はERR DEBUG command not allowed. If the enable-debug-command option is set to "local", you can run it from a local connection, otherwise you need to set this option in the configuration file, and then restart the server.と答えました。「コンソール」は、サーバで有効になっていても実行しません。
WRONGTYPE Operation against a key holding the wrong kind of value
NOAUTH Authentication required.
WRONGPASS invalid username-password pair or user is disabled.
NOPERM User lector has no permissions to run the 'set' command
NOPERM No permissions to access a key
OOM command not allowed when used memory > 'maxmemory'.
READONLY You can't write against a read only replica.
MASTERDOWN Link with MASTER is down and replica-serve-stale-data is set to 'no'.
NOREPLICAS Not enough good replicas to write.
LOADING Redis is loading the dataset in memory
BUSY Redis is busy running a script. You can only call SCRIPT KILL or SHUTDOWN NOSAVE.
MOVED 10042 10.100.37.234:7002
CROSSSLOT Keys in request don't hash to the same slot
CLUSTERDOWN The cluster is down
ERR unknown command 'FOO', with args beginning with: 'bar'
ERR value is not an integer or out of range
Valkey
Valkeyは、サーバの名前を含む2つを除いて同じメッセージを書きます:LOADING Valkey is loading the dataset in memoryとBUSY Valkey is busy running a script. You can only call SCRIPT KILL or SHUTDOWN NOSAVE.。上のうち9.1.2で発生させたほかの12個は、一字一句同じでした。Valkey 9.0以降のクラスタでは、SELECT 1はSELECT is not allowed in cluster modeではなくERR DB index is out of rangeと応答し、CLUSTER MIGRATESLOTSではスロットがASKなしで移動します。「レプリカとクラスタ」のトピックで説明しています。
番号付きのデータベースはスキーマではなく、アプリケーションを分ける方法でもありません。メモリー、パスワード、FLUSHALLを共有し、allkeys-lruでメモリーが少ないと、サーバは書き込みが行われているのとは別のデータベースからキーを追い出しました。さらにクラスタでは、データベース0しか存在しません。測定では、SELECT 1はERR SELECT is not allowed in cluster modeと、CONFIG GET databasesは1と応答しました。データを分けるには、キーの名前にプレフィックス(app:、cola:)を付けるか、別のサーバを使います。「コンソール」はSELECTを実行しません。データベースはツールのセレクタで選びます。
Valkey
Valkey 9.0はcluster-databasesでクラスタに番号付きのデータベースを持てますが、デフォルトは1です。valkey-cluster-1で測定すると、SELECT 1はERR DB index is out of rangeと応答しました。Calíopeは、Valkeyでもクラスタのセッションを0に保ちます。
DEBUG SLEEPとその仲間
DEBUG SLEEPはサーバを止め、DEBUG SEGFAULTはクラッシュさせます。Redis 7.0からDEBUGはオフの状態で出荷され(測定結果:ERR DEBUG command not allowed…)、「コンソール」はオンになっていても実行しません。
時間制限なしでデータを待つ
BLPOP、BRPOP、BLMOVE、BZPOPMIN、XREAD … BLOCK、WAITは何かが届くのを待ち、タイムアウトが0なら永遠に待ちます。待っているあいだ、その接続はほかに何の役にも立ちません。測定では、BLPOP cola 0は3秒後もflags=bでCLIENT LISTに残っていて、CLIENT UNBLOCKで解放されるまでそのままでした。BLPOP cola 1は1秒後に空で戻りました。「コンソール」の接続はセッションのすべてのツールが使うものなので、コンソールはそうしたコマンドを、タイムアウトが0か10秒より長い場合(XREAD BLOCK、WAIT、WAITAOFでは10000ミリ秒を超える場合)は実行しません。
SELECT * FROM pedidos WHERE cliente_id = 1; -- 可
SELECT * FROM pedidos WHERE cliente_id = 1 AND fecha >= '2026-01-01'; -- 可
SELECT * FROM pedidos WHERE cliente_id = 1 AND pedido_id = 1;
-- PRIMARY KEY column "pedido_id" cannot be restricted as preceding column "fecha" is not restricted
SELECT * FROM pedidos WHERE cliente_id = 1 ORDER BY pedido_id;
-- Order by currently only supports the ordering of columns following their declared order in the PRIMARY KEY
ORDER BYで指定できるのは、定義した順序かその逆だけで(ORDER BY fecha ASCはテーブルの順序を逆にします)、常に1つのパーティションの中だけです。JOINもサブクエリもありません。ある画面で状態ごとの注文が必要なら、その読み取りに合ったキーを持つ別のテーブルを作り(シードでは、estadoをパーティションキーにしたpedidos_por_estado)、アプリケーションが両方に書き込みます。これがこのモデルの原則、「クエリごとに1テーブル」です。
SELECT * FROM pedidos WHERE estado = 'enviado';
-- Cannot execute this query as it might involve data filtering and thus may have unpredictable
-- performance. If you want to execute this query despite the performance unpredictability,
-- use ALLOW FILTERING
ALLOW FILTERINGを付けるとクエリは実行されます(テストベッドでは6行のうち2行を返します)が、安くはなりません。条件に合うものを残すためにテーブル全体を読むので、数百万行あれば全行を読むだけの時間がかかり、コーディネータのタイムアウトを使い切ることもあります。小さなテーブルを眺めるには使えますが、アプリケーションでの答えは、別のテーブルかインデックスです。パーティションキーのないcount(*)もすべてを読み、Cassandraは「Aggregation query used without partition key」と警告します。
ScyllaDB
ScyllaDBも同じものを同じ文面で拒否しますが、測定した違いが3つあります。パーティションキーなしでクラスタリング列を絞り込むと(WHERE fecha >= '2026-01-01')実行され、「This query should use ALLOW FILTERING and will be rejected in future versions」という警告が残ります。順序に沿わないORDER BYは「Unsupported order by relation - column fecha doesn't have an ordering or EQ relation」と返します。そして、パーティションキーのないcount(*)は警告しません。
Cassandraは警告付きで受け付けます(「Your replication factor 3 for keyspace qa_t46_rf3 is higher than the number of nodes 1 for datacenter datacenter1」)。ONEの書き込みは通り、ノードが増えるまでQUORUMはすべてUnavailable(必要2、稼働1)で失敗します。
ScyllaDB
ScyllaDBはキースペースの作成時に拒否します(「Datacenter datacenter1 doesn't have enough token-owning nodes for replication_factor=3」)。逆に係数1では警告し(「Using Replication Factor datacenter1=1 lower than the minimum_replication_factor_warn_threshold=3 is not recommended」)、タブレットを使うキースペースではSimpleStrategyを受け付けません(「SimpleStrategy doesn't support tablet replication」)。2025.1は拒否しません。キースペースを作り、「RF-rack-valid」ではないと警告するだけです。またSimpleStrategyのキースペースはtabletなしで作られます。
ScyllaDBにもSTCS、LCS、TWCSがありますが、デフォルトの戦略は別のもの、IncrementalCompactionStrategy(ICS)です。コンパクション中に空きディスクをそれほど必要としないよう、STCSを少しずつ行うものです。UCSはありません(「Unable to find compaction strategy class 'UnifiedCompactionStrategy'」)。新しいテーブルはそれぞれタブレットに分割され(テストベッドのノードでは8つ)、タブレットごとに個別にコンパクションされます。nodetool compactのあとには、タブレットごとに1つ、8つのSSTableが残ります。テストベッドの2025.1では、各テーブルは1つのタブレットで作られました。
SELECT count(*) FROM t WHERE p = 1; -- 0
-- トレース: Read 0 live rows and 1200 tombstone cells
INSERT INTO t (p, c, v) VALUES (2, 1, null);
SELECT * FROM t WHERE p = 2;
-- トレース: Read 1 live rows and 1 tombstone cells
Read 0 live rows and 1200 tombstone cells for query SELECT * FROM qa_t47.t WHERE p = 1 LIMIT 100 ALLOW FILTERING; token -4069959284402364209 (see tombstone_warn_threshold)
1つのパーティションに100,600個あると、読み取りは何も返さず、丸ごと失敗します。
code=1300 [Replica(s) failed to execute read] … received 0 responses and 1 failures: READ_TOO_MANY_TOMBSTONES
Cassandra 5.0で測定:RF 1のキースペースでは比較するものがありません(「Replication factor is 1. No repair is needed for keyspace 'qa_t47'」)。リングでは「Repair completed successfully」となり、修復ごとにsystem_distributed.repair_historyにstarted_atとfinished_at付きの行が残ります。
通常の書き込みは事前に読みません。既に存在するキーへのINSERTはそれを置き換え、存在しないキーへのUPDATEはそれを作ります。軽量トランザクションは条件(IF NOT EXISTS、IF EXISTS、IF 列 = 値)を加え、サーバーはそれを確認して1ステップで書き込みます。同じパーティションへの他の条件付き書き込みが間に割り込むことはありません。応答には[applied]列が含まれ、条件が満たされなかったときは、その原因となった値も含まれます。
INSERT INTO cuentas (id, saldo, dueno) VALUES (1, 100, 'ana') IF NOT EXISTS;
-- [applied] True
INSERT INTO cuentas (id, saldo, dueno) VALUES (1, 999, 'otro') IF NOT EXISTS;
-- [applied] False | id 1 | dueno ana | saldo 100
UPDATE cuentas SET saldo = 80 WHERE id = 1 IF saldo = 100; -- [applied] True
UPDATE cuentas SET saldo = 50 WHERE id = 1 IF saldo = 100; -- [applied] False | saldo 80
UPDATE cuentas SET saldo = 50 WHERE id = 2 IF EXISTS; -- [applied] False
ScyllaDB
ScyllaDBは条件が満たされたときも列を返します。上の最初のUPDATEは[applied] True | saldo 100、つまり書き込む前の値を返し、存在しない行は列がnullで返ります。どちらでも[applied]は常に最初の列です。2025.1では、tabletを使うキースペースでは使えません(「Cannot use LightWeight Transactions for table …: LWT is not yet supported with tablets」)。
SERIAL is not supported as conditional update commit consistency. Use ANY if you mean "make sure it is accepted but I don't care how many replicas commit it for non-SERIAL reads"
You must use conditional updates for serializable writes
条件付きバッチは1パーティション
BATCHに条件を付けられるのは、すべての文が同じパーティションのものである場合だけです。そうでなければ、サーバーはバッチ全体を拒否します。「Batch with conditions cannot span multiple partitions」
Calíopeで見られること
「キースペース」では、行の挿入、編集、削除は常に軽量トランザクションとして行われます(挿入はIF NOT EXISTS、編集と削除はIF EXISTS)。キーが既に存在していれば「そのキーの行は既に存在します。置き換えていません。」、行がもうなければ「その行はもうサーバーにありません。」と表示します。「CQLクエリー」では、[applied]とその列が他の結果と同じくグリッドに出ます。「整合性レベル」は読み取り用にSERIALとLOCAL_SERIALを提供し、条件のフェーズにはサーバーのデフォルトであるSERIALが使われます。「トレース」をオンにすると、文のトレースが「トレース」で開き、Paxosのフェーズが見られます。
キーワード: cassandra、scylladb、軽量トランザクション、lightweight transaction、lwt、if not exists、if exists、applied、serial、local_serial、paxos、cas、batch
CREATE MATERIALIZED VIEW pedidos_por_estado AS
SELECT * FROM pedidos
WHERE estado IS NOT NULL AND cliente IS NOT NULL AND fecha IS NOT NULL
PRIMARY KEY ((estado), cliente, fecha);
SELECT * FROM pedidos_por_estado WHERE estado = 'abierto';
Cassandraでは無効の状態で届きます。初期設定では、CREATE MATERIALIZED VIEWは「Materialized views are disabled. Enable in cassandra.yaml to use.」と答えます。cassandra.yamlのmaterialized_views_enabled: trueと再起動で有効になりますが、それでも新しいビューには毎回警告が付きます。「Materialized views are experimental and are not recommended for production use.」ビューはベーステーブルと同期がずれることがあり、自動で直す仕組みはありません。
ScyllaDB
ScyllaDBではマテリアライズドビューは有効で、実験的な機能ではありません。tabletを使うキースペースでビューやインデックスを作ると、それらがある間はキースペースが「RF-rack-valid」のままでなければならないと警告されます。レプリケーション係数の変更、新しいラックへのノード追加、ラックを空にするノードの削除ができなくなります。2025.1では、tabletを使うキースペースはビューもインデックスも作れません(「Materialized views are not supported on base tables with tablets」「Secondary indexes are not supported on base tables with tablets」)。AND tablets = {'enabled': false}を付けて作る必要があります。
セカンダリインデックス
Cassandra
従来のインデックス(CREATE INDEX … ON pedidos (estado)、種類COMPOSITES)はローカルです。各ノードは自分の行だけをインデックス化するので、インデックスを使うクエリーはリング全体に問い合わせます。トレースにそれが表れます。
Index mean cardinalities are pedidos_estado:1.
Scanning with pedidos_estado.
Submitting range requests on 17 ranges …
Executing read on qa.pedidos
using index pedidos_estado
Consulting index pedidos_estado
for a list of rows containing keys
ローカルインデックス(CREATE INDEX … ON pedidos ((cliente), estado))は各パーティションの中をインデックス化し、WHEREにパーティションキーを指定して使います。
SAIとSASI
Cassandra 5.0+
Cassandra 5.0にはSAIインデックス(Storage-Attached Index)があります。CREATE INDEX … USING 'sai'です。各SSTableと一緒に保存され、範囲(WHERE total > 15)や同じWHEREでの複数の条件に使えます。作成直後は、構築が終わるまでクエリーがINDEX_NOT_AVAILABLEを返しました。
Cassandra
以前のインデックスであるSASIは無効の状態で届きます。「SASI indexes are disabled. Enable in cassandra.yaml to use.」
ScyllaDB
ScyllaDBではUSING 'sai'はベクトル列にしか使えません。「StorageAttachedIndex (SAI) is only supported on vector columns; use a secondary index for non-vector columns」。SASIはありません。「Non-supported custom class」。2025.1にはCUSTOMインデックスがまったくありません(「Unsupported CUSTOM INDEX class sai. Note that currently, Scylla does not support SASI or any other CUSTOM INDEX class.」)。
0 Execute CQL3 query
2094 Parsing SELECT * FROM pedidos WHERE cliente = 1;
3373 Executing single-partition query on pedidos
3678 Skipped 0/1 non-slice-intersecting sstables …
4107 Merged data from memtables and 1 sstables
4244 Read 2 live rows and 0 tombstone cells
4814 Request complete
ScyllaDB
ScyllaDBは同じ考え方で別のイベントを記録します。Creating read executor for token …はどのレプリカに問い合わせるか、Querying cache for range …はキャッシュから読んだかどうか、Page stats: 1 partition(s) (1 live, 0 dead), … 2 clustering row(s) (2 live, 0 dead)は生きている行と死んだ行をいくつ通過したかを示します。
遅いクエリーで探すもの
- SSTableをいくつ読んだか(Merged data from memtables and N sstables):多ければコンパクションが遅れています。
- tombstoneをいくつ通過したか(Read N live rows and M tombstone cells):「tombstoneとgc_grace_seconds」を参照してください。
- 範囲で読んだか(Submitting range requests on N ranges):クエリーがクラスター全体に問い合わせたということで、セカンダリインデックスとALLOW FILTERINGがそうします。
- 時間がどこで使われているか:同じノードの連続する2つのイベントの間の大きな飛び。
INSERT INTO ventas.pedidos …
-- Unauthorized: User lector has no MODIFY permission
-- on <table ventas.pedidos> or any of its parents
権限とリソース
権限はリソースに対して付与され、その下にあるすべてに効きます。すべてのキースペース → 1つのキースペース → 1つのテーブル、すべてのロール → 1つのロール、すべての関数 → 1つのキースペースの関数 → 1つの関数。権限はCREATE、ALTER、DROP、SELECT、MODIFY、AUTHORIZE、DESCRIBE、EXECUTEで、すべてがどのリソースにも使えるわけではありません。GRANT EXECUTE ON KEYSPACEは「… does not support any of the requested permissions」と答えます。GRANT ALL ON ALL KEYSPACESは、そのリソースが受け付けるものを付与します。
CREATE TABLE sesiones (id int PRIMARY KEY,
usuario text, clave text)
WITH default_time_to_live = 3600;
INSERT INTO sesiones (id, usuario, clave)
VALUES (1, 'ana', 'abc') USING TTL 10;
INSERT INTO sesiones (id, usuario, clave)
VALUES (2, 'luis', 'def');
UPDATE sesiones USING TTL 0
SET clave = 'ghi' WHERE id = 2;
SELECT id, ttl(usuario), ttl(clave) FROM sesiones;
-- 1 | 10 | 10
-- 2 | 3600 | null
nullは「失効しない」の意味です。10秒後には行1はもう出てきません。サーバーが受け付けないもの:負のTTL、630,720,000秒(20年:「ttl is too large」)を超えるTTL、キー列のTTL。
Cassandra
「Cannot use selection function ttl on PRIMARY KEY part id」。
ScyllaDB
「TTL is not legal on partition key component id」。
失効したものは消えず、tombstoneになる
失効したセルはtombstoneとしてディスクに残り、読み取りは削除と同じようにそれをたどります。失効した行1を読むトレースは「Read 0 live rows and 2 tombstone cells」と言います。多くの行に短いTTLを付けると、大量のDELETEと同じようにテーブルがtombstoneで埋まり、gc_grace_secondsを過ぎたコンパクションで消えます。詳しくはtombstoneの章にあります。
INSERT INTO sesiones (id, usuario)
VALUES (3, 'eva') USING TIMESTAMP 1000;
INSERT INTO sesiones (id, usuario)
VALUES (3, 'otro') USING TIMESTAMP 500;
SELECT usuario, writetime(usuario)
FROM sesiones WHERE id = 3;
-- eva | 1000
UPDATE visitas SET n = n + 1
WHERE pagina = 'inicio';
-- INSERT: "INSERT statements are not
-- allowed on counter tables, use UPDATE"
-- SET n = 4: "Cannot set the value of
-- counter column n"
-- USING TTL: "Cannot provide custom TTL
-- for counter updates"
カウンターは削除したら再利用できません。試したときに何が起きるかは製品によります。
Cassandra
カウンターが10のときにDELETEしてから+ 1すると11になり、古い値が戻ってきます。1つのテーブルにカウンターと通常の列を混ぜると「Cannot mix counter and non counter columns in the same table」になります。
ScyllaDB
DELETEの後の+ 1は失われ、行は戻ってきません。カウンターと通常の列を混ぜるのは設定エラー(0x2300)で、「Cannot add a non counter column … in a counter column family」になります。2025.1では、tabletを使うキースペースではカウンターを使えません(「Cannot use the 'counter' type for table …: Counters are not yet supported with tablets」)。
SELEC * FROM pedidos;
-- line 1:0 no viable alternative
-- at input 'SELEC'
line 1:0は行(1から)と列(0から)です。予約語を名前に使っても同じになります。引用符なしでtokenという列名はこのように失敗し、二重引用符で囲む("token")と受け付けられます。
0x2200 — Invalid:理解できるが実行できない
最も多いエラーで、メッセージはさまざまです。
- テーブルまたはキースペースが存在しない:
Cassandra
「table pedidos does not exist」。
ScyllaDB
「unconfigured table pedidos」。
- キーにも含まれずインデックスもない列でのフィルター:「Cannot execute this query as it might involve data filtering…」。解決策はALLOW FILTERINGを付けることではなく、別のテーブルかインデックスです。データモデルの章で説明しています。
- 型の違う値:「Invalid STRING constant (uno) for "id" of type int」。
- 無効の状態で出荷される機能:マテリアライズドビューやUDF。
0x2400 — Already exists
既に存在するもののCREATEは、キースペースとテーブルを伴います。IF NOT EXISTSを付ければエラーになりません。
ゼロで割るUDFは「java.lang.ArithmeticException: / by zero」と応答します。
ScyllaDB
UDFは無効の状態で出荷され(「User defined functions are disabled」)、それがなければこのエラーは出ません。
0x1001 — Overloaded
ノードが今は要求を受け付けません。負荷のほかに:
Cassandra
ロールのパスワードを5秒以内に2回変えても0x1001が返ります。
ScyllaDB
ScyllaDBは連続した2回のパスワード変更を受け付けます。
0x2300 — Config error
ScyllaDB
ScyllaDBはトポロジーやテーブルに収まらないものに使います:tabletのあるキースペースでノード数を超えるレプリケーション係数(「… doesn't have enough token-owning nodes for replication_factor=3」)、またはカウンターのテーブルの通常の列。2025.1では、そのレプリケーション係数は警告だけです。
- system_viewsはありません。Cassandraの44の仮想テーブルはどれもなく(「Keyspace system_views does not exist」)、system.peers_v2もありません。
- UDFは無効の状態で出荷され、実験的機能です。SAIインデックスはベクトル列にしか使えず、SASIはありません。
- ロールはsystem_authではなく、Raftで複製されるsystem.rolesにあります。
- tombstoneのしきい値を超えても失敗せずにページ単位で読み、削除したカウンターは復活せずに次のインクリメントを失います。
- WHERE id = 1 AND id = 2は0行を返します。Cassandraは拒否します。
CREATE TABLE t (i INTEGER, r REAL, x TEXT, b BLOB, n NUMERIC);
INSERT INTO t VALUES ('42', '42', 42, 42, '42');
INSERT INTO t VALUES (7.0, 7, '7', '7', '7.5');
SELECT typeof(i), typeof(r), typeof(x), typeof(b), typeof(n) FROM t;
-- integer | real | text | integer | integer
-- integer | real | text | text | real
CREATE TABLE s (i INTEGER, x TEXT) STRICT;
INSERT INTO s VALUES ('42', 'a'); -- 入る:42、何も失わずに変換できる
INSERT INTO s VALUES (1, 42); -- 入る:42はテキスト'42'として保存される
INSERT INTO s VALUES ('abc', 'a');
-- cannot store TEXT value in INTEGER column s.i
CREATE TABLE s2 (d DATETIME) STRICT;
-- unknown datatype for s2.d: "DATETIME"
SELECT typeof(TRUE), TRUE; -- integer | 1
SELECT typeof(date('2026-09-12')); -- text
SELECT typeof(julianday('2026-09-12')); -- real
CREATE TABLE n (v TEXT COLLATE NOCASE);
INSERT INTO n VALUES ('Cafe'), ('CAFE'), ('café');
SELECT v FROM n WHERE v = 'cafe'; -- Cafe, CAFE
SELECT v FROM n WHERE v = 'CAFÉ'; -- nada
SELECT upper('café'), length('café'), length(CAST('café' AS BLOB));
-- CAFé | 4 | 5
BEGIN; -- DEFERRED、デフォルト
UPDATE t SET v = 10 WHERE i = 1;
SAVEPOINT s1;
UPDATE t SET v = 20 WHERE i = 1;
ROLLBACK TO s1; -- ここまで戻すが、トランザクションは閉じない
SELECT v FROM t WHERE i = 1; -- 10
RELEASE s1;
COMMIT;
SQLITE_BUSYは5で、ほとんどいつも自分のせい
ほかの誰かが書き込みロックを持っているとき、応答はコード5のSQLITE_BUSYで、テキストはdatabase is lockedです。これは障害ではありません。1車線しかない資源の順番待ちです。それを障害に変えるのは、busy_timeoutが初期状態で0であることです。何も触らなければ、応答は即座で素っ気ないものになります。800 msに設定すると、同じ呼び出しは895待ってからあきらめました。
# 2つの接続を同時に。timeout=0なのですぐに応答する
a = sqlite3.connect(db, isolation_level=None, timeout=0)
b = sqlite3.connect(db, isolation_level=None, timeout=0)
b.execute("BEGIN IMMEDIATE"); b.execute("UPDATE t SET v=1 WHERE i=2")
a.execute("SELECT v FROM t WHERE i=2") # (0,) <- 以前の値を読む
a.execute("BEGIN IMMEDIATE") # SQLITE_BUSY (5) database is locked
a.execute("PRAGMA busy_timeout=800")
a.execute("BEGIN IMMEDIATE") # 895 ms待って、また5を返す
-- A:
BEGIN DEFERRED;
SELECT v FROM t WHERE i = 1; -- ここでAはデータベースのスナップショットを持つ
-- B、別の接続で:
BEGIN IMMEDIATE; UPDATE t SET v = 5 WHERE i = 2; COMMIT;
-- A、ここで書き込もうとする:
UPDATE t SET v = 2 WHERE i = 1;
-- SQLITE_BUSY_SNAPSHOT (517)、busy_timeoutでは直らない
ROLLBACK; -- 唯一の出口:手放してやり直す
CREATE TABLE padre (id INTEGER PRIMARY KEY);
CREATE TABLE hijo (id INTEGER PRIMARY KEY, p INTEGER REFERENCES padre(id));
INSERT INTO hijo VALUES (1, 999); -- 入る:親999は存在しないが、誰も気にしない
PRAGMA foreign_keys = ON;
INSERT INTO hijo VALUES (2, 999); -- FOREIGN KEY constraint failed
PRAGMA foreign_key_check; -- hijo | 1 | padre | 0
-- pedidos(id, cliente, estado, total, correo)、20 000行
EXPLAIN QUERY PLAN
SELECT * FROM pedidos WHERE cliente = 42 AND estado = 'pagado';
-- SCAN pedidos
CREATE INDEX i_cli ON pedidos(cliente);
-- SEARCH pedidos USING INDEX i_cli (cliente=?)
CREATE INDEX i_cli_est ON pedidos(cliente, estado);
-- SEARCH pedidos USING INDEX i_cli_est (cliente=? AND estado=?)
CREATE INDEX i_parcial ON pedidos(total) WHERE estado = 'pagado';
EXPLAIN QUERY PLAN
SELECT * FROM pedidos WHERE estado = 'pagado' AND total > 900;
-- SEARCH pedidos USING INDEX i_parcial (total>?)
EXPLAIN QUERY PLAN
SELECT * FROM pedidos WHERE total > 900;
-- SCAN pedidos <- 条件を繰り返さないと、インデックスはないのと同じ
CREATE INDEX i_correo ON pedidos(lower(correo));
EXPLAIN QUERY PLAN
SELECT * FROM pedidos WHERE lower(correo) = 'u42@ej.com';
-- SEARCH pedidos USING INDEX i_correo (<expr>=?)
CREATE TABLE kv (k TEXT PRIMARY KEY, v TEXT) WITHOUT ROWID;
-- 20 000行:1 335 296バイト、rowidありでは1 675 264
EXPLAIN QUERY PLAN SELECT v FROM kv WHERE k = 'clave-000042';
-- SEARCH kv USING PRIMARY KEY (k=?)
-- rowidありならこう表示された: USING INDEX sqlite_autoindex_kv_1 (k=?)
ANALYZE;
SELECT tbl, idx, stat FROM sqlite_stat1;
-- t | ia | 20000 20000 <- 20 000行、aの値ごとに20 000行
-- t | ib | 20000 1 <- 20 000行、bの値ごとに1行
SELECT page_count * page_size
FROM pragma_page_count(), pragma_page_size(); -- 10813440
DELETE FROM t WHERE i % 2 = 0;
PRAGMA freelist_count; -- 1ページ、ファイルは同じ大きさのまま
VACUUM; -- 5410816バイト、12 ms
ALTER TABLEにできることは4行に収まり、列の追加と削除で拒否されるものはそのメッセージとともに測定しました。作成、コピー、削除、名前の変更という回り道と、ここではそれが安全な理由。
対象:SQLite 3.35+
ALTER TABLEにできることは4つで、それ以上はありません。列の型を変える、列からNOT NULLを外す、外部キーを追加する:どれも存在せず、試してもスキーマのエラーにすら届かず、構文エラーになります。
ALTER TABLE t RENAME TO t2; -- desde siempre
ALTER TABLE t RENAME COLUMN a TO a2; -- 3.25
ALTER TABLE t ADD COLUMN g TEXT; -- desde siempre
ALTER TABLE t DROP COLUMN b; -- 3.35
ALTER TABLE t ALTER COLUMN g TYPE INTEGER;
-- near "ALTER": syntax error <- 存在せず、存在したこともない
ALTER TABLE t ADD COLUMN i TEXT DEFAULT (datetime('now'));
-- Cannot add a column with non-constant default
ALTER TABLE t ADD COLUMN j TEXT UNIQUE;
-- Cannot add a UNIQUE column
ALTER TABLE t ADD COLUMN k TEXT NOT NULL;
-- Cannot add a NOT NULL column with default value NULL
ALTER TABLE t DROP COLUMN id; -- cannot drop PRIMARY KEY column: "id"
ALTER TABLE t DROP COLUMN e; -- cannot drop UNIQUE column: "e"
ALTER TABLE t DROP COLUMN c; -- error in index i_c after drop column
CREATE VIEW v AS SELECT id, d FROM t;
ALTER TABLE t DROP COLUMN d; -- 通ってしまう、何も言われずに
SELECT * FROM v; -- no such column: d
PRAGMA integrity_check; -- ok
PRAGMA foreign_keys = OFF;
BEGIN;
CREATE TABLE t_nueva (id INTEGER PRIMARY KEY, a INTEGER NOT NULL);
INSERT INTO t_nueva (id, a) SELECT id, CAST(a AS INTEGER) FROM t;
DROP TABLE t;
ALTER TABLE t_nueva RENAME TO t;
-- ここでインデックス、トリガー、ビューを作り直す
COMMIT;
PRAGMA foreign_key_check;
PRAGMA foreign_keys = ON;
注意が3つあります。古いテーブルのインデックス、トリガー、ビューは一緒に消えるので、作り直す必要があります。DROP TABLEがそれらを持っていくからです。外部キーは回り道のあいだオフにし、オンに戻す前にforeign_key_checkでチェックします。そして型の変換は自分の責任です。CAST('a' AS INTEGER)は何の警告もなしに0を返します。
integrity_checkはデータベース全体をたどり、quick_checkはインデックスとテーブルの間の相互チェックを省きます。正常なデータベースではどちらもokと言い、同じデータベースの数百バイトをわざと壊すと、どちらもまったく同じことを言いました:Tree 2 page 4 cell 35: Rowid 0 out of order。2つの違いは大きなデータベースでしか表れず、どちらも何も修復しません。バックアップに戻るかどうかを決めるためのものです。
PRAGMA quick_check; -- ok
PRAGMA integrity_check; -- ok
-- 同じデータベースをわざと壊すと、どちらも同じ応答:
-- *** in database main ***
-- Tree 2 page 4 cell 35: Rowid 0 out of order
CREATE TABLE t (id INTEGER PRIMARY KEY, u TEXT UNIQUE, nn TEXT NOT NULL,
ch INTEGER CHECK (ch > 0), p INTEGER REFERENCES padre(id));
INSERT INTO t VALUES (1,'b','x', 5, 1); -- 1555 UNIQUE constraint failed: t.id
INSERT INTO t VALUES (2,'a','x', 5, 1); -- 2067 UNIQUE constraint failed: t.u
INSERT INTO t VALUES (3,'c',NULL,5, 1); -- 1299 NOT NULL constraint failed: t.nn
INSERT INTO t VALUES (4,'d','x',-1, 1); -- 275 CHECK constraint failed: ch > 0
INSERT INTO t VALUES (5,'e','x', 5, 999); -- 787 FOREIGN KEY constraint failed
PRAGMA max_page_count = 20;
INSERT INTO t ... ; -- 13 SQLITE_FULL database or disk is full
-- データベースを読み出し専用で開いた状態で:
INSERT INTO t ... ; -- 8 SQLITE_READONLY attempt to write a readonly database
-- 同じ接続でSELECTのカーソルを開いたまま:
DROP TABLE t; -- 6 SQLITE_LOCKED database table is locked
13がディスクであることはほとんどない
SQLITE_FULLはパーティションがいっぱいになったように聞こえますが、多くの場合、データベースが自分に課した上限max_page_countです。20ページにすれば1行で発生させられ、メッセージは本当にディスクがいっぱいのときと同じdatabase or disk is fullです。
11、26、そして目にすることのないもの
壊れたファイルの2つのコードは、損傷がどこにあるかで区別されます。意味をなさないのがページなら、database disk image is malformedとともにSQLITE_CORRUPT。意味をなさないのがヘッダなら、試しもせずにSQLITE_NOTADBと言います。そして21は変わり者です。ありえない順序でAPIを呼び出すことです。ほとんど目にすることはありません。どのドライバを使っていても、先にそれを捕まえて独自のエラーを出すからです。たとえばPythonでは、SQLiteのコードすら持たないProgrammingErrorが出てきます。
-- スキーマのページを上書きした状態で:
SELECT count(*) FROM t; -- 11 SQLITE_CORRUPT database disk image is malformed
-- ヘッダのページサイズを上書きした状態で:
SELECT count(*) FROM t; -- 26 SQLITE_NOTADB file is not a database
CREATE TABLE b (c0, c1, ... , c2000);
-- too many columns on b
SELECT 1 UNION ALL SELECT 1 UNION ALL ... ; -- 501 veces
-- too many terms in compound SELECT
ATTACH DATABASE 'x11.sqlite' AS a11;
-- too many attached databases - max 10
CREATE USER 'app'@'10.0.%' IDENTIFIED BY '...';
GRANT SELECT, INSERT, UPDATE, DELETE ON tienda.* TO 'app'@'10.0.%';
GRANT SELECT ON tienda.pedidos TO 'informes'@'%';
SHOW GRANTS FOR 'app'@'10.0.%';
CREATE ROLE 'lectura', 'escritura';
GRANT SELECT ON tienda.* TO 'lectura';
GRANT INSERT, UPDATE, DELETE ON tienda.* TO 'escritura';
GRANT 'lectura' TO 'informes'@'%';
GRANT 'lectura', 'escritura' TO 'app'@'10.0.%';
SET DEFAULT ROLE ALL TO 'app'@'10.0.%';
MariaDB 10.0.5+
MariaDBでは、GRANTは1つの文で1つのロールしか与えられず、アカウントのデフォルトロールは1つだけです。GRANT 'lectura', 'escritura'とSET DEFAULT ROLE ALLは構文エラーになります(13.0で測定)。
GRANT 'lectura' TO 'app'@'10.0.%';
GRANT 'escritura' TO 'app'@'10.0.%';
SET DEFAULT ROLE 'lectura' FOR 'app'@'10.0.%';
SELECT user, host FROM mysql.user WHERE authentication_string = '';
SELECT user, host FROM mysql.user WHERE host = '%';
SELECT * FROM information_schema.USER_PRIVILEGES
WHERE privilege_type IN ('SUPER', 'FILE', 'PROCESS', 'GRANT OPTION');
SELECT table_schema, COUNT(*) AS tablas, SUM(table_rows) AS filas
FROM information_schema.TABLES
WHERE table_type = 'BASE TABLE'
GROUP BY table_schema;
CHECKSUM TABLE pedidos, lineas;
ALTER TABLE pedidos
ADD COLUMN nota VARCHAR(255) NULL,
ALGORITHM=INSTANT;
ALTER TABLE pedidos
ADD INDEX idx_fecha (fecha),
ALGORITHM=INPLACE, LOCK=NONE;
ALTER TABLE pedidos
MODIFY COLUMN total DECIMAL(12,2) NOT NULL,
ALGORITHM=COPY, LOCK=SHARED;
SELECT default_character_set_name, default_collation_name
FROM information_schema.SCHEMATA
WHERE schema_name = DATABASE();
SELECT table_name, column_name, character_set_name, collation_name
FROM information_schema.COLUMNS
WHERE table_schema = DATABASE() AND character_set_name IS NOT NULL
AND character_set_name <> 'utf8mb4';
SHOW VARIABLES LIKE 'character_set%';
SELECT 'cafe' = 'café' COLLATE utf8mb4_0900_ai_ci AS acentos_iguales,
'Ana' = 'ana' COLLATE utf8mb4_0900_ai_ci AS mayusculas_iguales;
SELECT * FROM clientes
WHERE nombre = 'jose' COLLATE utf8mb4_0900_as_cs;
SHOW COLLATION WHERE charset = 'utf8mb4';
照合順序を混ぜると痛い目にあう utf8mb4_general_ciの列とutf8mb4_0900_ai_ciの列のJOINは、エラー1267 Illegal mix of collationsになります。列をCONVERT()やCOLLATEで包んでしのぐと、クエリーはその列のインデックスを使えなくなります。本当の解決はクエリーのCOLLATEではありません。スキーマの照合順序をそろえることです。
驚かずに変換する ALTER DATABASEが変えるのはこれから作るテーブルのデフォルトだけで、既存のテーブルは1つずつ変換しなければなりません。CONVERT TO CHARACTER SETはテーブル全体を書き直すので、重いDDLと同じ注意が必要です:
ALTER DATABASE tienda
CHARACTER SET utf8mb4
COLLATE utf8mb4_0900_ai_ci;
ALTER TABLE clientes
CONVERT TO CHARACTER SET utf8mb4
COLLATE utf8mb4_0900_ai_ci;
SHOW VARIABLES LIKE 'max_connections';
SHOW STATUS LIKE 'Threads_connected';
SHOW STATUS LIKE 'Max_used_connections';
SHOW VARIABLES LIKE 'wait_timeout';
SHOW VARIABLES LIKE 'max_allowed_packet';
SELECT email, COUNT(*) AS repetidos
FROM clientes
GROUP BY email
HAVING repetidos > 1;
INSERT INTO clientes (email, nombre) VALUES ('a@b.c', 'Ana')
ON DUPLICATE KEY UPDATE nombre = VALUES(nombre);
SELECT constraint_name, table_name, referenced_table_name
FROM information_schema.REFERENTIAL_CONSTRAINTS
WHERE constraint_schema = DATABASE();
SELECT l.* FROM lineas l
LEFT JOIN pedidos p ON p.id = l.pedido_id
WHERE p.id IS NULL;