このページはCalíope 1.7を紹介しています。私がいま作っているバージョンです。1.6は、Mac、iPad、iPhone、Apple Watch、Apple TV、Apple Vision Pro向けにApp Storeで公開中です。それぞれの機能がどのバージョンで入ったかは変更履歴に書いてあります。

すべてのトピックに「Calíopeで開く」ボタンを追加します。アプリがインストールされている場合にのみ機能します。

DBAハンドブック

データ型

数値型、文字列型、日付と時刻型、JSON型、空間型の範囲、バイト数、主な用途。MySQLとMariaDBの重要な違いも扱います。

対象: MySQL 5.7+ MariaDB 10.5+ Aurora 2+

データ型の選び方は、ディスク上のサイズ、インデックスの速さ、データの整合性を左右します。このガイドでは、MySQLとMariaDBでよく使われる型を、その範囲、バイト数、主な用途とともにまとめます。

整数型

- TINYINT:1バイト。符号付きの範囲は−128〜127(符号なしは0〜255)。真偽値のフラグや小さな状態に便利です。
- SMALLINT:2バイト。−32,768〜32,767。年齢や小さな数量に。
- MEDIUMINT:3バイト。−8,388,608〜8,388,607。MySQLとMariaDBに固有の型で、それ以外ではほとんど使われません。
- INT(INTEGER):4バイト。±2.1·10⁹。中規模のテーブルの主キーに使う標準の型です。
- BIGINT:8バイト。±9.2·10¹⁸。大きなテーブルの主キーや分散環境の識別子に。

MySQL 8.0+

MySQL 8.0以降、ZEROFILL修飾子と表示幅(INT(11))は非推奨で、ほとんどの場合は無視されます。新しいコードでは使わないでください。

CREATE TABLE orders (
    id        BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    quantity  SMALLINT UNSIGNED NOT NULL,
    status    TINYINT UNSIGNED NOT NULL DEFAULT 0
);

小数と浮動小数点数

- DECIMAL(M, D):正確な精度。全体でM桁、小数部がD桁。金額には必須です。
- FLOAT:4バイト。有効数字はおよそ7桁。近似値です。
- DOUBLE:8バイト。およそ15桁。近似値です。

CREATE TABLE prices (
    sku    VARCHAR(32) PRIMARY KEY,
    amount DECIMAL(12, 2) NOT NULL,
    tax    DECIMAL(4, 2)  NOT NULL DEFAULT 13.00
);

文字列

- CHAR(N):N文字の固定長で、最大255。すべての行が同じ長さになる場合(国コード、ハッシュ値)に高速です。
- VARCHAR(N):可変長で、1行あたり最大65,535バイト(ほかの列と共有)。長さを記録するために1〜2バイトを余分に使います。
- TEXT、MEDIUMTEXT、LONGTEXT:64 KiB、16 MiB、4 GiB。行の外に格納され、プレフィックスを付けなければキーとして使えません(KEY (col(255)))。
- BLOB、MEDIUMBLOB、LONGBLOB:それぞれのバイナリ版です。

日付と時刻

- DATE:3バイト。'1000-01-01'…'9999-12-31'。
- TIME:3バイト。'-838:59:59'…'838:59:59'。そう、24時間を超えられます(時刻ではなく時間の長さを表すため)。
- DATETIME:5バイトに、秒の小数部のために最大3バイトが加わります。タイムゾーンを持たず、保存や読み取りのときに変換もしません。保存した値がそのまま返ります。
- TIMESTAMP:4バイト。範囲は1970〜2038(MySQL。8.xを含む)または1970〜2106(MariaDB 11.5以降)。UTCで格納され、接続のtime_zoneに変換されます。
- YEAR:1バイト。1901〜2155。

MySQL 5.7+MariaDB 10.5+

DATETIMEとTIMESTAMPはどちらも秒の小数部の精度に対応しています。DATETIME(6)はマイクロ秒まで格納します。

CREATE TABLE events (
    id           BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    occurred_at  DATETIME(6) NOT NULL,
    created_at   TIMESTAMP   NOT NULL DEFAULT CURRENT_TIMESTAMP
);

JSON

MySQL 5.7以降とMariaDB 10.2以降のネイティブ型です。JSON_EXTRACTや->>で取り出したフィールドにインデックスを付けられ、MySQL 8.0以降はインデックス付きの生成列(配列に対するMULTI-VALUED INDEX)も使えます。

MySQL 5.7+

MySQLはJSONを最適化されたバイナリ形式(BSONに似たもの)で格納し、挿入時に構文を検証します。

MariaDB 10.2+

MariaDBのJSONはLONGTEXTの別名で、CHECK (JSON_VALID(col))による検証は任意です。バイナリ型ではなく、ディスク上でもかさみます。

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;

ENUMとSET

- ENUM:あらかじめ決めたN個の値(最大65,535)のうち1つを格納します。コンパクト(1〜2バイト)ですが融通が利きません。一覧を変えるにはALTER TABLEが必要です。
- SET:最大64個の値の組み合わせをビットマップで格納します。固定の権限やラベルに便利です。

値の一覧が頻繁に変わるなら避けてください。参照用のテーブルと外部キーのほうが保守しやすくなります。

選び方

1. 想定する範囲を収められる最も小さい型を使います。INTで足りるところにBIGINTを使うと、インデックスの領域が4倍になります。
2. 負の値が要らない列にはUNSIGNEDを付けます。範囲が2倍になります。
3. できるだけNULLを避けます。NOT NULL DEFAULT …の列は、行ごと、インデックスごとに1ビットを節約できます。
4. 実際のデータが20文字に収まるなら、VARCHAR(255)はVARCHAR(20)より高くつきません。宣言した長さが効いてくるのはプレフィックスインデックスだけです。

キーワード: データ型、INT、BIGINT、VARCHAR、TEXT、JSON、DATETIME、TIMESTAMP、DECIMAL、ENUM、SET、POINT、SPATIAL、BLOB、範囲、バイト

インデックス

インデックスの種類(Bツリー、HASH、FULLTEXT、空間インデックス)、単一列と複合インデックスの違い、書き込みを犠牲にせずに読み取りを速くする戦略。

対象: MySQL 5.7+ MariaDB 10.5+ Aurora 2+

インデックスは、ディスク容量と、INSERT/UPDATE/DELETEのたびにかかる負荷と引き換えに、検索を速くします。インデックス設計の良し悪しが、ミリ秒で終わるクエリーと1分かかるクエリーの差になります。

インデックスの種類

- Bツリー:InnoDBのデフォルト。等価検索、範囲(>、<、BETWEEN)、プレフィックス(LIKE 'abc%')、並べ替え(ORDER BY)に使えます。
- ハッシュ:等価検索のみ。MEMORYエンジンで使えます。InnoDBは内部にアダプティブハッシュインデックスを持っていますが、直接は制御できません。
- FULLTEXT:自然言語検索とブール検索。InnoDBとMyISAMで使えます。長いTEXT列に便利です。
- SPATIAL:POINTやPOLYGONなどのためのRツリー。NOT NULLの列が必要です。
- マルチバリュー:JSON配列の要素にインデックスを付けます。MySQL 8.0.17以降のみ。

-- 複合Bツリーインデックス
CREATE INDEX idx_orders_customer_date
    ON orders (customer_id, order_date DESC);

-- 全文インデックス
ALTER TABLE articles
    ADD FULLTEXT INDEX ft_title_body (title, body);

-- 空間インデックス
ALTER TABLE locations
    ADD SPATIAL INDEX sp_point (point);

単一列と複合

(A, B, C)の複合インデックスは、先頭から使う検索をカバーします:WHERE A = ?、WHERE A = ? AND B = ?、WHERE A = ? AND B = ? AND C = ?。ただし、WHERE B = ?だけの検索には使えません。

目安として、複合インデックスの列は選択性(それぞれの列の一意な値の多さ)と、絞り込みに使われる頻度の順に並べます。

-- 良い例:最も選択性の高い列を先頭に
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;

カバリングインデックス

クエリーが読むすべての列を含むインデックスは、テーブルを読みに行かずに済みます。EXPLAINを実行し、Extra列にUsing indexがあるかを確認します。

-- クエリーが読むのは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;

プレフィックスインデックス

長いTEXT列やVARCHAR列では、先頭のN文字だけにインデックスを付けます。妥当な選択性を保ちながら、インデックスのサイズを小さくできます。

CREATE INDEX idx_url
    ON pages (url(64));   -- 先頭の64文字

不可視インデックス

MySQL 8.0+

インデックスは不可視にできます。存在し、更新も続きますが、オプティマイザは無視します。インデックスを削除したときの影響を安全に試すのに便利です:

ALTER TABLE orders ALTER INDEX idx_legacy INVISIBLE;
-- 数時間パフォーマンスを監視する
ALTER TABLE orders ALTER INDEX idx_legacy VISIBLE; -- 元に戻す
-- または
DROP INDEX idx_legacy ON orders; -- 削除を確定する

MariaDB 10.6+

MariaDBではインデックスを不可視にするのではなく、IGNOREDで無視させます(13.0で測定。INVISIBLEは構文エラーになります)。効果は同じです:

ALTER TABLE orders ALTER INDEX idx_legacy IGNORED;
ALTER TABLE orders ALTER INDEX idx_legacy NOT IGNORED; -- 元に戻す

読み取りと書き込みへの影響

インデックスを1つ増やすたびに:
- それを使うクエリーは速くなります。
- インデックス付きの列に触れるすべてのINSERTとUPDATE、そしてすべてのDELETEが遅くなります。
- 追加の容量を使います(多くの場合、テーブルのサイズの10〜40%)。

書き込みの多いテーブル(ログ、メトリクス)では、欠かせないインデックスだけを最小限に保ちます。

最適化の戦略

1. まずEXPLAIN:type: ALL(フルスキャン)とkey: NULL(インデックスを使っていない)を探します。
2. 最適化の前に測定する:スロークエリーログで、最もコストの高いクエリーを見つけます。
3. 選択性と順序を組み合わせる:複合インデックスは、WHERE句とORDER BY句の順序に合わせます。
4. 冗長なインデックスを避ける:(A)、(A, B)、(A, B, C)は冗長です。(A, B, C)だけで足ります。
5. カーディナリティの低い列にインデックスを付けない:genderやactive(一意な値が1つか2つ)のインデックスはほとんど役に立ちません。

-- 診断
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;

キーワード: インデックス、INDEX、Bツリー、BTREE、HASH、FULLTEXT、空間インデックス、カバリングインデックス、複合インデックス、選択性、EXPLAIN、プレフィックス、不可視インデックス、カーディナリティ

正規化

第1正規形、第2正規形、第3正規形、非正規化すべき場面、データの整合性とパフォーマンスの釣り合いの取り方。

対象: MySQL 5.7+ MariaDB 10.5+ Aurora 2+ PostgreSQL 13+ SQLite 3.35+ SQL Server 2022+

正規化とは、冗長性を減らし、挿入・更新・削除の異常を防ぐようにスキーマを整理することです。正規形は積み重ねで、第3正規形(3NF)のテーブルは2NFと1NFも満たします。

第1正規形(1NF)

- 各列は、分割できない値を1つだけ持ちます(複数のエンティティを表すリストや入れ子のJSONは不可)。
- 各行は主キーで識別できます。
- 列に繰り返しのグループがありません(phone1、phone2、phone3)。

-- 悪い例:同じ「エンティティ」を繰り返す3つの列
CREATE TABLE customers_v1 (
    id      BIGINT PRIMARY KEY,
    name    VARCHAR(120),
    phone1  VARCHAR(20),
    phone2  VARCHAR(20),
    phone3  VARCHAR(20)
);

-- 良い例:関連テーブル
CREATE TABLE customers (
    id BIGINT PRIMARY KEY,
    name VARCHAR(120)
);
CREATE TABLE customer_phones (
    customer_id  BIGINT NOT NULL,
    phone    VARCHAR(20) NOT NULL,
    kind        VARCHAR(10) NOT NULL,
    PRIMARY KEY (customer_id, phone),
    FOREIGN KEY (customer_id) REFERENCES customers(id) ON DELETE CASCADE
);

MySQL 5.7+MariaDB 10.5+Aurora

例では、どのエンジンでもそのまま実行できるように、自然キー(customer_id, phone)を使っています。代理キーを使いたい場合、MySQLとMariaDBではid BIGINT AUTO_INCREMENT PRIMARY KEYと書き、決まった値の集合はENUM('mobile', 'home', 'office')で宣言します。

PostgreSQL 13+

例では、どのエンジンでもそのまま実行できるように、自然キー(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の列挙型に入った値は、二度と取り除けません。

SQLite 3.35+

例では、どのエンジンでもそのまま実行できるように自然キー(customer_id, phone)を使っており、SQLiteでも実行できます。実行できないのはその隣の部分で、2つのまったく違う形で失敗します:

- ENUM('mobile', 'home', 'office')は構文エラーです。決まった値の集合はCHECK (kind IN ('mobile', 'home', 'office'))で宣言し、その一覧はあとから変更できません。ALTER TABLE … ADD CONSTRAINTもDROP CONSTRAINTも構文エラーになるので、テーブルを作り直すことになります。
- id BIGINT AUTO_INCREMENT PRIMARY KEYは失敗しません。そのほうが厄介です。SQLiteはどんな型名でも受け付けるので、BIGINT AUTO_INCREMENTをまるごと列の型として飲み込み、何も採番しません。3.51で測定すると、2回挿入してもidは2回ともNULLのままでした。AUTO_INCREMENTのせいでもありません。id BIGINT PRIMARY KEYもまったく同じ動きをします。rowidの別名になって自動で採番されるのは、INTEGER PRIMARY KEYだけだからです。採番が必要なら、型はINTEGERと、その長さのまま書きます。

さらに、データがすでにおかしくなるまで気づかない罠があります。外部キーは、最初は無効になっています。3.51で、PRAGMA foreign_keysがデフォルトの0のまま測定すると、上のテーブルは存在しない顧客の電話番号を受け付け、顧客を削除してもON DELETE CASCADEは実行されません。PRAGMA foreign_keys = ONにすると、どちらもほかのエンジンと同じように動きます。このPRAGMAは接続ごとの設定で、ファイルには保存されず、トランザクションの中では何もしません。接続を開いた直後に実行します。

-- SQLite:接続ごとに、最初のトランザクションの前にオンにする
PRAGMA foreign_keys = ON;

CREATE TABLE customer_phones (
    customer_id  INTEGER NOT NULL,
    phone    TEXT NOT NULL,
    kind        TEXT NOT NULL CHECK (kind IN ('mobile', 'home', 'office')),
    PRIMARY KEY (customer_id, phone),
    FOREIGN KEY (customer_id) REFERENCES customers(id) ON DELETE CASCADE
);

SQL Server

例では、どのエンジンでもそのまま実行できるように自然キー(customer_id, phone)を使っており、SQL Serverでも実行できます。その隣は実行できません。AUTO_INCREMENTは構文エラー(Msg 102)、ENUM(…)も同じで、PostgreSQLの書き方も役に立ちません。GENERATED ALWAYS AS IDENTITYはMsg 156、BIGSERIALは型ではありません(Msg 2715)。ここでは代理キーをid BIGINT IDENTITY(1, 1) PRIMARY KEYと宣言し、決まった値の集合はCHECKで宣言します。SQL Server 2025で測定した2つのこと:

- CHECKには名前を付けます。名前がないと、サーバはテーブルを作るたびに変わる接尾辞付きの名前を作ります(CK__customer_p__kind__6EF57B66)。一覧を変えるにはその名前で削除する必要があります。自分で名前を付けておけば、変更はDROP CONSTRAINTとADD CONSTRAINTの2つのALTER TABLEで済み、新しい制約は信頼された状態で作られます(is_not_trusted = 0)。
- IDENTITYには欠番が出ます。CHECKに拒否されたINSERT(Msg 547)も、ROLLBACKで取り消したINSERTも、番号はそのまま消費します。1行目と2行目のあと、次に入った行は5でした。値を手で書くと、SET IDENTITY_INSERT … ONを使わない限りMsg 544になります。

-- SQL Server:代理キーと、名前付きの決まった値の集合
CREATE TABLE customer_phones (
    id          BIGINT IDENTITY(1, 1) PRIMARY KEY,
    customer_id  BIGINT NOT NULL,
    phone    VARCHAR(20) NOT NULL,
    kind        VARCHAR(10) NOT NULL
        CONSTRAINT ck_phone_kind CHECK (kind IN ('mobile', 'home', 'office')),
    UNIQUE (customer_id, phone),
    FOREIGN KEY (customer_id) REFERENCES customers(id) ON DELETE CASCADE
);

-- あとから一覧を変更する
ALTER TABLE customer_phones DROP CONSTRAINT ck_phone_kind;
ALTER TABLE customer_phones ADD CONSTRAINT ck_phone_kind
    CHECK (kind IN ('mobile', 'home', 'office', 'fax'));

第2正規形(2NF)

- 1NFを満たします。
- キー以外のすべての列が、主キーの一部ではなく主キー全体に従属します。複合キーに当てはまる規則です。

例:order_line (order_id, product_id, quantity, product_name)というテーブルは2NFに違反しています。product_nameは2つ組全体ではなく、product_idだけに従属するからです。

-- 悪い例:同じ製品の行ごとにproduct_nameが繰り返される
CREATE TABLE order_line_v1 (
    order_id      BIGINT,
    product_id    BIGINT,
    quantity      INT,
    product_name  VARCHAR(120),
    PRIMARY KEY (order_id, product_id)
);

-- 良い例:product_nameはproductsテーブルに置く
CREATE TABLE order_line (
    order_id    BIGINT,
    product_id  BIGINT,
    quantity    INT NOT NULL,
    PRIMARY KEY (order_id, product_id),
    FOREIGN KEY (product_id) REFERENCES products(id)
);

第3正規形(3NF)

- 2NFを満たします。
- キー以外の列が、キー以外のほかの列に従属しません(推移的従属がない)。

定番の例:employees (id, name, department_id, department_name)。部署名は従業員のキーに直接ではなく、department_idに従属します。

-- 悪い例:推移的従属
CREATE TABLE employees_v1 (
    id                BIGINT PRIMARY KEY,
    name              VARCHAR(120),
    department_id     BIGINT,
    department_name   VARCHAR(80)
);

-- 良い例
CREATE TABLE departments (
    id     BIGINT PRIMARY KEY,
    name   VARCHAR(80)  NOT NULL
);
CREATE TABLE employees (
    id             BIGINT PRIMARY KEY,
    name           VARCHAR(120),
    department_id  BIGINT NOT NULL,
    FOREIGN KEY (department_id) REFERENCES departments(id)
);

BCNFとそれより高い正規形

ボイス・コッド正規形(BCNF)は3NFをさらに厳しくしたもので、4NFと5NFは多値従属と結合従属を扱います。たいていのトランザクション系のスキーマでは、きれいに3NFまで到達すれば十分です。

非正規化すべき場面

意図的な非正規化は、パフォーマンスのために規則を破ることです。次の場合には妥当です:

1. 読み取りが多く、書き込みが少ない:テーブルにキャッシュしたフィールド(orders.total_paid)があれば、繰り返しのSUM(...)を避けられます。
2. レポートや分析:スタースキーマやスノーフレークスキーマは、わざと非正規化します。
3. 事前に計算した結果:マテリアライズドビューや集計テーブル。

受け入れることになる代償:

- 更新の異常:非正規化したデータが変わったら、N行を更新しなければなりません。
- 一時的な不整合:更新が途中で失敗すると、キャッシュしたフィールドがずれることがあります。
- トリガーやアプリケーションのロジック:データを同期させておく仕組みが必要です。

-- 例:読み取りのたびの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 ;

PostgreSQL 13+

PostgreSQLのトリガーには本体がありません。triggerを返す関数を呼ぶので、文が2つになります。MySQLのクライアントの機能である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();

PostgreSQL 17.6で測定すると、3 × 25.50と2 × 10.00の2行を挿入したあと、orders.totalは手を触れずに96.50になりました。トリガーがAFTERなのでRETURN NULLで問題ありません。BEFOREのトリガーならNEWを返す必要があります。

SQLite 3.35+

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;

SQLite 3.51で同じ例を測定すると、3 × 25.50と2 × 10.00の2行を挿入したあと、orders.totalは手を触れずに96.5になりました。

SQL Server

SQL Serverでは、上のALTER TABLEは実行できません。ここでは単にADDで、ADD COLUMNは構文エラー(Msg 156)です。

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 TRIGGERは送る内容の先頭に置く必要があり(ほかの文のあとに来るとMsg 111)、本体には;が含まれます。Calíopeでは、単独で実行するか、「$$」の区切り文字を使ってください。

キャッシュした合計には、トリガーを使わない方法もあります。SQL Serverにマテリアライズドビューはありませんが、インデックス付きビューが同じ役割を果たし、サーバが自分で保守します。WITH SCHEMABINDING、2部構成の名前、選択リストのCOUNT_BIG(*)が必要で(ないとインデックスの作成時にMsg 10138)、CREATE VIEWも単独で実行します。測定結果:行をもう1つ挿入したあと、ビューは手を触れずに新しい合計を返しました。

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);

推奨事項

1. デフォルトでは3NFで設計する。整合性が守られます。
2. 非正規化はデータに基づいてだけ行う:遅いクエリーを測定し、キャッシュを試し、比較します。
3. 非正規化したことを文書にする。DDLにコメントがないと、次のDBAが間違いだと思って「正規化」し直してしまいます。

キーワード: 正規化、1NF、2NF、3NF、BCNF、非正規化、冗長性、関数従属性、キー、整合性、トリガー

結合(JOIN)

INNER、LEFT、RIGHT、CROSS、SELF、FULL OUTER JOIN:それぞれをいつ使うかを、典型的な注文スキーマの例で説明します。

対象: MySQL 5.7+ MariaDB 10.5+ Aurora 2+ PostgreSQL 13+ SQLite 3.35+ SQL Server 2022+

JOINは、条件に基づいて2つ以上のテーブルの行を組み合わせます。一致する相手が見つからない行をどう扱うかは、結合の種類で決まります。

例では次のテーブルを使います:

CREATE TABLE customers (
    id      BIGINT PRIMARY KEY,
    name    VARCHAR(120) NOT NULL
);
CREATE TABLE orders (
    id          BIGINT PRIMARY KEY,
    customer_id BIGINT NOT NULL,
    total       DECIMAL(12, 2) NOT NULL,
    FOREIGN KEY (customer_id) REFERENCES customers(id)
);

INNER JOIN

両方のテーブルで一致する行だけを返します。デフォルトで、最もよく使われる結合です。

SELECT c.name, o.total
FROM customers c
INNER JOIN orders o ON o.customer_id = c.id;

LEFT JOIN(LEFT OUTER JOIN)

左のテーブルのすべての行と、右のテーブルで一致する行。右側に一致がないフィールドはNULLになります。「すべてのX、Yがあればそれも」という場合に便利です。

-- 注文があってもなくても、すべての顧客
SELECT c.name, COUNT(o.id) AS orders
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
GROUP BY c.id, c.name;

注文のない顧客:WHERE … IS NULLを使う定番のパターンです:

SELECT c.id, c.name
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
WHERE o.id IS NULL;

RIGHT JOIN

LEFTの逆です。ほとんどの場合、テーブルの順序を入れ替えたLEFT JOINとして書きます。そのほうが読みやすくなります。

-- 順序を入れ替えたLEFT JOINと同じ
SELECT c.name, o.id
FROM orders o
RIGHT JOIN customers c ON o.customer_id = c.id;

SQLite 3.39+

SQLiteは3.39からRIGHT JOINに対応しています。それより前のバージョンでは、順序を入れ替えてLEFT JOINとして書く必要があります。SQLite 3.51で、5人の顧客と、そのうち3人に分かれた6件の注文で測定すると、上のクエリーは8行を返します。一致した6行と、注文のない2人の顧客(o.idはNULL)です。

CROSS JOIN

直積です。Aのすべての行とBのすべての行を組み合わせます。ON句はありません。すべての組み合わせを作るのに便利です(レポート用のカレンダー×製品など)。

-- レポート用に(顧客, 月)のすべての組み合わせを生成
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;

FULL OUTER JOIN

両方のテーブルのすべての行。一致しない行は、相手のない側がNULLになります。

MySQL 5.7+MariaDB 10.5+Aurora

MySQLにもMariaDBにもFULL OUTER JOINはありません。MySQL 8.0.46と26.7、MariaDB 13.0で測定すると構文エラーになります。UNIONで代用します:

-- 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 3.39+

SQLiteも3.39からネイティブに対応しており、ここでもOUTERは省略できます。ただし専用の実行計画ノードはありません。SQLite 3.51で測定すると、EXPLAIN QUERY PLANにはいつものLEFT-JOINと、その下に2回目の走査RIGHT-JOIN ordersが表示されます。クエリーを2回の連続した走査として解決するためです。

-- SQLite 3.39+
SELECT c.name, o.id
FROM customers c
FULL OUTER JOIN orders o ON o.customer_id = c.id;

SQL Server

SQL Serverはネイティブに対応しており、OUTERは省略できます。SQL Server 2025で、5人の顧客と、うち3件は顧客のない6件の注文で測定すると、8行を返します。実行計画のノードはデータ量で変わります。この程度の行数では専用のノードはなく、2回の処理のConcatenationになります。Nested Loops (Left Outer Join)と、一致のない注文のためのNested Loops (Left Anti Semi Join)です。顧客50,000人と注文200,000件で、orders.customer_idにインデックスがあると、Merge Join (Full Outer Join)になります。

-- SQL Serverのネイティブな書き方
SELECT c.name, o.id
FROM customers c
FULL OUTER JOIN orders o ON o.customer_id = c.id;

STRAIGHT_JOIN

MySQL 5.7+MariaDB 10.5+Aurora

オプティマイザに、書かれた順序でテーブルを読ませます。自動の実行計画のほうが悪いと測定で確かめた場合にだけ使ってください:

SELECT STRAIGHT_JOIN c.name, o.id
FROM customers c, orders o
WHERE o.customer_id = c.id;

PostgreSQL 13+

PostgreSQLにはSTRAIGHT_JOINも、クエリー内のほかのヒントもありません。書くと構文エラー(42601)になります。あるのはセッションのパラメータです。join_collapse_limitとfrom_collapse_limit(どちらもデフォルトは8)、そしてenable_nestloop、enable_hashjoin、enable_mergejoinのスイッチ(3つともon)です。これらは自分のセッションで診断するためのものです。本番環境で方式を無効にすると、問題を直すのではなく隠すことになります。

SQLite 3.35+

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);

SQL Server

CROSS APPLYとOUTER APPLY

PostgreSQLのLATERALを別の名前で呼んだものです。右側のテーブルは、左側の行の列を読めるサブクエリーです。SQL ServerにLATERALはありません(Msg 156)。CROSS APPLYは、サブクエリーが何かを返した左側の行だけを残します(INNER JOINと同じ)。OUTER APPLYはすべてを残し、NULLを入れます(LEFT JOINと同じ)。典型的な用途は、グループごとの上位N件です。SQL Server 2025で、5人の顧客(うち2人に注文あり)で測定すると、CROSS APPLYは2行、OUTER APPLYは5行を返します。

-- 各顧客の最も高額な注文
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回ずつ実行するNested Loopsです。顧客50,000人と注文200,000件で、サブクエリーに使えるインデックスがないと、オプティマイザは実行のたびに一時的なインデックスを自分で作ります。ordersに対するIndex Spoolです。これが実行計画に現れたら、orders (customer_id, …)のインデックスが足りないというしるしです。

アンチジョインとセミジョイン

SQLのキーワードではなく、論理的なパターンです:

- セミジョイン(一致が少なくとも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のどれでも同じです。

パフォーマンス

1. ONの列にはインデックスを付けます。特に結合の「内側」(外側の行ごとに検索される側)に。
2. 結合の前に、できるだけ絞り込みます(必要に応じて各テーブルにWHERE)。
3. 式によるJOIN(ON LOWER(a.code) = LOWER(b.code))は避けます。インデックスが使われません。
4. EXPLAINは、読み取りの順序と方式を、エンジンごとの用語で示します。

MySQL 5.7+MariaDB 10.5+Aurora

Nested Loop、Hash Join(MySQL 8.0.18以降。8.0.20からはBlock Nested Loopを置き換えます)、そしてMariaDBではBlock Nested Loopとそのハッシュ版のBNLHです。

PostgreSQL 13+

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);

SQL Server

Nested Loops、Merge Join、Hash Matchで、それぞれ括弧の中に論理演算が付きます:(Inner Join)、(Left Outer Join)、(Full Outer Join)、(Left Anti Semi Join)。SQL ServerにはEXPLAINがありません。実行計画は、クエリーを実行しないSET SHOWPLAN_XML ONで要求します。Calíopeの実行計画ビューアがしているのがそれです。

ここでもアンチジョインにPostgreSQLのような違いは出ませんが、理由はSQLiteとは逆です。オプティマイザが両方を認識するのです。SQL Server 2025で、顧客50,000人と注文200,000件で測定すると、NOT EXISTSと同じ意味のLEFT JOIN … WHERE o.id IS NULLは、同じ実行計画(orders.customer_idのインデックスを使うMerge Join (Left Anti Semi Join))と同じ時間(5回の平均で21.6 msと19.8 ms)になります。EXISTSは、前にStream Aggregateの付いたMerge Join (Inner Join)になり、結合の前に顧客ごとに注文を1件だけ残します。

キーワード: 結合、JOIN、INNER JOIN、LEFT JOIN、RIGHT JOIN、FULL OUTER JOIN、CROSS JOIN、SELF JOIN、EXISTS、セミジョイン、アンチジョイン、STRAIGHT_JOIN、ネステッドループ、CROSS APPLY、OUTER APPLY、FORCE ORDER

サーバの設定

パフォーマンスを左右する変数:バッファプール、接続数、最大パケット、クエリーキャッシュ、そしてMySQLとMariaDBの重要な違い。

対象: MySQL 5.7+ MariaDB 10.5+ Aurora 2+

サーバのデフォルトの設定は、本番環境ではほとんどの場合最適ではありません。パフォーマンスと安定性に最も大きく影響する変数は次のとおりです。

innodb_buffer_pool_size

InnoDBの主なキャッシュで、テーブル、インデックス、データを持ちます。最も重要な変数です。

- 目安: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)やマテリアライズドビューに任せます。

MariaDB 10.5+

MariaDBではまだ使えますが、デフォルトでは無効です。ほとんど変化しないテーブルに対して、同一のクエリーを繰り返すワークロードでしか役に立ちません。

SHOW VARIABLES LIKE 'query_cache%';
SET GLOBAL query_cache_type = 'ON';
SET GLOBAL query_cache_size = 64 * 1024 * 1024;

ログと永続性

innodb_flush_log_at_trx_commitは、REDOログをいつフラッシュするかを制御します:

- 1(デフォルト) — コミットのたびにフラッシュとfsync。永続性は最大、速度は最小。厳密なACID。
- 2 — コミットのたびにフラッシュし、fsyncは1秒に1回。OSがクラッシュすると約1秒分を失う可能性があります。ほぼACID。
- 0 — フラッシュとfsyncは1秒に1回。MySQLがクラッシュすると約1秒分を失う可能性があります。ACIDではない。

レプリカや、限られた損失を許容できる環境では、2でスループットが3倍から5倍になることがあります。リスクを理解せずにプライマリで変更しないでください。

thread_pool

MariaDB 10.5+

MariaDBにはスレッドプールが標準で付いています(thread_handling = pool-of-threads)。短時間の接続が多いワークロードで、スレッドを作るコストを減らします。

MySQL 5.7+MySQL 8.0+

MySQL Communityにはなく、MySQL Enterprise Editionにだけあります。

tmp_table_size / max_heap_table_size

メモリー内の一時テーブルの最大サイズです。操作が上限を超えると、MySQLはそれをディスクに書き出し、速度が落ちます。2つの値は同じにしておきます。

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';

innodb_io_capacity / innodb_io_capacity_max

InnoDBがダーティページの書き出しとパージに使えるIOPSです。デフォルトはMySQL 8.0とMariaDBで200 / 2000、現在のMySQLで10 000 / 20 000です(26.7で測定)。

- 最新のSSD:2000 / 4000以上。
- HDD:デフォルトのままにします。

設定の永続化

MySQL 8.0+

MySQL 8.0では、my.cnfを編集せずにグローバルな変更を永続化できます:

SET PERSIST innodb_buffer_pool_size = 8589934592;
SET PERSIST_ONLY max_connections = 500; -- 再起動のときだけ適用される
RESET PERSIST innodb_buffer_pool_size;   -- 永続化を解除する

MariaDBでは、my.cnf(/etc/my.cnf.d/)を編集して再起動するか、インクルード(!include)を使って永続化します。

全般的な推奨事項

1. 何かに手を付ける前にワークロードを知る。書き込みの多いOLTPと、読み取り専用のデータウェアハウスでは、設定が違います。
2. 一度に1つの変数だけを変え、影響を測定します。
3. my.cnfの変更はすべて、理由を説明するコメントを付けて記録します。
4. 理解せずにブログの設定をコピーしない — 「最適な」値はハードウェアとワークロードに大きく左右されます。

Aurora

Amazon Auroraでは、これらはどれもファイルにはありません。my.cnfはなく、設定はクラスタとインスタンスのパラメータグループにあり、AWSのコンソールかCLIから適用します。innodb_buffer_pool_sizeはインスタンスのサイズからAWSが管理するので、手で設定しないでください。またSET GLOBALは次の再起動までしか続きません。固定するには、パラメータグループで変更します。

キーワード: 設定、バッファプール、innodb_buffer_pool_size、max_connections、max_allowed_packet、query_cache、thread_pool、tmp_table_size、innodb_flush_log_at_trx_commit、io_capacity、MY.CNF

制限と制約

データベース、テーブル、列の最大サイズ、識別子の名前の長さ、オブジェクト名に使える文字。

対象: MySQL 5.7+ MariaDB 10.5+ Aurora 2+

エンジンの制限を知っておくと、規模が大きくなったときに慌てずに済みます。ここでは、最近のMySQLとMariaDBで実際に効いてくる上限をまとめます。

データベースごと

- 全体のサイズ:ファイルシステムで決まります。innodb_file_per_table = ON(デフォルト)では、各テーブルが1つの.ibdファイルになります。ext4やXFSでの理論上の上限はエクサバイト単位で、実際の上限はストレージで決まります。
- データベースあたりのテーブル数:事実上無制限です。カタログ(information_schema、mysql.tables)は数十万個でも問題なく扱えます。10,000個を超えるテーブルを扱う負荷では、table_open_cacheの調整が必要です。

テーブルごと

- 行数:理論上は2⁶⁴行。実際には、スキーマとインデックスが適切なら数千億行。
- テーブルの最大サイズ:デフォルトのINNODB_PAGE_SIZE(16 KiB)で64 TiB。
- 列数:サーバではテーブルあたり最大4,096、InnoDBでは1,017ですが、実際の上限は数ではなく行サイズで決まります。
- 最大の行サイズ:65,535バイト(BLOB/TEXTは行の外に格納されるので含みません)。
- テーブルあたりのインデックス数:64。
- インデックスあたりの列数:16(InnoDBのBツリー)。
- インデックスキーの最大長:DYNAMIC/COMPRESSEDで3072バイト(MySQL 5.7以降とMariaDB 10.2以降のデフォルトの形式)。

-- テーブルのサイズを調べる
SELECT table_schema, table_name,
       ROUND((data_length + index_length) / 1024 / 1024, 2) AS mb
FROM information_schema.tables
WHERE table_schema = DATABASE()
ORDER BY (data_length + index_length) DESC
LIMIT 20;

列ごと

型最大サイズ
VARCHAR(N)65,535バイト(行のほかの部分と共有)
TEXT64 KiB
MEDIUMTEXT16 MiB
LONGTEXT4 GiB
BLOB対応するTEXTと同じ
JSON4 GiB

識別子(オブジェクト名)

- データベース、テーブル、列、インデックス、ビュー:64文字。
- 列のエイリアス:256文字。
- 関数、プロシージャ、トリガー、イベント:64文字。
- 制約(FK、CHECK、UNIQUE):64文字。

識別子に使える文字

- バッククォートなし:ASCIIの英字、数字、_、$。数字だけで始めることや、数字だけの名前はできません。
- バッククォートあり(`weird name`):U+0000(NUL)を除くすべてのUnicode文字。

推奨する規則:ASCIIのスネークケース(order_customer_id)。空白、アクセント記号付きの文字、大文字は避けてください。LinuxとmacOSで扱いが変わるシステムがあります。

-- 有効だが推奨しない
CREATE TABLE `orders 2024 year` (`Order Number` INT);

-- 推奨
CREATE TABLE orders_2024 (order_number INT);

大文字と小文字の区別

lower_case_table_names:

- 0:名前は作成したとおりに格納され、大文字と小文字を区別します。Linuxのデフォルト。
- 1:名前は小文字で格納され、比較では大文字と小文字を区別しません。Windowsのデフォルト。
- 2:名前はそのまま格納されますが、比較では大文字と小文字を区別しません。macOSとWindowsのみで、macOSのデフォルト。

既存のインストールでこの値を変えると、データが壊れます。サーバの初期化のときに決めてください。

クエリーごと

- 入れ子のサブクエリー:最大64階層。
- UNION:理論上は無制限ですが、数百を超えるとオプティマイザの性能が落ちます。
- プリペアドステートメントのパラメータ:65,535。
- IN(...)の値の数:実用的なのは数千まで。それを超えるなら、一時テーブルとのJOINを使ってください。

セッションごと

- セッション変数(@@SESSION.xxx):実行時に変えられるグローバル変数のほとんどを設定できます。
- ユーザ変数(@variable):名前は最大64文字。

文字セットと照合順序

- 推奨する文字セット: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;

推奨事項

1. 余裕を持って設計する:1,000万行を見込むなら、インデックスとパーティションは1億行に合わせて設計します。
2. BIGINT UNSIGNEDを使う:大きくなりうるテーブルの主キーに。INTは約20億で埋まります。
3. 文字セットと照合順序を明示する:データベース、テーブル、列の各レベルで。デフォルトに頼ると、移行が壊れることがあります。
4. モデルの制限を文書にする(年あたりの想定行数、列ごとの最大サイズ)。容量計画にも、異常なクエリーを見つけるのにも役立ちます。

キーワード: 制限、最大、サイズ、列、行、識別子、文字セット、照合順序、utf8mb4、lower_case_table_names、インデックス、行サイズ

ベストプラクティス

スキーマ設計、命名規則、バックアップ、レプリケーション、ユーザのセキュリティ、最小限のGRANT、監査。

対象: MySQL 5.7+ MariaDB 10.5+ Aurora 2+ SQL Server 2022+

アマチュアのデータベースと、本番環境で保守できるデータベースを分ける運用上の推奨事項です。

スキーマ設計

MySQL 5.7+MariaDB 10.5+Aurora

1. すべてのテーブルに主キーを付けます。自然なキーが見当たらなければ、id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEYを追加します。
2. 型を明示します:列が許す限りNOT NULLとDEFAULTを宣言します。NULLは「該当なし」を意味するべきで、「未入力」を意味するべきではありません。
3. 関連するテーブルの間には外部キーを必ず張ります。書き込みでマイクロ秒を失う代わりに、崩れない参照整合性が手に入ります。
4. 常にInnoDB。MyISAMは外部キーもトランザクションもサポートしておらず、レガシーなシステムに残っているだけです。

CREATE TABLE orders (
    id           BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
    customer_id  BIGINT UNSIGNED NOT NULL,
    status       TINYINT UNSIGNED NOT NULL DEFAULT 0,
    created_at   DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
    updated_at   DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6)
                             ON UPDATE CURRENT_TIMESTAMP(6),
    FOREIGN KEY (customer_id) REFERENCES customers(id)
        ON DELETE RESTRICT ON UPDATE CASCADE,
    INDEX idx_orders_customer (customer_id),
    INDEX idx_orders_created (created_at)
) ENGINE = InnoDB
  DEFAULT CHARSET = utf8mb4
  COLLATE = utf8mb4_unicode_ci;

SQL Server

1. すべてのテーブルに、名前付きの主キーを付けます。自然なキーがなければid BIGINT IDENTITY(1, 1)を使います。AUTO_INCREMENTとUNSIGNEDはMsg 102になります。複数のテーブルで1つの採番を共有するなら、DEFAULT NEXT VALUE FORを付けたSEQUENCEを使います。同じシーケンスを使う2つのテーブルには、重複なく1、2、3が振られました。失敗したINSERTやロールバックされたINSERTはIDENTITYの番号を消費するので、欠番が出るのは正常です。
2. 型を明示します:できる限りNOT NULLとDEFAULTを付け、日付はdatetimeではなくdatetime2にします。datetimeは1/300秒単位に丸められます(「データ型(SQL Server)」の項目で扱っています)。ON UPDATE CURRENT_TIMESTAMPはありません(Msg 156)。更新日時はアプリケーションかトリガーで設定します。
3. すべての制約に名前を付けます(pk_、fk_、df_、ck_)。名前がないと、SQL Serverは作成のたびに別の名前を作り(CK__<table>__<column>__<hex>)、名前のないDEFAULTはあとでDROP COLUMNを妨げます(Msg 5074)。
4. テーブルはそれぞれのスキーマに置き、すべてをdboに入れないようにします。スキーマが権限の境界になります(後述の「ユーザと権限」を参照)。スキーマを指定せずに作成したテーブルは作成者のデフォルトのスキーマに入り、saではそれがdboです。

-- まずスキーマを、独自のバッチで:CREATE SCHEMA sales;
CREATE TABLE sales.orders (
    id           BIGINT IDENTITY(1, 1) CONSTRAINT pk_orders PRIMARY KEY,
    customer_id  BIGINT       NOT NULL,
    status       TINYINT      NOT NULL CONSTRAINT df_orders_status DEFAULT 0,
    created_at   DATETIME2(3) NOT NULL CONSTRAINT df_orders_created_at DEFAULT SYSUTCDATETIME(),
    updated_at   DATETIME2(3) NOT NULL CONSTRAINT df_orders_updated_at DEFAULT SYSUTCDATETIME(),
    CONSTRAINT fk_orders_customers FOREIGN KEY (customer_id)
        REFERENCES sales.customers (id) ON UPDATE CASCADE,
    CONSTRAINT ck_orders_status CHECK (status BETWEEN 0 AND 4)
);
CREATE INDEX idx_orders_customer_id ON sales.orders (customer_id);
CREATE INDEX idx_orders_created_at  ON sales.orders (created_at);

命名規則

- テーブル:snake_case。コレクションを表すなら複数形(orders、customers)。
- 列:snake_case。冗長な接頭辞は付けません(customersの中ではcustomer_nameではなくname)。
- 外部キー:<table>_id(customer_id)。
- インデックス:idx_<table>_<columns>、一意ならuq_<table>_<columns>。
- 明示的な外部キー制約:fk_<table>_<target_table>。
- プロシージャと関数:接頭辞sp_/fn_は任意で、動詞は原形にします(fn_calculate_discount)。

一貫性は個人の好みに勝ります。規則をチームで合意し、例外なく適用してください。

SQL Server

SQL Serverで意味を持つのは2つの部分からなる名前schema.objectで、DDLでもクエリーでも常に両方を書きます。1つの部分だけの名前は、ユーザごとのデフォルトのスキーマに対して解決されます。SELECT … FROM ordersは、DEFAULT_SCHEMA = salesのユーザではsales.ordersになり、saではdbo.ordersを探したためMsg 208になりました。

自分のプロシージャにsp_接頭辞は絶対に付けません。これはシステムのプロシージャのものです。自作のdbo.sp_whoは決して実行されません。EXEC sp_whoでもEXEC dbo.sp_whoでも、実行されたのはシステムのほうでした。別の接頭辞を使うか、接頭辞なしにします(sales.compute_discount)。

バックアップ

MySQL 5.7+MariaDB 10.5+Aurora

1. 3-2-1戦略:コピーを3つ、異なる媒体を2つ、オフサイトに1つ。
2. 種類:
- mysqldump:論理バックアップ。移植性が高いが、復元は遅い(約5〜10 MB/秒)。
- mariabackup/xtrabackup:物理バックアップ。ずっと速いが、I/Oを短時間止める必要があります。
- ファイルシステムのスナップショット(LVM、ZFS):一瞬で済むが、ファイルシステムに依存します。
3. 復元をテストします。復元を確認していないバックアップはバックアップではありません。
4. 保持期間:毎日のものを7日、毎週のものを4つ、毎月のものを12は、妥当な出発点です。

Calíopeにはバックアップ機能が組み込まれています:「ヘルプ」›「バックアップ」。

-- トランザクションの一貫性を保った論理ダンプ
-- (SQLではなくシェルから):
-- mysqldump --single-transaction --routines --triggers --events \\
--           -u root -p my_db > my_db.sql

-- 物理バックアップ(mariabackup):
-- mariabackup --backup --target-dir=/srv/backup/full \\
--             --user=root --password=...

SQL Server

1. 3-2-1戦略:コピーを3つ、異なる媒体を2つ、オフサイトに1つ。
2. 今日の作業を失うことが許されないすべてのデータベースで、FULL復旧モデルとログのバックアップを使います。完全バックアップ、差分バックアップ、そして数分ごとのBACKUP LOGです。FULLでBACKUP LOGを取らないとログは際限なく増え、SIMPLEでは最後の完全バックアップまでしか戻れません。
3. 「常にWITH CHECKSUM」を付け、そのあとRESTORE VERIFYONLY … WITH CHECKSUMを実行します。バックアップにCHECKSUMがないと、この確認はできません(Msg 3187)。COMPRESSION(Expressにはありません)で、4,056 kBの完全バックアップが647 kBになりました。
4. 単発のバックアップはすべてCOPY_ONLYにします。そうしないと、以降の差分バックアップがそこから数えられてしまいます。
5. 復元をテストします。ときどき、別の名前で行います。VERIFYONLYがわかるのはファイルを読めることで、データベースが元に戻ることではありません。
6. 保持期間:毎日7、毎週4、毎月12を出発点にします。ただし、ログのバックアップはその前の完全バックアップと一緒でなければ役に立たないことを念頭に置いてください。

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;

ファイルはサーバのディスクに残ります。Calíopeの「バックアップ」は別物で(データベースやその一部を別のマシンに持っていくためのスクリプトです)、これらの代わりにはなりません。それぞれの役割は「バックアップと復旧(SQL Server)」の項目で扱っています。

レプリケーション

MySQL 5.7+MariaDB 10.5+Aurora

- 非同期レプリケーション(デフォルト):プライマリはレプリカを待ちません。リスク:プライマリが停止すると、直近のトランザクションを失うことがあります。
- 準同期レプリケーション:プライマリは、クライアントに応答する前に、少なくとも1つのレプリカからの確認を待ちます。
- グループレプリケーション(MySQL InnoDB Cluster、MariaDB Galera):合意に基づくマルチプライマリ。

推奨事項:

1. GTIDを有効にします(gtid_mode = ON)。自動フェイルオーバーと最近のツールに必要です。
2. binlog_format = ROW:非決定的な関数に対してSTATEMENTより堅牢です。
3. レプリケーション専用にします:REPLICATION SLAVEだけを持ち、IPを制限したreplicaユーザを使います。
4. 遅延を監視します:SHOW REPLICA STATUS(古いバージョンではSHOW SLAVE STATUS)で、Seconds_Behind_Source > 30になったらアラートを出します。

SQL Server

SQL Serverは独自のツールでレプリケーションを行います(Always On可用性グループ、ログ配布、トランザクションレプリケーションとマージレプリケーション)。どれもバイナリログとは似ておらず、調整するGTIDもbinlog_formatもありません。Calíopeはこれらを設定も監視もしません。SQL Serverのセッションには「レプリケーションモニタ」も「トポロジ」も表示されません。

ユーザと権限

最小権限の原則:どの接続も、できるだけ制限されたユーザを使います。

MySQL 5.7+MariaDB 10.5+Aurora

-- 権限を絞ったアプリケーション用のユーザを作成
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;

ルール:

1. アプリケーションごと、役割ごとに1ユーザ。監査がしやすくなります。
2. アプリケーションのユーザには「*.*の権限を与えません」。データベース単位かテーブル単位で付与します。
3. アプリケーションからrootユーザにアクセスしません。rootは管理作業専用です。
4. パスワードを定期的に変更し、強力な認証を使います(MySQL 8ではcaching_sha2_password、MariaDBではed25519)。
5. ホストを制限します('app'@'10.0.%.%')。'%'は使いません。

SQL Server

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;

ルール:

1. アプリケーションごと、役割ごとに1ログインとし、CHECK_POLICY = ONにします。これにより、たとえばログイン名を含むパスワードは拒否されます(Msg 33064)。
2. アプリケーションには「sysadminもdb_ownerも与えません」。DENYはsysadminを止められません。sysadminはdboとして入り、すべての確認を素通りします。
3. saは管理専用にし、別の管理者ができたらすぐに無効にします(ALTER LOGIN sa DISABLE)。
4. アプリケーションはオブジェクトを作成しません。自分のスキーマにデータの権限しかなければ、アプリケーションのCREATE TABLEはMsg 262になりました。
5. データベースを別のサーバに移すときは、ログインを元のSIDで作成します。そうしないと、ユーザが孤立します。

詳しくは「ログイン、ユーザ、権限(SQL Server)」の項目にあります。

SQL Server

クエリー、トランザクション、移行

1. 常にパラメータを使い、テキストを連結しません。パラメータのINリストには上限があります。sp_executesqlは2,098個まで受け付けました(2,099個ではMsg 180)。それ以上なら、一時テーブルかテーブル値パラメータを使います。
2. 書き込みを行うすべてのバッチとプロシージャで「SET XACT_ABORT ON」にし、IF @@TRANCOUNT > 0 ROLLBACK; THROW;で終わるTRY…CATCHを付けます。これがないと、2627はそのSQL文だけを中止し、COMMITがその前後をすべて確定してしまいます。
3. BEGIN TRANを入れ子にしません。内側のCOMMITは何も確定せず、内側のROLLBACKはすべてを元に戻します。一部だけを元に戻すにはSAVE TRANSACTIONを使います。
4. 人を待つ開いたトランザクションを残しません。トランザクションを開いたままスリープしていたセッションが、73.8 MBのログを抱えていました。DBCC OPENTRANで見つけられます。
5. 時間ではなく論理読み取りで測ります。ある値では速く、別の値では遅いプロシージャには注意してください。パラメータスニッフィングです。20行の顧客向けにコンパイルされたプロシージャは、10万行の顧客で300,177ページを読みました。スキャンなら6,085ページです。OPTION (RECOMPILE)で解消しますが、呼び出しのたびにコンパイルする代償があります。OPTIMIZE FOR UNKNOWNでは解消しませんでした。
6. 移行のたびに、ALTER TABLEの前にSET LOCK_TIMEOUTを設定し(これがないと、ほかの人のSELECTが変更の後ろで6秒待たされました)、DEFAULTには名前を付け、テーブルを書き直すALTER COLUMNのあとにはALTER INDEX … REBUILDを実行します。それまでは2倍の容量を使います。
7. 際限なく増えるテーブルは、大きくなる前にパーティション分割します。インデックスはそろえ、最後には常に空のパーティションを置きます。1年分をSWITCHで削除するのは1ミリ秒未満でしたが、DELETEでは180ミリ秒と21 MBのログがかかりました。
8. サーバの設定は意図して行います:max server memory (MB)は上限なしで出荷されるので、必ず上限を設定します。RECONFIGUREの前にはSELECT … FROM sys.configurations WHERE value <> value_in_useを確認します。RECONFIGUREは保留中のものをすべて適用し、ほかの人が書き残したものも含まれるからです。

どの項目にも、タイトルに「(SQL Server)」が付いた専用の項目があります:「制限」「トランザクションと分離レベル」「パフォーマンスと診断」「オンラインでのスキーマ変更」「パーティション分割」「サーバの設定」。

監査

MySQL 8.0+

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);

Calíopeは、サーバのログとは別に、実行したクエリーのローカルログ(「SQLログ」)を接続セッションごとに保持します。

本番環境の最低限のチェックリスト

MySQL 5.7+MariaDB 10.5+Aurora

1. ✅ 自動バックアップと、毎月の復元の確認。
2. ✅ 遅延を監視したレプリケーション。
3. ✅ 過剰な権限を持たないアプリケーションユーザ。
4. ✅ 外部からの接続ではTLSを必須に。
5. ✅ スロークエリーログを有効に(long_query_time = 1)。
6. ✅ ディスク容量の監視(80 %でアラート)。
7. ✅ セキュリティアップデートを四半期ごとに適用。

SQL Server

1. ✅ FULL復旧モデルと、スケジュールされたBACKUP LOG、WITH CHECKSUM、毎月テストする復元。
2. ✅ log_reuse_wait_descの監視:ログが解放されない理由がわかります。
3. ✅ sysadminもdb_ownerも持たないアプリケーションのログインと、無効にしたsa。
4. ✅ 独自の証明書を使ったTLS:これがないと、SQL Server 2022以降は厳格なTLS(TDS 8.0)を受け付けず、CalíopeはTDS 7.xのTLSで接続してその旨を表示します。
5. ✅ クエリーストアを有効に(新しいデータベースではデフォルトで有効です)し、max server memory (MB)に上限を設定。
6. ✅ ログとtempdbも含めたディスク容量の監視(80%でアラート)。
7. ✅ 累積更新プログラム(CU)を計画的に適用。

Aurora

Amazon Auroraでは、クラスタ内のレプリケーションは設定しません。リーダーノードはライターとボリュームを共有するため、間にバイナリログはなく、監視するSeconds_Behind_Sourceもありません。遅延はinformation_schema.replica_host_statusで測り、通常はミリ秒単位です。binlog_formatとGTIDが意味を持つのは、クラスタの外(別のクラスタ、RDS、外部のMySQL)にもレプリケーションする場合だけです。

キーワード: ベストプラクティス、設計、命名規則、バックアップ、レプリケーション、GTID、バイナリログ、GRANT、監査、セキュリティ、InnoDB、外部キー、TLS、SQL Server、IDENTITY、SEQUENCE、スキーマ、ログイン、XACT_ABORT、SQL Server Audit

パフォーマンスと最適化

EXPLAINによるクエリーの分析、スロークエリーログ、ボトルネックの特定、InnoDBのキャッシュ、performance_schemaの使い方。

対象: MySQL 5.7+ MariaDB 10.5+ Aurora 2+

最適化は測定から始まります。データがなければ、最適化は当て推量です。基本的な道具は次のとおりです。

EXPLAIN

オプティマイザがクエリーに選んだ実行計画を表示します。クエリーは実行しないので、本番環境でも安全に使えます。

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 — インデックスの外での並べ替え(大きなテーブルでは高コスト)。

EXPLAIN ANALYZE(MySQL 8.0.18以降。MariaDBでは単にANALYZE)

クエリーを実行し、ノードごとの実際の時間を表示します。EXPLAINより高コストですが、得られる情報はずっと多くなります。

EXPLAIN ANALYZE
SELECT c.name, COUNT(o.id) AS orders
FROM customers c
LEFT JOIN orders o ON o.customer_id = c.id
GROUP BY c.id;

Calíopeには、実行計画のツリーを描画する実行計画ビューアが組み込まれています。SQLエディタのツールバーの「実行計画ビューア」ボタンを、選んだクエリーに対して使います。

スロークエリーログ

long_query_time秒より長くかかったクエリーをすべて記録します。

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';

ログの分析:

- mysqldumpslow — MySQLに含まれている昔からのツール。
- pt-query-digest(Percona Toolkit) — 事実上の標準。フィンガープリントでまとめて統計を表示します。

performance_schema

サーバの詳細な統計を持つスキーマです。

-- 累積時間の合計が多い上位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;

MariaDB 10.5+

MariaDBには「userstat」変数もあります。オンにすると、ユーザごと、インデックスごと、テーブルごとの統計が追加され、場合によってはperformance_schemaより負荷が小さく済みます。

InnoDBのキャッシュ

- バッファプール — データとインデックス。重要な指標はヒット率(1 − Innodb_buffer_pool_reads / Innodb_buffer_pool_read_requests)。目標は99%超です。

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%';

- アダプティブハッシュインデックス — バッファプールのよく使われるページに対して自動で作られるハッシュ。MySQL 8.0ではデフォルトでオン、現在のMySQLとMariaDBではオフです(測定結果:8.0.46では1、26.7とMariaDB 13.0では0)。
- チェンジバッファ — バッファプールにないページへの変更をバッファします。

よくあるボトルネック

症状考えられる原因対処
CPU使用率100%インデックスのないクエリーや誤った推定EXPLAIN、スロークエリーログ
I/O使用率100%バッファプールの不足innodb_buffer_pool_sizeを増やす
接続数が上限に達するアプリでの接続のリークアプリケーションのプールを点検する
Threads_runningが高いロックの競合SHOW ENGINE INNODB STATUSを確認する
tmp_disk_tablesが増えるtmp_table_sizeが小さすぎるtmp_table_sizeを増やす
レプリケーションの遅延シングルスレッドや長いトランザクションslave_parallel_workersを有効にする

クエリーの最適化 — よくあるパターン

1. 必要なものだけを選ぶ。アプリケーションではSELECT *を避けます。
2. インデックスのある列に関数を使わない:
- 悪い例:WHERE YEAR(date) = 2024 → インデックスを使いません。
- 良い例:WHERE date >= '2024-01-01' AND date < '2025-01-01'。
3. 大きなオフセットを伴うLIMITは高コスト — 深いページ送りにはキーセットページネーションを使います:WHERE id > :last_seen ORDER BY id LIMIT 50。
4. 大きなテーブルでのCOUNT(*) — InnoDBはカウンタを保持していません。集計用の列や推定値(information_schema.tables.table_rows)を検討します。
5. 相関のないサブクエリーは1回だけ実行され、相関サブクエリーは外側の行ごとに1回実行されます。可能ならJOINに書き換えます。

推奨事項

基本的な監視用のダッシュボードを作ります(Calíopeにはあります:「ダッシュボード」)。内容は:

- 接続数(Threads_connected、Threads_running)。
- バッファプールのヒット率。
- 1分あたりのスロークエリーの数。
- レプリケーションの遅延。
- 表領域ごとのディスク容量。

劣化に早く気づくほど、直すのは簡単になります。

キーワード: パフォーマンス、最適化、EXPLAIN、EXPLAIN ANALYZE、スロークエリーログ、performance_schema、バッファプール、ファイルソート、カバリングインデックス、フィルタ、ボトルネック、PT-QUERY-DIGEST

トランザクションと分離レベル

ACID、COMMITとROLLBACK、4つの分離レベルと、それぞれが許す異常。

対象: MySQL 5.7+ MariaDB 10.5+ Aurora 2+

トランザクションは、複数の文を1つの単位にまとめ、すべて適用されるか、まったく適用されないかのどちらかにします。InnoDBではすべての文がトランザクションの中で実行されます。自分で開かなければ、サーバが文ごとに1つ開いてコミットします(autocommit = 1)。

ACID
- 原子性 — すべての変更が適用されるか、何も適用されないか。
- 一貫性 — データベースはある有効な状態から別の有効な状態へ移り、制約は守られます。
- 分離性 — 同時に実行されるトランザクションが、互いの途中の状態を見ることはありません。
- 永続性 — コミットしたものは、サーバがクラッシュしても残ります。

手動での制御
COMMITは確定し、ROLLBACKはSTART TRANSACTION以降に行ったことをすべて取り消します。

START TRANSACTION;
UPDATE cuentas SET saldo = saldo - 100 WHERE id = 1;
UPDATE cuentas SET saldo = saldo + 100 WHERE id = 2;
COMMIT;

セーブポイント
SAVEPOINTを使うと、トランザクションの残りを失わずに一部だけを取り消せます:

START TRANSACTION;
INSERT INTO pedidos (cliente_id) VALUES (42);
SAVEPOINT tras_pedido;
INSERT INTO lineas (pedido_id, sku) VALUES (LAST_INSERT_ID(), 'X-1');
ROLLBACK TO SAVEPOINT tras_pedido;
COMMIT;

4つのレベル

レベルダーティリード反復不能読み取りファントムリード
READ UNCOMMITTEDありありあり
READ COMMITTEDなしありあり
REPEATABLE READなしなしなし(InnoDB)
SERIALIZABLEなしなしなし

InnoDBのデフォルトはREPEATABLE READです。MVCCとギャップロックのおかげで、InnoDBはこのレベルでもファントムリードを防ぎます。SQL標準はそれを求めていません。

レベルを変更する

SET TRANSACTION ISOLATION LEVEL READ COMMITTED;

SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED;

SELECT @@transaction_isolation;

DDLに注意
CREATE、ALTER、DROP、TRUNCATEは暗黙のコミットを引き起こします。ROLLBACKでは取り消せません。途中で止まった移行は、テーブルをそのときの状態のまま残します。

長いトランザクション
開いたままのトランザクションがあると、InnoDBは一貫性のある読み取りのために、すべての行の古いバージョンを保持し続けなければなりません。忘れられたSTART TRANSACTIONはUNDOログを増やし、サーバ全体を劣化させます。次のようにして見つけます:

SELECT trx_id, trx_started, trx_mysql_thread_id, trx_query
FROM information_schema.INNODB_TRX
ORDER BY trx_started;

推奨事項
トランザクションは短く保ち、ビジネスロジックは外に、SQLだけを中に入れます。READ COMMITTEDはロックを減らし、多くのWebアプリケーションが使っています。同じトランザクションの中で2回の読み取りが同じ結果を返す必要があるなら、REPEATABLE READのままにします。

キーワード: トランザクション、COMMIT、ROLLBACK、SAVEPOINT、分離、ACID、MVCC、READ COMMITTED、REPEATABLE READ、SERIALIZABLE、AUTOCOMMIT、innodb_trx

ロックとデッドロック

InnoDBが何をロックするか、デッドロックがなぜ起きるか、推測に頼らずに診断する方法。

対象: MySQL 5.7+ MariaDB 10.5+ Aurora 2+

InnoDBはテーブルではなく行をロックし、書き込みのときに自動でロックします。同時実行の問題のほとんどは、クエリーが結果としてどの行をロックしたかに行き着き、それは使ったインデックスで決まります。

ロックの種類
- 共有(S):複数のトランザクションが同じ行を同時に読めます。
- 排他(X):書き込む側が取ります。ほかの誰も、その行をロック付きで読むことも書くこともできません。
- ギャップ:挿入を防ぐために、2つのインデックス値の*間*の範囲をロックします。REPEATABLE READとSERIALIZABLEのときだけです。
- ネクストキー:行と、その前のギャップ。インデックスを走査するときのInnoDBの通常のモードです。
- インテンション(IS/IX):中に行ロックがあることをテーブルのレベルで示し、LOCK TABLESが割り込むのを防ぎます。

実務上の帰結:クエリーがインデックスを使わないと、InnoDBはテーブル全体を走査し、一致した行だけでなく調べたすべての行をロックします。よいインデックスは速くするだけでなく、ロックされる範囲も小さくします。

ロック付きの読み取り
普通のSELECTは何もロックしません(MVCCで一貫したバージョンを読みます)。読んでから書くまでの間に誰にも割り込まれたくないなら、FOR UPDATEかFOR SHAREで明示的にロックを求めます:

START TRANSACTION;
SELECT saldo FROM cuentas WHERE id = 1 FOR UPDATE;
UPDATE cuentas SET saldo = saldo - 100 WHERE id = 1;
COMMIT;

デッドロックとは
2つのトランザクションが、それぞれ相手の持つロックを待っている状態です。どちらも先に進めません:

-- 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を受け取ります。サーバの障害でもデータの破損でもありません。正しい動作であり、アプリケーションはそのトランザクションを再試行しなければなりません。

エラー1205 Lock wait timeout exceededは別物です。こちらには循環がなく、待ち時間がinnodb_lock_wait_timeout(デフォルトは50秒)を超えただけです。

診断
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;

避ける方法
1. 常に同じ順序でアクセスする:すべてのコードが同じ順序でテーブルと行に触れれば、循環は生まれません。
2. トランザクションを短くする:ロックを持つ時間が短いほど、衝突の機会も減ります。
3. 絞り込みに使う列にインデックスを付ける:一致さえしなかった行をロックせずに済みます。
4. 再試行する:同時実行のシステムでは、たまのデッドロックは普通のことです。トランザクションを、間隔を空けて再試行する処理で包みます。
5. 不要なSELECT ... FOR UPDATEを避ける:書き込まないなら、ロックを求めないでください。

推奨事項
ロックの問題に向き合うときは、まずクエリーの実行計画を見てください。実際のデッドロックのほとんどは、足りないインデックスを追加すれば消えます。Calíopeの「プロセスリスト」で、どのセッションが待っているかがわかります。

キーワード: ロック、デッドロック、ギャップロック、ネクストキーロック、FOR UPDATE、FOR SHARE、エラー1213、InnoDBステータス、data_locks、ロック待ちタイムアウト

テーブルのパーティション分割

RANGE、LIST、HASH、KEY、パーティションプルーニング、DROP PARTITIONによる即時のパージ。

対象: MySQL 5.7+ MariaDB 10.5+ Aurora 2+

パーティション分割は、1つのテーブルをいくつかの物理的な部分に分けますが、サーバからは1つのテーブルに見えたままです。クエリーを魔法のように速くするわけではありません。得られるのはパーティションプルーニングと、なによりも数百万行を一瞬で削除できることです。

効果があるとき
はっきりしているのは、日付とともに増え、古いデータをパージするテーブルです:ログ、イベント、指標、監査。そこではDROP PARTITIONが、何時間もかかるDELETEの代わりになります。

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')
);

パーティションプルーニング
本当の利点です。WHEREがパーティション分割の列で絞り込んでいれば、サーバは結果を含みうるパーティションだけを読みます。EXPLAINのpartitions列で確認します。すべてのパーティションが表示されるなら、何もプルーニングしておらず、パーティション分割はコストになっているだけです。

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';

最初に知っておくべき制限
1. パーティション分割のキーは、すべての一意キーに含まれていなければなりません。主キーも含みます。だから例ではidだけではなくPRIMARY KEY (id, ocurrido)としています。
2. 外部キーは使えません。パーティション分割したテーブルは、FOREIGN KEYを持つことも参照されることもできません。
3. テーブルあたり最大8192パーティションで、それぞれがファイルディスクリプタを消費します。
4. キーで絞り込まないクエリーはすべてのパーティションに触れ、パーティション分割しない場合より遅くなります。
5. インデックスは各パーティションにローカルです。グローバルなインデックスはありません。

メンテナンス
次の期間のパーティションを追加し、最も古いものを削除するのが通常の作業です。DROP PARTITIONは実質的に一瞬で終わり、本当に領域を解放します。大量のDELETEではそうはなりません:

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;

推奨事項
日付でパーティション分割するのは、日付でパージするときだけにし、将来のパーティションは前もって作成します(またはスケジュールしたイベントで)。どの範囲にも収まらない行が届くと、INSERTは失敗します。安全網として、常にpmaxを残しておきます。

キーワード: パーティション分割、パーティション、RANGE、LIST、HASH、KEY、プルーニング、DROP PARTITION、REORGANIZE、information_schema.partitions、パージ

CTEとウィンドウ関数

WITH、WITH RECURSIVE、OVER ():入れ子のサブクエリーや一時テーブルを使わずに済む、モダンなSQL。

対象: MySQL 8.0+ MariaDB 10.2+ Aurora 3+ PostgreSQL 13+ SQLite 3.35+ SQL Server 2022+

CTE(WITH)とウィンドウ関数(OVER ())は、MySQL 8.0とMariaDB 10.2で導入されました。PostgreSQLには、これらのないサポート中のバージョンはなく、SQLiteではどちらもこのハンドブックの最低バージョンよりずっと前からあります(CTEは3.8.3から、ウィンドウは3.25から)。以前は入れ子のサブクエリー、一時テーブル、セッション変数が必要だったことを、読みやすい1つのクエリーで解決できます。

CTE:途中の段階に名前を付ける
CTEは、クエリーの実行中だけ存在する名前付きの結果です。長いクエリーを段階に分け、同じ途中結果を繰り返し書かずに2回参照できます:

MySQL 5.7+MariaDB 10.5+Aurora

月はDATE_FORMATで取り出します:

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;

PostgreSQL 13+

PostgreSQLではRECURSIVEは省略できず、書き忘れたときのエラーは誤解を招きます。17.6で測定すると、RECURSIVEのない同じクエリーは42P01を返します。arbolが存在しないテーブルだったときとまったく同じエラーです。

SQLite 3.35+

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;

よく使うもの
- ROW_NUMBER():パーティション内の通し番号。同順位はありません。
- RANK() / DENSE_RANK():同順位のある順位。RANKは番号を飛ばし、DENSE_RANKは飛ばしません。
- LAG() / LEAD():前または次の行の値。自己結合は要りません。
- FIRST_VALUE() / LAST_VALUE():ウィンドウの両端。
- NTILE(n):行をn個のグループに分けます。四分位数や百分位数に。
- OVER付きの集計:SUM、AVG、COUNT、MIN、MAXを、行をまとめずに。

フレーム(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 (…))は両方にあり、列ごとに定義を繰り返さずに済みます。

SQLite 3.35+

PostgreSQLにあってMySQLにない機能のほとんどが、SQLiteにはあります。3.51で測定しました:

- FILTER (WHERE …):集計で使えます:count(*) FILTER (WHERE total > 1000)。
- GROUPSフレームとEXCLUDE句:どちらも、ROWSとRANGEに加えて使えます。
- 名前付きのウィンドウ:OVER w … WINDOW w AS (…)は、ほかの2つと同じように使えます。
- DISTINCT ON:ありません。構文エラーになります。グループごとの1行は、すぐ下のパターンのとおりROW_NUMBER()で求めます。

SQL Server

SQL Serverは、PostgreSQLよりMySQLに近い位置にいます。2025で測定しました:

- 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()で求めます。

最も役に立つパターン:グループごとの上位N件
カテゴリーごとに売れ筋の製品を3つ取り出すには、ウィンドウを使わないと行ごとに相関サブクエリーが必要です。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;

パフォーマンス
どちらもただではありません。ウィンドウはパーティションごとに並べ替える必要があるので、PARTITION BYとORDER BYの順序ですでに行を返すインデックスがあれば、その並べ替えを省けます。見た目のよい書き方に落ち着く前に、EXPLAINで確かめてください。

MySQL 5.7+MariaDB 10.5+Aurora

その並べ替えが、EXPLAINに出るfilesortです。CTEにも注意してください。MySQL 8.0のオプティマイザはCTEを一時テーブルに実体化することがあり、同じ意味のサブクエリーより遅くなる場合があります。

PostgreSQL 13+

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でした。

SQL Server

ここではCTEは決して実体化されません。これには良い面と悪い面があります。1回だけ使えばPostgreSQLと同じくクエリーに展開されますが、2回参照すると2回計算されます。2025で20,000行に対して測定すると、GROUP BY付きのCTEを自分自身と結合した実行計画には、参照ごとに1つずつ、Index ScanとStream Aggregateが2つずつ残りました。AS MATERIALIZEDはありません。結果を求めるのが重いなら、先に一時テーブル(#t)に格納してください。

推奨事項
クエリーを読みやすくするためにCTEを使い、サーバが1回の処理でできることをアプリケーションでやらずに済むようにウィンドウ関数を使ってください。サーバがMySQL 5.7やMariaDB 10.1なら、どちらも使えません。そこでは今もサブクエリーが主役です。

キーワード: CTE、WITH、WITH RECURSIVE、ウィンドウ関数、OVER、PARTITION BY、ROW_NUMBER、RANK、DENSE_RANK、LAG、LEAD、NTILE、フレーム、階層、グループごとの上位N件

トランザクションと分離レベル(PostgreSQL)

1つのエラーがトランザクション全体を止める理由、セーブポイントで抜け出す方法、各レベルが実際にすること、そしてロールバックされるDDL。

対象: PostgreSQL 13+

トランザクションは、複数の文を、すべて適用されるか、まったく適用されないかの単位にまとめます。明示的なBEGINがなければ、PostgreSQLは各文を個別にコミットします。

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;

DDLもロールバックされます
CREATE、ALTER、DROPはトランザクションの中で実行されます。ここには暗黙のコミットはありません。移行が途中で失敗しても、作りかけのテーブルが残ることはありません。

BEGIN;
ALTER TABLE cuentas ADD COLUMN moneda text;
ROLLBACK;   -- この列は存在しなかったことになる

4つのレベル

レベルダーティリード反復不能読み取りファントムリード
READ UNCOMMITTEDなしありあり
READ COMMITTEDなしありあり
REPEATABLE READなしなしなし
SERIALIZABLEなしなしなし

READ UNCOMMITTEDは受け付けられ、そのとおりに報告されますが、READ COMMITTEDと同じように動作します:PostgreSQLには、どのレベルでもダーティリードがありません。デフォルトはREAD COMMITTEDです。

REPEATABLE READとSERIALIZABLEはブロックせず、中断します
直列化できないトランザクションは、待つ代わりにSQLSTATE 40001(could not serialize access…)で終わります。つまり、アプリケーションが再試行する必要があります:この2つのレベルでは、40001は失敗ではなく通常の動作です。SERIALIZABLEはSSIを使い、読み書きの依存関係を検出し、追加のロックは取りません。

ロックとデッドロック
デッドロックはdeadlock_timeout(デフォルトは1秒)のあとに検出され、サーバは2つのうち一方をSQLSTATE 40P01で終了させます。待たないようにするには、あるいは作業を複数のコンシューマに分けるには:

SELECT id FROM cuentas ORDER BY id FOR UPDATE SKIP LOCKED;

FOR UPDATE NOWAITは、待つ代わりにすぐに55P03で失敗します。注意:その失敗もトランザクションを中断させます。

長いトランザクション
ここでは、開いたままのトランザクションはUNDOログを増やしません。代わりにサーバ全体で、VACUUMが不要になった行バージョンを片付けるのを妨げ、テーブルは新しい行がなくても大きくなります。次のように見つけます:

SELECT pid, state, xact_start, now() - xact_start AS duracion, query
FROM pg_stat_activity
WHERE xact_start IS NOT NULL
ORDER BY xact_start;

idle_in_transaction_session_timeoutはデフォルトで0、つまり無制限です。これを設定しておくことが、忘れられたセッションがデータベース全体を劣化させるのを防ぐ安全網になります。

推奨事項
トランザクションは短く保ち、ビジネスロジックはその外に置きます。REPEATABLE READやSERIALIZABLEに上げるなら、本番で最初の40001が出てからではなく、上げる前に再試行の処理を書いてください。

キーワード: トランザクション、COMMIT、ROLLBACK、SAVEPOINT、分離、MVCC、READ COMMITTED、REPEATABLE READ、SERIALIZABLE、SSI、25P02、40001、40P01、デッドロック、SKIP LOCKED、pg_stat_activity

VACUUM、autovacuum、そして戻ってこない容量

新しい行がないのにテーブルが大きくなる理由、各操作が実際に片付けるもの、カウンタが当てにならないとき、そしてトランザクションIDの周回とは何か。

対象: PostgreSQL 13+

PostgreSQLでは、行を更新してもその行は変更されません:新しいバージョンを書き込み、古いものを不要な行として残します。削除しても、すぐには何も解放されません。これがここでのMVCCの仕組みで、あとで回収するのがVACUUMです。InnoDBには相当するものがなく、容量に関する驚きのほとんどはこれが原因です。

どう見えるか
50,000行のテーブルが12 MBでした。全行に対する1回のUPDATEで、1行も増えないまま23 MBになりました:50,000個の古いバージョンがまだファイルに残っているのです。PostgreSQL 17.6で測定しました。

それぞれが何をするか
- VACUUMは不要な領域を再利用可能としてマークします。オペレーティングシステムに容量を返しはしません:VACUUMのあとも例のテーブルは23 MBのままで、次の書き込みがその中に収まるようになっただけです。
- VACUUM FULLはテーブル全体を書き直し、容量を返します(11 MBまで減りました)。ただしACCESS EXCLUSIVEロックを取ります:実行中は誰も読み書きできず、完全なコピーのための領域も必要です。日常のメンテナンスではなく、最後の手段です。
- ANALYZEは何も片付けません:プランナの統計情報を更新します。

autovacuumは最初からオンです
autovacuumはonで出荷されています。不要な行がautovacuum_vacuum_threshold + autovacuum_vacuum_scale_factor × rowsを超えると、テーブルがキューに入ります。デフォルト値では「50 + 20 %」です。1,000万行のテーブルなら、何かが動き出すまでに200万行の不要な行が必要になるので、大きくて更新の多いテーブルでは、テーブルごとに係数を下げます:

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;

このカウンタには注意してください:推定値で、即時ではありません。17.6で測定したところ、50,000行を更新した直後はまだ0でした。ANALYZEのあとでようやく50,000になり、VACUUMのあとで0に戻りました。大量に書き込んだ直後に数値が動かなくても、保留中の作業がないという意味ではありません。

実行中のVACUUMは次のように追跡します:

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つのセッションが、触れてもいないテーブルを大きくするのです。

トランザクションIDの周回は本当の緊急事態です
トランザクションIDは32ビットで、再利用されます。どの行も未来に置かれることがないように、VACUUMは古いものを凍結します。autovacuum_freeze_max_ageはデフォルトで200,000,000です:その年齢を超えると、サーバは延期できないautovacuumを起動し、それでも追いつかなくなると、書き込みの受け付けを止めます。次のように監視します:

SELECT datname, age(datfrozenxid) AS edad
FROM pg_database
ORDER BY edad DESC;

その年齢が2億を大きく下回っているあいだは、何もする必要はありません。

推奨事項
autovacuumを無効にしないでください。新しい行がないのにテーブルが大きくなるなら、次の順に疑います:開いたままのトランザクション、サイズに対して高すぎるscale_factor、そして最後にVACUUM FULL。最後のものはテーブル全体をロックするので、メンテナンスの時間枠を取って行います。

キーワード: VACUUM、autovacuum、肥大化、MVCC、n_dead_tup、VACUUM FULL、FREEZE、周回、pg_stat_user_tables、pg_stat_progress_vacuum、DATFROZENXID、hot_standby_feedback、IDLE IN TRANSACTION

ロックとデッドロック(PostgreSQL)

各文が実際に何をロックするか、なぜALTER TABLEがあなたのSELECTを止めることがあるのか、誰が誰を待っているかを見る方法、そして40P01をどうするか。

対象: PostgreSQL 13+

PostgreSQLでは、ロックは2つの異なる場所にあり、それを取り違えると問題が見つからなくなります。テーブルのロックはpg_locksにあります。行のロックは行そのもの、そのヘッダの中にあるので、メモリーを使わず、テーブルロックに昇格することもなく、pg_locksにも現れません。100万行をロックしても、1行と同じコストです。

そして、例外のないルールが1つあります:ただのSELECTが行を待つことはありません。MVCCによって自分のバージョンを読みます。SELECTを止められるのはテーブルロックだけです。

どの文がどのテーブルモードを取るか

文モード
SELECTACCESS SHARE
SELECT … FOR UPDATE / FOR SHAREROW SHARE
INSERT、UPDATE、DELETEROW EXCLUSIVE
VACUUM、ANALYZE、CREATE INDEX CONCURRENTLYSHARE UPDATE EXCLUSIVE
CREATE INDEXSHARE
ALTER TABLE、TRUNCATE、DROP TABLE、VACUUM FULLACCESS EXCLUSIVE

最初の3つは互いに邪魔をしないので、通常の負荷ではブロックが起きません。最後のものはSELECTも含めてすべてと競合します。

落とし穴:待っているALTER TABLEは後ろのすべてを並ばせる
そのACCESS EXCLUSIVEは列に割り込みません:列に並びます。そして待っている間、あとから来たものはすべてその後ろで待ちます。前にある文とは何の問題もなかったはずのSELECTでさえもです。SELECTを実行しただけの開いたトランザクションが1つあれば、ALTER TABLEは何の作業も始めないまま、全員に対してテーブルを凍らせてしまいます。だから本番環境のDDLは上限を付けて発行し、再試行します:

SET lock_timeout = '3s';
ALTER TABLE cuentas ADD COLUMN moneda text;

行のモードは2つではなく4つ
強いものから弱いものへ:FOR UPDATE、FOR NO KEY UPDATE、FOR SHARE、FOR KEY SHARE。共存できるのは3組だけです(共有の2つどうしと、FOR NO KEY UPDATEとFOR KEY SHARE)。FOR UPDATEは4つすべてと競合します。大事なのはその変わった組み合わせです:キーに触れないUPDATEはFOR NO KEY UPDATEを取るので、その行を指す外部キーの検査をブロックしません。その検査が求めるのがFOR KEY SHAREです。

BEGIN;
SELECT saldo FROM cuentas WHERE id = 1 FOR UPDATE;
UPDATE cuentas SET saldo = saldo - 100 WHERE id = 1;
COMMIT;

ここでは待ち時間は無限
lock_timeoutはデフォルトで0、つまり上限なしです。50秒で打ち切るMySQLのinnodb_lock_wait_timeoutに相当するものはありません。セッションごとに危険な文の前で、あるいは設定で、これを設定することが、終わらない待ちをアプリケーションが再試行できるエラーに変えます。発動するとSQLSTATEは55P03です。

わざと待たない

SELECT id FROM cuentas WHERE id = 3 FOR UPDATE NOWAIT;

SELECT id FROM cuentas ORDER BY id FOR UPDATE SKIP LOCKED;

NOWAITはすぐに55P03で失敗します(そしてその失敗は、ほかの失敗と同じくトランザクション全体を中断させます)。SKIP LOCKEDは失敗しません:返す行が減ります。3行目を別のセッションがロックしているとき、2つ目のクエリーは1、2、4、5を返しました。これが、作業キューを複数のコンシューマで衝突も待ちもなく分け合う方法です。

デッドロック

-- セッション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;

サーバは2つのうち一方をSQLSTATE 40P01(deadlock detected)で終了させ、詳細にはどのプロセスとどの行かが書かれます。ただし、すぐには検出しません:待ちがdeadlock_timeout(デフォルトで1秒)を超えてはじめて循環を探すので、犠牲になる側が終わるまでその1秒がかかります。InnoDBはすぐに検出します。ここではデッドロックは1秒の待ちのコストがかかります。deadlock_timeoutを下げるのはただではありません:その作業は大多数を占める通常の待ちにも費やされるからです。

40P01はサーバの故障ではありません:正しい動作であり、アプリケーションはそのトランザクションを再試行しなければなりません。

診断
ここにはSHOW ENGINE INNODB STATUSはありません。クエリーが2つあり、どちらもロックが続いている間に実行する必要があります:

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;

pg_blocking_pidsは、もう一方が求めているものを持っているプロセスの一覧を返します。それがあなたが本当に知りたいことです。pg_locksで見えないのは行のロックです:行を待っている側はtransactionid、つまりその行を持っているトランザクションの番号を待っているように表示されます。そして、すでに起きたことの痕跡を残すには、log_lock_waitsがdeadlock_timeoutより長い待ちをすべてサーバのログに書きます。

Calíopeの「プロセス」は、その待ちを状態の列に表示します:ブロックされたセッションはLock: transactionidと表示されます。

アドバイザリーロック
テーブルもデータもありません:アプリケーションの2つのプロセスが同じことを同時にしないよう、サーバがあなたのために保持する番号です。

SELECT pg_try_advisory_lock(42);
SELECT pg_advisory_unlock(42);

あるセッションが42を持っている間、別のセッションからのpg_try_advisory_lock(42)は待たずにfalseを返します。スコープに注意してください:セッションレベルのものは「COMMITのあとも残り」、解放するか接続を閉じるまで解放されません。pg_advisory_xact_lockはトランザクションが終わると自動で解放されます。ほとんどの場合、欲しいのはこちらです。

SERIALIZABLEはブロックしない
このレベルではpg_locksにSIReadLockロックが現れます。これは誰もブロックしません:トランザクションが読んだものの印であり、競合は待ちとしてではなく、コミット時の40001として届きます。

推奨事項
どのエンジンでも同じく、行には常に同じ順序で触れ、トランザクションは短く保ってください。ここに特有なのは2つです:DDLの前には必ずlock_timeoutを設定すること(待っている側が全員を後ろに並ばせるため)、そして本番環境に出す前に再試行を書いておくこと。40P01を無害にするのはそれだけです。

キーワード: ロック、デッドロック、40P01、55P03、pg_locks、pg_blocking_pids、lock_timeout、deadlock_timeout、ACCESS EXCLUSIVE、FOR UPDATE、FOR KEY SHARE、SKIP LOCKED、NOWAIT、アドバイザリーロック

インデックス(PostgreSQL)

6つのインデックス方式とそれぞれが合う場面、部分インデックスと式インデックス、Index Only Scanがそれでもテーブルを読みに行くことがある理由、そして誰も使っていないインデックスの見つけ方。

対象: PostgreSQL 13+

インデックスは、容量と書き込みのたびの作業を代償に検索を速くします。MySQLから来て変わるのはその考え方ではなく、ここでは例外付きの1つではなく6つの方式があること、そしてMySQLでインデックスのオプションだったもののほとんど(プレフィックス、不可視)が、ここでは別のものだということです。

6つの方式

方式用途
Bツリーいつものもの:等価、範囲、ORDER BY、LIKE 'abc%'。迷ったらこれです。
ハッシュ等価だけ。PostgreSQL 10からはレプリケートされ、クラッシュ後も残ります。
GiSTジオメトリ、範囲、最近傍。PostGISの土台です。
SP-GiST偏って分布するデータ:重ならない範囲、プレフィックスによるテキスト。
GIN1つのフィールドの中の多数の値:jsonb、配列、全文検索。
BRIN物理的な順序が値に沿っている巨大なテーブル:挿入日時、時系列。

選び方は、理論よりサイズのほうがよく説明します。200,000行、22 MBのテーブルで、挿入とともに増えるタイムスタンプについて:

- その列のBツリー:4,408 kB。
- まったく同じ列のBRIN:24 kB。

BRINは行を保存せず、各ブロックの最小値と最大値を保存します。だから物理的な順序が値の順序に似ているときにしか役立ちません。そして似ているときは、ほとんどコストがかかりません。同じテーブルで、顧客の列のハッシュインデックスは7,032 kB、その列のBツリーは1,400 kBでした:より小さく、しかも範囲とソートにも使えます。だからBツリーがデフォルトの答えで、ハッシュは特定の場合のものです。

部分インデックス:考え方は半分、サイズは10分の1
インデックスはWHEREを持つことができ、そのときは一致する行だけをインデックスします。保留中のキューを問い合わせていて、保留中が5 %なら、その5 %をインデックスします:

CREATE INDEX idx_pendientes ON pedidos (cliente_id) WHERE estado = 'pendiente';

同じテーブルで測定すると、列の完全なインデックスは1,400 kB、部分インデックスは88 kBでした。クエリーのWHEREはインデックスのWHEREを含意していなければならず、そうでなければプランナは使いません。

ここにはプレフィックスインデックスはない:代わりに式インデックス
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));

そして細かい注意点:インデックスが効くのは、クエリーが式を同じように書いたときだけです。WHERE lower(email) = 'ana@ejemplo.com'は使いますが、WHERE email ILIKE 'Ana@%'は使わず、テーブル全体を読みます。

カバリングインデックスと、カバーしないことがある理由
INCLUDEは、インデックスに保存されるがソートには使われない列を加えます:

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;

削除する前に2つの注意があります。1つ目:カウンタは即時ではありません。テストでは、インデックスを使ったクエリー3回のあと、直後も1秒後も0のままで、3秒後にようやく3になりました。2つ目:最後のpg_stat_reset()からの数であり、レプリカではレプリカ自身の作業を数えます。だから月末のレポートだけが使うインデックスは、ほかの29日間は使われていないように見えます。

推奨事項
余分なインデックスは、すべてのINSERTと、その列のすべてのUPDATEで代償を払います。うまくいく順序は、プランを見る、CONCURRENTLYでインデックスを作る、もう一度プランを見る、1か月後にpg_stat_user_indexesを確認する、です。誰も使わないインデックスは中立ではありません:書き込み、容量、VACUUMの時間のコストがかかります。

キーワード: インデックス、BTREE、BRIN、GIN、GIST、SPGIST、HASH、部分インデックス、式インデックス、INCLUDE、INDEX ONLY SCAN、HEAP FETCHES、CONCURRENTLY、REINDEX、pg_stat_user_indexes、INDISVALID、jsonb_path_ops、可視性マップ

パフォーマンスと診断(PostgreSQL)

プランを読んで見積もりと測定値を比べること、互いを含意する列のための拡張統計情報、work_memが操作ごとである理由、そしてキャッシュヒット率が本当に測っているもの。

対象: PostgreSQL 13+

ここで診断するとは、プランを読んで2つの数字を比べることです。それ以外のすべて(インデックス、メモリー、統計情報)は、その比較から導かれます。

EXPLAINは実行しない。EXPLAIN ANALYZEは実行する
1つ目の形はプランを求めるだけです。2つ目は測定するために本当にクエリーを実行します。UPDATEやDELETEも含めてです。文が書き込むなら、包んでください:

BEGIN;
EXPLAIN (ANALYZE) DELETE FROM pedidos WHERE creado < '2020-01-01';
ROLLBACK;

大事な2つの数字
どのノードにも見積もりと測定値があります:rows=…はプランナが信じたもの、actual rows=…は実際に出たものです。この2つが大きく離れているとき、悪いプランは原因ではなく結果です。

互いを含意する2つの列(市と県)で測定した例:

- 補助なしでは、プランナは11,710行と見積もり、実際には60,000行出ました:2つの確率を独立しているかのように掛け合わせたのです。
- 拡張統計情報があると、見積もりは59,610になりました。

CREATE STATISTICS st_ciudad_prov (dependencies, ndistinct)
    ON ciudad, provincia FROM pedidos;
ANALYZE pedidos;

これはMySQLにはない道具で、「プランが私のインデックスを無視する」という類いの問題全体を解決します:テーブルの20 %を読むのにサーバが4 %だと思っていれば、もっともな理由で悪い選択をします。

頼まないと出ないBUFFERS

EXPLAIN (ANALYZE, BUFFERS) SELECT … ;

shared hitはメモリーにあったブロック、shared readは取りに行かなければならなかったブロックです。そしてtemp read/writtenが決め手です:クエリーがディスクにあふれたということです。また、track_io_timingはデフォルトでオフなので、オンにするまでI/Oの時間は表示されません。

work_memは接続ごとではなく操作ごと
これは最も驚かれ、最も間違えられる設定です。ソート、ハッシュ結合、ハッシュ集計のそれぞれがwork_memまで使うことができ、そうした操作を3つ含む(あるいは並列ワーカーが2つある)クエリーはその倍数を使います。出荷時の値は4 MBです。

300,000行で、ORDER BY付きの同じクエリーを測定すると:

- work_mem = 64kBでは:Sort Method: external merge Disk: 15680kBで、ソートのノードは約144 msかかりました。
- work_mem = 64MBでは:Sort Method: quicksort Memory: 29627kBで、約70 msでした。

本当のことを言う数字はSort Methodです。work_memを全体で上げると接続数と操作数で掛け算になります。慎重なやり方は、それが必要なセッションで上げることです:

SET work_mem = '64MB';

どのクエリーが最もコストがかかるか:pg_stat_statements
スロークエリーログに相当するものですが、集計されています:クエリーの形ごとに1行で、呼び出し回数、合計時間、行数があります。
Calíopeでは、クエリーを書かずに同じものを読めます:「スロークエリー」ツールがこの概要を表示し、拡張機能がないのか、ライブラリが読み込まれていないのかを区別し、どの行もエディタに送れます。

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();

これは、Calíopeの「ダッシュボード」が「データキャッシュ」として表示する数字です。最後に統計情報がリセットされてから、ファイルシステムまで降りずに済んだ読み取りがどれだけあったかを測るもので、使用中のメモリーの量ではありません。小さなデータベースでは、定義上とても高く出ます:テストでは99.85 %でした。低い値が続くなら、shared_buffersが小さすぎるということです。高い値は、すべてが順調だという証明にはなりません。

並列処理
max_parallel_workers_per_gatherはデフォルトで2で、プランナがそれを使うとGatherやGather Mergeのノードが現れます。各ワーカーはそれぞれのwork_memを持ちます。これが上の落とし穴のもう半分です。

推奨事項
うまくいく順序:pg_stat_statementsでクエリーを見つけ、EXPLAIN (ANALYZE, BUFFERS)で見て、rowsとactual rowsを比べ、そのうえで足りないのがインデックスなのか、統計情報なのか、メモリーなのかを決めます。プランを読む前にshared_buffersをいじるのは遠回りです。

キーワード: パフォーマンス、EXPLAIN、ANALYZE、BUFFERS、プラン、プランナ、見積もり、CREATE STATISTICS、拡張統計情報、work_mem、SORT METHOD、EXTERNAL MERGE、pg_stat_statements、shared_preload_libraries、pg_stat_database、キャッシュ、track_io_timing、並列処理

サーバの設定(PostgreSQL)

各パラメータがどこに書かれ、どれが優先されるか、何に再起動が必要か、effective_cache_sizeがメモリーを確保しない理由、そしてmax_connectionsを上げるべきではない理由。

対象: PostgreSQL 13+

PostgreSQLには378個のパラメータがあります(このサーバで数えました)が、よい知らせは、触るのはほんの一握りだということです。最初に学ぶべきなのは、どれを触るかではなく、どこに書かれ、いつ反映されるかです。

4つの場所があり、どれが勝ったかはサーバが教えてくれます
- postgresql.conf:通常のファイルで、手で編集します。
- postgresql.auto.conf:ALTER SYSTEMが書き込み、手では編集しません。そのことはファイル自身の1行目に書かれています。
- データベースごと、またはロールごと:ALTER DATABASE … SET、ALTER ROLE … SET。
- セッションごと:SET。接続が続くあいだ有効です。

どれが勝ったかは推測ではありません:pg_settingsのsource列が教えてくれます。測定結果:ALTER DATABASE demo SET work_mem = '32MB'のあと、新しい接続はsource = databaseで32MBを読みました。RESETのあとは、source = defaultで4MBでした。

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;

驚かずに済む、測定した細部:pending_restartは、再読み込みのその瞬間には点きません。直後はまだfalseで、0.5秒後にtrueになりました。同じ流れの中ではなく、あとで問い合わせてください。

shared_buffersとeffective_cache_sizeは同じではなく、一方は何も確保しません
- shared_buffersは実際のメモリーで、サーバ自身のキャッシュです。初期値は128 MBで、専用のサーバには少なすぎます。一般的な目安はRAMの25 %です。
- effective_cache_sizeは何も確保しません。PostgreSQLのキャッシュとオペレーティングシステムのキャッシュのあいだにあるとプランナが想定する量で、インデックスが割に合うかどうかの判断にだけ使われます。変えてもメモリーは1バイトも動かず、変わるのはプランです。

両者を取り違えると、キャッシュが増えると思ってeffective_cache_sizeを上げたり、プランが変わると思ってshared_buffersを上げたりすることになります。

max_connectionsは上げずに、前にプールを置きます
初期値は100で、ここでは各接続はスレッドではなく、オペレーティングシステムのプロセスです。1,000に上げるのは、数を大きくすることではありません:1,000個のプロセスと、そのメモリーと、操作ごとのwork_memです。答えはコネクションプーラ(PgBouncerなど)です。そして、これは再起動が必要なパラメータの1つです。

WALとチェックポイント
max_wal_size(初期値は1 GB)とcheckpoint_timeout(5分)が、すべてをディスクに書き出す頻度を決めます。チェックポイントが時間ではなくサイズで発生していると、サーバは集中的に書き込みます。log_checkpointsをオンにすると見え、max_wal_sizeを上げると直ります。checkpoint_completion_targetは最初から0.9で、これがその書き込みを集中させずに時間に分散させます。

autovacuum
ここではautovacuum_naptimeが60秒、autovacuum_max_workersが3(これは再起動が必要です)、autovacuum_vacuum_scale_factorが0.2、つまり行の20 %が変わるとテーブルが片付けられます。10億行のテーブルでは2億個の不要なバージョンを待つことになるので、大きなテーブルには専用の設定を持たせます:

ALTER TABLE eventos SET (autovacuum_vacuum_scale_factor = 0.01);

synchronous_commit:約束を変える唯一のパラメータ
オフにすると、COMMITはWALがディスクに届くのを待たなくなります。レイテンシが改善する代わりに、停電のときに最後のトランザクションを失う危険を負います(データベースの整合性ではなく、最新のコミットだけです)。これはuserなので、そのトレードオフを受け入れる場所でだけオフにできます:

SET synchronous_commit = off;

推奨事項
触るのは少なく、1つずつ、測りながら。ファイルを編集する代わりにALTER SYSTEMを使い(記録が残り、ALTER SYSTEM RESETで元に戻せます)、全体よりも先にデータベースごと、ロールごとに設定します。夜間のレポートにしか要らない設定の代償を、ほかの時間帯に払わせるべきではありません。

キーワード: 設定、postgresql.conf、postgresql.auto.conf、ALTER SYSTEM、pg_settings、pending_restart、pg_reload_conf、shared_buffers、effective_cache_size、work_mem、max_connections、プール、WAL、チェックポイント、autovacuum、synchronous_commit

ロール、権限、セキュリティ(PostgreSQL)

アカウントにホストが含まれない理由、ユーザでもありグループでもあるロール、付与が明日のテーブルには届かないという罠、そして行レベルセキュリティがオンなのに効かないことがある理由。

対象: PostgreSQL 13+

MySQLとの根本的な違いは、ここではアカウントにホストが含まれないことです。ana@192.168.1.%はありません:あるのはロールanaで、どこから接続でき、どう認証するかは、別のファイルpg_hba.confが決めます。

pg_hba.conf:最初に一致した行が勝ちます
上から下へ読まれ、そこで止まります。そして、見るためにファイルを開く必要はありません:

SELECT type, database, user_name, address, auth_method, error
  FROM pg_hba_file_rules
 ORDER BY rule_number;

error列は、ある行の書き方が間違っているかどうかを教えてくれます(これが、サーバが戻ってこない再起動から救ってくれます)。変更はSELECT pg_reload_conf()で、再起動なしに反映されます。テスト用のサーバには7つのルールがあり、127.0.0.1のものはtrust、そのほかすべてに当たる最後のものはscram-sha-256でした:順序こそがポリシーです。

ロールはユーザであり、同時にグループです
2つの概念があるわけではありません:CREATE USERはCREATE ROLE … LOGINそのものです。人とグループを分けるのはLOGIN属性だけで、ほかには何もありません。

CREATE ROLE app_ro;                       -- LOGINなし:グループとして働く
CREATE ROLE ana LOGIN PASSWORD 'secreta';
GRANT app_ro TO ana;                      -- anaはapp_roの権限を継承する

ロールはデフォルトで継承するので、anaは何もしなくてもapp_roの権限を使えます。NOINHERITにすると、権限はSET ROLEで明示的に取得しなければなりません。手順を明示的にしたいときに使います。

パスワードはscram-sha-256で保存されます。PostgreSQL 14からのデフォルトで、テスト用のサーバでも確認できました。md5はまだありますが、使うべきではありません。

本当の罠:付与は未来には届きません
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;                          -- 明日のテーブル

そして細かい注意:デフォルト権限は、スキーマではなくそれを宣言したロールのものです。そのため、テーブルを別のロールが作成するなら、FOR ROLEでも宣言する必要があります。宣言した内容はpg_default_aclに表示されます。

付与は、元に戻す必要のある痕跡も残します:ON ALL TABLESのあとでは、ロールの削除はDependentObjectsStillExistと、権限が残っているテーブルの一覧とともに失敗します(テストではPostGISのテーブルまで出てきました)。対になるのはREVOKE、またはDROP ROLEの前のDROP OWNED BY roleです。

publicスキーマは、もうみんなのものではありません
PostgreSQL 15から、PUBLICはスキーマpublicに対するUSAGEを持ち続けますが、CREATEはもう持っていません。17.6で測定:作成したばかりのロールではUSAGE = true、CREATE = falseになります。以前のバージョンのスクリプトを持ち込むと、以前はできていたユーザの最初のCREATE TABLEが失敗するのを目にすることになります。

定義済みロール:スーパーユーザなしで監視する
サーバには、すぐに使えるロールが15個付いています。余計なスーパーユーザを作らずに済むものは:
- pg_read_all_data、pg_write_all_data:すべてを読む、またはすべてに書く。それ以上の権限はありません。
- pg_monitor:統計情報のビューをすべて見られます。pg_read_all_statsとpg_read_all_settingsを含みます。
- pg_signal_backend:ほかの人のクエリーをキャンセルし、セッションを閉じます。
- pg_maintain(PostgreSQL 17以降):所有者でなくてもVACUUM、ANALYZE、REINDEXを実行できます。

行レベルセキュリティ
ポリシーは、同じテーブルの中で、各ロールがどの行を見るかを絞り込みます:

ALTER TABLE pedidos ENABLE ROW LEVEL SECURITY;

CREATE POLICY solo_lo_mio ON pedidos
    FOR SELECT USING (dueno = current_user);

信頼する前に、測っておくべきことがあります。同じポリシーと同じテーブルで:

- ポリシーの対象のロールは1行を見ました。正しい結果です。
- テーブルの所有者(スーパーユーザではない)は両方を見ました:所有者は、ALTER TABLE … FORCE ROW LEVEL SECURITYを宣言するまで、自分のポリシーの対象になりません。FORCEを付けると、1行になりました。
- スーパーユーザは、FORCEがあっても両方を見ました。スーパーユーザと、BYPASSRLSを持つロールは、常にポリシーを無視します。

つまり、テーブルの所有者として接続するアプリケーション(スーパーユーザならなおさら)では、行レベルセキュリティはオンなのに効いていません。確かめるには、実際のロールで接続して行を数えます。

推奨事項
アプリケーションごとに1つのロール、グループにはLOGINなし、そして管理用以外はSUPERUSERを持たせない。ALTER DEFAULT PRIVILEGESはGRANTと同じ変更の中に入れてください。そうしないと、権限は次のテーブルまでしか持ちません。そして、一括で付与したものは書き留めておいてください。1年後のDROP ROLEがそれを求めてきます。

キーワード: セキュリティ、ロール、ユーザ、グループ、pg_hba.conf、pg_hba_file_rules、SCRAM-SHA-256、GRANT、REVOKE、ALTER DEFAULT PRIVILEGES、pg_default_acl、PUBLIC、pg_read_all_data、pg_monitor、RLS、行レベルセキュリティ、CREATE POLICY、BYPASSRLS、DROP OWNED BY

バックアップと復旧(PostgreSQL)

論理と物理、それぞれの用途、pg_dumpが置いていくもの(そのせいで誰もログインできなくなるもの)、PITRがそのままでは動かない理由、そしてディスクを埋めかねないスロット。

対象: PostgreSQL 13+

バックアップには2つの種類があり、同じ目的のためのものではありません。選び間違えると、それがわかるのは復元の日です。

論理(pg_dump)物理(pg_basebackup)
コピーするものデータを作り直す文クラスタのファイルをそのまま
単位1つのデータベース、あるいは1つのテーブルクラスタ全体、すべてのデータベース
復元先別のバージョン、マシン、システム同じメジャーバージョン
向いていること移行、テーブルの移動、中身を読むことサーバの復旧と、PITR

論理バックアップ

-- SQLエディタではなく、コマンドラインで:
-- pg_dump -d demo -Fc -f demo.dump
-- pg_restore -d demo_nueva -j 4 demo.dump

デフォルトで使うべきは-Fc(カスタム)形式です:同じデータベースで、プレーンテキストのダンプは3.1 MB、カスタムは905 kBでした。しかも目次を持っているので(pg_restore -lは30のデータブロックを一覧にしました)、1つのテーブルだけを復元でき、-jで並列にもできます。

pg_dumpが運ばないもの、そして痛いのはそこ
ロールとクラスタ全体の設定は中に入っていません。測定結果:データベースのダンプにはCREATE ROLEが1つもなく、pg_dumpall --globals-onlyは存在した2つを出力しました。ダンプだけを復元すると、完璧なのに誰もログインできないデータベースが残ります。完全な論理バックアップは2つのファイルです:
- pg_dumpall --globals-only:ロール、パスワード、クラスタの権限。
- 各データベースのpg_dump。

pg_dumpは一貫性があり(スナップショットに対して動きます)、書き込み側をブロックしません。ただしACCESS SHAREロックを取るので、同時に発行されたALTER TABLEは待ち始め、すべてがその後ろに並びます。

物理バックアップ
pg_basebackupはクラスタ全体をコピーします。テストサーバで測定すると:-X streamで1.3秒で84 MBでした。このオプションはコピー中に生成されたWALも持ってきます。それがないとコピーは復元できません。WALのどの位置から再生するかを書いたbackup_labelが残ります:

START WAL LOCATION: 0/13000028 (file 000000010000000000000013)

PITR:ある時点までの復旧
物理バックアップが存在する理由ですが、そのままでは動きません:このサーバで測定すると、archive_modeはオフで出荷されています。アーカイブがなければ、物理バックアップは取得した瞬間をそのまま復元するだけで、1秒先にも進めません。

必要なものは3つです:
1. archive_mode = onと、WALの各セグメントを安全な場所にコピーするarchive_command(または別のマシンからのpg_receivewal)。
2. 定期的なpg_basebackup。
3. 復元時:バックアップのファイル、セグメントを取ってくるrestore_command、recovery_target_time = '…'、そしてデータディレクトリの空のrecovery.signalファイル。

古いバージョンから来た人がつまずくのは最後の点です:PostgreSQL 12からはrecovery.confはありません。パラメータはpostgresql.confに書き、「これは復旧である」と宣言するのはシグナルファイルです。

レプリケーションスロットは諸刃の剣
スロットは、コンシューマがまだ読んでいないWALをサーバが削除しないことを保証します。コンシューマが消えてスロットが残ると、ディスクがいっぱいになるまでWALがたまります(そしてディスクがいっぱいになるのは警告ではなく停止です)。次のように見張ってください:

SELECT slot_name, active, wal_status,
       pg_size_pretty(pg_wal_lsn_diff(pg_current_wal_lsn(), restart_lsn)) AS retenido
  FROM pg_replication_slots;

max_slot_wal_keep_sizeが上限を決めます:それを超えると、サーバはディスクを使い切るよりスロットを無効にするほうを選びます。

Calíopeのバックアップは論理バックアップ
バックアップツールが作るのはSQL(CREATEとINSERT)で、pg_basebackupではなくpg_dumpの系統です。移行とデータの復旧には向いています。サーバ全体をある時点まで復旧するには上に書いたものが必要で、それはクライアントではなくオペレーティングシステムの仕事です。

推奨事項
論理バックアップの2つのファイル(--globals-onlyとダンプ)を常に一緒に取り、バックアップではなく復元を試してください:エラーなく生成されたファイルでも復元に失敗することがあり、それを知る唯一の方法は実際のサーバに復元してみることです。

キーワード: バックアップ、pg_dump、pg_restore、pg_dumpall、GLOBALS、pg_basebackup、WAL、archive_mode、archive_command、PITR、recovery_target_time、recovery.signal、レプリケーションスロット、wal_status、復元

オンラインでのスキーマ変更(PostgreSQL)

DDLはトランザクション対応であること、コストがかかるのはALTERではなくロックであること、テーブル全体を書き直す変更、そしてデータベースを止めずに済むNOT VALID + VALIDATEのパターン。

対象: PostgreSQL 13+

ここではDDLはトランザクション対応です。これはマイグレーションの書き方を変えるもので、ALTERのたびにそれだけでコミットされるMySQLから来たときに最初に理解すべきことです。

BEGIN;
ALTER TABLE pedidos ADD COLUMN moneda text;
CREATE INDEX idx_moneda ON pedidos (moneda);
CREATE TABLE monedas (codigo text PRIMARY KEY);
ROLLBACK;

確認済み:そのROLLBACKのあと、3つのものはどれも残っていませんでした。途中で失敗したマイグレーションは、中途半端なデータベースを残しません。だから健全なやり方は、マイグレーション全体をトランザクションの中に入れることです。

例外は片手で数えられます:CREATE INDEX CONCURRENTLY、VACUUM、ALTER SYSTEMはトランザクションの中に入れることができません。

コストがかかるのはALTERではなくロック
ほとんどすべてのALTER TABLEはACCESS EXCLUSIVEを取り、これはSELECTとさえ競合します。変更自体は1ミリ秒で済んでも、ロックを待つのに何時間もかかることがあります(そして待っている間、後ろに来たものはすべて並びます)。だから本番環境のDDLは常に次のように発行します:

SET lock_timeout = '3s';
ALTER TABLE pedidos ADD COLUMN moneda text;

ロックが取れなければ3秒で失敗し、再試行されます。これがないと、1ミリ秒のマイグレーションがアプリケーション全体を止めることがあります。

テーブルを書き直すものと、そうでないもの
書き直すとはテーブル全体をコピーすることです:サイズに比例した時間がかかり、その間は2倍のディスクが必要です。500,000行、32 MBで測定すると:

文時間書き直すか
ADD COLUMN c int0.6 msいいえ
ADD COLUMN c int DEFAULT 7 NOT NULL1.6 msいいえ
ALTER COLUMN s TYPE varchar(100)(元は50)1.2 msいいえ
ALTER COLUMN s TYPE varchar(20)(元は50)204 msはい
ALTER COLUMN n TYPE bigint(元はint)191 msはい
ALTER COLUMN t TYPE varchar(200)(元はtext)193 msはい
DROP COLUMN c0.5 msいいえ
ALTER COLUMN c SET NOT NULL17.8 msいいえ(ただしテーブルを走査)
ADD CONSTRAINT … CHECK (…)11.6 msいいえ(走査する)
ADD CONSTRAINT … CHECK (…) NOT VALID0.5 msいいえ

この表をまとめるルール:広げるのはただで、狭めると書き直す。そしてデフォルト値付きのADD COLUMNは、PostgreSQL 11で書き直しをしなくなりました:列を空で追加してバッチで埋めるという昔の回り道は、もう必要ありません。

時間には表れない注意が2つあります:
- DROP COLUMNが一瞬で終わるのは、列を削除済みと印を付けるだけだからです:テーブルが書き直されるまで容量は戻りません。
- SET NOT NULLと通常のCHECKは書き直しませんが、ロックを持ったままテーブル全体を走査します。大きなテーブルでは、それだけでもう停止です。

データベースを止めずに済むパターン:NOT VALIDのあとにVALIDATE
制約は2段階で追加できます:まず、すでにあるものを検査せずに宣言し(一瞬で終わります)、そのあと検証します。検証は遅い部分ですが、ずっと弱いロックで済みます。

ALTER TABLE ddl_hija
    ADD CONSTRAINT fk_p FOREIGN KEY (padre) REFERENCES ddl_demo (id) NOT VALID;

ALTER TABLE ddl_hija VALIDATE CONSTRAINT fk_p;

測定結果:NOT VALIDの宣言はSHARE ROW EXCLUSIVE(読み取りは許す)で0.7 ms、検証はSHARE UPDATE EXCLUSIVEで67 msで、これは書き込み側すらブロックしません。一度に行っても時間は同じでしたが、強いロックを最初から最後まで持ったままでした。実際のテーブルでは、その違いがデプロイと停止を分けます。

制約がNOT VALIDになった瞬間から、サーバは新しい行にはそれを適用します。残っているのは古い行の検査だけです。

インデックス
通常のCREATE INDEXは書き込みを止めます。CREATE INDEX CONCURRENTLYは何も止めませんが、マイグレーションのトランザクションには入らないので、独立したステップにして、あとで確認します(pg_index.indisvalid)。

推奨事項
lock_timeoutは必ず。マイグレーションは、入れられないものを除いてトランザクションの中に。大きなテーブルの制約にはNOT VALID + VALIDATE。そして型の変更に注意してください:書き直しが隠れているのはそこです。型を狭める必要があるなら、ほとんどの場合、新しい列を追加し、バッチでコピーし、名前を変えるほうが得策です。

キーワード: DDL、ALTER TABLE、マイグレーション、トランザクション対応、ROLLBACK、lock_timeout、ACCESS EXCLUSIVE、書き直し、RELFILENODE、ADD COLUMN、DROP COLUMN、SET NOT NULL、NOT VALID、VALIDATE CONSTRAINT、CREATE INDEX CONCURRENTLY

データ型(PostgreSQL)

存在せず構文エラーになるもの、textがvarcharより劣らない理由、numericと浮動小数点、timestamptzが本当に保存しているもの、そしてjsonbが容量を節約しない理由。

対象: PostgreSQL 13+

型は、MySQLからの移行が最初の試みで失敗する数少ない場所の1つで、それはかえって好都合です:存在しないものは、半分受け入れられる代わりに構文エラーになります。

ここに存在しないもの
- UNSIGNED:42601 syntax error at or near "unsigned"。符号なし整数はありません。1つ上の型かCHECK (n >= 0)を使ってください。
- INT(11):これも42601です。MySQLの表示幅は存在せず、見かけどおりの意味だったこともありません。
- TINYINT、DATETIME、括弧付きのDOUBLE、そしてMySQLのSET型とENUM型。対応するのはsmallint、timestamptz、double precision、そして本来のenum型です。

テキスト:textを使えばそれで済む
同じ値'hola'で測定すると:textは5バイト、varchar(50)は5、char(50)は51でした。3つとも同じように保存されます。varchar(n)は長さの検査を加えるだけで、char(n)は空白で埋めます。そしてその空白は比較を変えます:'x' = 'x 'はtextでは偽、charでは真です。

ここではtextはvarcharより劣りません:ペナルティはありません。上限が業務上のルールであるときはvarchar(n)を、char(n)はほとんど使いません。

数値:お金にはnumeric、そしてそれは迷信ではない

SELECT (0.1::float8 + 0.2::float8) = 0.3::float8,   -- false
       (0.1::numeric + 0.2::numeric) = 0.3::numeric; -- true

測定結果:浮動小数点では0.1 * 3は0.30000000000000004、numericでは正確に0.3でした。numericは正確で任意精度ですが、その代償を容量と速度で払います(その値でfloat8の8に対して10バイト、そしてソフトウェアによる演算)。お金と、顧客の前で合計するあらゆる数字にはnumericを。

測定したサイズ:intは4、bigintは8、booleanは1、uuidは16(テキストなら36かかるところ)。

日付:ほとんどの場合timestamptz
timestampとtimestamptzは同じ8バイトです。違いはサイズでも、一方がタイムゾーンを保存することでもありません:どちらもタイムゾーンを保存しません。timestamptzは瞬間を保存し(入るときにUTCに、出るときにセッションのタイムゾーンに変換します)、timestampは時計の読みを保存するだけです。

同じ瞬間を、2つのセッションのタイムゾーンで測定すると:

TimeZonetimestamptztimestamp
Europe/Madrid2026-08-19 13:48:19+022026-08-19 13:48:19
UTC2026-08-19 11:48:19+002026-08-19 11:48:19

同じ瞬間を2通りに言っているのです。timestampでは変換はまったくありません:入れたものがそのまま出てきて、本当は何時だったのかを知る必要がある人には、それを知る手段がありません。dateは4バイト、intervalは16バイトです。

jsonかjsonbか:ほとんどの場合jsonb、そしてサイズのためではない

SELECT '{"b":1,"a":2,"a":3}'::json::text,   -- {"b":1,"a":2,"a":3}
       '{"b":1,"a":2,"a":3}'::jsonb::text;  -- {"a": 3, "b": 1}

jsonは届いたままのテキストを保存します:順序も空白も、重複したキーさえも保ちます。jsonbは解析済みのツリーを保存します:キーを並べ替え、重複は最後のものを残し、空白を正規化します。だからjsonbは問い合わせが速く、GINでインデックスでき、jsonが正しいのは、届いたとおりバイト単位でドキュメントを返さなければならないときだけです。

正しくないのは、jsonbが容量を節約するという話です:同一のドキュメント200,000件で測定すると、jsonは14 MB、jsonbは16 MBでした。jsonbを選ぶのは問い合わせ方のためで、重さのためではありません。

配列
配列は第一級の型で、独自の演算子(包含の@>、array_length)とGINインデックスがあります。タグや短いリストには便利です。要素が独自の属性を必要としたり、別のテーブルと結合しなければならなくなったりすると、すぐに便利ではなくなります。配列は節約したテーブルではありません:値です。

serialかIDENTITYか
serialは型ではありません:シーケンスを作り、そのnextvalをデフォルト値に設定する糖衣構文です。GENERATED ALWAYS AS IDENTITYは標準のもので、しかも列を保護します:値を手で挿入しようとすると428C9 cannot insert a non-DEFAULT value into columnと答えました。新しいテーブルにはIDENTITYを。

推奨事項
テキストにはtext、お金にはnumeric、瞬間にはtimestamptz、問い合わせるドキュメントにはjsonb、キーにはIDENTITY。そしてMySQLから移行するときは、構文エラーに仕事をさせてください:受け入れられて別の意味になる型よりはましです。

キーワード: データ型、TEXT、VARCHAR、CHAR、NUMERIC、FLOAT、DECIMAL、TIMESTAMPTZ、TIMESTAMP、タイムゾーン、JSON、JSONB、配列、UUID、SERIAL、IDENTITY、UNSIGNED、ENUM

エラーとSQLSTATE(PostgreSQL)

毎日目にするコード、コードではなくクラスを相手にプログラムする理由、誰も見せてくれないDETAILとHINTが実際に言っていること、そして接続が開きもしないときにコードがある場所。

対象: PostgreSQL 13+

ここにはエラー番号はありません。あるのはSQLSTATEです:5文字で、そのうち最初の2文字がクラスです。そしてプログラムが相手にするのはクラスです:何が失敗したのかを正確に知らなくても、何をすべきかを教えてくれます。

毎日目にするもの

コード何が起きたか
23505キーの重複:一意制約に違反しています
23503外部キー:参照先の行が存在しないか、子のある親を削除しようとしています
23502NOT NULL列にNULL
23514CHECK制約が拒否しました
22001テキストが型に収まりません
22P02入力の構文が無効:'hola'は整数ではありません
22012ゼロ除算
42601構文エラー
42703その列は存在しません
42P01そのテーブルは存在しません
42P07そのテーブルはすでに存在します
42883その関数または演算子は存在しません
42501権限がありません
25P02トランザクションが中断されていて、もう何も受け付けません
40001シリアライズできませんでした:再試行が必要です
40P01デッドロック:再試行が必要です
55P03ロックを取得できませんでした(NOWAITまたはlock_timeout)
57014クエリーがキャンセルされました(statement_timeoutか、誰かがキャンセルした)
3D000そのデータベースは存在しません
28000そのロールは存在しません

見るべきなのはクラス

クラス意味すること
08接続再接続して再試行
22データ入力値を直す
23整合性データの問題:ユーザに伝える
25トランザクションの状態ROLLBACKしてやり直す
28認可資格情報。再試行しない
40ロールバックトランザクション全体を再試行
42構文またはアクセスプログラムのバグ:再試行しても何も直らない
53リソース不足待つか、増やす
55オブジェクトが正しい状態にない場合による。55P03はロック
57オペレータの介入誰かがキャンセルしたか、上限が発動した

実際的な結論:アプリケーションはクラス40を再試行し、クラス42は再試行しません。再試行がこの2つを区別しないと、正当なトランザクションが失われるか、決して動かないクエリーが1,000回繰り返されます。

メッセージは3つの部分からなり、役に立つのは3つ目
MESSAGEは何が起きたか、DETAILは行や値、HINTは何をすべきかを伝えます。測定結果:

- 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".

MESSAGEしか表示しないクライアントは情報の半分を捨てています(しかも、抜け出す方法を教えてくれるほうの半分です)。Calíopeは3つすべてを組み立てて表示します。

さらに、エラーには個別のフィールドがあります:テーブル、列、制約の名前です。23505にはconstraint = er_d_pkeyが付いてきました。これがあるので、メッセージのテキストを解析せずに「そのメールアドレスはすでに登録されています」に変えられます。

エラーはトランザクションを中断させる
BEGINの中でエラーが起きると、ROLLBACKするまで、それ以降はすべて25P02と答えます。クライアントのバグではありません:そういう設計で、うまく抜け出す方法はセーブポイントです。

接続のエラーは応答には届かない
ロールやデータベースが存在しない、あるいはパスワードが間違っていると、接続は開きません:クライアントには「接続に失敗しました」としか見えません。コードはサーバのログにあり、それも頼んだときだけです:

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)。誰かが「接続できない」と報告してきたときに、推測するか知っているかの違いです。

推奨事項
アプリケーションのコードではクラスで分岐し、完全なコードはユーザが見るメッセージ(23505→「すでに存在します」)にだけ使ってください。SQLSTATEは必ず自分のログに保存してください:メッセージのテキストはサーバの言語で変わりますが、コードは変わりません。

キーワード: エラー、SQLSTATE、コード、クラス、23505、23503、42P01、42601、42883、42501、25P02、40001、40P01、55P03、57014、3D000、28000、DETAIL、HINT、再試行、log_error_verbosity

パーティション分割(PostgreSQL)

宣言的パーティション分割とプランナが本当にプルーニングするもの、グローバルインデックスも単一列の一意性もない理由、68 msのATTACHを0.5ミリ秒にするCHECK、そしてデフォルトパーティションのコスト。

対象: PostgreSQL 13+

ここでのパーティション分割は宣言的です:キーを宣言し、各パーティションは本物のテーブルです。親は1行も持ちません(測定結果:0バイトで、データは子に分散しています)。

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つあります:RANGE(日付、金額)、LIST(国、状態)、HASH(分散させるための分散)。

肝心なのはプルーニング
3年分の300,000行で測定すると:WHERE creado BETWEEN '2024-03-01' AND '2024-03-31'のクエリーは「pt_2024だけ」を走査しました。同じテーブルで、キーではない列で絞り込むと、すべてのパーティションに対してParallel Appendを行いました。

それが設計を決めるルールです:パーティションキーは、ほとんど常に絞り込みに使う列にする。クエリーがそれに触れなければ、パーティション分割は読み取りを減らしません:分散させるだけです。

存在しないもの:グローバルインデックス
親に作ったインデックスはパーティションごとに1つ作られます(測定結果:4つのパーティションに4つのインデックス)。テーブル全体にまたがる単一のインデックスはなく、そこから、設計する前に知っておくべき制限が生まれます:

ALTER TABLE pt ADD CONSTRAINT pt_uni UNIQUE (id, creado);

(id)だけのUNIQUEは0A000 unique constraint on partitioned table must include all partitioning columnsで拒否されます。宣言的パーティション分割では、識別子のグローバルな一意性は保証できません。それは制約ではなく、構造上一意であるシーケンスから得ます。

ATTACH:68 msと0.5ミリ秒の違い
既存のテーブルをアタッチすると、サーバはそのすべての行が範囲に収まるかを検査しなければなりません。300,000行で測定すると:

操作時間
事前のCHECKなしのATTACH67.9 ms
DETACH0.7 ms
同等の、検証済みのCHECKがあるATTACH0.6 ms

つまり:テーブルがすでに範囲を含意するCHECK制約を持っていれば、サーバは走査を省略します。10億行のテーブルでは、それが一瞬のACCESS EXCLUSIVEと30分間のそれとの違いです。

デフォルトパーティションはただではない
DEFAULTはどの範囲にも入らないものを受け止め、想定外の日付が挿入されたときのエラーを防ぎます。その代わりに、測定結果:ALTER TABLE … DETACH PARTITION … CONCURRENTLYは55000 cannot detach partitions concurrently when a default partition existsと答えました。さらに、新しいATTACHのたびに、入ってくる範囲の行を隠していないか確かめるためにデフォルトパーティションを走査しなければなりません。

本当にパーティション分割する理由
クエリーの速さのためではなく(それはインデックスの役目です)、メンテナンスのためです:
- ある期間をまるごと削除するのは、そのパーティションのDROP TABLEです:測定すると2.3 msで、容量はファイルシステムに戻ります。同等のDELETEはもっと時間がかかり、なによりVACUUMが掃除すべき不要行と、戻ってこない容量を残します。
- VACUUMとANALYZEはパーティションごとに動くので、メンテナンスの作業が履歴全体とともに増え続けることはなくなります。
- 古いデータは、稼働中のテーブルに触れずにデタッチしてアーカイブできます。

推奨事項
問い合わせるものではなく、削除するものでパーティション分割してください。そして、クエリーがWHEREにキーを含んでいるかは、思い込みではなくプランを見て確かめてください。既存のテーブルを分割する前に、本当に必要なのがインデックスではないか確認してください:パーティション分割は、メンテナンスを安くする代わりに動く部品を増やし、その取引はある程度のサイズを超えてはじめて元が取れます。

キーワード: パーティション分割、パーティション、RANGE、LIST、HASH、プルーニング、ATTACH、DETACH、DEFAULT、グローバルインデックス、UNIQUE、0A000、DROP PARTITION、メンテナンス

エンコーディングと照合順序(PostgreSQL)

ここにutf8の落とし穴がない理由、エンコーディングと照合順序の違い、それぞれで並び順がどう変わるか、そしてプレフィックスのLIKEがインデックスを使わない理由。

対象: PostgreSQL 13+

MySQLからの移行であれほど高くついた落とし穴は、ここにはありません:UTF-8ではないutf8は存在しません。エンコーディングはデータベースを作成するときに宣言し、UTF8はUTF-8のすべてです。

通常のtext列に4つの文字列を保存して測定すると:

値文字数バイト数
normal66
ñandú57
日本語39
修飾子付きの絵文字とテキスト1119

特別な宣言は何も必要ありませんでした。length()は文字を、octet_length()はバイトを数えます。MySQLでは型ごとに追いかけなければならなかった区別です。

エンコーディングと照合順序は別のもの
- エンコーディング:バイトをどう保存するか。データベースに属し、作成時に固定され、あとから変更できません:変えるにはダンプして作り直すことになります。
- 照合順序:どうソートし、どう比較するか。データベースごと、列ごと、式ごと、さらにはORDER BYの中でも設定できます。

テストサーバでは:server_encoding = UTF8で、データベースはlibcプロバイダのen_US.utf8でした。利用できる照合順序は815あり、2つのプロバイダから来ています:システムのもの(C、POSIX、en_US.utf8)とICUのもの(es-ES-x-icu、unicode)で、後者はPostgreSQL 15からデータベースのデフォルトのプロバイダにもできます。

照合順序で何が変わるか

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。辞書のようにソートします。

そして比較もそれとともに変わります:'a' < 'B'はCでは偽、スペイン語の照合順序では真です。「並び順がおかしく出る」リストは、ほとんどの場合アプリケーションのバグではありません:列の照合順序です。

LIKEにインデックスが役立つかは照合順序が決める
これは、自分で見つけるのが最も難しい実際的な細部です。言語的な照合順序(データベースのもの)では、通常のBツリーインデックスはプレフィックス検索の役に立ちません。200,000行で測定すると:

- 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);

text_pattern_opsはバイト単位で比較します。それこそがLIKE 'something%'に必要なものです。列の照合順序がCなら不要です。もともとそう比較するからです。

照合順序にはバージョンがあり、それが重要
データベースは、インデックスを作ったときの照合順序のバージョンを記録しています(測定結果:datcollversion = 2.36、システムのライブラリのもの)。オペレーティングシステムをアップグレードしてそのバージョンが変わると、順序が変わることがあり、前の順序で作られたインデックスは正しくなくなります:そこにある行が見つからない検索です。PostgreSQLはその不一致を警告し、答えはREINDEXです。

だから多くの人は、システムのアップグレードを生き延びなければならないデータベースにC照合順序かICUを選びます:ICUは独自のバージョンを持ち、システムのものに依存しません。

推奨事項
常にUTF8。照合順序はデータベースを作成するときに決めます。あとから変えるのは高くつくからです:コード、識別子、パスである列にはC(ソートが速く、LIKEでも使えます)、人が読むものには言語的な照合順序を。そしてLIKE 'x%'のクエリーがインデックスを使わないなら、クエリーをいじる前に照合順序を見てください。

キーワード: エンコーディング、UTF8、照合順序、COLLATE、ICU、LIBC、text_pattern_ops、LIKE、プレフィックス、ORDER BY、DATCOLLVERSION、REINDEX、octet_length

制限(PostgreSQL)

本当に痛いもの(63バイトの名前、1,600列、インデックスあたり32列)、識別子の上限がエラーではなく通知になる理由、そしてページに収まらない値をTOASTがどうするか。

対象: PostgreSQL 13+

PostgreSQLの上限はInnoDBのものとは似ておらず、日々痛いのは大きな上限ではありません。

実際にぶつかるもの

上限値超えるとどうなるか
識別子の長さ63バイト通知とともに切り詰められます
テーブルあたりの列数1,60054011 tables can have at most 1600 columns
インデックスあたりの列数3254011 cannot use more than 32 columns in an index
ページサイズ8 kB固定。サーバを再コンパイルしないかぎり

最初の3つは測定済みです:1,600列は問題なく作成でき、1,601列は失敗しました。32列のインデックスは作成でき、33列のものはできませんでした。

エラーにならないのは識別子の上限だけ
72文字の名前は63文字で保存され、サーバは通知でそれを伝えました:

identifier "t_aaa…" will be truncated to "t_aaa…"

通知はエラーではありません:文は続行されました。だから64文字目以降しか違わない2つの長い名前は同じオブジェクトになり、失敗はずっとあとになって現れます。これにぶつかるのは名前の生成器(インデックス、制約、バッチごとの一時テーブル)で、人の手ではありません。

大きな上限は、ほとんど問題にならない
ここでは測定していません(ディスクを埋めることになるので)。公式の数字です:

- テーブルの最大サイズ:32 TB。
- フィールドの最大サイズ:1 GB。
- 行の最大サイズ:1.6 TB。
- テーブルあたりの行数:上限は定められていません。
- クラスタあたりのデータベース数とデータベースあたりのテーブル数:実用上の上限はありません。

そのどれよりもずっと先に尽きるのはメンテナンスです:テラバイト級のテーブルに対するVACUUM、バックアップ、インデックスの再構築です。

TOAST:1 GBのtextが8 kBのページを壊さない理由
1行は1ページに収まらなければならず、1ページは8 kBです。大きな値は圧縮され、横のテーブル(TOASTと呼ばれるもの)に自動的に移されます。宣言は何も要りません。

text列に100,000バイトのテキストを保存して測定すると:

- octet_length:データは100,000バイト。
- pg_column_size:1,156バイト。つまり圧縮されました。
- テーブルは8,192バイトで、TOASTを含めると16 kBでした。

そこから実際的な結論が出ます:大きな列を持つテーブルへのSELECT *は、誰も見なくてもそれらの列を読むコストを払います。必要な列だけを求めるのはスタイルではなく、I/Oの問題です。

設定で決まる上限
これらはエンジンではなくインスタンスに属するので、pg_settingsに現れます:max_connections(出荷時は100)、max_locks_per_transaction(64)、max_wal_size、work_mem。実際のサーバで尽きるのはこちらです。上の表のものは、ほとんど尽きません。

推奨事項
見張るのは2つだけです:何かが名前を生成するときの63バイトと、誰かがスキーマの半分を入れた複合インデックスを提案してきたときのインデックスあたりの列数。そのほかのことは、ドキュメントではなくpg_settingsで知ることができます。

キーワード: 制限、識別子、63バイト、切り詰め、1600列、32列、インデックス、TOAST、ページ、8 kB、32 TB、1 GB、pg_settings、max_connections

エンコーディングと照合順序(SQL Server)

varcharとnvarchar、Nプレフィックス、_UTF8の照合順序、各サフィックスの意味、照合順序の競合、そしてN'…'のパラメータでインデックスが使われなくなる理由。

対象: SQL Server 2022+

MySQLから来た人へ:ここにはutf8mb4もSET NAMESもありません。SQL Serverでは、テキストのエンコーディングは別に宣言するものではありません。それを決めるのは照合順序で、テキスト型には2つのファミリーがあります。

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のバイト数
ñ22
日本語96
絵文字1つ44

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つは入りません。

Nプレフィックス
前にNのないリテラルはvarcharで、どの列に届くよりも前にデータベースのコードページに変換されます。Latin1_General_CI_ASのデータベースで測定した結果:

- 'Zúrich ✓' → Zúrich ?
- N'Zúrich ✓' → Zúrich ✓

さらに、そのデータベースのvarchar列では、リテラルにNを付けても、Zúrich ✓ 日本はZúrich ? ??として保存されました。データは書き込みの時点で失われ、エラーも警告も出ません。あとから列の照合順序を変えても戻りません。保存されたものはすでに疑問符です。

各サフィックスの意味

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からのレガシーなものです。

テスト用サーバには5540の照合順序があり、そのうち1585が_UTF8です(sys.fn_helpcollations())。

サーバ、データベース、列、式

SELECT SERVERPROPERTY('Collation'), DATABASEPROPERTYEX('demo', 'Collation');

照合順序は4つのレベルで設定され、それぞれ作成されるときに1つ上のレベルのものを受け継ぎます:

- サーバ — 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を付ければ解決します。

最もよくあるのは一時テーブルです。#tmpはtempdbにあり、あなたのデータベースではなくサーバの照合順序で作られます。_UTF8のサーバ上のLatin1_General_100_CI_ASのデータベースで測定すると、#tmpとのJOINはMsg 468になりました。解決策は、その列をCOLLATE DATABASE_DEFAULTで宣言することです。

照合順序とインデックス
列にインデックスのある100,000行で:

- 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で届いているなら、列の照合順序を確認してください。

推奨事項
新しいデータベースには、varcharを完全なUTF-8にする_100_…_SC_UTF8の照合順序を。一時テーブルの列はCOLLATE DATABASE_DEFAULTで宣言します。そして照合順序はデータベースを作成するときに決めてください。あとから変えるのは列ごとの作業になります。

キーワード: エンコーディング、照合順序、COLLATE、VARCHAR、NVARCHAR、UTF8、UTF-16、Nプレフィックス、Unicode、_SC、_BIN2、468、451、457、2628、TEMPDB、DATABASE_DEFAULT、DATALENGTH

データ型(SQL Server)

datetimeとdatetime2、booleanの代わりのbit、moneyとdecimal、断片化しないGUID、日付ではないrowversion、8000バイトの上限、2025のjson型。

対象: SQL Server 2022+

MySQLから来た人へ:いくつかの型は名前が似ていて動作が違い、2つの名前は誤解を招きます。timestampは日付ではなく、datetimeは丸めます。存在しないものはエラーになり、それはよいことです。UNSIGNEDはなく、tinyintは0から255までしかなく(-1ではMsg 220、算術オーバーフローになります)、SELECT TRUEはMsg 207になります。TRUEは言語の語ではありません。

日付:datetimeではなくdatetime2
datetimeは古い型で、1/300秒単位に丸めます。測定結果:

書いた値datetimeに保存された値
12:34:56.00112:34:56.000
12:34:56.00212:34:56.003
12:34:56.00512:34:56.007
12:34:56.78912:34:56.790
2026-09-30 23:59:59.9992026-10-01 00:00:00.000

最後の行がレポートを壊すものです。datetimeに対するWHERE fecha <= '2026-09-30 23:59:59.999'は、翌日の午前0時を含みます。1日分を取るには、どの型でもfecha >= '2026-09-30' AND fecha < '2026-10-01'を使います。

datetime2は100ナノ秒単位まで保存し(datetime2(7)では.1234567が書いたとおりに残りました)、1年から始まり(datetimeで'1700-01-01'はMsg 242、範囲外になります)、同じかそれ以下の容量しか使いません。測定結果:datetimeは8バイト、datetime2(7)は8、datetime2(3)は7、datetime2(0)は6です。そして考えずに混ぜないでください。同じ12:00:00.001でも、datetime2(3)とdatetimeでは等しくありません。datetimeはすでに.000として保存しているからです。

測定したほかのサイズ:dateは3バイト、timeは5、smalldatetimeは4(分単位)、datetimeoffsetは10です。

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はそれを値自身の時刻で、横にオフセットを付けて表示します。

booleanはない:あるのはbit
bitは0か1の数値で、真偽値ではありません。測定結果:5、-1、文字列'true'を代入すると1が保存されます。WHERE @bだけではMsg 4145になり(WHERE @b = 1と書く必要があります)、bit + bitはMsg 402になります。Calíopeは1または0として表示します。

金額:decimal、そしてmoneyは注意して

DECLARE @m money = 100, @m3 money = 3;
DECLARE @d decimal(19,4) = 100, @d3 decimal(19,4) = 3;
SELECT @m / @m3 * @m3 AS con_money,     -- 99.9999
       @d / @d3 * @d3 AS con_decimal;   -- 100.000000

moneyには4桁の固定小数があり、途中の結果でもそうです。1 / 3は.3333になり、3を掛けても整数に戻りません。decimalは結果のスケールを広げます(.3333333333333333333になりました)。計算にはdecimalを使います。moneyは8バイト、smallmoneyは4バイトです。floatはどこでもそうであるように2進数です。0.1 + 0.2は0.30000000000000004になり、0.3との等価は偽でした。decimalでは真です。

GUID:uniqueidentifier、そして順序が大事
uniqueidentifierは16バイトです。クラスタ化主キーにすると、NEWID()は行をインデックス全体にランダムに散らします。20,000行を1行ずつ挿入して測定した結果:

キー断片化ページ数平均充填率
NEWID()98 %11662 %
NEWSEQUENTIALID()1 %7299.5 %
int IDENTITY2 %4398 %

NEWSEQUENTIALID()は列のDEFAULTとしてしか使えません。SELECT NEWSEQUENTIALID()はMsg 302になります。CalíopeはGUIDを、SQL Serverと同じく大文字で表示します。

rowversionは日付ではない
rowversion(以前はtimestampと呼ばれていた型で、列の作成時にはその名前も今も受け付けられます)は、行への書き込みのたびに変わる8バイトのデータベースのカウンタです。測定結果:2つの行が0x…07D4と0x…07D5で作られ、1つ目を更新すると0x…07D6に変わりました。時刻はまったく保存しません。これは楽観的な同時実行制御のためのものです。バージョンを読み、WHERE ver = @readで更新します。Calíopeはバイナリとして[8 bytes]と表示します。

テキスト:8000バイト、またはmax
max以外では、varchar(n)は8000まで、nvarchar(n)は4000までです。varchar(8001)はMsg 131に、nvarchar(4001)はMsg 2717になります。それを超えるものはvarchar(max)とnvarchar(max)です。

行にも上限があります。1つのテーブルにchar(5000)の列が2つあるとMsg 1701になります(最小の行が10,007バイトになり、最大は8060です)。いっぱいのvarchar(8000)2つなら入ります(測定結果:1行に16,000バイト)。可変長のデータは、収まらないときにページの外に移るからです。

そして警告の出ない落とし穴:varchar(max)に代入したREPLICATE('x', 10000)は8000文字になりました。関数は引数の型で動作するからです。REPLICATE(CAST('x' AS varchar(max)), 10000)なら10,000文字です。

JSON:json型はSQL Server 2025から
SQL Server 2022では、JSONはnvarchar(max)に保存し、CHECK (ISJSON(col) = 1)で検証します。これは不正なテキストをMsg 547で拒否します。2025からはjson型があり、書き込み時にMsg 13609で拒否し、解析済みの形で保存します。142文字の注文を20,000件コピーして測定すると、jsonは5.4 MB、nvarchar(max)は6.0 MBでした。ごく小さなドキュメント{"b":1,"a":2}では逆で、60バイトに対して26バイトでした。

そして目に見える違いが1つあります。キーが重複していると、ISJSONは{"b":1,"a":2,"a":3}を受け付けますが、json型はそれを{"b":1,"a":2}として返しました。最初のキーを残し、何も言いません。キーの順序は保持します。Calíopeはjsonをテキストとして受け取り、そのまま表示します。

特殊な型
- geographyとgeometry — WKBではない独自のバイナリ形式を持つ型です。geography::Point(40.4168, -3.7038, 4326)は22バイトで、そのSTAsText()はPOINT (-3.7038 40.4168)になります。コンストラクタは緯度、経度を求め、テキストは経度、緯度で出てきます。Calíopeはセルを[22 bytes]と表示します。読むには、クエリーでcol.STAsText()を使います。
- hierarchyid — ツリーの中のパスです。'/1/3/'は2バイトで保存され、レベルは2、親は/1/でした。Calíopeはこれをデコードせず、セルにはunsupported type: hierarchyidと表示されます。クエリーでcol.ToString()を使えば読めます。
- sql_variant — 複数の型の値を、それぞれ自分の型のまま保持する列です。SQL_VARIANT_PROPERTY(@v, 'BaseType')はdecimalを返しました。Calíopeは基本型で表示します。カタログ(sys.extended_properties)では意味がありますが、自分のテーブルでは、たいてい設計のまずい列です。

IDENTITYかSEQUENCEか
IDENTITYは列のプロパティで、列を保護します。手で値を挿入するとMsg 544になります。SET IDENTITY_INSERT tabla ONを使えば挿入でき、そのあとカウンタは最大値から続きます(100を挿入したあと、次の行は101でした)。そして欠番が出ます。ROLLBACKで取り消したINSERTは3を使い切り、次の行は4でした。欠番はデータの消失ではありません。

SEQUENCEは、複数のテーブルで共有できる独立したオブジェクトです。2つのテーブルでDEFAULT NEXT VALUE FOR sqを使うと、値は一方に1と3、もう一方に2と分かれました。挿入する前に番号が必要なとき、または共有するときに使います。テーブルのキーにはIDENTITYを使います。

推奨事項
時計の読みにはdatetime2、時点にはdatetimeoffset、金額にはdecimal、bitは数値であることを承知のうえで使い、クラスタ化キーがGUIDならNEWSEQUENTIALID()か整数を、そしてjson型は最も古いサーバが2025の場合にだけ使います。テキストについては「エンコーディングと照合順序(SQL Server)」を参照してください。

キーワード: 型、DATETIME、DATETIME2、DATETIMEOFFSET、DATE、TIME、BIT、BOOLEAN、MONEY、DECIMAL、FLOAT、UNIQUEIDENTIFIER、GUID、NEWSEQUENTIALID、ROWVERSION、TIMESTAMP、VARCHAR、NVARCHAR、MAX、JSON、ISJSON、HIERARCHYID、GEOGRAPHY、SQL_VARIANT、IDENTITY、SEQUENCE、UNSIGNED

エラー(SQL Server)

Msgの読み方、レベルが教えること、毎日目にする番号、トランザクションを中断するエラーとしないエラー、THROWを使ったTRY…CATCH、そして18456の理由がある場所。

対象: SQL Server 2022+

MySQLから来た人へ:ここにも番号があります(PostgreSQLのようなSQLSTATEはありません)。ただし、すべてのエラーは4つのデータを持ち、そのすべてが重要です。Calíopeは次のように1行で表示します。sqlcmdは2行に分けます:

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).

- Msg:番号です。調べるときも、コードで対処するときも、この番号を使います。
- Level:重大度で、0から25まであります。誰の責任か、そして接続がどうなるかを示します。
- State:同じ番号を持つ原因をサーバが区別するためのサブコードです。ほとんどのエラーでは関係ありませんが、18456では、理由を示す唯一のものです。
- Line:バッチ内(2つのGOの間にあるもの)またはプロシージャ内の行です。プロシージャ内のエラーはProcedure p_falla, Line 3のように表示されます。

まず見るべきはレベル

レベル意味測定結果
0–10情報:エラーではありませんRAISERROR('aviso', 10, 1)はMsgなしで出力され、CATCHには飛びません
11–16ユーザが直せるもの:データ、構文、権限1205は13、2627、2601、229、916は14、102は15、それ以外のほとんどは16
17–19リソース不足、またはサーバ内部の障害—
20–25致命的:サーバが接続を閉じますRAISERROR(…, 20, 1) WITH LOGでセッションが終了しました(Msg 2745)

20以上のレベルは、sysadminでなく、WITH LOGも付けずに手で発生させることはできません。Msg 2754になります。Calíopeはレベル20以上を接続の切断として扱います。

毎日目にするもの
SQL Server 2025に対して1つずつ発生させました:

Msg何が起きたか
2627PRIMARY KEYまたはUNIQUE制約でのキーの重複。メッセージには制約名と値が含まれます
2601一意インデックス(制約ではない)でのキーの重複
547外部キーまたはCHECKが拒否した。INSERT、DELETE、CHECKで同じ番号です
515NOT NULL列にNULL
2628テキストが収まらない。テーブル、列、切り詰められた値が含まれます
8152同じものですが詳細がありません。互換性レベル140以下で出るものです
245不可能な変換:CAST('hola' AS int)
8134ゼロ除算
208そのオブジェクトは存在しません
207その列は存在しません
102構文エラー(Incorrect syntax near 'FORM')
2812「ストアドプロシージャが見つかりません」:SELEC 1でこれになります。裸の単語で始まるバッチはEXECだからです
229オブジェクトに対する権限の拒否
916そのログインには、そのデータベースのユーザがありません
911存在しないデータベースへのUSE
1205デッドロックの犠牲者:Rerun the transaction
1222ロックを待っている間にLOCK_TIMEOUTが尽きた
3902BEGIN TRANSACTIONなしのCOMMIT

任意の番号のテキストを見るには:SELECT text FROM sys.messages WHERE message_id = 1205 AND language_id = 1033;。英語のメッセージは16,785件あり、言語は22あります。

各エラーが何を取り消すか:PostgreSQLから来た人への罠
PostgreSQLでは、エラーはトランザクション全体を中断します。ここではエラーによって異なり、ほとんどはその文だけを中断します。何もオンにしない状態で測定しました:

BEGIN TRAN;
INSERT padre VALUES (10, 'd@x.es', 1);
INSERT padre VALUES (10, 'e@x.es', 1);  -- Msg 2627
INSERT padre VALUES (11, 'f@x.es', 1);
COMMIT;
-- 行10と11が残った

トランザクションは開いたままで、COMMITはうまくいったものをコミットし、バッチは何事もなかったかのように続きました。測定した各エラーの結果:

- その文だけ:2627、2601、547、515、2628、8134、そして1222も。待機が尽きたあとも@@TRANCOUNTは1のままでした。
- バッチとトランザクション:245(変換)は何もオンにしなくてもトランザクションをロールバックし、バッチを打ち切りました。犠牲者側の1205も同じです。トリガー内のROLLBACKはMsg 3609になり、バッチを打ち切ります。
- 接続:レベル20以上。

SET XACT_ABORT ONで変わる:これをオンにすると、同じ2627がトランザクション全体をロールバックし、バッチを打ち切りました(行20と21は残りませんでした)。トランザクションを開くすべてのプロシージャの先頭に書くものです。

TRY…CATCHと、それが捕捉しないもの

SET XACT_ABORT ON;
BEGIN TRY
  BEGIN TRAN;
  -- …
  COMMIT;
END TRY
BEGIN CATCH
  IF @@TRANCOUNT > 0 ROLLBACK;
  THROW;
END CATCH;

CATCHの中では、ERROR_NUMBER()、ERROR_SEVERITY()、ERROR_STATE()、ERROR_LINE()、ERROR_PROCEDURE()、ERROR_MESSAGE()がエラーの詳細を返します(測定結果:2627、14、1、4、NULL)。そしてXACT_STATE()は、そのトランザクションで何ができるかを示します。XACT_ABORTなしでは1(コミットできる)、ありでは-1(ロールバックしかできない)でした。

TRYが捕捉しないもの:自分のスコープ内に存在しないオブジェクトです。TRY内のSELECT * FROM tabla_que_no_existeは、CATCHを通らずにMsg 208として出てきました。コンパイル時に失敗するからです。EXEC sp_executesqlの中なら捕捉されました。レベル10以下のものも捕捉しません。

THROWとRAISERROR
- CATCHの中で引数なしのTHROW;は元のエラーを再スローします。外に届いた番号は8134のままでした。RAISERROR(ERROR_MESSAGE(), 16, 1)で再スローすると50000に変わり、元の番号は失われました。
- RAISERRORはバッチを止めません。そのあとに書いたものは実行されました。THROWは止めます。
- THROW 50001, 'text', 1は独自のエラーを発生させます。常にレベル16で、番号は50000以上です。RAISERRORはsp_addmessageで登録した番号も受け付け、その引数を埋めます。RAISERROR(50100, 16, 1, 42)はEl pedido 42 no existeになりました。

テキストは言語で変わり、番号は変わらない
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)、こちらは理由を示します。誰かが「入れない」とき、外からは同じ文に見えるものを、サーバのログなら一瞬で見分けられます。

Calíopeが表示するもの
文が失敗すると、Calíopeはサーバのメッセージを結果タブに全文で、上のMsg, Level, State, Lineの形で表示し、すべてのメッセージを1行に1つずつ表示します。制約名が重複したCREATE TABLEは2714に続けて1750を返し、どちらにも意味があります。それらのエラーはそのタブにとどまります。接続を妨げるもの(18456、4060)と、接続を切断するレベル20以上のものは、Calíopeのエラーログにも届きます。情報メッセージ(PRINT、レベル10のRAISERROR)は表示されません。

推奨事項
トランザクションを開くすべてのプロシージャで、SET XACT_ABORT ONと、IF @@TRANCOUNT > 0 ROLLBACK; THROW;を含むTRY…CATCHを使います。アプリケーションでは1205を再試行し(サーバがすでにロールバックしたトランザクション全体を再試行します)、データやプログラムの問題であるレベル14〜16は再試行しません。常に番号を保存し、テキストは保存しません。そして18456に直面したら、パスワードに触る前にサーバのログを読んでください。

キーワード: エラー、MSG、レベル、重大度、状態、18456、1205、2627、2601、547、208、102、2628、8152、515、229、916、4060、1222、TRY、CATCH、THROW、RAISERROR、xact_abort、xact_state、error_number、SYS.MESSAGES、xp_readerrorlog、再試行

インデックス(SQL Server)

テーブルそのものであるクラスタ化インデックス、キー検索とINCLUDEでそれをなくせる場合、フィルタ付きインデックス、式インデックスの代わりの計算列、列ストア、FILLFACTORと断片化、そして誰も使わないインデックス。

対象: SQL Server 2022+

MySQLから来た人へ:いちばん近いのはInnoDBです。ここでもテーブルはインデックスです。違うのは、そのインデックス(クラスタ化インデックス)を自分で選べて、主キーである必要がないこと、テーブルがそれを持たないこともあること、そしてプレフィックスインデックスや不可視インデックスの代わりに、フィルタ付きインデックス、計算列、列ストアがあることです。

以下はすべて、200,000行、約32 MBのテーブルで測定したものです。

テーブルはクラスタ化インデックス
PRIMARY KEYは、テーブルにまだなければクラスタ化インデックスを作成します。行はそのキーの順に保存されます。クラスタ化インデックスのないテーブルはヒープ(sys.indexesではHEAP)で、行は収まるところに置かれます。

MySQLからは見えないのがその代償です。クラスタ化キーはすべての非クラスタ化インデックスの中に入ります。インデックスはそれを使って行を見つけるからです。cliente_idに対する同じインデックスが、クラスタ化キーがintなら2,952 kB、uniqueidentifierなら5,416 kBでした。狭く、常に増えていくクラスタ化キーは、ほかのすべてのインデックスを安くします。NEWID()がクラスタ化キーとして良くない理由は、「データ型(SQL Server)」の項目で説明しています。

キー検索と、INCLUDEが直すもの
非クラスタ化インデックスが持つのは、自分の列とクラスタ化キーだけです。クエリーがそれ以外の列を求めると、見つかった行ごとにテーブルに戻らなければなりません。それがキー検索(Key Lookup)です。

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

INCLUDEは、その列で並べ替えずにインデックスのリーフに保存します。ここではインデックスが2,952 kBから4,752 kBに増えました。

キー検索は1行ごとに払うので、ごく早い段階で割に合わなくなります。測定結果:992行(テーブルの0.5 %)ではプランはまだインデックスとキー検索を使い、1,483行(0.7 %)ではもうテーブル全体をスキャンしていました。「使われない」インデックスは、たいていINCLUDEが足りないインデックスです。

左端のプレフィックスの規則
(fecha, cliente_id)のインデックスとWHERE cliente_id = 77では、SQL Serverはインデックスをたどってシークできません。PostgreSQLと同じく、テーブルより安ければインデックス全体をスキャンできます:Index Scanで論理読み取りは809。Index Seekではなく、列の順序はやはり重要です。

フィルタ付きインデックス
インデックスにはWHEREを付けられ、その場合は条件を満たすものだけをインデックスにします:

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の列に関数をかけると、インデックスをたどってシークできなくなります。ある1か月の同じ14,400行で:

- 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)、計算列の名前を書かずに

そのインデックスを作成するときも、そのあとテーブルに書き込むときも、セッションでQUOTED_IDENTIFIERとANSI_NULLSがオンになっている必要があります。sqlcmdは-Iを渡さないと1つ目をオフにするので、Msg 1934で失敗します。

集計には列ストア
列ストアインデックスは、各列を別々に圧縮して保存します。同じテーブルが、通常のクラスタ化インデックスでは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;

ランダムな値のcliente_idに対するインデックスを、段階ごとに測定しました:

時点断片化ページ数充填率
作成直後8 %35298 %
50,000件の挿入後99 %69662 %
REORGANIZE後0.5 %43499.6 %
REBUILD後0 %43399.8 %
REBUILD WITH (FILLFACTOR = 70)0 %61870 %
さらに50,000件の挿入後0 %61884 %

FILLFACTORは、挿入がページを分割せずに収まるように各ページに余白を残します。初日から容量を使い、あとの断片化を防ぎます。インデックスに保存されたまま残る(sys.indexes.fill_factor)ので、以降のREBUILDでも繰り返されます。REORGANIZEはブロックせずに圧縮し、中断できます。REBUILDはインデックス全体を作り直し、ONLINEなしでは実行中テーブルをロックします。

テーブルを止めずに再構築する

ALTER INDEX ix_cliente ON dbo.pedidos REBUILD WITH (ONLINE = ON, RESUMABLE = ON);

ONLINE = ONはEnterpriseエディションのものです(それと同じDeveloperエディションと、Azure SQLにもあります)。Standardには存在せず、REBUILDは実行中に書き込みを止めます。クラスタ化インデックスなら読み取りも止めます。RESUMABLE = ONで一時停止してあとから続けられ、ONLINE = ONが必要です(なければMsg 11438)。そしてONLINE = ONが失敗するケースが2つあります。テーブルに空間インデックスがある場合と、ALTER INDEX ALLの場合です。

不可視インデックスはない:代わりに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;

PostgreSQLよりも注意が必要です。このビューはメモリー上にあり、サーバを再起動すると空になります。測定結果:コンテナを再起動すると、そのテーブルの行は1つもなくなりました。インデックスを無効にしてもリセットされますが、REBUILDではリセットされません。月曜日に読み取り0のインデックスは、まだ誰も使う時間がなかっただけかもしれません。

もう半分は足りないものです。オプティマイザは、欲しかったインデックスをsys.dm_db_missing_index_detailsに記録します。cliente_idとestadoに対するクエリーのあと、推定効果99.6 %で[cliente_id], [estado]を提案しました。これはヒントであって命令ではありません。すでにあるインデックスを見ず、書き込みを考慮せず、空になる同じメモリー上にあります。

推奨事項
狭く、常に増えていくクラスタ化キー。検索や削除に使う外部キーごとにインデックス。プランにキー検索が出たら、別のインデックスより先にINCLUDE。そして「使われていない」インデックスを削除する前に、サーバがどれくらい前に起動したかを確かめてください(sys.dm_os_sys_infoのsqlserver_start_time)。

キーワード: インデックス、クラスタ化、非クラスタ化、ヒープ、キー検索、INCLUDE、フィルタ付きインデックス、計算列、列ストア、FILLFACTOR、断片化、REORGANIZE、REBUILD、ONLINE、RESUMABLE、DISABLE、dm_db_index_usage_stats、dm_db_missing_index_details、dm_db_index_physical_stats

制限(SQL Server)

実際にぶつかる制限(1行あたり8,060バイト、インデックスキーの900バイトと1,700バイト、1,024列、128文字の名前、2,100個のパラメータ)、テーブルの作成時には警告するだけであとで失敗するもの、そしてExpressエディションが決めるもの。

対象: SQL Server 2022+

SQL Serverの制限はInnoDBのものとはあまり似ておらず、日々ぶつかるのは大きなものではありません。SQL Server 2025に対して1つずつ発生させました。

実際にぶつかるもの

制限値超えたときに起きること
1行あたりのバイト数(ページ内)8,060固定長の列が収まらなければMsg 1701
クラスタ化インデックスのキー900バイト作成時に警告、挿入時にMsg 1946
非クラスタ化インデックスのキー1,700バイト作成時に警告、挿入時にMsg 1946
1テーブルあたりの列数1,024Msg 1702
インデックスキーの列数32Msg 1904
1テーブルあたりの非クラスタ化インデックス数999Msg 1910
名前の長さ128文字Msg 103
#一時テーブルの名前116文字Msg 193
1つのプロシージャまたは要求あたりのパラメータ数2,100Msg 180
プロシージャ、関数、トリガーの入れ子32レベルMsg 217
SELECTの列数4,096Msg 1056

行はページに収まる。可変長のものを除いて
ページは8 KBで、行はそこに収まらなければなりません:8,060バイトです。固定長の列なら、サーバはテーブルの作成時にこれを確かめます。char(5000)の列が2つあるとMsg 1701になり、行の最小サイズが10,007バイトになると伝えます。

可変長の列では確かめません。同じ行に、いっぱいまで入れたvarchar(8000)の値を2つ(16,000バイト)、何のエラーもなく保存できました。収まらないものは別のページ、ROW_OVERFLOW_DATAに移されます。sys.allocation_unitsには、行内のページが2つ、オーバーフローのページが2つありました。動作はしますが、その行を読むとオーバーフローした列ごとに読み取りが1回増え、設計からはそれが起きていることがわかりません。

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;

varchar(max)、nvarchar(max)、varbinary(max)は2 GBまで入り、同じようにして行の外に出ます。varchar(8001)が存在せずmaxに飛ばなければならない理由は、「データ型(SQL Server)」の項目で説明しています。

警告するだけの制限:インデックスキー
これがいちばん意外なものです。制限より幅の広い列に対するインデックスは、警告とともに作成されます:

Warning! The maximum key length for a clustered index is 900 bytes. The index 'cx' has maximum length of 1000 bytes.

警告ではデプロイは止まりません。テーブルは、誰かが長い値を保存する日まで動きます。900バイトは入り、901バイトでMsg 1946になりました。非クラスタ化インデックスでは制限は1,700バイトで、nvarcharでは1文字が2バイトなので、1,700文字ではなく850文字です。varchar(1000)に対するPRIMARY KEYはクラスタ化インデックスで、同じように動作します。

長い列で等価検索が必要なら、うまくいくのはダイジェストをインデックスにすることです。CHECKSUMかHASHBYTESを使った計算列にインデックスを作成し、完全な値も比較します。

名前:切り詰めではなくエラー
129文字の名前はMsg 103になり、文は実行されません。PostgreSQLは63バイトに切り詰めて先に進みますが、SQL Serverは名前を通しません。ローカルの一時テーブルの名前はもっと短く、116文字です。サーバがセッションを区別するための接尾辞を付けるからです。

パラメータ: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バイトに収まらなければなりません。

入れ子:32レベル
自分自身を呼び出すプロシージャは@@NESTLEVELが32に達し、次のレベルでMsg 217になりました。プロシージャ、関数、トリガー、ビューはまとめて数えられます。トリガーのある別のテーブルを更新するトリガーは、誰にも見えないままレベルを使い切ります。

大きなもの。ほとんど問題にはならない
これらは測定していません(テストベッドはDeveloperエディションで、測定するにはディスクをいっぱいにする必要があります)。Microsoftのドキュメントの数値を示します:

- データベースのサイズ:524,272 TB。データファイル1つ:16 TB。
- 1テーブルあたりの行数:ストレージ以外の制限はありません。
- バッチのサイズ:ネットワークパケットサイズの65,536倍(デフォルトは4,096バイトなので256 MB)。
- 接続数:32,767(@@MAX_CONNECTIONS)で、user connectionsが0ならほかの上限はありません。

エディションが決めるもの
ここには実際にぶつかる制限があり、それはエンジンではなくライセンスから来ます。これも測定していません。テストベッドはDeveloperで、制限がないからです。

- Express:SQL Server 2022ではデータベースあたり10 GB、2025では50 GB。バッファプールのメモリーは約1.4 GB。1ソケットか4コアの少ないほう。サイズに達するとデータベースは大きくならず、書き込みが失敗します。
- Standard:データベースのサイズに上限はありませんが、メモリーとコアには上限があり、ONLINE = ONのREBUILDのような一部のEnterpriseの機能がありません。

接続しているサーバがどれかは、SELECT SERVERPROPERTY('Edition')でわかります。

推奨事項
3つに注意してください。テキストの列にインデックスを作るときのインデックスキー(警告するだけだから)、何かがINのリストを生成するときの2,098個のパラメータ、そしてサーバがExpressならデータベースのサイズです。それ以外はエラー番号で知ることになり、その番号は常に何が制限なのかを示しています。

キーワード: 制限、最大、8060、行、ページ、row_overflow_data、オーバーフロー、インデックスキー、900バイト、1700バイト、1024列、SPARSE、column_set、128文字、識別子、一時テーブル、2100パラメータ、sp_executesql、NESTLEVEL、入れ子、Express、10 GB、50 GB、エディション、Msg 1946、Msg 1702、Msg 180

ロックとデッドロック(SQL Server)

ここではなぜSELECTがUPDATEを待つのか、READ_COMMITTED_SNAPSHOTで何が変わるか、誰が誰をブロックしているかの見方、1205のグラフが保存される場所、そして行ロックがいつテーブルロックに変わるか。

対象: SQL Server 2022+

MySQLやPostgreSQLから来た人がまずつまずくのはこれです:SQL Serverでは、デフォルトでSELECTがUPDATEを待ちます。InnoDBとPostgreSQLは行の最後にコミットされたバージョンを読んで先に進みますが、SQL Serverは素のREAD COMMITTEDでは共有ロックを要求し、相手がコミットするかロールバックするまで待ちます。ここで説明することはすべて、2つのセッションを同時に開いてSQL Server 2025に対して測定したものです。

待つSELECT

-- セッションA
BEGIN TRAN;
UPDATE dbo.cuentas SET saldo = 50 WHERE id = 1;

-- セッションB
SELECT saldo FROM dbo.cuentas WHERE id = 1;

セッションBは7.5秒間止まっていました。ちょうどAがROLLBACKするまでにかかった時間で、その行のキーに対するLCK_M_Sで待っていました。そしてAは何もしている必要はありません。トランザクションを開いて書き込み、そのまま黙ったセッション(statusがsleeping、open_transaction_countが1)でも同じようにブロックします。これが日常のケースです:COMMITを忘れたアプリケーション。

READ_COMMITTED_SNAPSHOT:ほかの2つと同じように読む

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();

1つ目は、実際に知りたいことに答えます。blocking_session_idは、このセッションが求めているものを握っているセッションです。2つ目ははしご全体を見せます。行を更新する側はキーにX、ページとテーブルにIXを持ち、待っている側はrequest_statusがWAITで表示されます。PostgreSQLと違い、ここでは行ロックが実際に一覧に、1行に1つずつ表示されます。

Calíopeの「プロセス」の一覧は「状態」列に待機を表示しますが(ブロックされたセッションはLCK_M_S、LCK_M_U、LCK_M_Xで表示されます)、誰がブロックしているかは表示しません。それは1つ目のクエリーでわかります。そして「KILL CONNECTION」は、ここでほかのセッションのロックを解放する唯一の方法です。KILLはセッション全体を閉じてそのトランザクションをロールバックします。SQL Serverでは、ほかのセッションのクエリーだけをキャンセルすることはできないからです。

UPDLOCK:ここでのFOR UPDATE
SELECT … FOR UPDATEはありません。その役目はヒントが果たします:

BEGIN TRAN;
SELECT saldo FROM dbo.cuentas WITH (UPDLOCK, ROWLOCK) WHERE id = 1;
UPDATE dbo.cuentas SET saldo = saldo - 100 WHERE id = 1;
COMMIT;

これはUロックを取ります。ほかのセッションからの通常の読み取りは待たずに通り、UPDLOCK付きの別の読み取りは待たされました。そして、いちばんばかげたデッドロックを防ぎます。REPEATABLE READの2つのセッションが同じ行を読んでから更新すると1205で終わりましたが、読み取りにUPDLOCKを付けると、2つとも順番に完了しました。

ここでも待機は無期限
@@LOCK_TIMEOUTは-1、つまり制限なしです。PostgreSQLのlock_timeoutと同じで、InnoDBの50秒とは違います。セッションごとに、ミリ秒単位で設定します:

SET LOCK_TIMEOUT 3000;

発動するとMsg 1222になり、何もロールバックしません。トランザクションは開いたままで、受け取った側がロールバックしなければなりません。各エラーが何をロールバックするかは、「エラー(SQL Server)」の項目で説明しています。

わざと待たない

SELECT saldo FROM dbo.cuentas WITH (NOWAIT) WHERE id = 3;

SELECT id FROM dbo.cuentas WITH (READPAST) WHERE id BETWEEN 1 AND 5;

NOWAITは同じ1222ですぐに失敗し、バッチは続きます。READPASTはここでのSKIP LOCKEDです。ほかのセッションが行3をロックしているとき、1、2、4、5を返しました。複数のコンシューマが互いを待たずに作業キューを共有する方法です。

NOLOCKは存在しなかったものを読む

SELECT saldo FROM dbo.cuentas WITH (NOLOCK) WHERE id = 3;

ほかのセッションが、あとでロールバックしたUPDATEの途中にあるとき、この読み取りは50を返しました。一度もコミットされなかった残高です。NOLOCKはREAD UNCOMMITTEDであり、「速く読む」ことではありません。ほかのトランザクションがまだ捨てるかもしれないものを読むことです。ドキュメントによると、読んでいる間にページが分割されると、行を飛ばしたり2回読んだりすることがあります。私はそれを再現していません。待たない読み取りが欲しいなら、答えはREAD_COMMITTED_SNAPSHOTです。待たず、コミットされたデータだけを見ます。

命取りの抱擁

-- セッション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つ見つけた直後はもっと頻繁に探すのです。

どちらが倒れるかは決められます。セッションAでSET DEADLOCK_PRIORITY HIGHにすると、3回ともBが犠牲者になりました。LOWは繰り返せるバッチ処理に向いています。1205は障害ではありません。アプリケーションがそのトランザクションを再試行しなければなりません。

グラフはすでに保存されている
オンにするものは何もありません。最初から動いているsystem_health拡張イベントセッションが、すべてのデッドロックをそのグラフとともに保存しています。誰が倒れたか、それぞれがどの文を実行していたか、どのインデックスのどのキーを待っていたか。

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';

測定したことが2つあります。ファイルは遅れます。6つ発生させた直後には2つしかなく、数秒後にはすべてありました。そしてすぐにそれらを持っているセッションのring_bufferはメモリー上にあります。サーバを再起動すると、新しいものは持っていましたが以前のものは失っていて、ファイルにはまだ残っていました。

行からテーブルへ:エスカレーション
ロックはそれぞれメモリーを使い、1つの文が1つのテーブルにロックを溜めすぎると、SQL Serverはそれをテーブル全体に対する1つのロックに置き換えます。20,000行のテーブルの先頭N行を更新して数えました:
- 6,200行までは、キーに対するXロックが6,200個。
- 6,300行からは1つだけ:ページを経ずにテーブルにX。そして、誰も触れていない行20,000を取りに行った別のセッションは、LOCK_TIMEOUTまで待ちました。

ドキュメントの数値は5,000で、ここでは少し遅れて切り替わりました。REPEATABLE READでの8,000行のSELECTも、テーブルにSという同じ結果になりました。これはテーブルごとに制御します:

ALTER TABLE dbo.cuentas SET (LOCK_ESCALATION = DISABLE);

これで、20,000行すべてに対する同じUPDATEは、20,000個のキーロックを持ち、テーブルには1つも持ちませんでした。TABLEがデフォルトです。AUTOは、ドキュメントによると、パーティション分割されたテーブルではパーティションにエスカレーションします。オフにするのはただではありません(ロックはどれもサーバのメモリーです)。よくある解決策は別のものです:5,000行未満のまとまりで書き込むことです。

アプリケーションロック
PostgreSQLのアドバイザリーロックに相当するものです。アプリケーションの2つのプロセスが同じことを同時にしないように、サーバが保持する名前です。

DECLARE @r int;
EXEC @r = sp_getapplock @Resource = 'cierre-diario', @LockMode = 'Exclusive',
     @LockOwner = 'Session', @LockTimeout = 0;
-- …
EXEC sp_releaseapplock @Resource = 'cierre-diario', @LockOwner = 'Session';

リソースをほかのセッションが保持していると、待たずに-1を返しました。0は「許可された」という意味です。エラーにはならないので、番号を確かめる必要があります。デフォルトの@LockOwner = 'Transaction'では、トランザクションが終わると自動で解放され、トランザクションの外で要求すると-999を返しました。

推奨事項
どのエンジンでもそうであるように、行には常に同じ順序で触れ、トランザクションは短くします。ここに固有なのは3つです。アプリケーションが許すならすぐにREAD_COMMITTED_SNAPSHOTをオンにすること(読み取りが書き込みを待たなくなり、NOLOCKの誘惑がなくなります)。UPDATEで終わる読み取りにはUPDLOCK。そして1205の再試行を、本番環境に出る前に書いておくことです。

キーワード: ロック、デッドロック、ブロッキング、1205、1222、LCK_M_S、blocking_session_id、dm_tran_locks、dm_exec_requests、read_committed_snapshot、RCSI、NOLOCK、READPAST、UPDLOCK、NOWAIT、lock_timeout、deadlock_priority、エスカレーション、lock_escalation、system_health、sp_getapplock

オンラインでのスキーマ変更(SQL Server)

DDLはトランザクション対応で、コストはSch-Mロックとそれが作る待ち行列であること、テーブルを書き直すALTER、ONLINE、RESUMABLE、WAIT_AT_LOW_PRIORITY、そしてここではなぜWITH NOCHECKでロックが軽くならないか。

対象: SQL Server 2022+

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;

そのROLLBACKのあとには何も残りませんでした。列も、インデックスも、テーブルもなく、列の名前はnotaに戻っていました。移行全体が1つのトランザクションに収まります。見つかった例外:CREATE DATABASEとALTER DATABASE(Msg 226)、そしてRESUMABLEのインデックス(Msg 574)はトランザクションの中に入れられません。

コストは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;

3秒でALTERはMsg 1222になり、そのうしろのSELECTはその瞬間に通りました。あとで再試行します。

テーブルを書き直すもの、書き直さないもの
200,000行(1,364ページ)で測定しました。証拠は、それぞれの文が書いたトランザクションログです:

文時間ログ書き直す?
ADD c int NULL4 ms1 kBいいえ
ADD c int NOT NULL DEFAULT 720 ms2 kBいいえ
ADD c datetime2 NOT NULL DEFAULT SYSDATETIME()< 1 ms2 kBいいえ
ADD c uniqueidentifier NOT NULL DEFAULT NEWID()259 ms38 MBはい
ALTER COLUMN estado varchar(100)(元は50)4 ms< 1 kBいいえ
ALTER COLUMN estado varchar(20)(元は50)237 ms36 MBはい
ALTER COLUMN estado nvarchar(50)(元はvarchar)244 ms38 MBはい
ALTER COLUMN n bigint(元はint)228 ms36 MBはい
ALTER COLUMN importe decimal(12,2)(元は10,2)99 ms1.5 kBいいえ
ALTER COLUMN importe decimal(20,2)(元は10,2)246 ms38 MBはい
ALTER COLUMN nota varchar(200) NOT NULL(元はNULL)241 ms36 MBはい
ALTER COLUMN cliente int NULL(元はNOT NULL)4 ms< 1 kBいいえ
DROP COLUMN nota5 ms1 kBいいえ

読み方:
- すべての行で同じデフォルト(定数、または一度だけ評価されるSYSDATETIME())はメタデータに保存され、行には触れません。NEWID()は行ごとに違う値を与えるので、すべての行を書かざるを得ません。
- varcharを広げるのはただです。狭める、ファミリーを変える(nvarchar、max)、整数を大きくする、NOT NULLにする、decimalのディスク上のサイズを変える、これらはどれも書き直します。decimal(10,2)とdecimal(12,2)は同じ9バイトを使うので、あの変更は書き直しませんでした。
- 書き直すものはすべて、テーブルを2倍のサイズにします。それらの文のあと、テーブルは1,364ページから2,723ページになり、そのままでした。古い列がすべての行で場所を取ったままだからです。int → bigintのあと、ALTER TABLE dbo.pedidos REBUILDで1,495ページになりました。そしてDROP COLUMNが一瞬で終わるのは何も解放しないからです。前もあとも1,364ページで、REBUILDのあとで1,075ページでした。

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ではエラーになります。

WAIT_AT_LOW_PRIORITY:待ち行列を作らずに待つ
これはPostgreSQLにはありません。再構築は別の待ち行列でロックを待ち、うしろに来るものの邪魔をしません:

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;

4秒で構築を一時停止しました。それを始めたセッションは接続を切断され(Msg 1219)、sys.index_resumable_operationsはPAUSEDと5 %を示し、インデックスはまだ存在しませんでした(sys.indexesにありませんでした)。RESUMEが残りを45秒で終えました。巨大なインデックスを、いくつかのメンテナンス時間帯に分けて作れます。ONLINE = ONが必要で(なければMsg 11438)、トランザクションの中には入れられず(Msg 574)、ドキュメントによると、SQL Server 2022からは主キーまたは一意キーのALTER TABLE … ADD CONSTRAINTでも使えます。2025では一意キーで確かめました。

WITH NOCHECK:ここではロックは軽くならない
PostgreSQLのNOT VALID + VALIDATEのパターンにはここにも双子がありますが、同じことはしません。3,000,000行で:

ALTER TABLE dbo.grande WITH NOCHECK ADD CONSTRAINT ck_id CHECK (id > 0);  -- 13 ms
ALTER TABLE dbo.grande WITH CHECK CHECK CONSTRAINT ck_id;                 -- 346 ms

一度に追加すると375 msかかりました。そして3つとも、検証も含めて、テーブルにSch-Mを取りました。2つに分けるとスキャンは分散されますが、PostgreSQLのように後半のためのもっと弱いロックはありません。

そして罠を残します。WITH NOCHECKで追加した制約は信頼されていない(is_not_trusted = 1)ので、オプティマイザはそれに頼りません。信頼されたCHECK (importe >= 0)があれば、WHERE importe < 0はテーブルを読みさえしませんでしたが、信頼されていないと論理読み取りは1,364でした。無効にしてからWITH CHECKなしで有効に戻したときも同じです(ALTER TABLE … CHECK CONSTRAINT ck_importeのあとも信頼されていないままでした)。一括読み込みがよくやることです。それを見つけるには:

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 …))を書いたほうがよいでしょう。

推奨事項
すべてのALTER TABLEの前にSET LOCK_TIMEOUT。移行はトランザクションの中で。インデックスにはWAIT_AT_LOW_PRIORITY … SELF付きのONLINE = ON。それぞれのDEFAULTにはきちんとした名前を。そして型の変更には注意してください。テーブルを書き直し、次のREBUILDまで2倍のサイズのままにします。大きな列を狭めたりファミリーを変えたりする必要があるなら、たいていは新しい列を追加し、まとまりごとにコピーして、sp_renameで名前を変えるほうがうまくいきます。

キーワード: DDL、ALTER TABLE、移行、トランザクション対応、ロールバック、SCH-M、LCK_M_SCH_M、lock_timeout、ONLINE、RESUMABLE、wait_at_low_priority、abort_after_wait、REBUILD、書き直し、ADD COLUMN、ALTER COLUMN、DROP COLUMN、WITH NOCHECK、is_not_trusted、sp_rename

パーティション分割(SQL Server)

パーティション関数とパーティション構成、オプティマイザが実際に除外するもの、一意インデックスがキーを含まなければならない理由、ミリ秒で削除するためのSWITCHとTRUNCATE、データの読み込みに必要な信頼されたCHECK、そして空のパーティションでしかただではないSPLITとMERGE。

対象: SQL Server 2022+

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);

関数が境界を決め、構成がそれぞれの部分をどのファイルグループに置くかを示します。3つの境界で4つのパーティションになります。2024年より前のすべて、続く2年のそれぞれ1年分、そして2026年以降のすべてです。LISTやHASHはなく、範囲だけです。そして未来を予測する必要はありません。最初の境界より前や最後の境界よりあとに来るものにも、常に置き場所があります。

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'138
同じ範囲を変数かパラメータで138
cliente = 424つ中4つ—
YEAR(creado) = 20244つ中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つだけ読みます。

設計の規則はほかのエンジンと同じです:パーティションキーは、ほとんど毎回フィルタに使う列です。クエリーがそれに触れなければ、パーティション分割は読み取りを減らさず、散らすだけです。

アラインされたインデックスと一意性
ONなしで作成したインデックスはテーブルの構成を受け継ぎます。アラインされたインデックスで、パーティションごとに1つの部分を持ちます。その結果はPostgreSQLと同じです:

CREATE UNIQUE INDEX ux_id ON dbo.ventas (id);

これはMsg 1908を返しました。パーティション分割の列は、一意インデックスのキーの一部でなければなりません。ここにはPostgreSQLにはない逃げ道があります(ON [PRIMARY]でアラインされていないインデックスとして作成する)が、代償があります。そのようなインデックスがあると、下で説明するSWITCHが動かなくなります(Msg 7733)。だからdbo.ventasの主キーは(id)ではなく(creado, id)なのです。

SWITCHとTRUNCATE:1年分をミリ秒で削除する
2023年のすべて、100,009行を削除するのを、トランザクションの中で測定しました:

操作時間ログ
DELETE … WHERE creado < '2024-01-01'180 ms21 MB
TRUNCATE TABLE dbo.ventas WITH (PARTITIONS (1))4.9 ms24 kB
ALTER TABLE dbo.ventas SWITCH PARTITION 1 TO dbo.ventas_2023< 1 ms1.7 kB

SWITCHはデータを動かしません。1つの部分を丸ごと別の持ち主に渡します。だから移動先のテーブルに多くを求め、それぞれの条件にはエラーがあります:
- 空であること:1行あるとMsg 4905。
- 同じ列と同じインデックス:列が1つ多いとMsg 4943。
- そして、ドキュメントによると、パーティションと同じファイルグループにあること。

データの読み込みには信頼されたCHECKが必要
これがPostgreSQLとの違いです。PostgreSQLではCHECKはスキャンを1回省くだけです。CHECKなしでテーブルをパーティションに戻すと、時間が長くかかるのではなく、Msg 4982で拒否されます。制約があると9.5 msで入りました:

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;

そして信頼されている必要があります。無効にしてからWITH CHECKなしで有効に戻すと(多くの一括読み込みがすること)、SWITCHはMsg 4972を返しました。WITH CHECK CHECK CONSTRAINTのあとは入りました。こうして稼働中のテーブルに触れずに新しい期間を読み込みます。別のテーブルを埋め、CHECKを付けて、SWITCHで入れるのです。

SPLITとMERGE:空のパーティションでだけただ
境界は関数で動かします:

ALTER PARTITION SCHEME ps_anio NEXT USED [PRIMARY];
ALTER PARTITION FUNCTION pf_anio() SPLIT RANGE ('2027-01-01');
操作時間ログ
空のパーティションでのSPLIT< 1 ms3.5 kB
データの入った2024年のパーティションでのSPLIT RANGE ('2024-07-01')347 ms11 MB
その2つの半分のMERGE167 ms6.2 MB
空のパーティションとのMERGE< 1 ms3.3 kB

データの入ったパーティションを分割すると、境界の片側にある行が移動し、それぞれがログを書きます。コストは移動するものに比例して増えます。健全な習慣は、常に最後に空のパーティションを1つ置いておき、次の期間のデータが来る前にそれを分割することです。そしてSPLITはそのたびにNEXT USEDを使い切ります。もう一度宣言しないと、次のSPLITはMsg 7710を返し、何も変えませんでした。

パーティションごとのメンテナンス
変わるパーティションだけを再構築します:

ALTER INDEX pk_ventas ON dbo.ventas REBUILD PARTITION = 2 WITH (ONLINE = ON);

4つすべての214 msに対して、パーティション1つなら33 ms(オンラインで148 ms)でした。締めた年にはもう書き込みがありません。毎晩再構築する理由はありません。

ロックのエスカレーションをパーティションごとに
デフォルト(LOCK_ESCALATION = TABLE)では、2024年の16,440行のUPDATEがテーブル全体のXロックにエスカレーションし、ほかのセッションから2025年の行を更新するとMsg 1222になりました。次のようにすると

ALTER TABLE dbo.ventas SET (LOCK_ESCALATION = AUTO);

エスカレーションはパーティションにとどまり(HOBTにX)、2025年の行は9 msで更新されました。ドキュメントは、異なるパーティションでエスカレーションするセッションの間でAUTOがデッドロックを招くことがあると警告しています。エスカレーションのそれ以外のことは「ロックとデッドロック(SQL Server)」にあります。

SWITCHもSch-Mを取る
1行だけに触れたトランザクションが開いていると、SWITCHはSch-Mを待ち続け、そのあとに実行した別の行のSELECTはSWITCHのうしろの待ち行列で待ちました。対策はインデックスと同じです:

ALTER TABLE dbo.ventas SWITCH PARTITION 1 TO dbo.ventas_2023
    WITH (WAIT_AT_LOW_PRIORITY (MAX_DURATION = 1 MINUTES, ABORT_AFTER_WAIT = SELF));

うしろのSELECTは0.5秒で終わり、SWITCHは61秒後にMsg 1222であきらめ、トランザクションには手を出しませんでした。その待ち行列のそれ以外のことは「オンラインでのスキーマ変更(SQL Server)」にあります。

制限とエディション
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。最後には常に空のパーティションを置いて、ただで分割できるようにします。そして、クエリーがパーティションを除外しているかどうかを、思い込まずに実際のプランで確かめてください。

キーワード: パーティション分割、パーティション、パーティション関数、パーティション構成、RANGE RIGHT、RANGE LEFT、$PARTITION、パーティションの除外、アライン、SWITCH、TRUNCATE、SPLIT RANGE、MERGE RANGE、NEXT USED、lock_escalation、1908、4982、7733

パフォーマンスと診断(SQL Server)

時間ではなく論理読み取り、推定プランと実際のプラン、パラメータスニッフィングと測定したその対策、「スロークエリー」が読むプランキャッシュ、最初からオンになっているクエリーストア、そして本当に何かを語る待機。

対象: SQL Server 2022+

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;

論理読み取り1回は、メモリーから読んだ8 kBのページ1つです。比べるべき数値はこれです。時間は負荷やキャッシュで変わりますが、これは変わらないからです。ある顧客の20件の注文で:
- インデックスなし:論理読み取り6,085(テーブル全体)、12 ms。
- cliente_idにインデックスあり:読み取り62。

Calíopeではそれらの行は表示されません。サーバはそれを情報メッセージとして送り、エディタが表示するのは行とエラーで、メッセージではないからです。同じ数値は、クエリーを実行した直後にプランキャッシュから読めます:

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で囲んでください。

Calíopeの「実行計画ビューア」は推定プランを要求します。推定行数の演算子ツリーで、実際の行数も、時間も、コストもありません。形は見えます(Seekを期待したところにIndex Scanがある、など)。推定と現実を比べるには、実際のプランが必要です。

パラメータスニッフィング
プロシージャやパラメータ化クエリーが初めて実行されるとき、オプティマイザは渡された値を見て、その値に合わせてプランをコンパイルします。そのプランはキャッシュに残り、以降の実行は別の値が来てもそれを再利用します。プロシージャと2人の顧客で再現しました。注文が20件の顧客と100,000件の顧客です。

CREATE OR ALTER PROCEDURE dbo.pedidos_de @cliente int AS
  SELECT SUM(importe) AS total, COUNT(*) AS n
  FROM dbo.pedidos WHERE cliente_id = @cliente;
コンパイル時の値小さな顧客大きな顧客
小さいほう読み取り62読み取り300,177、99 ms
大きいほう読み取り6,085読み取り6,085

小さな顧客向けにコンパイルされたプランは、インデックスをシークして1行ずつテーブルに行きます。20行には完璧ですが、100,000行では読み取りが300,000回になり、テーブル全体をスキャンするより50倍多くなります。逆にコンパイルされると、小さな顧客がテーブル全体をスキャンします。決めるのは到着の順番で、再起動や統計情報の変化でそれが逆転します。だから「昨日は問題なかった」のです。

実際のプランを見れば推測せずにわかります。悪いケースではEstimateRows="20"とActualRows="100000"と示し、パラメータの中にParameterRuntimeValue="(1)"と並んでParameterCompiledValue="(4243)"がありました。

対策として試したもの:
- OPTION (RECOMPILE):クエリーの最後に付けます。62と6,085になり、値ごとに専用のプランが使われます。代償は呼び出しのたびにコンパイルすることで(ここでは2〜3 ms)、1秒に何千回も実行されるクエリーではただではありません。
- OPTION (OPTIMIZE FOR UNKNOWN):平均的な値(約20行)に合わせてコンパイルし、70と306,429になりました。とても大きな値が1つあるときには役に立ちません。
- EXEC sp_recompile 'dbo.pedidos_de':プロシージャのプランを捨てます。その場しのぎであって、対策ではありません。
- SQL Server 2022からは、互換性レベル160以上で、サーバが値に応じて同じクエリーに複数のプランを持てます。デフォルトでオンですが、100,000行対20行では発動しませんでした。200,000行対1行では発動し、プランが2つになりました。当てにしないでください。

いちばんコストのかかるクエリー:プランキャッシュ
サーバは、キャッシュしたすべてのプランの数値を記録しています。実行回数、時間、読み取り、行数です。query_hashはクエリーの形でまとめます。顧客番号だけが違う5つのSELECTをそれぞれ単独で実行すると、キャッシュには5つのプラン(80 kB)と、ただ1つのquery_hashが残りました。

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;

planes列が1より大きいのは、パラメータスニッフィングの痕跡です。測定した注意点が2つあります。再起動のあと、11個のクエリーのテキストは残っていましたが、それ以前の数値は残っていませんでした。900秒ごとにディスクに書き込むからです。そしてデータベースごとであって、サーバごとではありません。

Calíopeはこれを使いません。「スロークエリー」は、サーバ全体に対してすでにあるプランキャッシュを読みます。オンにもオフにもしません。それは管理のためのALTER DATABASEだからです。

待機:時間がどこに行くか
sys.dm_os_wait_statsは、最後の再起動以降にサーバがどれだけ、なぜ待ったかを合計します。1,547種類あり、フィルタしないと一覧の先頭は仕事を待っているスレッドです:DISPATCHER_QUEUE_SEMAPHORE、BROKER_TASK_STOP、SLEEP_…のたぐい。これらは除外しなければなりません。本当に何かを語るもの:
- LCK_M_…:ロック。「ロックとデッドロック(SQL Server)」で説明しています。
- PAGEIOLATCH_…:ディスクからのページを待っています。メモリーかインデックスが足りません。
- WRITELOG:コミットのたびにトランザクションログを待っています。
- CXPACKETとCXCONSUMER:並列処理。
- SOS_SCHEDULER_YIELD:CPU。
- RESOURCE_SEMAPHORE:並べ替えやハッシュのためのメモリーを待っているクエリー。

累積なので、1回のスナップショットはあまり役に立ちません。2回取って差を取ってください。私が再起動したあと、合計は1,210万msから70万msに下がりました。

並列処理
最初の状態では、cost threshold for parallelismは5、max degree of parallelismは0(すべてのコア)です。しきい値がとても低いので、中くらいのスキャンでももう並列になり得ます。別の200,000行のテーブルでは、1つのスキャンが16スレッドを使い、実時間152 msに対してCPUを2,094 ms使いました。高いCXPACKETはエラーではありません。その2つの値が最初のままになっているしるしです。

ほかの項目にあること
- SQL_照合順序のvarchar列に対するnvarcharのパラメータは、読み取りが3から334になります:「エンコーディングと照合順序(SQL Server)」。
- キー検索、プランがインデックスをやめてテーブルを読むとき、YEAR(fecha)と範囲の違い、そしてサーバが欲しがるインデックス:「インデックス(SQL Server)」。
- 信頼されていないCHECK制約はオプティマイザに使われません(読み取り1,364対0):「オンラインでのスキーマ変更(SQL Server)」。
- クエリーがいくつのパーティションを読んだかは、実際のプランにしか示されません:「パーティション分割(SQL Server)」。

推奨事項
うまくいく順番:「スロークエリー」で(履歴が必要ならクエリーストアで)クエリーを見つけ、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、待機、並列処理

バックアップと復旧(SQL Server)

3つの復旧モデルとログを増やし続けるのはどれか、サーバのディスク上のBACKUP DATABASEとBACKUP LOG、ログチェーン、STOPATでDELETEの1分前に戻る方法、COPY_ONLY、そしてCalíopeのバックアップとの違い。

対象: SQL Server 2022+

MySQLから来た人へ:ここにはmysqldumpも独立したバイナリログもありません:物理バックアップはT-SQLのSQL文で、そこから先へ進めるための履歴は、各データベースのトランザクションログです。PostgreSQLから来た人にとっての違いは単位です:バックアップと復元の単位はサーバ全体ではなく1つのデータベースです。ここで説明することはすべてSQL Server 2025で測定したものです。

復旧モデルがほとんどすべてを決める
これは各データベースのプロパティで、3つあります:

モデルログが保持するもの特定の時点への復旧
SIMPLECHECKPOINTのたびに自動で空になるできない:最後のバックアップまでだけ
FULLBACKUP LOGでバックアップするまで、すべてできる
BULK_LOGGED一括読み込み以外のすべて(一括読み込みはページ単位で記録)できる。ただし読み込みの途中は除く

新しいデータベースはmodelのモデルをコピーし、このサーバではそれがFULLです:

SELECT name, recovery_model_desc, log_reuse_wait_desc
FROM sys.databases;

ログのバックアップなしのFULLはディスクをいっぱいにする
これが落とし穴です。データベースが最初の完全バックアップを持つまでは、FULLはSIMPLEのように振る舞います:2 kBの行を20,000行挿入すると、ログの使用量は42.2 MBに達し、CHECKPOINTで8.1 MBに下がりました。最初のBACKUP DATABASEの後は、同じことをしても何も解放されませんでした:55.4 MB、次に102.8 MBで、ファイルは72 MBから136 MBに増えました。再利用できない理由はlog_reuse_wait_descが伝えます:

- LOG_BACKUP:BACKUP LOGを待っています。ディスクがいっぱいになるほとんどすべての原因です。
- ACTIVE_TRANSACTION:誰も閉じない、開いたままのトランザクション。
- CHECKPOINT:心配はいりません。次のチェックポイントで解放されます。

BACKUP LOGで使用量は15.1 MBに下がりましたが、ファイルは136 MBのままでした:ログをバックアップすると中の領域は解放されますが、ディスクに返すわけではありません。FULLのデータベースで誰もBACKUP LOGをスケジュールしていないなら、スケジュールするか、SIMPLEに切り替えてください。

バックアップを取る

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;

パスは自分のではなくサーバのディスクのものです:ファイルを書くのはSQL Serverのプロセスです。Linuxでは通常のフォルダは/var/opt/mssql/backup/です。テスト用のコンテナにはそれがなく、最初のBACKUPが作成しました。

- 完全バックアップはデータベース全体をコピーします:私のテストでは150 MB。
- 差分バックアップは、最後の完全バックアップ以降に変わったものをコピーします:2,000行を変えた後で3.1 MB。
- ログのバックアップは前回以降のログをコピーし、これで特定の時点に到達できます。

CHECKSUMはデフォルトではオンになっておらず(backup checksum defaultは0)、検証するときに気づきます:それなしで取ったバックアップに対するRESTORE VERIFYONLY … WITH CHECKSUMはMsg 3187になりました。常に使い、検証してください:

RESTORE VERIFYONLY FROM DISK = '/var/opt/mssql/backup/tienda_full.bak'
    WITH CHECKSUM;

ログチェーン
ログのバックアップはそれぞれ前回が終わったところから始まり、すべてを順番に復元します。1つ飛ばすと、欠けているLSNとともにMsg 4305になりました。警告なしにそれを壊すものが2つあります:

- SIMPLEに切り替えてからFULLに戻すこと:その後、以前の完全バックアップがあっても、次の完全バックアップまでBACKUP LOGはMsg 4214になりました。
- 自分の知らないファイルへの、ほかの誰かのBACKUP LOG。履歴はmsdbにあります:

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;

COPY_ONLY:邪魔をしないコピー
通常の完全バックアップは、その後の差分バックアップの基点になります。データベースを別のマシンに移すために一度だけ取ると、今夜の差分バックアップはもう日曜日の完全バックアップと合いません。WITH COPY_ONLYならそうはなりません:次の差分バックアップは前の完全バックアップを指したままで、それをCOPY_ONLYの上に復元するとMsg 3136になりました。ルーチン外のコピーはすべてCOPY_ONLYで取ります。

DELETEの1分前に戻る
最初から最後まで再現しました:500件の注文、4秒後のWHEREなしのDELETE、そして元のデータベースに触れずに新しいデータベースへ復旧:

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;

500件すべてが戻りました。知っておくべきこと:

- NORECOVERYはデータベースをRESTORINGのままにし、続きを待ちます:読むとMsg 927になります。最後の手順にRECOVERYを付けます。
- 別の名前で復元するときはMOVEが必須です。付けないと元のデータベースのファイルとぶつかります。その論理名はRESTORE FILELISTONLYでわかります。
- STOPATはサーバの時刻で、小数点以下3桁です:7桁にするとMsg 3217になりました。
- 先にログの末尾をバックアップせずに、稼働中のデータベースの上に復元するとMsg 3159になりました。末尾とは最後のBACKUP LOG以降に起きたことで、BACKUP LOG … WITH NORECOVERYでバックアップします。これもデータベースをRESTORINGのままにします。

BULK_LOGGEDとその代償
21 MBのテーブルのSELECT … INTOは、FULLでは22.1 MBのログを書き、BULK_LOGGEDでは1.0 MBでした。しかし次のログのバックアップは、読み込んだページを運ぶので同じ22.1 MBになりました。そしてそのバックアップをSTOPAT付きで復元するとMsg 4341になりました:一括読み込みを含む区間では、特定の時点ではなく終わりにしか到達できません。読み込みのためにBULK_LOGGEDに切り替え、すぐにFULLに戻し、その前後でBACKUP LOGを取ってください。

別のサーバで復元する
データベースのバックアップにはそのユーザが含まれますが、masterにあるサーバのログインは含まれません。別のマシンで復元すると、各ユーザはそのログインから孤立します。見つけ方とつなぎ直し方は、SQL Serverのセキュリティのトピックにあります。

Calíopeがすること
「バックアップ」は.bakではなくテキストのスクリプトを書きます:バッチの間にGOを挟んだSQL文、1,000行ずつのINSERTの行、ISO 8601の日付、0x…のバイナリなので、別のサーバでも、別のバージョンでも、セッションが別の言語でも同じように戻ります。「ユーザのパスワードを含める」をオンにすると、ログインはCREATE LOGIN … WITH PASSWORD = 0x… HASHEDとして、各データベースのユーザはCREATE USER … FOR LOGINとして書かれます:スクリプトを復元しても、誰も孤立しません。パーティション分割されたテーブルはパーティション分割されたまま戻ります:スクリプトは、なければ先にそのパーティション関数とパーティション構成を作成します。

SQLエディタからBACKUP DATABASEを実行することもできますが(ほかのSQL文と変わりません)、ファイルはサーバのディスクに残ります:Calíopeはそれを持ってきません。

BACKUP DATABASECalíopeの「バックアップ」
何かデータベースのページSQLスクリプト
どこに置かれるかサーバのディスク自分で選んだ場所
復元できる先ドキュメントによれば、同じバージョンかそれより新しいもの別のサーバや別のバージョン。中身を読むこともできる
特定の時点までできる(ログを使って)できない:ダンプした時点のもの
サイズと速度どんなサイズでも速い大きなデータベースでは遅い

推奨事項
モデルはデータベースごとに決めます:今日の作業を失っても構わないところはSIMPLE、そうでないところはスケジュールされたBACKUP LOG付きのFULLです。週1回の完全、毎日の差分、数分ごとのログが通常のルーチンで、常にWITH CHECKSUMを付けます。log_reuse_wait_descを見張り、一度だけのコピーはすべてCOPY_ONLYにし、ときどき実際に別の名前で復元してください:一度も復元したことのないバックアップは、ただの意図です。Calíopeのバックアップは、データベースやその一部を別のマシンに持っていくためのもので、サーバのバックアップの代わりにはなりません。

キーワード: バックアップ、復元、復旧、復旧モデル、FULL、SIMPLE、bulk_logged、トランザクションログ、log_reuse_wait_desc、BACKUP LOG、ログチェーン、NORECOVERY、STOPAT、copy_only、差分、CHECKSUM、VERIFYONLY、ポイントインタイム、3159、4214、4341

ログイン、ユーザ、権限(SQL Server)

サーバのログインとデータベースのユーザ、別のサーバで復元するとユーザが孤立する理由とつなぎ直す方法、スキーマ単位の付与、DENYとそれを回避する者、所有権の連鎖、包含ユーザ、行レベルセキュリティ、そしてsaをどうするか。

対象: SQL Server 2022+

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」でログインに結び付いています:孤立はそこから生まれます。

パスワードはポリシー(CHECK_POLICY、デフォルトでオン)を通ります:3種類の文字から8文字、そしてログイン名を含まないこと。ログインanaに対する'Xq7#ana_pw9'は、ほかの条件を満たしていてもMsg 33064になりました。

孤立したユーザ:別のサーバで復元する
再現しました:ユーザanaを持つデータベースをバックアップし、ログインを削除して同じ名前で作り直し(別のマシンならそうなるように)、データベースを復元しました。anaはもうコピーに入れませんでした:そのユーザは古いsidを保持し、新しいログインは別のsidを持っていたからです。作り直すこともできませんでした:Msg 15023、ユーザはすでに存在します。見つけるには:

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になりました。

GRANT、DENY、REVOKE:2つではなく3つの状態
- GRANTは付与します。
- DENYは禁止し、どんなGRANTにも優先します:rrhh.nominasにDENY SELECTがあると、anaは、スキーマを付与されていても、db_datareaderのメンバーであってもMsg 229になりました。
- REVOKEは、GRANTであれDENYであれ、そこにあるものを取り除きます。REVOKEの後、anaはロール経由で再び読めるようになりました。

誰がDENYを回避するかを、1つずつ測定しました:
- 「db_owner」のメンバー:回避しません。Msg 229になりました。
- 「CONTROL SERVER」を持つログイン:これも回避せず、Msg 229。
- 「sysadmin」のメンバー:回避します。すべてのデータベースにdboとして入り(USER_NAME()がそう伝えます)、権限が一切確認されないので、拒否されたテーブルを読みました。

何ができるかをサーバに尋ねる

EXECUTE AS USER = 'ana';
SELECT permission_name, subentity_name
FROM fn_my_permissions('rrhh.nominas', 'OBJECT');
SELECT HAS_PERMS_BY_NAME('rrhh.nominas', 'OBJECT', 'UPDATE');
REVERT;

fn_my_permissionsはSELECTを4回返しました:テーブルに1回、その3つの列にそれぞれ1回です。ロール、スキーマ、DENYをすでに解決したうえでの実効的な権限を、誰にもパスワードを尋ねずに確かめる方法です。

所有権の連鎖
ビューやプロシージャと、それが読むテーブルの所有者が同じなら、ビューを使える人はテーブルに対する権限を必要としません。dbo.pedidosに何の権限もないanaが、ビューdbo.v_totalesとプロシージャdbo.p_totalを通して読めました。オブジェクトが見せるものだけにアクセスを与える、よい方法です。しかし動的SQLは連鎖を断ち切ります:中にEXEC('SELECT …')がある同じプロシージャはMsg 229になりました。

包含ユーザ
包含データベースでは、ユーザは自分のパスワードを持ち、ログインを必要としないので、データベースと一緒に移動し、孤立することがありません:

EXEC sp_configure 'contained database authentication', 1;
RECONFIGURE;
CREATE DATABASE tienda CONTAINMENT = PARTIAL;
-- tiendaの中で:
CREATE USER carla WITH PASSWORD = 'Rt5%jueves_z2';

このオプションはデフォルトでオフです。オンにすると、carlaは接続するときにデータベースを指定して入れました。指定しないとLogin failedです。Calíopeでは、接続の「データベース」欄にデータベースを入れるということです。

行レベルセキュリティ
フィルタ述語を持つポリシー:

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);

anaには自分の行が見えました。そしてPostgreSQLと違い、ここでは誰も回避しません:saとsysadminには0行が見えました。彼らにとってUSER_NAME()はdboだからです。決めるのは述語で、管理者がすべてを見る必要があるなら、述語がそう言わなければなりません。また、フィルタは読まれるものを絞り込むだけです:anaは別の所有者の行をエラーなしで挿入しました。それを防ぐにはBLOCK PREDICATEが必要です。

sa
saはsidが0x01のログインで、sysadminのメンバーであり、その名前は入ろうとする誰もが知っています。パスワードが間違っていると、クライアントでは常に状態1で18456になります。理由はサーバのログにしかなく、そこでは同じ試行が状態8、パスワードの誤りとして表示されます。よくあるやり方は、独自の名前で管理用のログインを作成してからALTER LOGIN sa DISABLEすることです。

Calíopeがしないこと
Calíopeが話すのはSQL認証だけです:Windows認証もMicrosoft Entra IDも扱いません。

Calíopeがすること
「ユーザ」はサーバのログインを一覧にし、publicをアカウントの1つとして含め、saは別にして「システムユーザ」として示します。権限はレベル(サーバ、データベース、スキーマ、テーブル、ルーチン)ごとに表示され、DENYは3つ目の状態として描かれます:色を変えるだけでなく、専用の記号、取り消し線、DENYという語で示します。チェックを外すことはできません。取り除くことはチェックを外すことではなく、REVOKEだからです。「アカウントをロック」はALTER LOGIN … DISABLEです。ログインのないデータベースユーザ(孤立したものや包含のもの)は表示されません:それらには、SQLエディタで上のクエリーを使ってください。

推奨事項
人やアプリケーションごとに1つのログインを用意し、権限は人ではなくロールにスキーマ単位で付与します。sysadminは管理のためだけにします。どんなDENYも届かないからです。ビューやプロシージャを通して与えられるものは、中に動的SQLを入れずに、その方法で与えます。データベースを別のマシンに移すときは、ログインをsid付きで作るか、復元した直後に孤立ユーザのクエリーを実行します。そしてsaは無効にします。

キーワード: セキュリティ、ログイン、ユーザ、ロール、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

サーバの設定(SQL Server)

sp_configureとRECONFIGURE、valueとvalue_in_useが違う理由、再起動が必要なオプション、設定されていないメモリーの上限、それぞれのMAXDOPが優先される場所、データベース単位の設定、tempdb、そしてmssql-conf。

対象: SQL Server 2022+

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になりました。

EXEC sp_configure 'show advanced options', 1;
RECONFIGURE;

書いただけでは適用されない: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は、まさに今警告したチェックを飛ばすものなので、習慣で使うものではありません。

再起動が必要なもの
is_dynamic = 0のオプションはRECONFIGUREでは有効になりません:ここでは107のうち23で、その中にはuser connections、fill factor (%)、locks、priority boostがあります。sp_configure 'user connections', 500とRECONFIGUREの後、valueは500になり、value_in_useはサービスを再起動するまで0のままでした。

変更はすべて、時刻とセッションとともにサーバのログに記録されます: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になりました。

EXEC sp_configure 'max server memory (MB)', 12288;   -- 16 GBのうち12 GB
RECONFIGURE;

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)と比べられます。

並列処理が待機にどう表れるかは「パフォーマンスと診断(SQL Server)」で説明しています。

データベース単位の設定
demoのsys.database_scoped_configurationsには41のオプションがあります。MAXDOPのほかに、LEGACY_CARDINALITY_ESTIMATION(0)、PARAMETER_SNIFFING(1)、QUERY_OPTIMIZER_HOTFIXES(0)などがあります。サーバのほかの部分に触れずに、1つのアプリケーションの振る舞いを変えるための方法です。

SELECT name, value
FROM sys.database_scoped_configurations
WHERE is_value_default = 0;

tempdb
このサーバには15のプロセッサが見え、tempdbには8 MBのデータファイルが8個と、ログファイルがあり、64 MBずつ増えます:セットアップの時点で、プロセッサごとに1つ、8つまで作成されます。これは#temporaryテーブル、メモリーに収まらないソート、READ_COMMITTED_SNAPSHOTの行バージョンを支えるデータベースです。

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 Server)

別のトランザクションの中のBEGIN TRANが入れ子にならない理由、SAVE TRANSACTIONで一部をロールバックする方法、各レベルが何をロックするか、SNAPSHOTの3960、そして忘れられたトランザクションがログに何をするか。

対象: SQL Server 2022+

トランザクションは、複数のSQL文をまとめて、全体が適用されるかまったく適用されないかの単位にします。MySQLやPostgreSQLと同じく、明示的なトランザクションがなければ、SQL Serverは各SQL文をそれぞれ単独でコミットします(自動コミット)。違うのは細部で、ここで説明することはすべてSQL Server 2025に対して測定したものです。

BEGINではなくBEGIN TRAN
単独のBEGINはトランザクションではなくT-SQLのブロックを開きます:サーバはMsg 102、Incorrect syntax near 'BEGIN'と応答しました。書くのはBEGIN TRAN(またはBEGIN TRANSACTION)です。開いているトランザクションがないときのCOMMITはMsg 3902になり、ROLLBACKはMsg 3903になります。

暗黙モード
SET IMPLICIT_TRANSACTIONS ONにすると、データに触れる最初のSQL文がトランザクションを開き、それはCOMMITまで開いたままになります。測定しました:@@TRANCOUNTは0で、ただのSELECTで1に上がり、COMMITでようやく0に戻りました。このモードでコミットを忘れたセッションは、ロックを持ち続け、ログを抱え込みます。これは後で説明します。

入れ子は入れ子にならない
別のトランザクションの中のBEGIN TRANは、@@TRANCOUNTのカウンタを上げるだけです。決めるのは外側です:
- 内側のCOMMITは何もコミットしません:カウンタを2から1に下げただけで、外側のROLLBACKは内側のものも含めて両方のUPDATEを元に戻しました。
- 内側のROLLBACKはすべてを元に戻します:@@TRANCOUNTを0にし、外側のCOMMITは、トランザクションが残っていなかったのでMsg 3902になりました。
- 内側に名前を付けても役に立ちません:ROLLBACK TRAN interiorはMsg 6401になり、カウンタは2のままでした。ロールバックできるのはセーブポイントまでだけです。

BEGIN TRAN;                                  -- @@TRANCOUNT = 1
  UPDATE cuentas SET saldo = saldo - 10 WHERE id = 1;
  BEGIN TRAN;                                -- @@TRANCOUNT = 2
    UPDATE cuentas SET saldo = saldo + 10 WHERE id = 2;
  COMMIT;                                    -- @@TRANCOUNT = 1、何もコミットされていない
ROLLBACK;                                    -- 両方のUPDATEを元に戻す

始めたときと違う@@TRANCOUNTで終わるプロシージャはMsg 266になります:これは「エラー(SQL Server)」で説明しています。

セーブポイント:SAVE TRANSACTION
トランザクションを失わずに作業の一部を元に戻すには、地点に印を付けてそこへ戻ります。ここでは重複したINSERTが2627で失敗し、私はその地点に戻り、COMMITがその前の行と後の行をコミットしました:

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;

測定した制限が2つあります:開いているトランザクションがないときのSAVE TRANSACTIONはMsg 628になり、XACT_ABORT ONでは、同じエラーでトランザクションが破綻した状態になり(XACT_STATE()が-1)、その地点に戻るとMsg 3931になりました:抜け出す方法は、全体をロールバックすることだけでした。

エラーでトランザクションが中断されることはほとんどない
PostgreSQLと違い、SQL文のエラー(重複キー、制約)はそのSQL文だけを中断し、トランザクションはそれまでにしたことを抱えたまま生き続けます。XACT_ABORTなしでは、最後のCOMMITがエラーの前と後のものをコミットします。SET XACT_ABORT ONでは、同じエラーがすべてを元に戻し、バッチを終了します。どのエラーが単独でトランザクションをロールバックするかは「エラー(SQL Server)」で説明しています。

分離レベル
デフォルトはREAD COMMITTEDです。2つのセッションで測定しました:1つ目はある範囲の4行を読み、4秒待ってからもう一度読みます。2つ目はその間に、その範囲に1行を挿入し、読まれた行の1つを更新します。
- 「READ UNCOMMITTED」は、ほかのセッションがコミットしていないものを読みます:「ロックとデッドロック(SQL Server)」のNOLOCKです。
- 「READ COMMITTED」は、読んだものにロックを保持しません。オフの状態で来るREAD_COMMITTED_SNAPSHOTがなければ、読み取りは書き込む側を待ちます。これは「ロックとデッドロック(SQL Server)」で説明しています。
- REPEATABLE READは、5つの共有ロック(KEY S)を最後まで保持しました:読んだ行のUPDATEは3秒待ちました。しかしINSERTはすぐに入り、2回目の読み取りは4行ではなく5行を数えました:ファントム行です。
- SERIALIZABLEは範囲をロックしました(KEY RangeS-S):INSERTは3秒待ち、2回目の読み取りも4行のままでした。
- 「SNAPSHOT」はトランザクション開始時点のスナップショットを読み、誰もブロックしませんが、データベースごとに許可する必要があり、オフの状態で来ます:snapshot_isolation_state_descは、demoでも、modelでも、新しいデータベースでもOFFでした。許可しないままだと、SET TRANSACTION ISOLATION LEVEL SNAPSHOTとBEGIN TRANは通り、最初のSELECTがMsg 3952になりました。

ALTER DATABASE mi_base SET ALLOW_SNAPSHOT_ISOLATION ON;
SET TRANSACTION ISOLATION LEVEL SNAPSHOT;

SNAPSHOTは待たない:3960で中断する
許可した状態では、2つ目のセッションは、行を読んだだけの1つ目を待たずにその行を更新しました。1つ目はもう一度読み、相手の101がすでにコミットされているのに、自分のスナップショットの値100が見えたままでした。それを更新しようとすると、サーバはMsg 3960(update conflict)でそれを中断し、XACT_STATE()は-1のままになりました。SNAPSHOTのもとでは、3960は正常な動作です:アプリケーションがトランザクション全体を再試行する必要があります。READ_COMMITTED_SNAPSHOTは別物です:レベルを変えるのではなく、READ COMMITTEDの読み方を変えます。それが「ロックとデッドロック(SQL Server)」で説明していることです。

忘れられたトランザクションがログを抱え込む
トランザクションの中で眠っているセッション(status = sleeping、open_transaction_count = 1)は、ログの再利用を妨げます。CHECKPOINTでログが空になるデータベースで、5,000行を挿入して削除し、CHECKPOINTを実行しました。そのセッションがなければ使用中は6.5 MBでしたが、あると73.8 MBで、log_reuse_wait_descはACTIVE_TRANSACTIONでした。それを閉じた後は5.1 MBです。ログのサイクルの残りは「バックアップと復旧(SQL Server)」で説明しています。見つけるには:

DBCC OPENTRAN;   -- 最も古いもの:SPIDと開始時刻

SELECT session_id, status, open_transaction_count, last_request_end_time
FROM sys.dm_exec_sessions
WHERE open_transaction_count > 0;

DDLもロールバックされる
ALTER TABLE、CREATE INDEX、CREATE TABLEは、例外(CREATE DATABASEはMsg 226になります)を除いてトランザクションの中に入ります:これは「オンラインでのスキーマ変更(SQL Server)」で説明しています。

Calíopeがすること
「SQLエディタ」は、渡されたものを1つの接続でSQL文ごとに実行し、最初に失敗したところで止まります。メニューからのトランザクションがなければ、終わると接続をプールに返し、トランザクションが開いたままならそれをロールバックします(IF @@TRANCOUNT > 0 ROLLBACK):手で書いたBEGIN TRANとそのCOMMITは同じ実行に入れてください。エラーの前のものは、接続が返されるときにロールバックされます。sqlcmdやアプリケーションなら、コミットされていたはずです。
複数の実行にまたがるトランザクションには、ツールバーの「トランザクション」メニューがあります:「BEGIN」はBEGIN TRANSACTIONを送ってタブ用に接続を確保し、そこで実行したものは「COMMIT」か「ROLLBACK」まで中に残ります。タブを閉じるとロールバックされます。サーバが単独でロールバックした場合(245、1205、またはXACT_ABORTのもとでの任意のエラー)や、SQL内のCOMMITがそれを終わらせた場合は、「進行中のトランザクション」のラベルが消え、ステータスバーがそう伝えます:それ以降、各SQL文はすぐに確定されます。

推奨事項
書き込みをするすべてのバッチやプロシージャでSET XACT_ABORT ONを使い、IF @@TRANCOUNT > 0 ROLLBACK;とTHROW;で終わるTRY…CATCHを書きます。内側が独立することを期待してBEGIN TRANを入れ子にしないでください:そのためにあるのがセーブポイントです。SNAPSHOTに上げるなら、上げる前に3960の再試行を書いてください。そしてときどきDBCC OPENTRANを:忘れられたセッションは、ディスクがいっぱいになるまで何も警告しません。

キーワード: トランザクション、コミット、ロールバック、BEGIN TRAN、@@TRANCOUNT、SAVE TRANSACTION、セーブポイント、xact_abort、xact_state、implicit_transactions、分離レベル、REPEATABLE READ、SERIALIZABLE、SNAPSHOT、allow_snapshot_isolation、3960、3952、3902、6401、3931、active_transaction、DBCC OPENTRAN

データ型(ClickHouse)

名前にサイズが入った整数とJSONに収まらないUInt64、正確なDecimal、バイト列としてのString、日付の範囲、そしてコストを伴うラッパーとしてのNullableとLowCardinality。

対象: ClickHouse 25.3+

ClickHouseでは、型によって各値がディスク上で占める容量と圧縮のされ方が決まります。そのため、トランザクション処理向けのデータベースよりも慎重に選びます。型の名前にはサイズが含まれ、大文字と小文字が区別されます。uint64ではなくUInt64です。

整数:サイズは名前に入っている
Int8からInt256まで、UInt8からUInt256まであります。範囲を超えてもエラーにはならず、値が一周します。toInt8(300)は44を、toUInt8(-1)は255を返します。

UInt64の最大値は18446744073709551615ですが、JSONで数値が正確なのは9007199254740991(2^53 − 1)までです。そのため、サーバはJSON形式では64ビット整数を引用符で囲んで送り(output_format_json_quote_64bit_integers)、Calíopeはそれを浮動小数点数ではなく正確なテキストとして読みます。

Decimal:正確で、スケールは固定
Decimal(P, S)は、P桁のうちS桁を小数部として、浮動小数点の丸めなしで格納します。除算ではスケールに収まらない部分が切り捨てられます。toDecimal32(1, 2) / 3は0.33になります。乗算ではスケールが足し合わされます。Decimal(9, 2)同士を掛けるとDecimal(9, 4)になります。

Stringは文字ではなくバイト列
Stringは、UTF-8でないものを含め、どんなバイト列でも受け付けます。length('ñandú')は7(バイト数)、lengthUTF8('ñandú')は5になります。FixedString(N)はゼロで埋めます。toFixedString('ab', 4)は61 62 00 00を格納します。テキストが有効なUTF-8でない場合、Calíopeはそれを無害化して表示し、そのセルに印を付けます。

日付:範囲はそれぞれ異なり、超えてもエラーにならない

型範囲サイズ
Date1970-01-01~2149-06-062バイト
Date320000-01-01~9999-12-314バイト
DateTime1970年からの秒数4バイト
DateTime64(P)小数部P桁(0~9)8バイト

範囲外になると、Dateは警告なしで端の値にとどまります。toDate('2149-06-07')は2149-06-06を、toDate('1969-12-31')は1970-01-01を返します。生年月日や歴史的な日付にはDate32を使ってください。

タイムゾーンは各行ではなく列に属します(DateTime64(3, 'Asia/Tokyo'))。タイムゾーンがなければサーバのものが適用され、Calíopeは列見出しにそれを表示します。

NullableとLowCardinality:コストの異なるラッパー
Nullable(T)はNULLのマスクを別に保持します。100万行で、Int64は非圧縮で7.63 MiB、Nullable(Int64)は8.58 MiBでした。さらに、Nullableの列は、allow_nullable_keyをオンにしない限りソートキーに含められません。値がないことをデフォルト値(0や空文字列)で表せるなら、Nullableは使わないほうが得策です。

LowCardinality(T)は辞書でエンコードします。100万行に異なる値が100個の場合、Stringの列は圧縮後に254 KiB、LowCardinality(String)の列は7.6 KiBでした。サーバ自身のドキュメントは、異なる値がおよそ1万個未満ならこれを勧め、10万個を超えるなら勧めていません。

コレクションと構造化型
- Array(T)は1から始まり、範囲外はエラーではなくデフォルト値を返します。[10,20,30][5]は0になります。
- Map(K, V)は重複したキーを受け付けます。map('a',1,'a',2)は両方を保持し、['a']は最初の値を返します。
- Enum8は、一覧にない名前を挿入時に拒否します。UUIDは16バイト、IPv4は4バイトを使います。

CalíopeはコレクションをコンパクトなJSONで、数値は届いたとおり正確に表示します。集計の状態(AggregateFunction)は、テキストとして表示できないことを示します。

キーワード: 型、UINT64、INT64、Decimal、String、FixedString、DATE、Date32、DateTime、DateTime64、タイムゾーン、Nullable、LowCardinality、Array、MAP、TUPLE、ENUM、UUID、IPv4、JSON、ClickHouse

テーブルエンジン(ClickHouse)

MergeTreeファミリーとそれぞれのバリアントがマージ時に行うこと、Replicated系、データを格納しないエンジンやメモリーに保持するエンジン、そしてエンジンを変更できない理由。

対象: ClickHouse 25.3+

ClickHouseでは、すべてのテーブルがエンジンを宣言し、エンジンによって、データの格納方法、受け付けるインデックス、レプリケーションするかどうか、マージ時に何が起きるかが決まります。これはパフォーマンスの細部ではなく、テーブルの性質そのものです。サーバは自分が知っている84個のエンジンを、それぞれの対応機能とともにsystem.table_enginesで公開しています。

MergeTreeファミリー:ほとんどの場合に選ぶもの
データはソートキーで並べられたパートとして書き込まれ、サーバがバックグラウンドで次々にマージします。バリアントはマージ時に、そしてそのときだけ、追加の処理を行います。

エンジンマージ時に行うこと
MergeTree並べ替えと結合以外は何もしません。
ReplacingMergeTree(ver)キーごとに1行、最も大きいバージョンを残します。
SummingMergeTree同じキーの行の数値列を合計します。
AggregatingMergeTree集計の状態を結合します(-State / -Merge)。
CollapsingMergeTree(sign)符号が+1と−1の行の組を打ち消し合わせます。

落とし穴は「そのときだけ」という点です。ReplacingMergeTreeで測定すると、同じキーを2回挿入した場合、普通のSELECTでは2行になります。FINALを付けると1行、OPTIMIZE TABLE … FINALの後はFINALなしでも1行です。SummingMergeTreeも同じで、2つの行('x', 1)と('x', 2)はマージまで2行のままで、マージ後に3を持つ1行になります。マージには予定がないため、これらのエンジンへのクエリーは集計するかFINALを使う必要があります。

Replicated:同じファミリーにコピーを加えたもの
ReplicatedMergeTreeとそのバリアント(ReplicatedReplacingMergeTree…)は、ClickHouse Keeperが調整するレプリカを追加します。一覧の中でレプリケーションと挿入の重複排除に対応するのは、これらだけです(supports_replication、supports_deduplication)。

MergeTree以外のエンジン
- Memory:RAM上に非圧縮で保持します。サーバ上の説明によると、再起動するとテーブルは空になります。
- Logとそのファミリー:一度だけ書き込む小さなテーブル用で、インデックスはありません。
- Buffer:データをメモリーに保持し、別のテーブルに書き出します。現在は代わりに非同期挿入が推奨されています。
- Distributed:何も格納しません。クエリーをクラスタのシャードに分散します。
- Merge:複数のテーブルを一度に読み、書き込みは受け付けません。
- Dictionary:辞書をテーブルとして見せます。
- Null:書き込まれたものは破棄されます。マテリアライズドビューの入力として使います。

これらはどれも、スキップインデックス、プロジェクション、ソートキー、TTLを受け付けません。system.table_enginesによると、それらはすべてMergeTreeファミリーだけのものです。

エンジンは変更できない
ALTER TABLE … MODIFY ENGINEはなく、サーバは構文エラーを返します。変更するには、新しいエンジンで別のテーブルを作成してINSERT INTO … SELECTでデータをコピーするか、列とパーティショニングが一致していればATTACH PARTITION … FROMでパーティションをコピーします。ReplacingMergeTreeからMergeTreeへの移行で測定済みです。

Calíopeでは
ビルダーは、サーバが引数なしで作成できるエンジンを提供し、テーブルを変更するときにはエンジンを変更できないことを示します。カタログには各テーブルのエンジンが表示されます。

キーワード: テーブルエンジン、エンジン、MergeTree、ReplacingMergeTree、SummingMergeTree、AggregatingMergeTree、CollapsingMergeTree、レプリケーション、MEMORY、LOG、BUFFER、DISTRIBUTED、辞書、MERGE、NULL、FINAL、ClickHouse

キーとスキップインデックス(ClickHouse)

グラニュールの疎なインデックスとしてのソートキー、異なる場合の主キー、SAMPLE BY、スキップインデックスとプロジェクション、そしてキーを変更できない理由。

対象: ClickHouse 25.3+

ClickHouseには、行を指すインデックスはありません。どれだけ読むかを決めるのはソートキーです。データはソートキーで並べて格納され、主インデックスは疎です。行ごとではなく、行のブロックごとに1つの印を保持します。

グラニュール:読み込みの単位
それぞれのブロックはグラニュールと呼ばれ、デフォルトで8,192行を含みます(index_granularity)。サーバはグラニュール単位で読み飛ばし、残りを読みます。ORDER BY (fecha, cliente, id)の100万行のテーブルには123個のグラニュールがあり、EXPLAINがそれを示します。

EXPLAIN indexes = 1
SELECT count() FROM eventos WHERE fecha = '2026-02-01';

キーの最初の列で絞り込むと、実行計画にはGranules: 3/123と二分探索が表示されます。キーの2番目の列であるclienteだけで絞り込むと、Granules: 101/123と表示され、ほぼテーブル全体になります。キーの列の順序が重要です。よく絞り込みに使い、異なる値が少ない列を先に置きます。

主キーはソートキーより短くできる
PRIMARY KEYはORDER BYの先頭部分でなければなりません。書かなければ、両者は同じです。ORDER BY (fecha, cliente, id) PRIMARY KEY (fecha, cliente)とすると、行は3つの列で並べられ、メモリー上のインデックスは2つだけを保持します。メモリーは少なく、ディスク上の順序は同じです。主キーは一意ではありません。同じキーを持つ2つの行が並んで存在します。

SAMPLE BY
データの一部をクエリーできます(SELECT … SAMPLE 0.1)。サンプリングの式は主キーに含まれていなければならず、そうでなければサーバはCREATEを拒否します。通常はハッシュを使います(ORDER BY intHash32(id) SAMPLE BY intHash32(id))。測定結果:10万行に対するSAMPLE 0.1は10,039行を数えました。

スキップインデックス:探すのではなく、飛ばす
グラニュールのグループごとに要約を保持し、探しているものが確実に含まれていないグラニュールを飛ばすために使います。
- minmax:最小値と最大値。
- set(N):最大N個の異なる値。
- bloom_filter:おおよその所属判定。キーに含まれない列での等値比較に使います。
- ngrambf_v1とtokenbf_v1:テキスト内の断片や単語の検索に使います。

同じテーブルでの測定結果:bloom_filterで1つのURLを探すと、123個すべてではなく123個中20個のグラニュールを読みました。そして、テーブル全体に散らばった金額に対するminmaxは、1つも飛ばしませんでした。スキップインデックスが役立つのは、値がキーに沿ってまとまっている場合だけです。

プロジェクション:別の順序でもう一度持つテーブル
プロジェクションはデータを別の順序でもう一度格納し、役立つ場合はサーバが自分で選びます。

ALTER TABLE eventos ADD PROJECTION por_cliente (SELECT * ORDER BY cliente);
ALTER TABLE eventos MATERIALIZE PROJECTION por_cliente;

clienteでの同じクエリーは、101個から123個中1個のグラニュールに減りました。その代わり、プロジェクションは5.3 MiB多く使い、挿入のたびにデータを2回書き込みます。

ソートキーは変更できない
ALTER TABLE … MODIFY ORDER BYでは、その同じALTERで作成した列を末尾に追加することしかできません。並べ替えたり、既存の列を追加したりするとエラーになります(Existing column … is used in the expression that was added to the sorting key)。キーを本当に変えるには、別のテーブルを作成してデータをコピーします。

Calíopeでは
ClickHouseでは、カタログの「インデックス」タブは「キー」という名前になり、ソートキー、パーティションキー、異なる場合は主キー、サンプリングキー、スキップインデックス、プロジェクションを表示します。ビジュアルな実行計画ビューアは読み込みごとのグラニュール数を数え、インデックスアドバイザはCREATE INDEXではなくスキップインデックスを提案します。

キーワード: ソートキー、ORDER BY、主キー、主インデックス、グラニュール、index_granularity、SAMPLE BY、スキップインデックス、データスキップインデックス、MINMAX、SET、bloom_filter、ngrambf_v1、tokenbf_v1、プロジェクション、EXPLAIN、ClickHouse

パーティションとTTL(ClickHouse)

PARTITION BYとパート、日単位のパーティショニングがよくある間違いである理由、OPTIMIZEとDROP PARTITION、そしてマージ時に適用される行と列のTTL。

対象: ClickHouse 25.3+

ClickHouseでのパーティションは、PARTITION BYの値を共有するパートのグループで、主な役割はデータの管理です。たとえば1か月分をまとめて削除したり移動したりします。クエリーを速くするのはソートキーの役目で、パーティショニングはその代わりにはなりません。

パートとパーティション
挿入のたびに、触れるパーティションごとに少なくとも1つのパートが書き込まれ、サーバが後でそれらをマージします。マージは常に同じパーティションの中で行われます。system.partsがそれらを一覧表示し、数に入るのはactive = 1のものだけです。マージ済みのパートは、削除されるまでしばらく非アクティブのまま残ります。

CREATE TABLE ventas (fecha Date, id UInt64, importe Decimal(10, 2))
ENGINE = MergeTree
PARTITION BY toYYYYMM(fecha)
ORDER BY (fecha, id);

測定結果:9か月にわたる30万行を2回挿入すると、9個のパーティションに18個のアクティブなパートが残りました。

日単位のパーティショニングがよくある間違いである理由
パーティションが多いと小さなパートが多くなり、パートにはコストがあります。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;

OPTIMIZE … PARTITION … FINALは、そのパーティションのパートを1つにマージします。DROP PARTITIONは、ファイルを削除してパーティション全体を消します。測定結果は5ミリ秒でした。翌月をALTER TABLE … DELETE WHEREで削除すると54ミリ秒かかりました。ミューテーションはパートを手放すのではなく書き直すためで、この差はサイズとともに大きくなります。

TTL:マージ時に適用される有効期限

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;

テーブルのTTLは期限切れの行を削除し、列のTTLはその値をデフォルトに戻します。どちらもマージ時に行われ、挿入時でも正確な時刻でもありません。測定結果:そのテーブルに2年前の行を挿入すると、クエリーしてもまだ残っており、OPTIMIZE … FINALの後に消えました。強制しなければ、サーバは4時間ごとに期限を確認します(merge_with_ttl_timeout)。そのため、TTLは一度きりの削除ではありません。期限切れのデータがしばらく見え続けることがあります。

Calíopeでは
カタログの「キー」タブには、各テーブルのパーティションキーが表示されます。サーバ情報の「ディスク使用量」は、system.partsのアクティブなパートを合計し、圧縮率を表示します。テーブルメンテナンスでは、1つのパーティションに限定したOPTIMIZEを実行できます。FINALは、パーティション全体を書き直すため、デフォルトでオフになっています。

キーワード: パーティション、PARTITION BY、パート、system.parts、アクティブ、parts_to_throw_insert、max_partitions_per_insert_block、OPTIMIZE、DROP PARTITION、TTL、有効期限、toYYYYMM、マージ、ClickHouse

マテリアライズドビュー(ClickHouse)

キャッシュされたテーブルではなく挿入のトリガー:TOと内部テーブルの違い、POPULATE、system.view_refreshesで見る更新可能なビュー、そしてUPDATEもDELETEも見えない理由。

対象: ClickHouse 25.3+

ClickHouseのマテリアライズドビューは、結果をキャッシュした保存済みのクエリーではありません。挿入のトリガーです。ソーステーブルに行のブロックが届くたびに、ビューはそのブロックに対してSELECTを実行し、結果を書き込み先のテーブルに書き込みます。それ以前にあったものも、後で変わったものも、ビューには見えません。

TOを使うか、内部テーブルを使うか

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;

TOを使うと、書き込み先は事前に作成された、独自の名前を持つ普通のテーブルになります。TOを使わないと、ビュー自身がENGINEを持ち、サーバはビューのUUIDを使った.inner_id.<uuid>という隠しテーブルを作成します。測定結果:TOなしのビューは.inner_id.ed264ea3-…を作成し、system.tablesでのそのUUIDは同じものでした。TOを使えばデータがどこにあるかがわかり、データを失わずにビューを削除できるので、推奨される方法です。

それ以前のデータは入らない(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を保ったままでした。ソースを修正したら、書き込み先は手作業で再計算します。

書き込み先もマージされる
書き込み先がSummingMergeTreeやAggregatingMergeTreeの場合、挿入のたびに部分的な行が残り、マージ時にまとめられます。書き込み先へのクエリーは、個々の行を読むのではなく集計します(sum(total)、-Merge)。

更新可能なビュー:もう1つの種類

CREATE MATERIALIZED VIEW resumen
REFRESH EVERY 1 MINUTE
ENGINE = MergeTree ORDER BY k AS
SELECT k, count() AS c FROM origen GROUP BY k;

REFRESH付きのビューは、キャッシュされたテーブルのように動作します。一定の間隔ごとにクエリー全体を再実行して結果を置き換えるので、ミューテーションによる変更も含めてソース全体が見えます。system.view_refreshesは、その状態(Scheduled、Running…)、最後に成功した更新、次回の更新を示します。ClickHouse 26.8での測定結果:実験的な設定なしで動作します。

Calíopeでは
ClickHouseでは、カタログに「マテリアライズドビュー」タブがあり、それぞれがどのテーブルから読み、どのテーブルに書き込むか(内部テーブルの場合は、クエリーに使う名前)、書き込み先のサイズと行数、最後に書き込まれた行、更新可能なビューであればそのスケジュールと更新履歴を表示します。エディタのツリーでは、普通のビューとは別にまとめられています。

キーワード: マテリアライズドビュー、TO、inner_id、POPULATE、REFRESH EVERY、更新可能、view_refreshes、トリガー、SummingMergeTree、ミューテーション、ClickHouse

ミューテーションと削除(ClickHouse)

非同期のパートの書き直しとしてのUPDATEとDELETE、system.mutationsと失敗の理由、KILL MUTATION、mutations_sync、軽量DELETE、そしてミューテーションが行数を数えない理由。

対象: ClickHouse 25.3+

ClickHouseは行をその場で変更しません。ALTER TABLE … UPDATEとALTER TABLE … DELETEはミューテーションです。サーバはそれを記録してすぐに応答し、その後、影響を受けるパートをバックグラウンドで書き直します。1行しか変わらなくても、パート全体を書き直します。

実行すると何が起きるか

ALTER TABLE eventos UPDATE estado = 'cerrado' WHERE id < 10;
SELECT mutation_id, parts_to_do, is_done FROM system.mutations WHERE table = 'eventos';

5万行のパートが2つあるテーブルでの測定結果:文は10ミリ秒で、parts_to_do = 2とis_done = 0を返しました。完了すると、パートall_1_1_0とall_2_2_0はall_1_1_0_3とall_2_2_0_3に置き換えられていました。10行を変えるために、それぞれ5万行が書き直されたことになります。古いパートは、old_parts_lifetimeが経過するまで(デフォルトは480秒)非アクティブのままディスクに残ります。

待つか、待たないか
mutations_syncはデフォルトで0で、文は待ちません。1では現在のレプリカを、2ではすべてのレプリカを待ちます。測定結果:mutations_sync = 2では、次のSELECTはすでに新しい値を見ていました。

ミューテーションが失敗したとき
失敗したミューテーションは、取り消されることも破棄されることもありません。再試行を続け、そのテーブルで後ろに並んだミューテーションをブロックします。測定結果:ゼロ除算を含むミューテーションの後、次のミューテーションは、最初のものが削除されるまで適用されませんでした。理由はsystem.mutations.latest_fail_reasonにあり(Code: 153 … Division by zero)、次のようにして削除します。

KILL MUTATION WHERE database = 'demo' AND table = 'eventos' AND NOT is_done SYNC;

ミューテーションがすでに書き直した部分は、元に戻りません。

ミューテーションは行数を数えない
サーバは、ミューテーションをキューに入れるときに影響を受ける行を数えません。system.query_logではwritten_rows = 0と表示されます。ミューテーションについて表示される行数は、どれも作り話になってしまいます。

軽量DELETE
DELETE FROM テーブル WHERE …はパートを書き直しません。行に削除済みの印を付けてクエリーから隠し、次のマージで実際に削除されます。測定結果:10万行のうち、DELETE FROM … WHERE id < 3の後には99,997行が見え、apply_deleted_mask = 0では10万行すべてがまだ残っていました。lightweight_deletes_syncはデフォルトで2で、印がすべてのレプリカに届くまで待ちます。

Calíopeでは
ClickHouseでセルを編集したり行を削除したりすると、ALTER TABLE … UPDATEまたはALTER TABLE … DELETEが書き込まれ、エディタは行数ではなく、ミューテーションがキューに入ったことをその識別子とともに示します。プロセスには、完了していないミューテーションが失敗の理由をテキストで添えて表示され、キャンセルすることもできます。

キーワード: ミューテーション、ALTER TABLE UPDATE、ALTER TABLE DELETE、system.mutations、latest_fail_reason、KILL MUTATION、mutations_sync、DELETE FROM、軽量DELETE、lightweight_deletes_sync、parts_to_do

トランザクションと原子性(ClickHouse)

トランザクションはありません:INSERTが保証すること、ブロックをまたいで原子的でない理由、ブロックの重複排除、そしてBEGINなしで読み込みを冪等にする方法。

対象: ClickHouse 25.3+

ClickHouseにはトランザクションがありません。BEGIN TRANSACTIONはCode: 48 … Transactions are not supported (NOT_IMPLEMENTED)を返し、implicit_transaction = 1も同じです。COMMITもROLLBACKもなく、それぞれの文が独立しています。

INSERTが保証すること
INSERTはブロック単位で書き込まれ(max_insert_block_size、デフォルトは1,048,449行)、各ブロックは原子的です。全体が入るか、まったく入らないかのどちらかです。ブロックをまたいだ保証はありません。測定結果:10万行ずつのブロックで40万行をINSERT … SELECTし、25万行目で失敗させると、10万行が書き込まれたまま残りました。失敗したINSERTはテーブルを読み込み途中の状態にすることがあり、それを元に戻すものはありません。

ブロックの重複排除
Replicated*テーブルは、最近挿入したブロックのハッシュを覚えておき(replicated_deduplication_window、1万個)、同じブロックが再び届くと破棄します。測定結果:ReplicatedMergeTreeで同じINSERTを2回実行すると、4行ではなく2行が残りました。普通のMergeTreeではこのウインドウは0で(non_replicated_deduplication_window)、同じINSERTを2回実行すると4行が残りました。

読み込みを冪等にする方法
- 同じブロックでバッチを繰り返します。Replicated*では、重複排除が繰り返し分を破棄します。
- insert_deduplication_tokenでバッチに名前を付けます。ウインドウを100にしたMergeTreeでの測定結果:同じトークンで異なるデータを持つ2回目のINSERTは入りませんでした。
- ステージング用のテーブルに読み込み、ALTER TABLE … REPLACE PARTITION … FROMでパーティション全体を一度に入れ替えます(サーバのドキュメントによる)。
- 次のマージまで重複した行があっても構わない場合は、ReplacingMergeTreeを使い、FINAL付きで読みます。

Calíopeでは
ClickHouseでは、エディタに「トランザクション」メニューは表示されません。開くものも元に戻すものもなく、それぞれの文は実行した時点で完了しています。

キーワード: トランザクション、BEGIN、COMMIT、ROLLBACK、原子性、INSERT、ブロック、max_insert_block_size、重複排除、insert_deduplicate、insert_deduplication_token、replicated_deduplication_window、冪等、NOT_IMPLEMENTED

パフォーマンス(ClickHouse)

PREWHERE、FINALのコスト、EXPLAINで読まれるグラニュール、max_threadsとmax_memory_usage、log_comment付きのsystem.query_log、そしてキーを使わない3つのクエリー。

対象: ClickHouse 25.3+

ClickHouseでのパフォーマンスは、ほとんどがどれだけ読むかで決まります。キーがいくつのグラニュールを飛ばし、いくつの列を取り出す必要があるかです。

EXPLAINでのグラニュール

EXPLAIN indexes = 1
SELECT count() FROM eventos WHERE fecha BETWEEN '2026-02-01' AND '2026-02-03';

ORDER BY (fecha, cliente, id)の100万行で、実行計画にはGranules: 5/123と表示されました。123個のグラニュールのうち5個を読んだということです。この比率が、最初に見るべきものです。

キーを使わない3つのクエリー
同じテーブルでの測定結果:
- キーに含まれない列で絞り込む(url LIKE '%/p/12%'):123/123。
- キーの列とそうでない列を混ぜたOR(fecha = … OR url = …):123/123。
- キーの2番目の列だけで絞り込む(cliente = 42):101/123。

PREWHERE
PREWHEREを使うと、サーバはまず絞り込みの列を読み、条件を満たした行についてだけ残りの列を取り出します。optimize_move_to_prewhereは1で、これを自動で行います。20行を返すurlでの絞り込みと、SELECTに200バイトの列を含めた場合の測定結果:最適化ありで30 MiBを読み、なしでは204 MiBでした。

FINALにはコストがある
FINALは、ReplacingMergeTreeの重複した行をその場でマージします。100万個のキーを持つ200万行での測定結果:FINALなしのsum(v)は42ミリ秒で、重複も数えました。FINAL付きでは323ミリ秒かかり、メモリーは5.5 MiBではなく42 MiBを使いました。

スレッドとメモリー
max_threadsはauto(N)で、コアごとに1つです(測定したサーバでは8)。max_memory_usageは0で、クエリーごとの上限はありません。上限を設けると、それを超えたクエリーはCode: 241 … Query memory limit exceededで失敗します。

system.query_log
すべてのクエリーがここに実行時間、read_rows、read_bytes、memory_usageを残し、log_commentでタグを付けると見つけやすくなります。

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';

Calíopeでは
ビジュアルな実行計画ビューアは、読み込みの各ステップで読んだグラニュールを表示し、1つも飛ばしていない場合は赤で示します。スロークエリーはsystem.query_logから取得され、時間の横に読んだバイト数とメモリーが表示されます。Calíope自身が実行するものにはlog_commentでタグが付けられ、非表示にできます。

キーワード: パフォーマンス、PREWHERE、FINAL、グラニュール、EXPLAIN、インデックス、max_threads、max_memory_usage、query_log、read_bytes、memory_usage、log_comment、ソートキー

マージとOPTIMIZE(ClickHouse)

バックグラウンドのマージ、system.mergesとsystem.parts、OPTIMIZEとOPTIMIZE FINALの違い、パートのしきい値、そして「パートが多すぎる」が最もよくある運用上のエラーである理由。

対象: ClickHouse 25.3+

INSERTのたびに、ディスク上に新しいパートが作成されます。サーバはバックグラウンドで、小さなパートを大きなパートにマージします。それによってテーブルのパートが少なく保たれ、ReplacingMergeTree、TTL、軽量削除が適用されます。

パートとマージを見る

SELECT partition, name, rows, active FROM system.parts WHERE table = 'eventos';
SELECT table, elapsed, progress, num_parts, result_part_name FROM system.merges;

非アクティブなパートは、すでにマージに含まれたパートです。old_parts_lifetime(480秒)が経過するまでディスクに残り、その後削除されます。

OPTIMIZEとOPTIMIZE FINAL
OPTIMIZE TABLEはマージを今すぐ要求します。OPTIMIZE TABLE … FINALは、すでに1つしかなくても、パーティションごとにパートが1つになるまでマージを強制します。測定結果:マージを止めてINSERTを5回実行すると、パートは5つになりました。小さかったためOPTIMIZEで1つにまとまり、その1つのパートにOPTIMIZE … FINALを実行すると、パート全体が書き直されました。FINALはテーブルを書き直すので、大きなテーブルでは大きな作業になります。

パートが多すぎる
INSERTがパートを作る速さがマージの速さを上回ると、サーバはまず速度を落とし、その後で拒否します。
- parts_to_delay_insert(デフォルトは1,000):これを超えると、INSERTのたびに遅延を入れます。
- parts_to_throw_insert(3,000):これを超えると、拒否します。

しきい値を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つのパートになりました。

Calíopeでは
テーブルメンテナンスでは、FINALオプション付きのOPTIMIZEを実行できます。FINALはテーブルを書き直すため、最初はオフになっていて、実行前に警告します。1つのパーティションに限定することもできます。サーバ情報には、アクティブなパートを合計した各テーブルのサイズと圧縮率が表示されます。

キーワード: マージ、OPTIMIZE、OPTIMIZE FINAL、system.merges、system.parts、パート、非アクティブなパート、old_parts_lifetime、parts_to_delay_insert、parts_to_throw_insert、パートが多すぎる、TOO_MANY_PARTS、async_insert

レプリケーションとKeeper(ClickHouse)

ReplicatedMergeTreeとKeeper、{shard}と{replica}のマクロ、system.replicas、SYNC REPLICAとRESTART REPLICA、Keeperがないときに起きること、DROP TABLE … SYNC、そしてsystem.clusters。

対象: ClickHouse 25.3+

ClickHouseのレプリケーションはテーブル単位です。それぞれのReplicated*テーブルは、Keeper(またはZooKeeper)を通じてコピーと調整します。Keeperは、どのパートが存在し、何をコピーする必要があるかを記録します。プライマリはなく、どのレプリカも書き込みを受け付けます。

レプリケートされたテーブルを作成する

CREATE TABLE ventas (id UInt32, v String)
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/ventas', '{replica}')
ORDER BY id;

パスはKeeperの中でテーブルを識別し、すべてのレプリカで同じです。{shard}と{replica}は、各サーバが自分の設定から埋めるマクロです(system.macros)。測定したペアでは、両方のレプリカがshard = 01で、replicaは一方がr1、もう一方がr2です。テーブルは各レプリカで作成し、行は自動的に届きます。

system.replicas

SELECT table, is_readonly, is_session_expired, absolute_delay, queue_size, active_replicas, total_replicas FROM system.replicas;

- absolute_delay:最も新しいレプリカから何秒遅れているか。
- queue_size:保留中のタスク(パートの取得、マージ、ミューテーション)。
- is_readonlyとis_session_expired:レプリカがKeeperを失ったこと。

測定結果:r2で取得を止め(SYSTEM STOP FETCHES)、r1に1行挿入すると、r2はqueue_size = 1で2行のままでした。再開すると3行になりました。

SYNC REPLICAとRESTART REPLICA
SYSTEM SYNC REPLICA テーブルは、レプリカがキューを空にするまで待ちます。測定では、3行すべてが最新の状態で戻りました。SYSTEM RESTART REPLICA テーブルは、状態をKeeperから読み直します。その後はis_readonly = 0、active_replicas = 2になりました。

Keeperがなければ、読み出し専用
Keeperを止めての測定結果:テーブルはis_readonly = 1とis_session_expired = 1になり、読み込みは引き続き動作しましたが、INSERTは120秒待ってTIMEOUT_EXCEEDEDで失敗しました。Keeperが戻ると、レプリカは自動的に回復しました。

DROP TABLE … SYNC
レプリケートされたテーブルをSYNCなしで削除すると、Keeperに残った痕跡は後で片付けられるため、すぐに作り直すとCode: 253 … Replica … already exists (REPLICA_ALREADY_EXISTS)で失敗します(測定済み)。DROP TABLE … SYNCでは、文がその片付けを待ちます。

system.clusters
クラスタはSQLで作成するのではなく、サーバの設定で宣言します。system.clustersは、シャードとレプリカとともにそれらを一覧表示します。テストベッドでは、caliope_parに2つのレプリカ(clickhouse-r1とclickhouse-r2)を持つ1つのシャードがあります。

Calíopeでは
ClickHouseでは、レプリケーションモニタはテーブルごとに、アクティブなレプリカ数と合計数、欠けているレプリカの名前、遅延とキュー、そして別の状態としてKeeperの喪失を表示します。トポロジは、system.clustersが宣言する構成を描きます。

キーワード: レプリケーション、ReplicatedMergeTree、Keeper、ZooKeeper、マクロ、シャード、レプリカ、system.replicas、absolute_delay、queue_size、is_readonly、SYNC REPLICA、RESTART REPLICA、system.clusters、REPLICA_ALREADY_EXISTS

セキュリティ:ユーザ、ロール、権限(ClickHouse)

サーバのファイルにあるユーザとSQLで作成したユーザの違い、ロール、GRANTと部分的な取り消し、クォータ、設定プロファイル、行ポリシー、そしてパスワードをURLに入れない理由。

対象: ClickHouse 25.3+

ClickHouseはユーザを2つの場所に保持し、ユーザがどちらから来たかによって、そのユーザに対してできることが決まります。

2つのユーザストレージ

SELECT name, storage, auth_type FROM system.users;

- users_xml:サーバのファイル(users.xmlとusers.d/)で宣言されたユーザ。変更するにはそれらのファイルを編集するしかなく、それらに対するALTER USERはCode: 495 … ACCESS_STORAGE_READONLYで失敗します(測定済み)。
- local_directory:CREATE USERで作成したユーザ。SQLで管理し、サーバがディスクに保存します。

SQLでユーザを作成するには、アクセス管理の権限を持つアカウント(設定のaccess_management、またはACCESS MANAGEMENT権限)が必要です。

ロール、GRANT、部分的な取り消し

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でした。

パスワードはURLに入れない
HTTPインターフェイスはアドレスに?user=…&password=…を受け付けますが、URLはターミナルの履歴、プロキシのログ、ネットワークツールに残ります。測定したサーバは自身のログではそれを隠しますが(password=[HIDDEN])、ほかのものは隠しません。Calíopeは資格情報をX-ClickHouse-UserとX-ClickHouse-Keyのヘッダで、クエリーをリクエストの本文で送ります。

Calíopeでは
ユーザには、アカウントとロールがストレージとともに一覧表示されます。users_xmlのものには南京錠のアイコンが付き、編集できません。部分的な取り消しは取り消し線付きでREVOKEのラベルとともに表示されます。書き込むには、接続しているアカウントにアクセス管理の権限が必要です。権限がなければ、ボタンは使えない状態になり、バナーに理由が表示されます。

キーワード: セキュリティ、ユーザ、ロール、GRANT、REVOKE、部分的な取り消し、is_partial_revoke、SHOW GRANTS、system.users、users_xml、local_directory、アクセス管理、クォータ、設定プロファイル、行ポリシー、パスワード、アクセス拒否

サーバの設定(ClickHouse)

クエリーの設定とサーバの設定の違い、config.dとusers.d、HTTPで通信するときに設定を永続化する方法、そしてCalíopeがすべてのリクエストで送る設定。

対象: ClickHouse 25.3+

ClickHouseには2種類の設定があり、それらを混同するのが設定時に最もよくある間違いです。

クエリーの設定とサーバの設定

SELECT name, value, changed FROM system.settings WHERE changed;
SELECT name, value, changeable_without_restart FROM system.server_settings WHERE name = 'max_concurrent_queries';

- system.settings(測定したサーバでは1,817個):クエリーに適用されます。クエリー(末尾のSETTINGS …)、セッション、プロファイル、またはユーザで設定します。
- system.server_settings(447個):max_concurrent_queries(デフォルトは1,000)やmax_server_memory_usageのように、プロセス全体に適用されます。サーバの設定で変更し、changeable_without_restart列が再起動なしで適用されるかどうかを示します。測定したサーバでは、447個のうち339個が再起動を必要とします。

config.dとusers.d
設定は/etc/clickhouse-server/にあります。サーバ用のconfig.xmlと、ユーザ、プロファイル、クォータ用のusers.xmlです。自分の変更はconfig.d/とusers.d/の中の別ファイルに入れます。サーバはそれらをメインの設定と結合するので、アップグレードで上書きされません。公式のDockerイメージには、それぞれのフォルダにすでに1つずつ入っています。

HTTPではSETは続かない
HTTPのリクエストはそれぞれ独立しています。SET max_threads = 7はそのリクエストにだけ適用され、次のリクエストにはもうありません(測定済み)。設定はリクエスト自体で送るか(?max_threads=5)、ユーザに永続化します。

ALTER USER ana MODIFY SETTINGS max_threads = 3;

測定結果:それ以降、anaの新しい接続にはすべて、changed = 1でこの設定が付きます。MODIFYという語を付けないと、ALTER USER … SETTINGSはアカウントの設定をすべて置き換えます。また、これはSQLで作成したユーザでしか動作せず、users_xmlのユーザではACCESS_STORAGE_READONLYで失敗します。

Calíopeがすべてのリクエストで送るもの
数値を精度を失わずに読み、自分のクエリーを識別するための、5つの固定の設定です。
- output_format_json_quote_64bit_integers、output_format_json_quote_decimals、output_format_json_quote_64bit_floats、output_format_json_quote_denormals:64ビット整数、小数、infやnanのような値が引用符付きで届きます。JSONでは64ビット整数を正確に表せず、これらの設定がないとinfとnanはnullとして届きます。
- log_comment:system.query_logでCalíopeのクエリーにタグを付けます。

エディタはsession_idを使いません(複数の文を持つレポートだけが自分のセッションを開きます)。その理由は「制限(ClickHouse)」で説明しています。

Calíopeでは
スロークエリーは、そのしきい値をALTER USER … MODIFY SETTINGSで保存します。アカウントを変更できない場合は、コントロールの代わりに注記でそのことを示します。

キーワード: 設定、サーバの設定、system.settings、system.server_settings、config.xml、users.xml、config.d、users.d、ALTER USER、MODIFY SETTINGS、SET、max_threads、再起動、log_comment、output_format_json_quote

制限(ClickHouse)

max_query_size、クエリーごとのメモリー、結果の上限、同時実行クエリー、パートが多すぎる場合、挿入ブロック、そしてエディタがsession_idを使わない理由。

対象: ClickHouse 25.3+

クエリーのテキスト
max_query_sizeはデフォルトで262,144バイトです。それより長いクエリーは、262,145番目の位置でCode: 62 … Max query size exceededで失敗します(測定済み)。クエリーごとにSETTINGS max_query_size = …で引き上げられます。Calíopeのダンプは、INSERT文をこの上限以下にまとめます。

メモリー
測定したサーバではmax_memory_usageは0(クエリーごとの上限なし)で、実際の上限はプロセスのmax_server_memory_usageです。上限を低くすると、クエリーはCode: 241 … MEMORY_LIMIT_EXCEEDEDで失敗し、メッセージにはどれだけ使おうとしたかが示されます。

結果のサイズ
max_result_rowsとmax_result_bytesはデフォルトで0です。上限を設定すると、result_overflow_mode = 'throw'(デフォルト)ではクエリーがCode: 396 … TOO_MANY_ROWS_OR_BYTESで失敗し、'break'ではその時点までに読んだものを返します。

同時に実行するクエリー
max_concurrent_queriesはサーバ全体(デフォルトは1,000)、max_concurrent_queries_for_userはユーザごと(0、上限なし)の設定です。超えると、新しいクエリーがCode: 202 … TOO_MANY_SIMULTANEOUS_QUERIESで失敗します(上限を1にして測定済み)。

パートが多すぎる
INSERTのたびにパートが作成されます。1つのパーティションでアクティブなパートが1,000個(parts_to_delay_insert)になるとサーバは挿入を遅らせ、3,000個(parts_to_throw_insert)になるとTOO_MANY_PARTSで拒否します。詳しくは「マージとOPTIMIZE(ClickHouse)」で説明しています。

挿入ブロック
サーバは、INSERTの行を最大max_insert_block_size行(1,048,449行)のブロックにまとめます。大きなINSERTが途中で失敗すると、それまでのブロックは書き込まれたまま残ります。原子的ではありません。詳しくは「トランザクションと原子性(ClickHouse)」で説明しています。

1つのリクエストに1つの文、そしてセッションなし
HTTPでは、各リクエストは1つの文しか受け付けません。;で区切った2つの文はMulti-statements are not allowedで失敗します(測定済み)。インターフェイスにはセッション用のsession_idがあり、それを使えばSETはリクエストをまたいで続きます。しかし、セッションは一度に1つのクエリーしか受け付けません。最初のクエリーの実行中に同じsession_idで2つ目を送ると、Code: 373 … SESSION_IS_LOCKEDで失敗します(測定済み)。そのため、エディタはこれを使いません。同じ接続の2つのタブがこのエラーになってしまうからです。複数の文を持つレポートだけが、実行のたびに新しく、ほかの誰とも共有しない自分のセッションを開きます。どのリクエストにも、そのデータベースと設定が付いています。

キーワード: 制限、max_query_size、max_memory_usage、メモリー、max_result_rows、max_result_bytes、result_overflow_mode、max_concurrent_queries、同時実行クエリー、parts_to_throw_insert、パートが多すぎる、max_insert_block_size、session_id、セッション、複数の文

よくあるエラー(ClickHouse)

日常的に目にするエラーコードを、その番号と名前とともに:原因と対処法。

対象: ClickHouse 25.3+

ClickHouseのエラーには、どれも番号と名前が付いています(Code: 60 … (UNKNOWN_TABLE))。番号はバージョンが変わっても同じで、ドキュメントで調べるときは名前を使います。HTTPでは、番号はX-ClickHouse-Exception-Codeヘッダでも届きます。この一覧のエラーは、どれも測定したサーバで実際に発生させたものです。

60 · UNKNOWN_TABLE
そのデータベースにテーブルがありません。HTTPにはUSEがなく、データベースはリクエストのたびに送られます。選んでいるデータベースを確認するか、database.tableと書いてください。

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…)を使えば解決します。

241 · MEMORY_LIMIT_EXCEEDED
クエリーがメモリーの上限を超えました。メッセージには、どれだけ使おうとしたかが示されます。早い段階で絞り込む、集計する列を減らす、サーバに余裕があればmax_memory_usageを引き上げる、といった方法が有効です。

202 · TOO_MANY_SIMULTANEOUS_QUERIES
サーバ全体またはそのユーザで、同時に実行しているクエリーが多すぎます。待てば解決します。プロセスに実行中のものが表示されます。

497 · ACCESS_DENIED
権限が不足しています。メッセージには、どの権限かが示されます(it's necessary to have the grant SELECT ON demo.eventos)。データベース全体に権限が付与されていても、部分的な取り消しがあるとこのエラーになります。

164 · READONLY
クエリーが読み出し専用モードで実行されています(ユーザ、そのプロファイル、またはクエリー自体にreadonly = 1)。書き込めるアカウントが必要です。

373 · SESSION_IS_LOCKED
同じsession_idで2つのリクエストが同時に送られました。Calíopeがセッションを開くのは複数の文を持つレポートのときだけで、実行のたびに新しいセッションを使います。このエラーが出た場合は、ほかのクライアントが原因です。

395 · FUNCTION_THROW_IF_VALUE_IS_NON_ZERO
クエリーのthrowIfが発動し、メッセージはクエリーが指定したものです。クエリーがすでに行を返し始めていた場合、エラーは応答の最後に届きます。Calíopeはそれを途中で切れた結果ではなく、エラーとして表示します。

420 · CANNOT_UPDATE_COLUMN
ソートキーの列に対するALTER TABLE … UPDATEです。キーは変更できません。新しいキーで別のテーブルを作成し、データをコピーします。

495 · ACCESS_STORAGE_READONLY
サーバのファイルで宣言されたユーザ(users_xml)を変更しようとしました。users.d/で変更するか、SQLで別のユーザを作成します。

516 · AUTHENTICATION_FAILED
ユーザまたはパスワードが間違っています。サーバはどちらかを示しません。HTTPではステータス403で届きます。

Calíopeでは
これらのエラーがエディタに表示されると、「解決方法を表示」のバナーからこのトピックに移動できます。

キーワード: エラー、コード、UNKNOWN_TABLE、SYNTAX_ERROR、TYPE_MISMATCH、MEMORY_LIMIT_EXCEEDED、TOO_MANY_SIMULTANEOUS_QUERIES、ACCESS_DENIED、READONLY、SESSION_IS_LOCKED、THROWIF、CANNOT_UPDATE_COLUMN、ACCESS_STORAGE_READONLY、AUTHENTICATION_FAILED、60、62、53、241、202、497、164、373、395、420、495、516

数値(Oracle)

NUMBERは何も言わずにスケールに丸め、精度を超えるものは拒否します。BINARY_DOUBLEは2進数です。

対象: Oracle 26ai+

Oracleでは、ほとんどすべての数値がNUMBERです:10進数で、正確で、最大38桁です。INTEGERとSMALLINTは名前としてしか存在せず(測定では、どちらの列もスケール0のNUMBERとして作成されました)、UNSIGNEDも、サイズ別の整数型もありません。

NUMBER(p,s):スケールは丸め、精度は拒否する

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

右側でスケールを超えた分は警告なしに丸められ、左側に収まらない分はORA-01438になります。負のスケールは10の位や100の位に丸め、NUMBERだけなら値をそのまま格納します。

正確で、サイズは可変
NUMBERは10進数を格納します:0.1 + 0.2 = 0.3は真です。サイズは宣言ではなく値で決まります:VSIZEで測定すると、1は2バイト、123.45は4バイト、1234567890123は8バイトでした。

BINARY_DOUBLEとBINARY_FLOATは2進数
IEEE 754のdoubleとfloatです:計算は速く、いつもの誤差もあります。0.1d + 0.2dは3.0000000000000004E-001になり、0.3dと比べると偽でした。一方、FLOATだけなら、精度をビットで指定するNUMBERです(FLOAT(126))。金額にはNUMBER(p,s)を使います。

BOOLEANは23aiから

CREATE TABLE bo (v BOOLEAN);
INSERT INTO bo VALUES (TRUE), (FALSE), (NULL), ('yes'), (0);
SELECT COUNT(*) FROM bo WHERE v;  -- 2

'yes'や0も受け付け、比較なしで使えます(WHERE v)。23aiより前には存在せず、CHECK付きのNUMBER(1)を使うのが慣例です。

Calíopeでは
グリッドはNUMBERを浮動小数点を通さずに正確に表示し、BOOLEANをTRUEまたはFALSEとして表示します。

キーワード: 数値、NUMBER、10進数、精度、スケール、BINARY_DOUBLE、BINARY_FLOAT、FLOAT、BOOLEAN、ORA-01438、Oracle

テキストと空文字列(Oracle)

VARCHAR2はCHARと指定しないかぎりバイトで数え、上限は4000で、空文字列はNULLです。

対象: Oracle 26ai+

空文字列はNULL
ほかのエンジンから来たときにいちばん驚くのがこれです:Oracleでは''はまさにNULLです。測定では、'' IS NULLは真、LENGTH('')は0ではなくNULLで、''を挿入すると列はNULLになるため、COUNT(column)はそれを数えません。NOT NULLの列は''をORA-01400で拒否します。

VARCHAR2(5)は5文字ではなく5バイト
テストベッドのデータベースはAL32UTF8とNLS_LENGTH_SEMANTICS = BYTEを使っています:列の長さは、特に指定しないかぎりバイトで数えられます。

CREATE TABLE t (b VARCHAR2(5), ch VARCHAR2(5 CHAR));
INSERT INTO t (b)  VALUES ('ñandú'); -- ORA-12899 (actual: 7, maximum: 5)
INSERT INTO t (ch) VALUES ('ñandú'); -- OK
SELECT LENGTH('ñandú'), LENGTHB('ñandú') FROM dual; -- 5, 7

アクセント付きの文字やñは1文字で2バイトを使います。アクセントのあるテキストにはVARCHAR2(n CHAR)を使います。Calíopeの「ビルダー」が書くのもこれです。

上限:4000バイト、またはCLOB
イメージの設定であるMAX_STRING_SIZE = STANDARDでは、VARCHAR2(4001)はORA-00910になり、SQLで4000バイトを超えて連結するとORA-01489になります。それを超えるものはCLOBにします:TO_CLOB(…)に連結すると8000文字になりました。EXTENDEDへの切り替えはデータベース全体の変更で、セッションの設定ではありません。

CHARは空白で埋める
'ab'を入れたCHAR(5)は'ab '(LENGTHは5)を格納しますが、それでも= 'ab'は真です。Oracleが比較の前にリテラルを埋めるからです。CHARが必要になることはまずありません:VARCHAR2は何も埋めません。

NVARCHAR2とNCLOB
各国語文字セットを使います。テストベッドではAL16UTF16です。AL32UTF8のデータベースでは、すでにそれらを使っているアプリケーションにしか必要ありません。

Calíopeでは
空文字列を持つパラメータはNULLとして送られます。どのみちサーバもそうします。

キーワード: テキスト、VARCHAR2、CHAR、CLOB、NVARCHAR2、空文字列、NULL、BYTE、CHARセマンティクス、AL32UTF8、ORA-12899、ORA-01400、ORA-01489、Oracle

日付と時刻(Oracle)

DATEは時刻を、TIMESTAMPは秒未満を格納し、タイムゾーンは2つの異なる型で扱います。

対象: Oracle 26ai+

DATEには時刻がある
DATEは年、月、日、時、分、秒を7バイトで格納します。格納しないのは秒未満です:DATEに入れたTIMESTAMP '2026-10-16 14:30:05.987'は14:30:05になりました。だから、これは何も言わずに失敗します:

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になりました。見えるものは、誰が尋ねるかで変わります。

演算
2つのDATEを引くと日数になり(DATE '2026-10-17' - DATE '2026-10-16'は1)、1/24を足すと1時間が加わります。2つのTIMESTAMPを引くとインターバルになります。1か月は固定の長さではありません:ADD_MONTHS(DATE '2026-01-31', 1)は2026-02-28になりましたが、DATE '2026-01-31' + INTERVAL '1' MONTHはORA-01839になりました。

リテラルは常にANSIで
セッションのNLS_DATE_FORMATはDD-MON-RRで、書式マスクなしのTO_DATE('16-OCT-26')が正しかったのは、たまたま一致していたからにすぎません。セッションの書式や言語が違えば、同じテキストが失敗するか別の日付になります。DATE '2026-10-16'とTIMESTAMP '2026-10-16 14:30:05'は何にも依存しません。

Calíopeでは
CalíopeはSQLを生成するときにANSIリテラルを書きます。グリッドはDATEを時刻付きで、WITH TIME ZONEをオフセット付きで、WITH LOCAL TIME ZONEをセッションのタイムゾーンで表示します。

キーワード: 日付、時刻、DATE、TIMESTAMP、WITH TIME ZONE、WITH LOCAL TIME ZONE、INTERVAL、ADD_MONTHS、NLS_DATE_FORMAT、ORA-01839、Oracle

Bツリーインデックス(Oracle)

Oracleの通常のインデックス:オプティマイザがいつそれを使うか、そして値のない行がなぜそこに入らないか。

対象: Oracle 26ai+

単純なCREATE INDEXはBツリーインデックスを作成します。統計情報のある20,000行のcliテーブルで測定しました:

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

ワイルドカードで始まるLIKEはツリーをたどれず、列に関数をかけた場合も同じです:インデックスはUPPER(email)ではなくemailで並んでいるからです。そのためにあるのが関数ベースのインデックスです(「関数ベース、不可視、ビットマップのインデックス(Oracle)」)。

値のない行はインデックスに入らない
Bツリーは、キー全体がNULLの行を格納しません。退会日が200件でNULLが19,800件のとき、WHERE baja IS NULLはTABLE ACCESS FULLになりました:インデックスにはそれらの行がないからです。複数列のインデックスでは、NULLでない列が1つあれば行は含まれます。

複合インデックスでは最初の列が支配する
(estado, pais)のインデックスはestadoで並び、その中でpaisで並びます。pais = 'ES'だけで測定すると、オプティマイザはテーブル全体を読みました。いつも絞り込みに使う列を先頭に置きます。

オプティマイザは統計情報で判断する
条件がテーブルの大部分を返すなら、インデックスを行ったり来たりするより全体を読むほうが安く、Oracleはそうします。最新の統計情報がないと判断を誤ります:データを読み込んだあとはDBMS_STATS.GATHER_TABLE_STATSです。

キーはインデックスを伴い、外部キーは伴わない
主キーや一意キーはインデックスを作成します(「主キーと一意キー(Oracle)」)。外部キーは作成しません(「外部キー(Oracle)」)。

Calíopeでは
「実行計画ビューア」はこれらのノードをそのまま表示し、「カタログ」はインデックスを列とともに一覧にし、「テーブルメンテナンス」→「ANALYZE」が統計情報を収集します。

キーワード: インデックス、Bツリー、CREATE INDEX、INDEX RANGE SCAN、TABLE ACCESS FULL、NULL、複合、オプティマイザ、DBMS_STATS、Oracle

関数ベース、不可視、ビットマップのインデックス(Oracle)

式をインデックス化すること、削除せずにインデックスを無効にすること、そしてビットマップが邪魔になるとき。

対象: Oracle 26ai+

関数ベースのインデックス

CREATE INDEX ix_cli_email_up ON cli (UPPER(email));
-- WHERE UPPER(email) = 'U77@X.ES' → INDEX RANGE SCAN IX_CLI_EMAIL_UP

式の結果をインデックス化するもので、クエリーは同じ式を書く必要があります:UPPER(email)なら使われ、LOWER(email)では使われません。Oracleはそれを隠し仮想列として保持し(ALL_IND_COLUMNSにはSYS_NC00005$と表示されます)、式はALL_IND_EXPRESSIONSにあります(UPPER("EMAIL"))。データに手を触れずに、大文字と小文字を区別せずに検索する方法です。

不可視インデックス
ALTER INDEX ix_cli_email INVISIBLEは、インデックスを保守し続けながらオプティマイザの目から隠します:測定では、emailによるクエリーがINDEX RANGE SCANからTABLE ACCESS FULLになりました。削除する前に、そのインデックスが必要かどうかを試せます:何も悪くならなければ削除し、悪くなればVISIBLEで再構築なしにすぐ戻せます。

ビットマップインデックス
CREATE BITMAP INDEX … (estado)は26ai Freeで作成できました。値ごとにビットマップを格納し、異なる値が少なければ非常にコンパクトで、分析クエリーでは複数の条件を組み合わせられます。しかし、書き込まれる行はどれも、多くの行をカバーするビットマップの一区間をロックします:同時に書き込みがあるテーブルにはBツリーを使います。

それぞれが何か
ALL_INDEXES.INDEX_TYPEはNORMAL、FUNCTION-BASED NORMAL、BITMAP…を示し、VISIBILITYは可視かどうかを示します。ドメインインデックス(テキスト、空間)は、独自の型を持つ別の系統です。

Calíopeでは
「カタログ」は関数ベースのインデックスを式とともに、ドメインインデックスを型とともに表示します。インデックスアドバイザは1つの列へのBツリーを提案し、ワイルドカードで始まるLIKEには提案しません。

キーワード: インデックス、関数ベース、FUNCTION-BASED、UPPER、INVISIBLE、VISIBLE、BITMAP、ドメイン、ALL_IND_EXPRESSIONS、Oracle

使用不可のインデックスと再構築(Oracle)

テーブルを移動するとインデックスがUNUSABLEになります:その確認方法、何が壊れるか、どう直すか。

対象: Oracle 26ai+

インデックスはROWIDを通じて各行を指しています。ROWIDは行が物理的にどこにあるかを示すものです。行を動かすものはすべて、インデックスが何も指していない状態を残し、Oracleは間違った結果を返す代わりに、そのインデックスに使用不可の印(UNUSABLE)を付けます。

原因

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

測定では、主キーのものを含めてテーブルの5つのインデックスすべてがUNUSABLEになりました。UPDATE INDEXESなしでパーティションを削除したり切り捨てたりしても同じことが起きます(「パーティションプルーニングとメンテナンス(Oracle)」)。

何が壊れるか
オプティマイザはそのインデックスを無視し、クエリーは遅くなりながらも動き続けます:使用不可の主キーによるSELECTは応答しました。深刻なのは一意インデックスの場合です:主キーがUNUSABLEのとき、INSERTはORA-01502になりました。テーブルが新しい行を受け付けなくなります。

直し方
ALTER INDEX ix_cli_email REBUILDで再構築します:測定では、そのインデックスはVALIDになり、ほかはUNUSABLEのままでした。1つずつ再構築します。

壊さないほうがよい:MOVE ONLINE
ALTER TABLE cli MOVE ONLINEは、インデックスを保守しながらテーブルを移動します:同じテーブルで測定すると、5つともVALIDのままでした。何も壊さずにテーブルを詰めたり、別の表領域に移したりする方法です。

それぞれの状態
統計情報があれば、ALL_INDEXESがNUM_ROWS、DISTINCT_KEYS、LEAF_BLOCKSを示します:IX_CLI_EMAILは63のリーフブロックに20,000の異なるキーを持ち、IX_CLI_BAJAは200行でキーは1つだけでした。19,800件のNULLがそこにないからです。ディスク上のサイズはDBA_SEGMENTSにあります。

Calíopeでは
「カタログ」はVALIDでないインデックスに、その状態を表示します。

キーワード: インデックス、UNUSABLE、VALID、REBUILD、MOVE、MOVE ONLINE、ROWID、ORA-01502、LEAF_BLOCKS、DISTINCT_KEYS、Oracle

主キーと一意キー(Oracle)

それぞれがインデックスを作成し、一意キーは複数のNULLを通し、制約はCOMMITまで待つことができます。

対象: Oracle 26ai+

制約ごとに、そのインデックス
PRIMARY KEYやUNIQUEは、使えるものがなければ一意インデックスを作成します。名前がないと、制約とそのインデックスはSYS_C…と呼ばれます(測定ではSYS_C0010145、UNIQUE)。名前を付けましょう(CONSTRAINT pk_cli PRIMARY KEY (id)):エラーに表示されるのはその名前です。

UNIQUEとNULL

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

NULLはいくつでも入ります。キー全体がNULLのものはインデックスに入らないからです。しかし、複数列の一意キーで1つの列に値がある場合は入りません:(1, NULL)を2回入れるとORA-00001になりました。

遅延制約

CREATE TABLE dfr (id NUMBER,
  CONSTRAINT dfr_pk PRIMARY KEY (id) DEFERRABLE INITIALLY DEFERRED);
INSERT INTO dfr VALUES (1);
INSERT INTO dfr VALUES (1);  -- OK
COMMIT;                      -- ORA-02091: transaction rolled back

DEFERRABLE INITIALLY DEFERREDの制約は、文ごとではなく確定時に検査され、失敗するとトランザクション全体がロールバックされます:測定では、テーブルは空になりました。キーを入れ替えたり、関連するテーブルを任意の順序で読み込んだりできます。そのインデックスは一意ではありません(NONUNIQUE)。トランザクションの途中では重複がありうるからです。

エラーが場所を言う
26aiでは、ORA-00001が制約、テーブル、列の名前を挙げます:unique constraint (DBA_MANUAL.UQ_U) violated on table DBA_MANUAL.U columns (A)。

Calíopeでは
エラーのテキストは、制約の名前とともにそのままエディタに届きます。

キーワード: 主キー、PRIMARY KEY、UNIQUE、NULL、DEFERRABLE、INITIALLY DEFERRED、ORA-00001、ORA-02091、SYS_C、Oracle

外部キー(Oracle)

インデックスを作成せず、ON UPDATEがなく、削除せずに無効化と有効化ができます。

対象: Oracle 26ai+

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

DISABLEは何でも書き込めるようにし、ENABLEはあるものをすべて検査して、孤立した行があるとORA-02298になります。ENABLE NOVALIDATEは新しい行に対してだけ有効にします:状態はNOT VALIDATEDのままで、次の孤立した行はもう失敗します。外部キーが有効なとき、親テーブルのTRUNCATEはORA-02266になります。

Calíopeでは
「ER図」は外部キーを、スキーマをまたぐものも含めて描きます。Calíopeが書くDDLは、すべてのテーブルのあとで最後に外部キーを作成します。

キーワード: 外部キー、FOREIGN KEY、REFERENCES、ON DELETE CASCADE、ON DELETE SET NULL、ON UPDATE、NOVALIDATE、インデックス、ORA-02291、ORA-02292、ORA-02298、ORA-02266、Oracle

アイデンティティとシーケンス(Oracle)

ALWAYSは値を書かせず、BY DEFAULTは書かせます。そしてシーケンスは設計上、欠番を残します。

対象: Oracle 26ai+

アイデンティティ列

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

- CURRVALは自分のセッションの最後のNEXTVALです:最初のNEXTVALより前ではORA-08002になります。
- 同じ行の2つのNEXTVALは同じ値になります。
- 列のDEFAULT sq.NEXTVALはその列を自動増分にし、シーケンスはテーブル間で共有できます。

欠番は普通のこと
ROLLBACKで取り消されたNEXTVALは戻ってきません:測定では、1が出て、取り消され、次は2でした。CACHE 20では、Oracleは20個の番号をメモリーに予約し(2つ使った時点でLAST_NUMBERはもう21を示していました)、インスタンスが再起動すると使われなかった番号は失われます。欠番はデータの消失ではありません。欠番のない採番(請求書など)が必要なら、シーケンスからは得られません。

Calíopeでは
「ビルダー」の「アイデンティティ (IDENTITY)」チェックボックスはGENERATED BY DEFAULT AS IDENTITYを作成し、「カタログ」はシーケンスをそのタブに一覧にします。アイデンティティの裏にある内部のシーケンスは含みません。

キーワード: アイデンティティ、IDENTITY、GENERATED ALWAYS、BY DEFAULT、シーケンス、SEQUENCE、NEXTVAL、CURRVAL、CACHE、欠番、ORA-32795、ORA-08002、Oracle

範囲とインターバルによるパーティション分割(Oracle)

日付でパーティション分割したテーブル、パーティションを自動で作成するインターバル、そしてどこにも入らない行がどうなるか。

対象: Oracle 26ai+

パーティション分割されたテーブルは、クエリーする側から見れば1つのテーブルで、内部では複数です:各パーティションはそれぞれ独立したセグメントで、別々に読んだり、空にしたり、削除したりできます。

インターバル付きの範囲

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'));

宣言するのは最初のパーティションだけです。INTERVALがあると、Oracleは、まだパーティションのない月の行が初めて届いたときに新しいパーティションを作成します:測定では、10か月に分散した30,000行で11個のパーティションができました。最初のものと、システム名(SYS_P696、SYS_P697…)の10個で、それぞれHIGH_VALUEに上限を持ちます。

各パーティションはセグメントで、しかも小さくない
1行しかないパーティションが8 MBを使いました:パーティションの最初の領域は、通常のテーブルのものよりずっと大きいのです。日単位やごく細かいパーティションでは、空き領域がたまっていきます。

インターバルがなければ、隙間はエラー
インターバルのない範囲やリストでは、どのパーティションにも入らない行はORA-14400(no partition found)になります。これはINTERVALで、VALUES LESS THAN (MAXVALUE)の最後のパーティションで、あるいはリストならDEFAULTで避けられます(「リスト、ハッシュ、自動のパーティション分割(Oracle)」)。

パーティションキーは勝手には変わらない
行を別のパーティションに移すことになるパーティション列のUPDATEは、ORA-14402になります。ALTER TABLE … ENABLE ROW MOVEMENTでそれが可能になります:行はパーティションとROWIDを変えます。

どこを見るか
ALL_TAB_PARTITIONS:名前、位置、HIGH_VALUE、そして統計情報があればパーティションごとのNUM_ROWS。ALL_PART_TABLES:種類とキー。

Calíopeでは
「ディスク使用量」は、テーブルのすべてのパーティションが使う容量を合計します。

キーワード: パーティション、RANGE、INTERVAL、NUMTOYMINTERVAL、HIGH_VALUE、MAXVALUE、ROW MOVEMENT、ORA-14400、ORA-14402、ALL_TAB_PARTITIONS、Oracle

リスト、ハッシュ、自動のパーティション分割(Oracle)

既知の値にはリスト、自動で増えるようにするにはAUTOMATIC、行を分散させるにはハッシュ。

対象: Oracle 26ai+

リスト

CREATE TABLE vl (id NUMBER, pais VARCHAR2(2))
  PARTITION BY LIST (pais)
  (PARTITION p_es VALUES ('ES'),
   PARTITION p_am VALUES ('MX', 'AR'));
INSERT INTO vl VALUES (1, 'CL'); -- ORA-14400
ALTER TABLE vl ADD PARTITION p_otros VALUES (DEFAULT);
INSERT INTO vl VALUES (1, 'CL'); -- p_otros

国、地域、状態など、既知の値が少ない列向けです。1つのパーティションに複数の値を入れることができ、DEFAULTが残りを集めます。

自動リスト
PARTITION BY LIST (pais) AUTOMATICは、インターバルが範囲に対してするのと同じように、新しい値ごとにパーティションを作成します:測定では、p_esしかなかったテーブルに'PE'を挿入すると、パーティションが2つになりました。

ハッシュ

CREATE TABLE vh (id NUMBER) PARTITION BY HASH (id) PARTITIONS 4;
-- 10000 → 2471, 2527, 2521, 2481
-- PARTITIONS 3 → 2471, 5008, 2521

ハッシュ値で行を分散します。パーティションが4つなら10,000行はほぼ均等になり、3つでは1つのパーティションがほかの2つの倍になりました。パーティションの数は2の累乗にします。

ハッシュは、古いデータを削除するのにも、ある範囲だけを読むのにも役立ちません:書き込みの多いテーブルを分散させるため、あるいは同じキーでパーティション分割された2つのテーブルを結合するためのものです。

コンポジット
組み合わせることができます:日付で範囲、各月の中ではリストかハッシュ(SUBPARTITION BY)。各サブパーティションはセグメントで、各セグメントは最小の領域を使います(「範囲とインターバルによるパーティション分割(Oracle)」):月数×サブパーティション数はすぐに膨らみます。

どれを選ぶか
日付でクエリーし削除するなら範囲かインターバルで、ほとんどの場合はこれです。少数の値のどれかで絞り込むならリスト、行を分散させたいだけならハッシュです。

キーワード: パーティション、LIST、AUTOMATIC、DEFAULT、HASH、SUBPARTITION、コンポジット、2の累乗、ORA-14400、Oracle

パーティションプルーニングとメンテナンス(Oracle)

Oracleが1つのパーティションだけを読むのはいつか、そして使用不可のインデックスを残さずに1か月分を削除する方法。

対象: Oracle 26ai+

プルーニング
条件からどのパーティションに行がありうるかがわかれば、Oracleはそれだけを読みます。ventasのEXPLAINで測定しました:

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

同じことを別の書き方にすると、10個のパーティションすべてを読みます:パーティション列に関数をかけると、インデックスが使えなくなるのと同じようにプルーニングもできなくなります。条件は列に直接、半開区間として書きます。

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)

ローカルインデックスはテーブルと同じようにパーティション分割され、その部分はパーティションとともに動きます。グローバルインデックスはすべてにまたがり、パーティションを1つ削除するとUNUSABLEになります(「使用不可のインデックスと再構築(Oracle)」)。DROPやTRUNCATEの最後にUPDATE INDEXESを付けると、Oracleがそれを保守します:測定では、グローバルインデックスはVALIDのままでした。時間はかかりますが、何も壊しません。

2つのうちどちらか
クエリーがパーティション列でも絞り込むならローカル、それと関係のないもの(一意の識別子など)で検索するならグローバルです。

Calíopeでは
「実行計画ビューア」はPARTITION RANGE SINGLEやPARTITION RANGE ALLのノードを表示し、プルーニングが行われたかどうかがわかります。

キーワード: パーティション、プルーニング、PARTITION RANGE SINGLE、DROP PARTITION、TRUNCATE PARTITION、UPDATE INDEXES、LOCAL、グローバル、UNUSABLE、Oracle

表領域とクォータ(Oracle)

データがどこにあるか、クォータのないユーザが挿入できない理由、そして使用量の読み方。

対象: Oracle 26ai+

表領域はデータの論理的な入れ物で、その下に1つ以上のファイルがあります。各テーブルとインデックスはどれか1つにあり、各ユーザにはデフォルトの表領域があります。

テストベッドのデータベースのもの
26ai Freeでは:SYSTEMとSYSAUX(ディクショナリとOracleのコンポーネント。触ってはいけません)、UNDOTBS1(UNDO)、TEMP(メモリーに収まらないもののソート用)、そしてユーザに割り当てられるUSERSです。ファイルは自動的に大きくなります:DBA_DATA_FILESはすべてについてAUTOEXTENSIBLE = YESを表示しました。

クォータ
ユーザを作成しても、容量は与えられません。測定結果:

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;

USED_PERCENTは、今日のファイルではなく、表領域が大きくなれる最大サイズに対して計算されます:USERSは約250 MBを使って0.16 %と表示しました。自動拡張する表領域は、最大に達するずっと前にサーバのディスクを埋めることがあるので、ディスクは別に見張ります。USED_SPACEとTABLESPACE_SIZEはブロック単位で、このデータベースでは1ブロック8192バイトです。

Calíopeでは
「ダッシュボード」は「最も使用率の高いストレージ」としてUSED_PERCENTが最も高い表領域を表示し、「ヘルスモニタ」は毎回の周期でそれを収集します。

キーワード: 表領域、クォータ、QUOTA、USERS、SYSTEM、SYSAUX、TEMP、UNDO、DBA_DATA_FILES、AUTOEXTENSIBLE、DBA_TABLESPACE_USAGE_METRICS、USED_PERCENT、ORA-01950、Oracle

セグメントとエクステント(Oracle)

テーブル、インデックス、パーティション、LOBはどれもセグメントです。空のテーブルにはセグメントがありません。

対象: Oracle 26ai+

セグメントとは、オブジェクトが占める容量のことです:テーブル、インデックス、パーティション、LOB。セグメントはエクステント単位で大きくなり、このデータベースでは各エクステントは8 KBのブロックでできています。

空のテーブルは何も使わない
イメージの値であるDEFERRED_SEGMENT_CREATION = TRUEでは、テーブルを作成しても何も確保されません:

CREATE TABLE vacia (id NUMBER);
-- DBA_SEGMENTS: 0
INSERT INTO vacia VALUES (1);
-- DBA_SEGMENTS: 64 KB, 1 extent, 8 blocks

SEGMENT CREATION IMMEDIATEは作成時に確保します(これも64 KB)。クォータのないテーブルが作成はされ、挿入で失敗するのはこのためです(「表領域とクォータ(Oracle)」)。

LOBを持つテーブルは3つのセグメント
CLOB列を持つテーブルは3つになりました:テーブル(TABLE)、LOBのデータ(LOBSEGMENT、名前はSYS_LOB…)、そのインデックス(LOBINDEX、SYS_IL…)です。26aiでは、LOBはデフォルトでSECUREFILEです。

正しく数える
テーブルのサイズはそのセグメントの合計です:テーブル自身、パーティション、LOB、インデックスのもの。DBA_SEGMENTS(自分のものならUSER_SEGMENTS)がSEGMENT_NAMEとSEGMENT_TYPEごとにそれを返します。LOBのものはDBA_LOBSでテーブルと対応付けます。

ゴミ箱
DROP TABLEは削除しません:テーブルの名前をBIN$…に変え、容量を持ったままゴミ箱に残します(DBA_RECYCLEBINがそれを一覧にしました)。FLASHBACK TABLE x TO BEFORE DROPは行ごとそれを戻しました。本当に解放するのはDROP TABLE x PURGEかPURGE RECYCLEBINです。

Calíopeでは
「ディスク使用量」は、テーブルごとにそのセグメント、パーティション、LOB、インデックスのものを合計します:読めるならDBA_SEGMENTSで、読めなければUSER_SEGMENTSで。

キーワード: セグメント、エクステント、DBA_SEGMENTS、USER_SEGMENTS、DEFERRED_SEGMENT_CREATION、LOBSEGMENT、SECUREFILE、ゴミ箱、RECYCLEBIN、FLASHBACK、PURGE、Oracle

戻ってこない容量:DELETE、TRUNCATE、SHRINK(Oracle)

行を削除しても、容量は戻らずフルスキャンも速くなりません。空にするか詰めると戻ります。

対象: Oracle 26ai+

Oracleはテーブルをある目印、最高水位標(HWM)まで埋め、フルスキャンは行があってもなくても、その下のすべてを読みます。

DELETEは何も返さない

-- 50 000 × ~200 bytes → DBA_SEGMENTS: 12 MB, 1536 blocks
DELETE FROM hw;
-- DBA_SEGMENTS: 12 MB, 1536 blocks

すべての行を削除して統計情報を収集したあと、ALL_TABLESはNUM_ROWSが0、BLOCKSが1504と表示しました:SELECT COUNT(*)はまだその空のブロックを読みます(TABLE ACCESS FULL)。容量は新しい行に再利用されますが、表領域には戻りません。

TRUNCATEは返す
TRUNCATE TABLEはテーブルを空にして目印を下げます:測定すると、新しい50,000行とTRUNCATEのあと、セグメントは0.06 MB、8ブロックになりました。これはDDLです:ROLLBACKでは取り消せず、セッションが保留していたものを確定します(測定結果:別のテーブルへのINSERTが確定されました)。有効な外部キーがそのテーブルを指していると、ORA-02266で失敗します。

SHRINK SPACEは空にせずに詰める

ALTER TABLE hw ENABLE ROW MOVEMENT;
ALTER TABLE hw SHRINK SPACE;
-- DBA_SEGMENTS: 0.06 MB, 8 blocks

行を先頭に移し、残りを返します。ROWIDが変わるのでROW MOVEMENTが必要です。もう1つの方法はALTER TABLE … MOVE ONLINEで、テーブルを書き直してインデックスを保守します(「使用不可のインデックスと再構築(Oracle)」)。

やる価値があるとき
もう大きくならないテーブルの大部分を削除したあと、あるいはフルスキャンが行数から想像されるよりずっと長くかかるときです。テーブルがまた埋まるなら、詰めても意味がありません:容量はどのみち再利用されたはずです。

Calíopeでは
「ディスク使用量」は行ではなくセグメントのサイズを示します:0行で12 MBのテーブルは、DELETEで空にしたテーブルです。「テーブルメンテナンス」→「ANALYZE」は、何行残っているかを伝える統計情報を収集します。

キーワード: 容量、最高水位標、DELETE、TRUNCATE、SHRINK SPACE、ROW MOVEMENT、MOVE ONLINE、BLOCKS、ORA-02266、Oracle

確定、ロールバック、そして確定するDDL(Oracle)

Oracleは自分では何も確定しません:COMMIT、ROLLBACK、セーブポイント、そして保留中のものを確定するDDL。

対象: Oracle 26ai+

Oracleでは、トランザクションは最初の書き込みとともに自然に始まり、COMMITかROLLBACKで終わります。BEGINはなく、サーバは自分では何も確定しません:自動コミットは、クライアントがオンにするかしないかのものです。

保留中の作業は書いた本人にしか見えない
2つのセッションで測定すると:確定していないINSERTのあと、もう一方のセッションは5行と数え、COMMITのあとは6行でした。そして確定せずに閉じたセッションは自分の作業をロールバックします:書いた行は、誰にとっても存在しなかったことになります。

DDLは保留中のものを確定する

INSERT INTO cuenta VALUES (7, 70);
CREATE TABLE otra (x NUMBER);  -- COMMIT
ROLLBACK;
SELECT COUNT(*) FROM cuenta WHERE id = 7;  -- 1

CREATE、ALTER、DROP、TRUNCATE、GRANTは、実行される前に開いているトランザクションを確定します。そのあとのROLLBACKには、取り消すものが何も残っていませんでした。

セーブポイント

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

エラーはトランザクションを捨てません:その前のものはまだ保留中で、COMMITがそれを保存します。すべてか無かにしたいなら、エラーを受け取ったときにロールバックしてください。

Calíopeでは
「SQLエディタ」は実行のたびに確定します。トランザクションの中で作業するには、「トランザクション」→「BEGIN」です:そこから先は「COMMIT」か「ROLLBACK」まで何も確定されず、途中でDDLがトランザクションを終わらせると、ステータスバーがそれを伝えます:「トランザクションはもう開いていません。SQL自体が終了させたか、エラーのあとサーバがロールバックしました。これから実行するものはすぐに確定されます。」。ヒント「サーバは自動では確定しません」が最初のときに知らせます。

キーワード: トランザクション、COMMIT、ROLLBACK、SAVEPOINT、自動コミット、DDL、確定、ロールバック、Oracle

読み取り一貫性(Oracle)

読む側は書く側を待ちません:どのクエリーも、始まったときに確定されていたデータを見ます。

対象: Oracle 26ai+

Oracleは読み取りをロックしません。どのクエリーも、別のセッションがまさにその行を変更していても、クエリーが始まったときに確定されていた状態のデータベースを見ます。

書く側は読む側を止めない

-- A
UPDATE cuenta SET saldo = 0 WHERE id = 1;
-- B
SELECT saldo FROM cuenta WHERE id = 1;  -- 100 (0.01 s)

Aが行を変更して確定していない間、Bは待たずに、途中の変更を見ることもなく、すぐに前の値を読みました。それを提供するために、OracleはAのトランザクションが保持していた*UNDO*から前のバージョンを作り直します。

各文は1つの瞬間を見る
長いクエリーはテーブル全体を同じ瞬間で見ます:行をたどっている間にほかの人が確定したものは、途中からでも入ってきません。READ COMMITTED(デフォルトのレベル)で続けて実行した2つのクエリーは、それぞれが自分の瞬間を取るので、違うデータを見ることがあります(「分離レベルとSET TRANSACTION(Oracle)」)。

待つのは書き込みだけ
同じ行に書き込む2つのセッションは互いを待ちます:2つ目は、1つ目が確定するかロールバックするまで止まります(「行ロックと、誰が誰をブロックするか(Oracle)」)。ただのSELECTはブロックもしなければブロックもされません。SELECT … FOR UPDATEはブロックします。書き込むために行を確保するからです。

確定していないものは、ほかの人には存在しない
Oracleには「ダーティ」な読み取りはありません:どのセッションも、ほかのセッションの確定していない変更を見ることはできず、それを許すレベルもありません。確定せずに閉じたセッションは、データベースを元のままにします。

Calíopeでは
「SQLエディタ」は実行のたびに確定するので、書いたものはすぐにほかの人に見えます。「トランザクション」→「BEGIN」の中では、変更は「COMMIT」まであなただけのものです:別の接続を使う別のタブや「カタログ」ツールには見えません。

キーワード: 読み取り一貫性、UNDO、READ COMMITTED、ダーティリード、マルチバージョン、SELECT FOR UPDATE、Oracle

分離レベルとSET TRANSACTION(Oracle)

Oracleのレベルは2つ、READ COMMITTEDとSERIALIZABLEで、それに読み出し専用のトランザクションがあります。

対象: Oracle 26ai+

Oracleが受け付ける分離レベルは2つで、標準の4つではありません。測定結果:READ UNCOMMITTEDとREPEATABLE READはORA-02179(valid options: ISOLATION LEVEL { SERIALIZABLE | READ COMMITTED })になります。

READ COMMITTED、いつものもの
各文は、その文が始まったときに確定されていたものを見ます。デフォルトのレベルで、ほとんどすべてのアプリケーションが使うものです。

SERIALIZABLE:トランザクション全体が1つの瞬間を見る

-- 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

Aがすでに999を確定していたのに、Bは100を見続けました:トランザクション全体が、始まった瞬間のデータベースを見るのです。そしてその瞬間のあとにほかの人が変更した行を変更しようとすると、Oracleはそれを統合しません:ORA-08177になり、それを受け取った側はロールバックしてやり直します。ROLLBACKのあと、Bは999を読みました。複数のクエリーにわたって合計が合わなければならないレポートや、ほかの人の書き込みを上書きしてはならない書き込みのためのもので、再試行を代償とします。

読み出し専用

SET TRANSACTION READ ONLY;
INSERT INTO cuenta VALUES (12, 12);  -- ORA-01456
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;  -- ORA-02179

SET TRANSACTION READ ONLYは、SERIALIZABLEと同じ凍結したスナップショットを、書き込みなしで提供します。合計が合わなければならない、複数のクエリーからなる長いレポートには、これが正しい選択です。

最初に置く
SET TRANSACTIONはトランザクションの最初の文でなければならず、そのCOMMITかROLLBACKまで続きます。セッション全体には:ALTER SESSION SET ISOLATION_LEVEL = SERIALIZABLE。

Calíopeでは
「SQLエディタ」は実行のたびに確定するので、各文がそれぞれのトランザクションです:単独のSET TRANSACTIONは次の文まで届きません。効かせるには、「トランザクション」→「BEGIN」、次にSET TRANSACTIONとクエリー、最後に「COMMIT」か「ROLLBACK」です。

キーワード: 分離レベル、READ COMMITTED、SERIALIZABLE、READ ONLY、SET TRANSACTION、ORA-08177、ORA-02179、ORA-01456、Oracle

行ロックと、誰が誰をブロックするか(Oracle)

Oracleはページやテーブルではなく行をロックし、誰が誰を待っているかはV$SESSIONが教えてくれます。

対象: Oracle 26ai+

Oracleは行をロックします。書き込まれた行は、書き込んだ側のCOMMITかROLLBACKまで予約されたままです。テーブルのほかの行は空いたままで、読み取りが待つことはありません(「読み取り一貫性(Oracle)」)。ロックはエスカレートしません:1,000行を予約してもテーブルロックにはなりません。

書き込みが残すもの
1行を確定せずにUPDATEしたあとで測定しました:

SELECT type, lmode FROM v$lock
 WHERE sid = 50 AND type IN ('TM', 'TX');
-- TM 3, TX 6

モード3のTMはテーブルに対する共有ロックで、トランザクションが続く間、その構造が変わるのを防ぎます。モード6のTXはトランザクションです:変更された行はこれを指し、それらを書き込みたい側はこれを待ちます。

誰が誰を待つか
別のセッションが同じUPDATEを試みている状態で:

SELECT blocking_session, event, wait_class
  FROM v$session
 WHERE blocking_session IS NOT NULL;
-- 50, enq: TX - row lock contention, Application

BLOCKING_SESSIONは行を押さえているセッションのSIDです。それが確定すると、待っていた側はひとりでに続行しました:かかったのは2.12秒、もう一方が確定するまでの時間です。

どうするか
ほとんどの場合、ブロックしているセッションは誰かが閉じ忘れたトランザクションです:自動コミットをオフにしたツールか、書き込んだまま確定しないプログラムです。正しい直し方は、そのセッションから確定するかロールバックすることです。強制終了すると、保留中だったものがすべてロールバックされます。

インデックスのない外部キー
「外部キー(Oracle)」ですでに扱いました:インデックスのない外部キーがあると、親テーブルでの削除が子テーブル全体をロックします。

Calíopeでは
「プロセス」は、待っているセッションの待機イベント(enq: TX - row lock contention)の前に「50によってブロック中」と表示し、ブロックしているセッションは同じリストから強制終了できます。

キーワード: ロック、行、V$LOCK、V$SESSION、BLOCKING_SESSION、TX、TM、enq: TX、行ロック競合、Oracle

待たない:NOWAIT、WAIT、SKIP LOCKED(Oracle)

止まらずに行を求める方法と、使用中のテーブルへのDDLがORA-00054になる理由。

対象: Oracle 26ai+

デフォルトでは、予約された行に書き込みたい側は無制限に待ちます。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

NOWAITはORA-00054ですぐに失敗します。WAIT nは最大n秒待ちます:26aiで測定すると、時間切れのときも別のエラーではなくORA-00054が返りました。SKIP LOCKEDは予約された行を飛ばして空いている行を返します:3行目が使われているとき、返ったのは4行目だけでした。

キューにはSKIP LOCKED
作業を分け合う複数のプロセスが、互いに邪魔することなく「次の保留中の行」を読めます:それぞれが別の行を取り、どれも待ちません。返るものはほかのプロセスがしていることに左右されるので、レポートには向きません。

DDLと使用中のテーブル
別のセッションでcuentaの1行が変更され、確定されていない状態で:

TRUNCATE TABLE cuenta;                       -- ORA-00054
LOCK TABLE cuenta IN EXCLUSIVE MODE NOWAIT;  -- ORA-00054
ALTER TABLE cuenta ADD nota VARCHAR2(10);    -- OK (3.06 s)

TRUNCATEとLOCK TABLE … NOWAITはテーブル全体を必要とするので、ORA-00054ですぐに失敗します。一方、列の追加はもう一方のセッションが確定するのを待ち、3.06秒後に問題なく完了しました。どのDDLにも余裕を持たせるには:ALTER SESSION SET DDL_LOCK_TIMEOUT = 30(秒)。

26aiのメッセージ
26aiでは、ORA-00054のテキストが何が予約されていたかを説明します:"TX"はトランザクションが押さえている行、"TM"はテーブル全体で、オブジェクト番号が付きます。

Calíopeでは
「SQLエディタ」でのORA-00054は、全文とともにエラーバナーに表示されます。「プロセス」には、テーブルや行を押さえているのが誰かが表示されます(「行ロックと、誰が誰をブロックするか(Oracle)」)。

キーワード: NOWAIT、WAIT、SKIP LOCKED、FOR UPDATE、ORA-00054、DDL_LOCK_TIMEOUT、TRUNCATE、LOCK TABLE、キュー、Oracle

デッドロック(Oracle)

2つのトランザクションが互いを待つ:Oracleがそれを検出し、一方の文が失敗し、もう一方は待ち続けます。

対象: Oracle 26ai+

デッドロックは円環です:AはBが押さえている行を待ち、BはAが押さえている行を待ちます。どちらも先に進めず、Oracleがひとりでに解決します。

測定結果

-- 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)で失敗しました。

失敗するのは文で、トランザクションではない
いちばん混乱を招くのがこれです。Oracleがロールバックするのは、エラーを受けた文だけです:Aのトランザクションはまだ開いていて、4行目を押さえたままです。そのため、AがROLLBACKを実行するまでBは待ち続け(V$SESSIONにブロックされたセッションが1つ)、それからBが完了しました。ORA-00060を受けた側は、トランザクションをロールバックする(またはすでにしたことを確定する)必要があります。そうしなければロックは続きます。

どちらが倒れるか
失敗する文は円環の2つのうち、Oracleが検査したときに検出した側のものです:コストや古さで選ばれるわけではありません。デッドロックはそれぞれ、セッションと行を記したトレースファイルをサーバに残し、アラートログにも1行残します。

避け方
ほとんどの場合、原因は同じ行を異なる順序で書き込むことです。アプリケーション全体が同じ順序で(たとえばキーの順で)更新すれば、円環はできません。インデックスのない外部キーがもう1つの典型的な原因です(「外部キー(Oracle)」)。

ORA-00054ではない
ORA-00054は、使用中の行をNOWAITで求めることです。ORA-00060は本物の円環です。前者は待てば解決し、後者はそうではありません。

Calíopeでは
エラーはテキストとともに「SQLエディタ」に表示されます。「トランザクション」→「BEGIN」の中にいるなら、それまでにしたことはまだ保留中です:行を解放するにはCOMMITかROLLBACKを選びます。

キーワード: デッドロック、ORA-00060、ロック、ROLLBACK、順序、トレース、Oracle

オプティマイザとEXPLAIN PLAN(Oracle)

Oracleがコストでプランを選ぶ仕組み、その見方、そしてインデックスがあるときFETCH FIRSTで何が変わるか。

対象: Oracle 26ai+

Oracleはコストでプランを選びます:テーブルの統計情報から各ステップが何行を返すかを見積もり、最も安いプランを採ります。測定したノードはALL_ROWSモードで動いています:すべての行を返すまでの時間を最適化するモードです。

プランを見る

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

EXPLAIN PLANはクエリーを実行せずにプランを保存し、DBMS_XPLAN.DISPLAYがそれを表示します。内側から外側へ読みます:インデックスがROWIDを返し、それからそれらの行が読まれます。Rowsは見積もりで、実際に出た数ではありません。Costはプランを比べるための内部単位で、時間ではありません。

インデックスかテーブル全体か
20,000行のうち10行ならインデックスでした。テーブルのほぼ全体にあたるcliente > 10では、プランはTABLE ACCESS FULLでした:インデックスからテーブルへ19,900回飛ぶより、テーブルを端から読むほうが安いからです。

FETCH FIRSTと順序

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です。

統計情報がなければ推測する
統計情報のない、読み込んだばかりのテーブルでは、プランにdynamic statistics used: dynamic sampling (level=2)という注記が付きました:Oracleは最適化しながらテーブルをサンプリングしたのです。最新の統計情報があれば、より良い判断をします(「統計情報とDBMS_STATS(Oracle)」)。

Calíopeでは
「実行計画ビューア」は、この同じプランを、各ノードの行数とコストとともにツリーとして描きます。インデックスアドバイザは、フィルタ付きのTABLE ACCESS FULLを見つけるとインデックスを提案します。CalíopeはPLAN_TABLEに書いたものを削除します。

キーワード: オプティマイザ、コスト、EXPLAIN PLAN、DBMS_XPLAN、プラン、TABLE ACCESS FULL、INDEX RANGE SCAN、FETCH FIRST、COUNT STOPKEY、Oracle

統計情報とDBMS_STATS(Oracle)

オプティマイザは統計情報で判断します:その収集方法、古くなるとき、そしてヒストグラムが加えるもの。

対象: Oracle 26ai+

統計情報は、各テーブルに何行あるか、各列に異なる値がいくつあり、それらがどう分布しているかをオプティマイザに伝えます。Oracleはメンテナンスウインドウで自動的に収集しますが、大量に読み込んだあとはそれを待たないほうがよいでしょう。

収集し、古くなっていないか確かめる

BEGIN
  DBMS_STATS.GATHER_TABLE_STATS(USER, 'PED');
END;
SELECT num_rows, last_analyzed FROM user_tables
 WHERE table_name = 'PED';                -- 20000, 2026-10-16
-- INSERT … 80000
SELECT num_rows FROM user_tables
 WHERE table_name = 'PED';                -- 20000
SELECT stale_stats FROM user_tab_statistics
 WHERE table_name = 'PED';                -- YES

収集する前は、NUM_ROWSとLAST_ANALYZEDはNULLでした。80,000行を追加したあとも、NUM_ROWSは20,000のままでした:統計情報はスナップショットであって、カウンタではありません。STALE_STATS = 'YES'は、スナップショットがもうテーブルに似ていないことを警告します。Oracleは、行の10 %を超えて変わるとそう印を付けます。

ヒストグラム:1つの値がほかと違うとき
ヒストグラムがないと、オプティマイザはどの値も同じ頻度で現れると仮定します。pedでは、estadoに2つの値があります:行の1 %を占める'RARO'と、残りの'COMUN'です。

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)

頻度ヒストグラムがあれば、各値がそれぞれのプランを得ます:まれな値にはインデックス、よくある値にはテーブル全体です。

何が収集されるか
GATHER_TABLE_STATSはそのインデックスの統計情報も収集します(cascade)。スキーマ全体にはDBMS_STATS.GATHER_SCHEMA_STATSです。収集はテーブルを読むので、大きなテーブルでは時間がかかります:測定では、20,000行で0.43秒でした。

Calíopeは勝手に収集しない
あなたが頼まないかぎり、どのツールもDBMS_STATSを実行しません:収集には時間がかかり、すべてのセッションのプランを変えるからです。

Calíopeでは
「テーブルメンテナンス」→「ANALYZE」はcascade付きでDBMS_STATS.GATHER_TABLE_STATSを呼び出し、結果を読み直します:「OK:統計情報によると20000行(…に収集)」。「サーバ情報」→「ディスク使用量」は、見積もり行数を統計情報の日付とともに表示します。

キーワード: 統計情報、DBMS_STATS、GATHER_TABLE_STATS、NUM_ROWS、LAST_ANALYZED、STALE_STATS、ヒストグラム、オプティマイザ、Oracle

カーソルキャッシュとV$SQL(Oracle)

バインドが重要な理由、V$SQLが数えるもの、そしてCalíopeがAWRを使わない理由。

対象: Oracle 26ai+

Oracleは実行するすべての文(そのテキストとプラン)をカーソルキャッシュに保持し、V$SQLはそれをカウンタとともに表示します:実行回数、時間、CPU、読み取りです。

リテラルかバインドか

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

値をテキストに書き込むと、クエリーはそれぞれ別の文になります:3つのテキスト、3回の解析、キャッシュに3つのカーソル。バインド(:id)を使えば、カーソル1つで3回の実行です。何千もの値を扱うアプリケーションでは、リテラルがキャッシュを埋め、解析でCPUを消費します。3つのリテラルはFORCE_MATCHING_SIGNATUREを共有しています:値を除いたテキストのシグネチャで、まとめるのに役立ちます。CURSOR_SHARINGはEXACTでした:Oracleはリテラルを勝手に書き換えません。

最初のバインド値が支配する(バインドピーク)

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

Oracleは最初の値'RARO'を見てインデックスを選び、18回の実行すべてでそれを再利用しました。'COMUN'の場合もです。こちらはテーブルの99 %を占め、リテラルならTABLE ACCESS FULLになっていた値です(「統計情報とDBMS_STATS(Oracle)」)。IS_BIND_SENSITIVE = Yは、値が重要であることをOracleが知っていることを示します。最終的に別のプランを作ることもありますが(適応カーソル共有)、測定ではそうなりませんでした。

AWRは使わない
DBA_HIST_*、AWR、ASHはパフォーマンスの履歴を保持し、Standard EditionとEnterprise Editionでは、別途ライセンスが必要なDiagnostics Packの一部です:それらを照会することは、それを使うことです。V$SQL、V$SESSION、V$SYSSTATには必要ありません。

Calíopeでは
「スロークエリー」はV$SQLを読みます:しきい値を超えた各カーソルの平均実行を、呼び出し回数、CPU、読み取りとともに表示し、概要はFORCE_MATCHING_SIGNATUREでまとめます。CalíopeはAWRもASHも照会しません。

キーワード: V$SQL、カーソル、バインド、リテラル、バインドピーク、適応カーソル共有、FORCE_MATCHING_SIGNATURE、CURSOR_SHARING、AWR、ASH、Diagnostics Pack、Oracle

ユーザ、ロール、権限(Oracle)

Oracleではユーザはスキーマでもあります。権限はシステム権限とオブジェクト権限として与えられ、ほとんどの場合はロールを通します。

対象: Oracle 26ai+

Oracleではユーザはスキーマです:ユーザが作成したものはそのユーザに属し、ほかの人が使うには権限が必要です。権限には2種類あります:システム権限(CREATE SESSION、CREATE TABLE、ALTER SYSTEM…)と、オブジェクト権限(特定のテーブルに対するSELECT、INSERT…)です。CREATE SESSIONがなければログインさえできません:ORA-01045です。

権限がなければ、テーブルは存在しない

-- dba_lector
SELECT * FROM dba_manual.cliente;
-- ORA-00942: table or view "DBA_MANUAL"."CLIENTE" does not exist

Oracleは「権限がありません」とは言いません:ほかの人のスキーマに何があるかを明かさないよう、テーブルが見つからないと言います。存在するとわかっているテーブルでのORA-00942は、たいてい権限の不足です。

ロールは権限をまとめる

-- 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"

ロールは一度付与すれば、その権限をすべて運びます。読み取りロールがあれば読むことができ、付与されていないものは名前を挙げて拒否されます:26aiでは、オブジェクト権限が足りないとORA-41900になり、どれが足りないかを言います。新しいロールはすでに開いているセッションには届きません:接続していたセッションは、ログインし直すまでORA-00942を受け取り続けました。

いまセッションが持っているもの

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;

SESSION_ROLESとSESSION_PRIVSは、付与されたものではなく、セッションが実際に持っているものを示します。デフォルト以外のロールはログイン時に有効になりません:SET ROLEで要求します。

C##ユーザはここには属さない

CREATE USER c##global IDENTIFIED BY "Xx_pass1";
-- ORA-65094: invalid local user or role name

プラガブルデータベース(FREEPDB1)では、ユーザはローカルです。共通ユーザ(C##)はコンテナのルートに作成され、Calíopeはそこへは行きません。

Calíopeでは
「ユーザ」はアカウントとロールを一覧にし、権限を4枚のカード(システム、スキーマ、テーブル、ルーチン)で、継承されたロールのメンバーシップとともに表示します。ユーザの作成、付与と取り消し、ロック、CASCADE付きの削除を行い、書き込む前にあなたの権限を確認します:できないことは、理由とともに無効の状態で表示されます。Oracle自身のアカウントは表示されますが、変更はしません。

キーワード: Oracle、ユーザ、スキーマ、ロール、権限、GRANT、REVOKE、SESSION_PRIVS、SESSION_ROLES、SET ROLE、ORA-00942、ORA-41900、ORA-01045、C##、CDB、PDB

プロファイル、期限切れになるパスワード、ロックされたアカウント(Oracle)

ユーザのプロファイルが、何回の失敗を許すか、パスワードがいつ期限切れになるか、ロックがどれだけ続くかを決めます。

対象: Oracle 26ai+

すべてのユーザにはプロファイルがあり、プロファイルがパスワードの制限を決めます。ほかのプロファイルを持たないユーザはDEFAULTを使います:

SELECT resource_name, limit FROM dba_profiles
 WHERE profile = 'DEFAULT';
-- FAILED_LOGIN_ATTEMPTS   10
-- PASSWORD_LIFE_TIME      180
-- PASSWORD_GRACE_TIME     7
-- PASSWORD_LOCK_TIME      1
-- INACTIVE_ACCOUNT_TIME   365
-- IDLE_TIME               UNLIMITED

26ai Freeの出荷時の状態はこうです:10回失敗するとアカウントが1日ロックされ、パスワードは180日で期限切れになり、1週間の猶予があります。

失敗した試行とロック

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;

間違ったパスワードを2回入れると、正しいパスワードも拒否されます:ロック時間が過ぎるか誰かがロックを解除するまで、ORA-28000です。ACCOUNT LOCKは手動でロックし、同じエラーになります。

期限切れになるパスワード

-- 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";

猶予期間中はセッションが開き、警告が出ます:26aiではORA-28098です。猶予が過ぎるとORA-28001になり、変更するまで入る手段はありません。PASSWORD EXPIREは手動で期限切れにします。

アプリケーション用のアカウントには、日曜日に止まらないよう、通常は期限のない専用のプロファイルを与えます。人のアカウントにはデフォルトのものを使います。

Calíopeでは
「接続をテスト」は、パスワードが猶予期間にあるときサーバの警告を表示します:接続はそれでも開きます。ORA-28000とORA-28001は、サーバのテキストとともにアクセス拒否として報告されます。「ユーザ」は各アカウントのヘッダにプロファイル、状態、パスワードの期限を表示し、そこからパスワードの変更やロックとその解除ができます。プロファイルはCalíopeからは作成しません:それは「SQLエディタ」でのCREATE PROFILEです。

キーワード: Oracle、プロファイル、PROFILE、パスワード、期限切れ、PASSWORD_LIFE_TIME、PASSWORD_GRACE_TIME、FAILED_LOGIN_ATTEMPTS、ロック、ACCOUNT LOCK、ORA-28000、ORA-28001、ORA-28098、ORA-01017

バックアップと復旧:RMAN、フラッシュバック、Calíopeのダンプ(Oracle)

Oracleのデータベースを復旧するのはRMANで、フラッシュバックは最近の誤りを取り消し、Calíopeのバックアップはスキーマ単位のSQLダンプです。

対象: Oracle 26ai+

Oracleのバックアップは、サーバ自身のツールであるRMANで行います:データファイルとアーカイブされた*REDO*をコピーし、それを使ってデータベース全体を任意の時点に復旧します。Calíopeはそれに代わるものではありません:サーバのファイルを読むことも、コピーを管理することもしません。

SELECT log_mode, flashback_on FROM v$database;
-- ARCHIVELOG   NO
SELECT value FROM v$parameter WHERE name = 'undo_retention';
-- 900

テストノードは*REDO*をアーカイブしている(ARCHIVELOG)ので、RMANはある時点に復旧できたでしょう。*フラッシュバック・データベース*はオフです。

フラッシュバック:数分前に戻る

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を付けるとゴミ箱を経由せず、元に戻す手段はありません。

Calíopeのバックアップ
「バックアップ」は、選んだスキーマのSQLダンプを書きます:DBMS_METADATAによる各オブジェクトのDDL、まとめたINSERT文としてのデータ、そしてユーザです。スキーマを別の場所に移したり、読めるコピーを持っておいたりするためのもので、ポイントインタイムリカバリではありません。カバーしないもの:16 MiBを超えるLOBは、そう書いたコメント付きでNULLとして書き出されます。また26aiではユーザのDDLにパスワードが含まれないため、復元されたアカウントはパスワードなしで作られます。復元は、選んだスキーマでダンプを1文ずつ実行します。

キーワード: Oracle、バックアップ、復旧、RMAN、ARCHIVELOG、フラッシュバック、AS OF TIMESTAMP、ゴミ箱、RECYCLEBIN、FLASHBACK TABLE、PURGE、undo_retention、ダンプ、ORA-38305

パラメータとNLS(Oracle)

サーバのパラメータはV$PARAMETERにあります。NLSは日付と数値の書き方を決め、各セッションが変更できます。

対象: Oracle 26ai+

Oracleの設定はV$PARAMETERにあります:各パラメータの値と、セッションが変更できるのか(ISSES_MODIFIABLE)、システム全体でしか変更できないのかです。

SELECT name, value, isses_modifiable
  FROM v$parameter
 WHERE name IN ('open_cursors', 'optimizer_mode',
                'nls_length_semantics', 'max_string_size');
-- max_string_size       STANDARD  FALSE
-- nls_length_semantics  BYTE      TRUE
-- open_cursors          300       FALSE
-- optimizer_mode        ALL_ROWS  TRUE

open_cursorsはシステム全体でしか変更できません。optimizer_modeとnls_length_semanticsはセッションでも変更できます。STANDARDのmax_string_sizeはVARCHAR2を4000バイトに保ちます。EXTENDEDに変えるのは、後戻りできないデータベースの操作です。

システムを変えるには権限が要る

-- 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

テキストに変換した日付の書式と小数点の記号は、セッションのNLSパラメータから来ます。それを変えると、同じクエリーが別のものを書き、以前は動いていた変換が失敗します。だからハンドブックの例は、日付をリテラルで書き(DATE '2026-10-16')、明示的なマスクで変換しています:誰のセッションにも依存しません。

BYTEのnls_length_semanticsでは、VARCHAR2(40)は40バイトを意味します。ASCII以外のテキストでは、入る文字数が減ります。VARCHAR2(40 CHAR)は文字で数えます。

Calíopeでは
「サーバ情報」は、V$PARAMETERを「グローバル変数」に、セッションのNLSをSYS_CONTEXTのコンテキストとともに「セッション変数」に表示します。どちらも読み出し専用です。V$ビューを読む権限がなければ、タブはサーバのテキストとともに、読めない理由を表示します。

キーワード: Oracle、パラメータ、V$PARAMETER、ALTER SYSTEM、ALTER SESSION、NLS、NLS_DATE_FORMAT、NLS_NUMERIC_CHARACTERS、nls_length_semantics、max_string_size、ORA-01031、ORA-01722

メモリー、プロセス、セッション(Oracle)

SGAはインスタンスの共有メモリー、PGAは各セッション自身のものです。プロセスとセッションが接続数の上限を決めます。

対象: Oracle 26ai+

Oracleのインスタンスはメモリーを2つに分けます:すべてのセッションが共有するSGA(ブロックキャッシュ、カーソルキャッシュ…)と、ソート、結合、変数の保持のために各プロセスが持つPGAです。

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

FreeのノードではSGAは1.5 GiBを超えず、そのほとんどがブロックキャッシュと共有プールです。PGAは待機中は10 MBで、ピーク時には720 MBに達しました。

プロセスとセッション

SELECT name, value FROM v$parameter
 WHERE name IN ('cpu_count', 'processes', 'sessions',
                'pga_aggregate_target', 'pga_aggregate_limit');
-- cpu_count                    2
-- pga_aggregate_limit 2147483648
-- pga_aggregate_target 536870912
-- processes                  200
-- sessions                   322

processesはサーバのプロセス数の上限、sessionsはセッション数の上限です。上限に達すると、新しい接続は拒否されます。pga_aggregate_targetはPGAの目標値で、pga_aggregate_limitは超えさせない上限です。

アプリケーション次第のこと
開いている接続はどれも、PGAを持つ1つのセッションと1つのプロセスです。小さな接続プールは、同時に開いた何百もの接続よりよく動き、sessionsを使い切りません。

Calíopeでは
Calíopeはプロファイルごとに最大5つの接続のプールを開き、きれいな状態で返します。「サーバ情報」は「情報」にセッションの最大数(sessionsから)を、「ダッシュボード」はSGAとPGAのメモリーと開いている接続を表示します。「プロセス」はユーザのセッションを一覧にし、終了させたり、そのクエリーをキャンセルしたりできます。

キーワード: Oracle、メモリー、SGA、PGA、V$SGAINFO、V$PGASTAT、PROCESSES、SESSIONS、pga_aggregate_target、pga_aggregate_limit、接続、プール

制限(Oracle)

Oracle 26ai Freeの上限とデータベース自体の上限:128バイトの識別子、4000バイトのVARCHAR2、38桁のNUMBER、1000列。

対象: Oracle 26ai+

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

- 識別子(テーブル、列、ユーザ)は最大128バイトです。
- VARCHAR2は、max_string_sizeがSTANDARDなら4000バイト、EXTENDEDなら32767まで達します。PL/SQLのVARCHAR2変数は常に32767まで達します。
- NUMBERの精度は最大38桁です。
- テーブルの列は最大1000列です。
- SQLでのテキストの連結は4000バイトを超えません:ORA-01489です。それ以上はCLOBです。

Calíopeでは
Calíopeはセルごとに16 MiBまでLOBを読みます:それを超える分は切り捨てられ、セルには実際のサイズが表示されます。デコードしない型(オブジェクト、XMLTYPE、バイナリのJSON、VECTOR、BFILE、カーソル)は、型の名前とともに「未デコード」と表示されます。

キーワード: Oracle、制限、Free、26ai、CPU、メモリー、12 GB、識別子、128バイト、VARCHAR2、4000、32767、NUMBER、38、列、1000、ORA-00972、ORA-00910、ORA-01727、ORA-01792、ORA-01489

よくあるORA-エラー(Oracle)

よくあるORA-エラーのそれぞれが何を意味するか(Oracle 26aiで測定)、そしてCalíopeがそれをどう扱うか。

対象: Oracle 26ai+

Oracleのエラーには、ORA-接頭辞付きの5桁の番号とサーバのテキストがあり、Calíopeはそれをそのまま表示します。Oracleのドキュメントで検索するのはそれだからです。サーバがエラーの場所を示すと、「SQLエディタ」はその語を選択し、ステータスバーに行と列が加わります。「解決方法を表示」の帯から、ここに来られます。

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.

オブジェクトと名前
- ORA-00942:テーブルやビューが存在しない…あるいは存在するが権限がない。Oracleはこの2つを区別しません。
- ORA-00904:無効な識別子。存在しない列か、つづりの誤りです。
- ORA-00955:その名前は、スキーマ内の別のオブジェクトがすでに使っています。
- ORA-00972:128バイトを超える名前。

データ
- ORA-00001:一意キーの重複。テキストが制約と列の名前を挙げます。
- ORA-02291 / ORA-02292:外部キーが親を見つけられない/親に子がある。
- ORA-01400:NOT NULLの列へのNULL。
- ORA-12899:値が収まらない。実際のサイズと最大値を示します。
- ORA-01722:数値でないテキスト。暗黙の比較でも起きます。
- ORA-01843 / ORA-01830:無効な月/日付マスクがテキストより先に終わる。
- ORA-01489:4000バイトを超える連結。

トランザクションとロック
- ORA-00054:行が使用中。NOWAITのときに起き、26aiではWAIT nが時間切れになったときにも起きます。
- ORA-00060:デッドロック。ロールバックされるのは文だけです。
- ORA-08177:SERIALIZABLEでの競合。ORA-01456:READ ONLYトランザクションでの書き込み。
- ORA-01555:snapshot too old。長いクエリーが、すでに再利用された*UNDO*を必要とした。
- ORA-01013:クエリーがキャンセルされた。「停止」であなたが頼んだものです。

権限と接続
- ORA-41900:オブジェクト権限が足りない。どれが足りないかを言います。ORA-01031:システム権限が足りない。
- ORA-01017:無効なユーザ名またはパスワード。ORA-28000:アカウントがロックされている。ORA-28001:パスワードの期限切れ。Calíopeはこれらをアクセス拒否として報告します。
- ORA-12514 / ORA-12505:リスナーがそのサービス/そのSIDを知らない。プロファイルの「サービス」を確認してください。
- ORA-12170:接続が時間内に届かなかった。ネットワーク、ファイアウォール、ポートのいずれかです。
- ORA-02396:セッションがアイドル時間を超えた。Calíopeは接続の切断として報告します。
- ORA-38305:復旧したいものがゴミ箱にない。

アプリケーションのエラー
ORA-20000からORA-20999は、PL/SQLコードがRAISE_APPLICATION_ERRORで、任意のテキストとともに発生させるものです:その意味は書いた人が決めます。

キーワード: Oracle、エラー、ORA-、ORA-00942、ORA-00904、ORA-00001、ORA-02291、ORA-01722、ORA-00054、ORA-00060、ORA-01555、ORA-01017、ORA-28000、ORA-12514、ORA-41900、RAISE_APPLICATION_ERROR

ベストプラクティス(Oracle)

CalíopeからOracleを使うときにする価値のあること:必要なだけの権限を持つアカウント、短いトランザクション、バインド、最新の統計情報、そして本当のバックアップ。

対象: Oracle 26ai+

仕事ごとにアカウントを1つ、必要なだけの権限で
データを扱うのにSYSTEMやSYSを使わないでください。アプリケーションごとに専用のスキーマ、人ごとに専用のユーザを用意し、権限は読み取りロールか書き込みロールを通して与えます。何かがORA-00942やORA-41900になったら、足りない権限を付与します。もっと大きな権限ではありません。

短いトランザクションを、意図して確定する
Oracleは自動では確定せず、DDLは保留中のものを確定します。必要なときにトランザクションを開き(「SQLエディタ」の「トランザクション」→「BEGIN」)、必要なことだけをして閉じます:使用中の行はCOMMITかROLLBACKまでほかの全員をブロックし、変化するデータに対する長いクエリーはORA-01555で終わることがあります。

リテラルではなくバインドを
値をテキストに書き込んだクエリーは、値ごとに別の文になります:解析がもう1回、キャッシュにカーソルがもう1つ。バインド(:cliente)なら1つだけです。アプリケーションが繰り返すものには、常に*バインド*を使います。

最新の統計情報
オプティマイザは統計情報からプランを選びます。大量に読み込んだあとは統計情報を収集し(「テーブルメンテナンス」→「ANALYZE」)、「サーバ情報」→「ディスク使用量」で日付を確認します。

テキストと日付
ASCII以外のものが入るなら、テキストは文字数で宣言し(VARCHAR2(40 CHAR))、日付はリテラル(DATE '2026-10-16')か明示的な書式マスクで書きます:セッションのNLSに依存するものは、セッションごとに変わります。

本当のバックアップ
「バックアップ」のダンプは、スキーマを移したり読める形で保管したりするためのものです。データベースを復旧するには、アーカイブされた*REDO*とRMANを使います。

キーワード: Oracle、ベストプラクティス、推奨事項、権限、ロール、SYSTEM、トランザクション、COMMIT、バインド、統計情報、NLS、CHAR、日付、RMAN、バックアップ

アーキテクチャ:TiDB、TiKV、PD(TiDB)

TiDBへの接続の裏にどんな部品があり、データがどこにあり、そのうちCalíopeが何を表示するか。

対象: TiDB 8.5+

TiDBはMySQLのプロトコルを話しますが、その裏にあるのは1台のサーバではありません:3つの部品があります。最小構成のTiDB 8.5.8クラスタで測定しました:

SELECT VERSION();
-- 8.0.11-TiDB-v8.5.8
SELECT TYPE, INSTANCE, STATUS_ADDRESS
  FROM information_schema.CLUSTER_INFO;
-- tidb  tidb:4000        tidb:10080
-- pd    tidb-pd:2379     tidb-pd:2379
-- tikv  tidb-tikv:20160  tidb-tikv:20180

TiDB:SQLノードで、データは持たない
Calíopeが接続する相手です。文を解析し、計画を立て、結果を集めますが、行は保存しません:ロードバランサの後ろに複数のTiDBノードを置けば、どれも同じように応答します。だから@@datadirはディレクトリではありません:PDのアドレス(tidb-pd:2379)を返します。

TiKV:データで、リージョンに分割される
行とインデックスは、順序付きのキーと値の組としてTiKVにあり、リージョンに分割されています。リージョンはregion-split-size(このバージョンでは256 MiB)を超えて大きくなると分割されます。新しいテーブルはどれも、自分のリージョンから始まります:

SHOW TABLE ped REGIONS;   -- 1リージョン
SPLIT TABLE ped BETWEEN (0) AND (1000000)
  REGIONS 4;
SHOW TABLE ped REGIONS;   -- 5リージョン

各リージョンには複数のコピーがあり、Raftで合意します。それがTiDBのレプリケーションで、バイナリログもSHOW REPLICA STATUSもありません。

PD:割り振る役
PD(Placement Driver)は、各リージョンがどこにあるかを把握し、トランザクションのタイムスタンプを発行し、リージョンをノードからノードへ移します。replication.max-replicasは3ですが、テストベッドのようにTiKVが1台だけだと、測定した570個のリージョンはどれもコピーが1つでした:3つのコピーには3台のTiKVノードが必要です。

tidb_version()が本当に持っているものを教えてくれる
Edition: CommunityとStore: tikv。Store: unistoreなら、ノードはPDもTiKVもなしに単独で動いています:テスト用で、そこにはリージョンも、テーブルのサイズも、古いバージョンのガベージコレクションもありません。

Calíopeでは
「サーバ情報」はバージョンとtidb_version()の内容を表示し、「ダッシュボード」は、すべてのTiDBノードのセッションを集めるCLUSTER_PROCESSLISTでセッションを数えます。TiDBがステータスポート(10080)でしか公開しないものは読まないので、SQLにないものはn/aと表示されます。トポロジとレプリケーションモニタは提示されません:レプリケーションはTiKVの中のRaftだからです。スニペット「テーブルのリージョン」がSHOW TABLE … REGIONSを用意します。

キーワード: アーキテクチャ、TiDB、TiKV、PD、リージョン、Raft、CLUSTER_INFO、tidb_version、SPLIT TABLE、UNISTORE

キー、AUTO_INCREMENT、AUTO_RANDOM(TiDB)

AUTO_INCREMENTの番号が飛ぶ理由、クラスタ化キーとは何か、そしてAUTO_RANDOMがすべての挿入を同じリージョンに集めないようにする仕組み。

対象: TiDB 8.5+

AUTO_INCREMENTは一意だが、連続ではない
各TiDBノードは30,000個ずつまとめて番号を確保し、その中から払い出します。ノード1台で測定:3行は1、2、3になり、ノードを再起動したあとの次の行は30001になりました。

INSERT INTO ped (cliente) VALUES (4);
SELECT id FROM ped;
-- 1, 2, 3, 30001
SHOW TABLE ped NEXT_ROW_ID;
-- 60001

TiDBノードが複数あると、それぞれが自分の確保分から番号を払い出すので、IDの順序は行が届いた順序とも一致しません。IDに欠番があるのはエラーではありません。エラーなのは、連続したIDを当てにするアプリケーションのほうです。どうしても必要なら、テーブルを作成するときのAUTO_ID_CACHE 1で連続になります(測定結果:1、2、3)が、番号のたびにクラスタに問い合わせることになります。

主キーが行のキー
8.5のデフォルトであるtidb_enable_clustered_index = ONでは、主キーはTiKVが行を保存するキーです(CLUSTERED)。NONCLUSTEREDの場合や主キーがない場合、行は隠しキー_tidb_rowidを持ち、主キーはただのインデックスの1つになります。テーブルがどちらなのかは、information_schema.TABLESのTIDB_PK_TYPEにあります。

ホットスポットとAUTO_RANDOM
連続したキーは同じリージョンに入るので、AUTO_INCREMENTを持つテーブルは、すべての挿入を最後のリージョン、つまり1台のTiKVに書き込みます。AUTO_RANDOMはそれを避けます:IDの上位ビットに、文ごとに変わるシャードを入れます。

CREATE TABLE ev (
  id BIGINT AUTO_RANDOM PRIMARY KEY,
  tipo VARCHAR(20));
-- 1行ずつのINSERTを4回:
SELECT id, id >> 58 AS shard FROM ev;
-- 8358680908399640580  29
--  864691128455135237   3
-- 3170534137668829190  11
-- 5476377146882523143  19

デフォルトは5ビットで、32の範囲です。1つのINSERTの行は同じシャードを共有します。3つのルールがあり、どれもエラー8216になります:BIGINTにだけ、クラスタ化主キーにだけ、そしてSET @@allow_auto_random_explicit_insert = 1でない限りIDは手で書かない。整数のキーを持たないテーブルでは、SHARD_ROW_ID_BITS = 4が同じように隠しキーを分散させます。

Calíopeでは
「ビルダー」は、BIGINTの主キーで、列を作成するときだけ「ランダムID (AUTO_RANDOM)」を提示します:TiDBではあとから追加も削除もできないからです。「バックアップ」のファイルは復元するときにallow_auto_random_explicit_insertをオンにし、「データ移行」はコピーのあいだ移行先の接続でそれをオンにするので、IDは元のまま届きます。スニペット「AUTO_RANDOMキーを持つテーブル」がCREATE TABLEを用意します。

キーワード: AUTO_INCREMENT、AUTO_RANDOM、AUTO_ID_CACHE、主キー、CLUSTERED、NONCLUSTERED、_tidb_rowid、SHARD_ROW_ID_BITS、ホットスポット、リージョン、8216、TiDB

統計情報、ANALYZE、その健全性(TiDB)

TiDBのオプティマイザが統計情報のあるときとないときにどう判断するか、SHOW STATS_HEALTHYが何を測るか、そしていつ自分でテーブルを分析するか。

対象: TiDB 8.5+

オプティマイザは、各テーブルの統計情報から実行計画を選びます。20,000行のテーブルcliで、pais = 'ES'が10行に1行、paisにインデックスがある状態で測定しました。

統計情報がなければ、実行計画は当て推量
テーブルを読み込んだ直後、TiDBはまだ行数を知りませんでした:ノードが件数を書き出すまでの1分近く、SHOW STATS_METAはRow_countが0だと言い、EXPLAINは推定値にstats:pseudoの印を付けました:

EXPLAIN SELECT * FROM cli
 WHERE pais = 'ES';
-- IndexLookUp、推定10行、
-- stats:pseudo
ANALYZE TABLE cli;
EXPLAIN SELECT * FROM cli
 WHERE pais = 'ES';
-- TableFullScan、推定2000行

統計情報があれば、20,000行のうち2,000行だとわかり、インデックスを2,000回行き来するよりテーブル全体を読むほうが安くなります。

SHOW STATS_HEALTHY:前回の分析からどれだけ変わったか
0から100までで、100は分析したばかり、一度も分析していないテーブルは0です。20,000行のうち10,800行を変更したあと:

SHOW STATS_META
 WHERE Table_name = 'cli';
-- Modify_count 10800
SHOW STATS_HEALTHY
 WHERE Table_name = 'cli';
-- Healthy 46

自動分析
tidb_enable_auto_analyze = ONでは、変更された割合がtidb_auto_analyze_ratio(0.5:半分)を超えると、TiDBはtidb_auto_analyze_start_time〜tidb_auto_analyze_end_timeの時間帯(デフォルトでは1日中)のうちに自分でテーブルを分析します。測定では、46になってから1分足らずでジョブが現れ、健全性は100に戻りました:

SHOW ANALYZE STATUS
 WHERE Table_schema = 'dba_manual';
-- analyze table …       finished
-- auto analyze table …  finished

大量の読み込みのあとは待つ必要はありません:ANALYZE TABLEで、次の文から実行計画が直ります。SHOW ANALYZE STATUSは実行中のものも、処理した行数とともに表示します。

Calíopeでは
「テーブルメンテナンス」→ ANALYZEは、選んだテーブルごとにANALYZE TABLEを送ります。TiDBのスニペット2つが上のクエリーを用意します:「テーブルごとの統計の健全性」と「テーブルを分析して状態を表示」。

キーワード: 統計情報、ANALYZE TABLE、SHOW STATS_META、SHOW STATS_HEALTHY、SHOW ANALYZE STATUS、tidb_auto_analyze_ratio、自動分析、STATS:PSEUDO、オプティマイザ、TiDB

スロークエリーとSTATEMENTS_SUMMARY(TiDB)

TiDBがスロークエリーをどこに保存するか、しきい値がミリ秒でノードごとのものである理由、そしてSQL文ごとの要約が何を数えるか。

対象: TiDB 8.5+

TiDBには2つの情報源があり、測るものが違います。

スローログ
各TiDBノードは、tidb_slow_log_thresholdを超えたすべての文をファイル(tidb-slow.log)に書き込み、テーブルとして公開します:information_schema.SLOW_QUERYはこのノードのものを、CLUSTER_SLOW_QUERYはすべてのノードのものを読みます。

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);

Is_internal = 1は、統計情報のものなど、TiDBが自分で実行する文です。TiDBは調べた行の代わりにキーを数えます:Process_keysとTotal_keys。

しきい値はミリ秒で、ノードのもの
tidb_slow_log_thresholdは、設定ファイルが別の値を指定しない限り300(ミリ秒)です。long_query_timeは互換性のために存在しますが、何も制御しません。しきい値はインスタンスのものです:SET GLOBALは接続しているノードだけを変え、再起動するとそのconfig.tomlの値([instance]、tidb_slow_log_threshold = …)に戻ります。残しておくべきものはファイルに書きます。

STATEMENTS_SUMMARY:すべての文を、形ごとにまとめて
遅いかどうかに関係なく、実行をDIGEST(値を?に置き換えた文)ごとにまとめます。20,000行に対するLIKE '%77@x.es'は45ミリ秒かかりました:ログには入りませんでしたが、ここにはありました:

SELECT EXEC_COUNT, AVG_LATENCY,
       AVG_PROCESSED_KEYS, DIGEST_TEXT
  FROM
  information_schema.STATEMENTS_SUMMARY
 WHERE SCHEMA_NAME = 'dba_manual';
-- 1  45466490  20000
-- select count ( ? ) from `cli`
--   where `email` like ?

時間はナノ秒です。要約は30分の時間枠で動き(tidb_stmt_summary_refresh_interval = 1800)、直前の24枠はSTATEMENTS_SUMMARY_HISTORYに移り、形の数の上限は3,000です。要約はノードのメモリーにあります(tidb_stmt_summary_enable_persistent = OFF):測定では、再起動で要約とその履歴が空になりました。

Calíopeでは
「スロークエリー」は、「設定」でログがオンかどうかとしきい値(秒で入力し、Calíopeが接続しているノード向けにミリ秒に変換します)を、「エントリー」で内部のものを除いたCLUSTER_SLOW_QUERYを、「SQL文ごと」でSTATEMENTS_SUMMARYの現在の時間枠を表示します。どちらもここからは消去しません:ログは各ノードのファイルで、要約はひとりでに入れ替わるからです。

キーワード: スロークエリー、SLOW_QUERY、CLUSTER_SLOW_QUERY、tidb_slow_log_threshold、long_query_time、STATEMENTS_SUMMARY、STATEMENTS_SUMMARY_HISTORY、DIGEST、ナノ秒、TiDB

トランザクション、ロック、デッドロック(TiDB)

悲観的モードと楽観的モードが何をするか、2つある分離レベル、そして誰が誰を待っているかをどこで見るか。

対象: TiDB 8.5+

TiDB 8.5.8で、2行のテーブルctaと同時に2つのセッションを使って測定しました。

デフォルトは悲観的
tidb_txn_modeはpessimisticです:トランザクションの中のUPDATEは、InnoDBと同じように実行した時点で行をロックし、もう一方のセッションは待ちます。待機の上限はMySQLと同じinnodb_lock_wait_timeout(50秒)で、時間切れになると同じエラー1205になります。

頼めば楽観的
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]

TiDBはそれを再試行しません(tidb_disable_txn_auto_retry = 1):アプリケーションが最初からやり直します。

分離レベルは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秒待ちました。

誰が誰を待っているか
セッションが待っているあいだ、DATA_LOCK_WAITSはそれが求めている行と、それを持っているトランザクションを、TIDB_TRXは開いているトランザクションとその状態を示します:

SELECT TRX_ID, CURRENT_HOLDING_TRX_ID,
       SQL_DIGEST_TEXT
  FROM information_schema.DATA_LOCK_WAITS;
SELECT SESSION_ID, STATE
  FROM information_schema.TIDB_TRX;
-- 3300917310  Idle
-- 3300917312  LockWaiting

TIDB_TRXは接続しているノードのもので、CLUSTER_TIDB_TRXはすべてのノードのものを集めます。

デッドロックは記録される
交差した2つのトランザクションは、MySQLと同じように片方に1213を返し、TiDBはその循環を残します:トランザクションごとに1行で、求めたキーと、それを持っていた相手が入ります。

SELECT DEADLOCK_ID, TRY_LOCK_TRX_ID,
       TRX_HOLDING_LOCK, KEY_INFO
  FROM information_schema.DEADLOCKS;
-- 1  …194308  …373508  "handle_value":"2"
-- 1  …373508  …194308  "handle_value":"1"

残るのは直近の10件です:ノードの設定(SHOW CONFIG)のpessimistic-txn.deadlock-history-capacityがそれを決めます。

Calíopeでは
「SQLエディタ」の「トランザクション」メニューは、tidb_txn_modeが示すモードのまま、START TRANSACTIONでトランザクションを開きます。コミット時の9007は何も保存されなかったという意味で、そのエラーはこのトピックに案内します。「プロセス」はノードのセッションを表示し、「KILL QUERY」で待機を打ち切れます:測定では、待っていたセッションにcontext canceledが返りました。「ダッシュボード」は「テーブルロック」のカードを描画しません。TiDBはそのカウンタをSQLで公開しないからです:待機は上のクエリーで調べます。

キーワード: トランザクション、悲観的、楽観的、tidb_txn_mode、BEGIN OPTIMISTIC、分離、REPEATABLE READ、READ COMMITTED、DATA_LOCK_WAITS、DEADLOCKS、TIDB_TRX、innodb_lock_wait_timeout、1205、1213、8048、9007、TiDB

オンラインDDLとADMIN SHOW DDL JOBS(TiDB)

TiDBでALTER TABLEがテーブルをロックしない理由、スキーマ変更を追跡して取り消す方法、そしてALTERにできないこと。

対象: TiDB 8.5+

TiDBでは、スキーマ変更はどれもジョブです:いくつかの状態(delete only、write only、write reorganization、public)を経て、そのあいだもテーブルは読み書きされ続けます。100,000行のテーブルpedで測定しました。

新しいインデックスは書き込みを止めない
ALTER TABLE ped ADD INDEX ix_cliente (cliente)は2.6秒かかり、同時に送ったUPDATEとINSERTは7ミリ秒で終わりました。ジョブはリストに残ります:

ADMIN SHOW DDL JOBS 3;
-- JOB_ID JOB_TYPE     ROW_COUNT STATE
-- 1688   add index    100002    synced
-- 1687   create table 0         synced

ROW_COUNTはジョブが処理した行で、STATEはrunning、synced(すべてのノードで完了)、rollback done、cancelledのいずれかです。同じリストがinformation_schema.DDL_JOBSにあり、そこでは絞り込めます。COMMENTSのingest, DXFは、インデックスが高速な経路(tidb_ddl_enable_fast_reorg)と分散タスク(tidb_enable_dist_task)で作られたという意味で、どちらも8.5ではオンです。

ALTERは開いているトランザクションを待ち、誰もALTERを待たない
tidb_enable_metadata_lock = 1では、スキーマ変更は、すでにそのテーブルを使ったトランザクションが終わるのを待ちます:pedにトランザクションを開いたまま送ったADD COLUMNは、そのCOMMITまでちょうど4秒かかりました。待っているあいだ、別のセッションは11ミリ秒でpedを読み書きしました。MySQL 26.10では、同じ読み取りが、待ち行列に入ったALTERの後ろで2.5秒待ちました。このビューは、誰が変更を止めているかを示します:

SELECT job_id, query, session_id,
       SQL_DIGESTS
  FROM mysql.tidb_mdl_view;
-- 1690  ALTER TABLE ped ADD COLUMN …
--   ["begin","select `importe` …"]

ジョブを取り消す
ADMIN CANCEL DDL JOBS 1704は、終わっていないジョブを元に戻します:それを始めたALTERは8214(Cancelled DDL job)を受け取り、ジョブはrollback doneで終わります。

ALTERにできないこと
- クラスタ化主キーの列を、古い型を含まない型に変える(INT → VARCHAR):8200。INTからBIGINTならできます。
- クラスタ化テーブルの主キーを削除する:8200。別のテーブルを作って行をコピーします。
- AUTO_INCREMENTの列を追加する:8200。
- 文字セットをutf8mb4からlatin1に変える:8200。
- 複数の変更を含むALTER(TiDBは受け付けます)の中で、その同じALTERが追加する列を使う:1072。
- データが新しい型に収まらないMODIFY('n0'をINTに)は1292になり、全体が元に戻ります。

そして、受け付けるのに実行しないものもあります:ENGINE = MyISAMは何も変えず、FULLTEXTインデックスは警告1214を出して作成されず、ORDER BYは警告付きで無視されます。パーティション分割はオンラインです:PARTITION BY HASH(id) PARTITIONS 4は100,000行を20秒で書き直しました。

Calíopeでは
「ビルダー」と「スキーマ比較」はALTER TABLEを生成します。TiDBが許さないものは、8200と「よくあるエラーとその意味」へのリンク付きで戻ってきます。「SQLアラート」では、例「10分以上実行中のスキーマ変更」がDDL_JOBSのrunningのジョブを数え、スニペット「最近のDDLジョブ」がADMIN SHOW DDL JOBSを用意します。

キーワード: オンラインDDL、ALTER TABLE、ADD INDEX、ADMIN SHOW DDL JOBS、DDL_JOBS、ADMIN CANCEL DDL JOBS、メタデータロック、tidb_mdl_view、tidb_enable_metadata_lock、PARTITION BY、1072、1292、8200、8214、TiDB

GC、AS OF TIMESTAMP、FLASHBACK(TiDB)

TiDBが各行の古いバージョンをどれだけ保持するか、少し前の状態のテーブルを読む方法、そして削除したテーブルを取り戻す方法。

対象: TiDB 8.5+

TiKVは行を上書きしません:タイムスタンプ付きの新しいバージョンを保存します。古いバージョンがあるから過去を読めるのであり、GC(ガベージコレクタ)が期限切れになったものを削除します。

デフォルトは10分

SELECT @@tidb_gc_life_time,
       @@tidb_gc_run_interval;
-- 10m0s  10m0s
SELECT VARIABLE_VALUE FROM mysql.tidb
 WHERE VARIABLE_NAME =
       'tikv_gc_safe_point';
-- 20261023-01:47:38.216 +0000

10分ごとの実行のたびに、GCはセーフポイントを決めます:実行した時刻からtidb_gc_life_timeを引いたものです。測定結果:01:57:38の実行で、セーフポイントは01:47:38。その時点より前のものは、もう読めません。tidb_gc_max_wait_time(86,400秒)は、開いているトランザクションがそれを引き止められる最大の時間で、tidb_gc_life_timeを大きくすると、より多くのバージョンを保持する代わりに余裕が増えます。

AS OF TIMESTAMP:その時点のテーブル

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;

FLASHBACK DATABASEは、削除したデータベースで同じことをします。3つとも古いバージョンを読むので、GCの時間枠の中でしか使えません:セーフポイントを過ぎると、読めるものは何も残っていません。

Calíopeでは
スニペット「10秒前の状態のテーブルを読む」がAS OF TIMESTAMPを用意し、「SQLエディタ」の自動補完はTiDBでそれを提示します。「AIアシスタント」はその存在を知っていて、以前の時点を読むときに勧めます。エディタで9006が出ると、このトピックに案内されます。

キーワード: GC、ガベージコレクション、tidb_gc_life_time、tidb_gc_run_interval、セーフポイント、tikv_gc_safe_point、AS OF TIMESTAMP、tidb_snapshot、ステイルリード、FLASHBACK TABLE、FLASHBACK DATABASE、8135、9006、TiDB

パーティション、配置、リソースグループ(TiDB)

グローバルインデックスを持つMySQLのパーティション、TiKVがテーブルのコピーをどこに置くか、そして各ユーザがどれだけ使えるか。

対象: TiDB 8.5+

TiDB 8.5.8で、TiDBノード、PDノード、TiKVノードを1台ずつ、10,000行のテーブルventasを使って測定しました。

MySQLのパーティション
TiDBには、MySQLの構文でRANGE、RANGE COLUMNS、LIST、LIST COLUMNS、HASH、KEYがあります。パーティション分割がいつ割に合うかは「テーブルのパーティション分割」で扱っています。ここにあるのは変わる点です。

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'));

リストのどのパーティションにも入らない値は、MySQLと同じく1526になります。

プルーニングはaccess objectに表れる
TiDBのEXPLAINにはpartitionsの列がありません:読んでいるパーティションはaccess objectに入ります。

EXPLAIN SELECT SUM(total) FROM ventas
 WHERE dia >= '2026-03-01';
-- TableReader  partition:p2026

パーティションを変更する
ADD、DROP、TRUNCATE PARTITIONはMySQLと同じように動きます。REORGANIZE PARTITIONはパーティションを2つに分け、EXCHANGE PARTITION … WITH TABLEは同じ形の空のテーブルと入れ替えます:p2026aの3620行がventas_archに移りました。ALTER TABLE … PARTITION BYはすでに行のあるテーブルをパーティション分割し、REMOVE PARTITIONINGは1つに戻します。どちらもDDLジョブで、「オンラインDDLとADMIN SHOW DDL JOBS(TiDB)」で説明しているように追跡します。

パーティションの列を含まない一意キー:GLOBAL
MySQLでは、どの一意キーもパーティション分割の列を含まなければなりません。TiDB 8.5では、グローバルインデックスでそのルールを外せます:

CREATE TABLE u (id INT, k INT,
  UNIQUE KEY (k) GLOBAL)
PARTITION BY HASH (id) PARTITIONS 4;

GLOBALがないと、同じCREATEは8264(Global Index is needed for index 'k')になります。SHOW CREATE TABLEはそれを/*T![global_index] GLOBAL */と書きます。

配置:コピーをどこに置くか
Placement Rules in SQLは、テーブルが持つコピーの数と、それをどのTiKVノードに置くかを、各ノードのラベルに従って決めます:

CREATE PLACEMENT POLICY tres_copias
  FOLLOWERS=2;
ALTER TABLE ventas
  PLACEMENT POLICY=tres_copias;
SHOW PLACEMENT;
-- TABLE dba_manual.ventas
--   FOLLOWERS=2  PENDING

TiKVが1台だけだと、ポリシーは保存されますがPENDINGのままです。残りの2つのコピーを置く場所がないからです。どのノードも満たさない制約(ラベル付きのノードがない状態でのCONSTRAINTS='[+region=madrid]'など)は、その場で8243で拒否されます。SHOW CREATE TABLEはポリシーを/*T![placement] PLACEMENT POLICY=… */として表示し、information_schema.PLACEMENT_POLICIESがそのすべてを一覧にします。

リソースグループ:それぞれがどれだけ使えるか
デフォルトでオンのtidb_enable_resource_controlでは、グループは、それを使うセッションが消費する1秒あたりのリクエストユニット(RU)に上限を設けます:

CREATE RESOURCE GROUP informes
  RU_PER_SEC=500;
CREATE USER 'informes'@'%'
  IDENTIFIED BY '…'
  RESOURCE GROUP informes;

セッションはSET RESOURCE GROUPでグループを切り替え、1つの文だけならRESOURCE_GROUP(informes)のヒントで切り替えます。CALIBRATE RESOURCEはクラスタが提供できるRUの数を推定します:テスト用のクラスタでは22,956でした。グループは、暴走したクエリーを打ち切ることもできます:

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

STATEMENTS_SUMMARYのRESOURCE_GROUP列は、各文がどのグループで実行されたかを示します。

Calíopeでは
パーティション、配置、リソースグループの画面はありません:「SQLエディタ」で書きます。「スキーマ比較」と「データ移行」は、足りないテーブルを作成するときPARTITION BY句をコピーします。2つのテーブルのパーティション分割が違う場合、スキーマ比較はパーティションを組み直す代わりに、手動の変更として記録します。エディタで8264、8243、8253が出ると、ここにリンクします。

キーワード: パーティション、PARTITION BY、RANGE、LIST、HASH、KEY、REORGANIZE PARTITION、EXCHANGE PARTITION、グローバルインデックス、GLOBAL、8264、配置、PLACEMENT POLICY、FOLLOWERS、8243、リソースグループ、RESOURCE GROUP、RU_PER_SEC、QUERY_LIMIT、8253、CALIBRATE RESOURCE、TiDB

ユーザ、ロール、TLS(TiDB)

MySQLから変わるもの:ロール、必須のTLSを外してしまうALTER USER、パスワードポリシー、認証方式。

対象: TiDB 8.5+

TiDB 8.5.8で測定しました。アカウント、GRANT、SHOW GRANTSはMySQLと同じように動き、共通する点は「セキュリティ、ユーザ、ロール」で扱っています。ここにあるのは違う点です。

ロールは、MySQL 8と同じ

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';

ロールは、アカウントがロックされた(account_locked = Y)mysql.userの行です。誰がどのロールを持っているかはmysql.role_edgesに、デフォルトのロールはmysql.default_rolesにあります。

REQUIREのないALTER USERは必須のTLSを外す
MySQLでは、ALTER USER … ACCOUNT LOCKはTLSの要求をそのまま残します。TiDBでは消してしまいます:

ALTER USER 'app'@'%' REQUIRE SSL;
ALTER USER 'app'@'%' ACCOUNT LOCK;
SHOW CREATE USER 'app'@'%';
-- … REQUIRE NONE … ACCOUNT LOCK …

パスワードや制限を変えるときも同じことが起きます。ALTER USERのたびに繰り返す必要があります:… REQUIRE SSL ACCOUNT UNLOCK。また、TiDBにはないmysql.userのssl_type列からは読み取れません:SHOW CREATE USERと、mysql.global_privのPrivフィールド({"ssl_type":1})にあります。

TLSはノードでオンにする
have_sslは、ノードが証明書付きで起動するまでDISABLEDで、証明書は設定ファイルの[security]セクションで指定します。require_secure_transportは存在し、OFFで届きます。

パスワード
validate_password.enableはOFFで届きます。オンにすると、MySQLのポリシー(MEDIUMで8文字)が適用されます。何度か失敗したあとのロックと有効期限はどちらも動き、mysql.userに保持されます:

ALTER USER 'app'@'%'
  FAILED_LOGIN_ATTEMPTS 3
  PASSWORD_LOCK_TIME 1;
ALTER USER 'app'@'%'
  PASSWORD EXPIRE INTERVAL 90 DAY;

認証方式
default_authentication_pluginはmysql_native_passwordです。TiDBはcaching_sha2_password、tidb_sm3_password、auth_socketも受け付けます。Calíopeは最初の2つでサインインします。ほかの2つのどちらかを使うアカウントでは接続が失敗し、エラーがサーバの求める方式を挙げます。

Calíopeでは
「ユーザ」はアカウントを一覧にし、ロールも同じリストに表示されます。グローバル、データベース、テーブルの権限を付与し、取り消します。ルーチンの範囲は提示しません(TiDBにはルーチンがないため)。1時間あたりの制限も提示しません(TiDBは受け付けて捨てるため)。アカウントのロックとロック解除ができ、書き込むALTER USERのたびに、直前にSHOW CREATE USERから読んだ、そのアカウントがすでに持っていたREQUIREを繰り返します。アカウントがどのロールを持っているかの表示やロールの付与はしません:それは「SQLエディタ」で、上の文を使って行います。

キーワード: ユーザ、ロール、CREATE ROLE、SET DEFAULT ROLE、role_edges、default_roles、REQUIRE SSL、ALTER USER、SHOW CREATE USER、global_priv、TLS、have_ssl、validate_password、FAILED_LOGIN_ATTEMPTS、PASSWORD EXPIRE、tidb_sm3_password、auth_socket、caching_sha2_password、TiDB

TiDBにはないMySQLの機能

トリガー、ルーチン、バイナリログ、OPTIMIZE、FULLTEXT、空間データ型など:TiDBが何と答えるか、何が代わりになるか、Calíopeが何を表示しなくなるか。

対象: TiDB 8.5+

TiDB 8.5.8で測定しました:サーバにないもの、それをどう伝えるか、何が代わりになるか、Calíopeが何を表示しなくなるか。コードの表は「よくあるエラーとその意味」にあります。

トリガー、ルーチン、イベント
CREATE TRIGGERとCREATE EVENTは1064、CREATE PROCEDUREは8108になります。information_schemaのTRIGGERS、ROUTINES、EVENTSのテーブルは存在し、常に空です。トリガーがしていたことはアプリケーションに移すか、整合性を守るだけだったなら外部キーに移します。イベントは外部のスケジューラに移します。
Calíopeでは:「カタログ」はその3つのタブを表示せず、注記でそれを伝え、代わりに「シーケンス」を表示します。「データ移行」は何が移らないかを伝えます。

バイナリログとレプリケーション
SHOW BINLOG EVENTSは1064、SHOW REPLICA STATUSは1235になります。SHOW MASTER STATUSは、位置の代わりにTSOを持つtidb-binlogのファイルを返します。TiDBは内部ではTiKVノード間のRaftでレプリケーションし(「アーキテクチャ:TiDB、TiKV、PD(TiDB)」を参照)、外部へはSQLでは操作しないTiCDCでレプリケーションします。
Calíopeでは:TiDBのセッションでは、「バイナリログ」、「レプリケーションモニタ」、「トポロジ」はナビゲータに表示されず、レプリカのグループのエディタはTiDBのプロファイルを提示しません。

メンテナンス
OPTIMIZE TABLEは8200、CHECKSUM TABLEとREPAIR TABLEは1064になります。TiKVは自分で圧縮するので、最適化するものはありません。CHECK TABLEの役目はADMIN CHECK TABLEが果たします:インデックスが行と一致するかを確かめ、すべて問題なければ何も返しません。FLUSH TABLESはOKと答えて何もしません。
Calíopeでは:「テーブルメンテナンス」にはANALYZEと、ADMIN CHECK TABLEを実行するチェックだけが残ります。

インデックスと型
FULLTEXTインデックスは警告1214付きで受け付けられ、作成されません:SHOW CREATE TABLEにも出てきません。空間データ型はありません:POINTは1064になります。
Calíopeでは:「ビルダー」は通常と一意のBTREEインデックスを提示し、FULLTEXTやSPATIALはなく、空間データ型のグループもありません。代わりに、キーにAUTO_RANDOMとCLUSTEREDを提示します(「キー、AUTO_INCREMENT、AUTO_RANDOM(TiDB)」を参照)。

文とセッション
SERIALIZABLEとREAD UNCOMMITTEDは8048になります(「トランザクション、ロック、デッドロック(TiDB)」を参照)。LOCK TABLESは警告1235を出し、何もロックしません。XAもHANDLERもなく(1064)、CREATE TABLE … AS SELECTもなく(1105、not implemented yet)、SELECT … INTO @vもありません:代わりにSET @v = (SELECT …)を使います。SET SESSION TRANSACTION READ ONLYは1235を返し、セッションは書き込みを続けます。SET UNIQUE_CHECKS=0は受け付けられますが、一意キーはそれでもチェックされます。
Calíopeでは:読み出し専用のタグがあると、書き込みを止めるのはサーバではなくCalíopeです(「保護レベル」を参照)。TiDBの「バックアップ」にはSET UNIQUE_CHECKS=0は入りません。

パフォーマンスと診断
performance_schemaはオフで、そのテーブルは空です。SHOW PROFILESは空で返り(have_profiling = NO)、mysql.slow_logは存在しません。スロークエリーはSLOW_QUERYに、ダイジェストの要約はSTATEMENTS_SUMMARYにあります(「スロークエリーとSTATEMENTS_SUMMARY(TiDB)」を参照)。SHOW GLOBAL STATUSは11行を返し、Threads_connectedもInnodb_*もありません:セッションはCLUSTER_PROCESSLISTから取ります。EXPLAIN FORMAT=JSONはなく、ツリーはFORMAT='tidb_json'です。
Calíopeでは:「SQLエディタ」の「プロファイル」ボタンは表示されず、「ダッシュボード」はTiDBが公開しないものにn/aを描画し、「実行計画」はtidb_jsonのツリーを読みます。

キーワード: 互換性、MySQL、トリガー、プロシージャ、ルーチン、イベント、バイナリログ、TiCDC、レプリカ、OPTIMIZE、8200、CHECKSUM、REPAIR、ADMIN CHECK TABLE、FULLTEXT、1214、空間、POINT、XA、HANDLER、LOCK TABLES、SERIALIZABLE、8048、performance_schema、SHOW PROFILES、TiDB

データモデリング:埋め込みか参照か

データをドキュメントの中に置くときと参照するとき:MongoDBの4つのパターン、際限なく増える配列というアンチパターン、それぞれのコスト(測定済み)。

対象: MongoDB 7.0+

SQLでは、スキーマは正規化に従います:それぞれの事実は1か所に置き、クエリーがJOINでそれを組み立て直します。MongoDBではアクセスパターンに従います:一緒に読むものは一緒に保存します。問いはもはや「値の重複をどう避けるか」ではなく、「1回の読み込みで何を得たいか」です。

埋め込みか参照か

注文は明細を自分の中に持つこともできますし、明細が独自のコレクションに置かれて注文を指し返すこともできます。

// 埋め込み:注文が明細を中に持つ
db.p_emb.insertOne({ _id: 1, cliente: 7, fecha: new Date(),
  lineas: [ { sku: "A-7", cantidad: 2, precio: 19.9 } ] })
db.p_emb.findOne({ _id: 1 })

// 参照:明細は別に置かれ、注文を指す
db.p_ref.insertOne({ _id: 1, cliente: 7, fecha: new Date() })
db.l_ref.insertOne({ pedido: 1, sku: "A-7", cantidad: 2, precio: 19.9 })
db.l_ref.createIndex({ pedido: 1 })
db.p_ref.aggregate([ { $match: { _id: 1 } },
  { $lookup: { from: "l_ref", localField: "_id",
               foreignField: "pedido", as: "lineas" } } ])

MongoDB 8.2で、明細が3行ある注文100,000件を相手に測定しました:1件の注文を明細とともに読むコストは、埋め込みで0.25 ms、$lookupで0.30 msです。ただしl_ref.pedidoのインデックスがないと67 msになります。その場合、注文ごとに300,000行の明細をすべてたどるからです。ディスク上では、埋め込んだ注文が2.9 MB、別々の2つのコレクションは1.1 MB + 5.2 MBでした。ここでは埋め込みのほうが容量の面でも安く済みました。

埋め込んでもインデックスはあきらめません。配列の中のフィールドに張ったインデックスはマルチキーになり、同じように働きます。インデックスなしで{"lineas.sku": "A-7"}を探すと、100,000件のドキュメントに対するCOLLSCANで42 msかかります。インデックスがあれば、同じ2,000行に対して検査するのは2,000件で2 msです。さらに、1つのドキュメントはトランザクションなしでアトミックに変更されます:1つのupdateOneの中のカウンターの$incと状態の$setは、両方とも反映されるか、どちらも反映されないかのどちらかです。

アンチパターン:増え続ける配列

db.sensor.insertOne({ _id: 1, lecturas: [] })
const tanda = Array.from({ length: 10000 }, (_, i) => ({ t: new Date(), v: i }))
db.sensor.updateOne({ _id: 1 }, { $push: { lecturas: { $each: tanda } } })
db.sensor.stats().avgObjSize   // 288 919 bytes tras 10 000 lecturas

空のドキュメントは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とは比べものになりません。上限のわからないまま増える配列は、置き場所を間違えた参照です。

バケット:読み取り値をN件ずつのドキュメントにまとめます。

const lote = Array.from({ length: 200 }, (_, i) => ({ t: new Date(), v: i }))
db.cubos.insertOne({ sensor: 1, desde: new Date(), n: 200, lecturas: lote })
db.cubos.createIndex({ sensor: 1, desde: -1 })

同じ540,000件の読み取り値が、ばらばらなら540,000件のドキュメント、データ6.5 MB、インデックス8.4 MBです。200件ずつのバケットにすると2,700件のドキュメント、データ4.2 MB、インデックス82 KBです。挿入にかかるのは、バケットで203 ms、ばらばらで901 msです。最新の1件を読むと、バケットからは0.20 ms、ばらばらでは0.30 ms、埋め込んだ配列からは46.80 msです。配列は丸ごと取得しなければならないからです。

拡張参照:いつも画面に出ている親のフィールドをいくつか、子にコピーします。顧客の名前とともに注文を100件一覧表示すると、名前を中に複製した場合は0.35 ms、$lookupでは1.00 msです。代償は書き込みのときに払います:顧客の名前を変えると、その顧客の1,000件の注文に触れることになり、clienteにインデックスがあれば2 msです。複製するのは、ほとんど変わらないものにします。

計算済みの値:読むたびに計算し直す代わりに、合計をあらかじめ保存しておきます。注文1件では差はわかりません(ドキュメントから読んで0.35 ms、その場で合計して0.30 ms)。100,000件すべてを集計すると差がわかります:フィールドを読めば14 ms、計算し直せば129 msです。

サブセット:ドキュメントの中には表示する数行だけを置き、残りは独自のコレクションに置きます。レビューが500件ずつある製品が500件:すべてを埋め込むとドキュメントの平均は80,738バイトです。最新の5件とカウンターだけなら889バイトで、コレクションは6.1 MBから60 KBに縮みます。製品のページは0.55 msから0.25 msになり、20件のレビューのページは、独自のコレクションから別に取得します。

ルールを1行で言えば:親と一緒に読まれ、その親だけに属し、上限があるものは埋め込みます。際限なく増えるもの、複数の親で共有されるもの、単独で問い合わせるものは参照にします。

キーワード: モデリング、埋め込み、参照、ドキュメント、パターン、バケット、サブセット、拡張参照、計算済みの値、配列、スキーマ、16 MB

BSONの型と照合順序

MongoDBが各値をどの型で保存するか、どれだけの容量を取るか、異なる型どうしがどう比較されるか、アクセント付きのテキストがどう並ぶか。

対象: MongoDB 7.0+

SQLでは、テーブルが型を宣言し、すべての行がそれに従います。MongoDBでは、型は値ごとに一緒に運ばれます:CREATE TABLEはなく、同じコレクションの2つのドキュメントが、同じフィールドに整数と文字列を持つこともあります。この形式はBSONといい、JSONに欠けている型を加えたバイナリの拡張です:32ビットと64ビットの整数、正確な10進数、日付、バイナリ、そしてObjectIdです。

_idとObjectId

すべてのドキュメントには_idがあります:一意で、変更できず、コレクションが生まれた時点からインデックスが張られています。自分で書かなければ、クライアントがObjectIdを入れます:12バイトで、そのうち4バイトが秒単位の時刻、5バイトがプロセスごとのランダムな値、3バイトがカウンターです。時刻とともに増えるので、日付をまったく保存しなくても日付の範囲の検索に使えます。

const oid = ObjectId()
oid.getTimestamp()            // 2026-09-12T01:28:05.000Z
oid.toString().slice(0, 8)    // "6aa4aaa5": los 4 bytes de tiempo

// 9月1日以降に作成されたもの(_idのインデックス経由)
const desde = ObjectId.createFromTime(Date.parse("2026-09-01") / 1000)
db.pedidos.countDocuments({ _id: { $gte: desde } })

文字列として保存しても高くつくだけで、得るものはありません:{_id: ObjectId()}は22バイト、同じ値を文字列にすると39バイトです。

整数、倍精度浮動小数点数、10進数

数値の型は4つあり、その違いはドキュメントに表れます。{_id: 1, a: …}に対する$bsonSizeは、intで21バイト、longかdoubleで25バイト、decimalで33バイトです。金額をdecimalに入れるのは、SQLでDECIMALに入れるのと同じ理由です:0.1と0.2を倍精度浮動小数点数として足すと0.30000000000000004になり、10進数として足すと0.3になります。

落とし穴はクライアント側にあります。mongoshは値を見て型を選ぶので、手で書いた7はintとして、3000000000はdoubleとして保存され、9007199254740993は9007199254740992として保存されます:2⁵³を超えたらNumberLong("…")と書く必要があります。

db.tipos.insertMany([
  { v: 7 }, { v: 7.5 }, { v: 3000000000 },
  { v: 9007199254740993 }, { v: NumberLong("9007199254740993") }
])
db.tipos.aggregate([ { $project: { v: 1, t: { $type: "$v" } } } ])

ただし、問い合わせでは4つの型は1つの数値として扱われます:コレクションにint、long、double、decimalでどれも7の値があると、{v: 7}は4件すべてを見つけ、{v: "7"}は1件も見つけません。$typeなら区別でき、"number"はそれらをまたまとめます。

日付

Dateは1970年からのミリ秒を表す8バイトで、常にUTC、タイムゾーンはありません。DATETIME / TIMESTAMPの組はここにはありません:型は1つだけで、タイムゾーンは読む側が適用し、マイクロ秒は書き込みの時点で失われます。BSONのTimestampはユーザのデータ用ではありません。レプリケーションの内部時計です。

db.eventos.insertOne({ _id: 1, d: new Date("2026-09-11T21:05:00.123Z") })
db.eventos.aggregate([ { $project: {
  utc: { $hour: "$d" },
  mx:  { $hour: { date: "$d", timezone: "America/Mexico_City" } }
} } ])                        // utc 21 · mx 15

バイナリとUUID

BinDataはバイト列をサブタイプとともに保存し、UUID()はサブタイプ4です。BinDataとしてのUUIDはドキュメントの中で29バイト、同じものをハイフン付きの文字列で書くと49バイトです。

異なる型の間の順序

型が混在するフィールドに対するsortは失敗しません:型の間には全順序があり、14件のドキュメントで測定するとこうなります。

MinKey → フィールドなしとnull → 数値 → 文字列 → オブジェクト → バイナリ → ObjectId → ブール値 → 日付 → Timestamp → 正規表現 → MaxKey。

配列がこのリストにないのは、配列はその最小の要素で比較されるからです:[9, 10]は数値の間に並びます。そして、フィールドがないものはnullとまったく同じように並び、{v: null}はその両方を見つけるほどです。区別するには{v: {$type: "null"}}と{v: {$exists: false}}を使います。

UTF-8と照合順序

選ぶ文字セットはありません。BSONの文字列はUTF-8、それだけです:"café ☕ 日本語 👩‍💻"は14文字で31バイトあり、入れたとおりに戻ってきます。

選ぶのは照合順序で、デフォルトはバイナリです:collationがなければcafeとcaféは別の値で、Árbolはzorroのあとに並びます。localeとstrengthを指定したcollation(1はアクセントと大文字・小文字を無視し、2は大文字・小文字だけを無視し、3はすべてを区別します)は、比較と並べ替えを同時に変えます。

db.clientes.find({ n: "cafe" }).collation({ locale: "es", strength: 1 })
db.clientes.find().sort({ n: 1 }).collation({ locale: "es" })

そして、ここにもSQLと同じ落とし穴があります:クエリーの照合順序は、インデックスの照合順序と同じでなければなりません。nに普通のインデックスを張ると、クエリー{n: "cafe"}は1件のドキュメントを検査するIXSCANです。同じクエリーにcollationを付けるとCOLLSCANに落ち、7件すべてを検査します。解決策は、すべてのクエリーにcollationを書くことではなく、それをコレクションに与えることです。そうすれば、そのインデックスは最初から照合順序を持って生まれます。

db.createCollection("clientes", { collation: { locale: "es", strength: 1 } })
db.clientes.createIndex({ n: 1 }, { unique: true })
db.clientes.insertOne({ n: "cafe" })
db.clientes.insertOne({ n: "CAFÉ" })   // E11000:ここでは同じ値

注意がさらに2つあり、どちらも測定済みです。$regexは照合順序を無視します:strength: 1でも/^CAF/はcafeを見つけませんが、{n: "CAFE"}は見つけます。そしてnumericOrdering: trueを付けると、文字列"1"、"2"、"10"は"1"、"10"、"2"の順ではなく、数値として並びます。

キーワード: BSON、型、ObjectId、Decimal128、NumberLong、日付、UUID、BinData、照合順序、UTF-8、アクセント、並べ替え

インデックス:どれを選ぶかとESRルール

MongoDBの11種類のインデックスとそれぞれが合う場面、複合インデックスの順序を決めるESRルール、カバードクエリー、そして上限と書き込みのコスト。すべて`explain`で測定しています。

対象: MongoDB 7.0+

インデックスはフィールドの値に対するBツリーで、それが役立っているかどうかを示す数字はexplain("executionStats")から得られます:totalDocsExaminedとnReturnedの比較です。前者が後者よりずっと大きければ、サーバは捨てるためだけにドキュメントを読んでいます。

const est = ["nuevo", "pagado", "enviado", "cerrado"]
const docs = []
for (let i = 0; i < 20000; i++) docs.push({
  cliente: i % 499, estado: est[i % 4], total: (i % 997) + 0.5,
  fecha: new Date(Date.UTC(2026, 0, 1 + (i % 240)))
})
db.pedidos.insertMany(docs)
const busca = { cliente: 42, estado: "pagado" }

db.pedidos.find(busca).explain("executionStats")   // COLLSCAN · 10 / 20000

db.pedidos.createIndex({ cliente: 1 })
db.pedidos.find(busca).explain("executionStats")   // IXSCAN · 10 / 40

db.pedidos.createIndex({ estado: 1, cliente: 1, fecha: -1 })
db.pedidos.find(busca).explain("executionStats")   // IXSCAN · 10 / 10

ESRルール

複合インデックスは先頭から順にたどられるので、フィールドの順序がすべてを決めます。等価のフィールドを先に(E)、次にソートのフィールド(S)、範囲のフィールド(R)を最後に置きます。範囲をソートより前に置くと、インデックスは絞り込みはしても並べ替えはせず、メモリー上で並べ替えるSORTステージが現れます:下のクエリーでは1,826件のドキュメントです。

const q = { estado: "pagado", fecha: { $gte: new Date("2026-06-01") } }

db.pedidos.createIndex({ estado: 1, fecha: 1, total: 1 })          // E-R-S
db.pedidos.find(q).sort({ total: 1 }).hint("estado_1_fecha_1_total_1").explain()
// FETCH <- SORT <- IXSCAN

db.pedidos.createIndex({ estado: 1, total: 1, fecha: 1 })          // E-S-R
db.pedidos.find(q).sort({ total: 1 }).hint("estado_1_total_1_fecha_1").explain()
// FETCH <- IXSCAN

このステージには上限があります:internalQueryMaxBlockingSortMemoryUsageBytesは104,857,600バイトで、それを超えると、ディスクの使用を許可しない限りクエリーは失敗します。

カバードクエリー

クエリーが読むフィールドをインデックスがすべて持っていれば、サーバはドキュメントにまったく触れません。_idに注意してください:デフォルトで射影に含まれ、インデックスには含まれないので、手で外す必要があります。

db.pedidos.find({ estado: "pagado" }, { _id: 0, estado: 1, total: 1 })
  .hint("estado_1_total_1_fecha_1").explain("executionStats")
// PROJECTION_COVERED · nReturned 5000 · totalDocsExamined 0

db.pedidos.find({ estado: "pagado" }, { estado: 1, total: 1 })
  .hint("estado_1_total_1_fecha_1").explain("executionStats")
// totalDocsExamined 5000

ほかの種類

種類用途測定したこと
マルチキー配列のフィールド1つの複合インデックスに配列が2つ:エラー171
テキスト単語の検索コレクションに1つだけ。2つ目は85
2dsphereGeoJSONと$nearないと$nearは291を返す
hashed長いキーに対する等価範囲とsortはCOLLSCANに戻る
ワイルドカード$**決まっていないスキーマクエリーごとに絞り込めるのは1つのフィールドで、2つではない
TTL期限切れになるドキュメントコレクタは60秒ごとに動く
部分コレクションの一部クエリーはそのフィルタを繰り返す必要がある
スパースフィールドのないドキュメントを飛ばすそれでsortするとドキュメントが失われる
一意一意性フィールドのない2つのドキュメントが衝突する
非表示削除せずに削除を試すcollModで元に戻る

黙ってデータを失う2つは、並べて見るのがいちばんです:

db.socios.insertMany([{ a: 1 }, { a: 2 }, { b: 9 }, { b: 8 }])
db.socios.createIndex({ a: 1 }, { sparse: true })
db.socios.find().sort({ a: 1 }).hint("a_1")   // 2 / 4

db.correos.createIndex({ correo: 1 }, { unique: true })
db.correos.insertOne({ otro: 1 })
db.correos.insertOne({ otro: 2 })   // E11000 · dup key: { correo: null }

非表示はその逆です:維持はされ続けますが、プランナはそれを見ません。hidden: trueでは、同じクエリーが20,000件のドキュメントに対するCOLLSCANでした。collModで表示に戻すと、1,940件に対するIXSCANです。

上限

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

インデックス名とキーのサイズには実質的な上限がありません:400文字と2,000バイトが何の問題もなく受け付けられました。

代償

インデックスはどれも、書き込みのたびにコストがかかります。同じ20,000件の挿入に、インデックスなしで60 ms、5つで101 ms、10個で160 msかかりました。しかも容量を取ります:pedidosの6つのインデックスは合計1.9 MBで、データは916 KBです。

だから、すべてにインデックスを張りはしません。カーディナリティの低いフィールドはめったに役立ちません:値が4種類しかないestadoは、5,000件を返すために5,000件のドキュメントを検査します。そして、複合インデックスがすでにそのフィールドで始まっていれば、単一のインデックスは冗長です:{ cliente: 1 }と{ cliente: 1, fecha: 1 }が検査するのは、どちらも同じ40件です。

キーワード: インデックス、ESR、複合、マルチキー、テキスト、地理空間、ハッシュ、ワイルドカード、TTL、部分、スパース、一意、非表示、カバードクエリー、explain、IXSCAN、COLLSCAN、totalDocsExamined、MongoDB

集計パイプライン:$matchから$mergeまで

パイプラインのステージとその並べ方、JOINとしての$lookupとインデックスがないときのコスト、$graphLookup、ウィンドウ関数、$facetと$unionWith、そして$merge対$out。

対象: MongoDB 7.0+

パイプラインはステージのリストで、各ステージは前のステージが生み出したドキュメントを受け取ります。順序を書くのはユーザで、パフォーマンスのほとんどはそこで決まります。

const est = ["nuevo", "pagado", "enviado", "cerrado"]
const docs = []
for (let i = 0; i < 20000; i++) docs.push({
  cliente: i % 499, estado: est[i % 4], total: (i % 997) + 0.5,
  fecha: new Date(Date.UTC(2026, 0, 1 + (i % 240)))
})
db.pedidos.insertMany(docs)

db.pedidos.aggregate([
  { $match: { estado: "pagado" } },
  { $group: { _id: "$cliente", gastado: { $sum: "$total" }, pedidos: { $sum: 1 } } },
  { $match: { pedidos: { $gte: 11 } } },
  { $sort: { gastado: -1 } },
  { $limit: 3 }
])
// { _id: 37, gastado: 522.5, pedidos: 11 } ほか2件

常に、まず絞り込む

インデックスを使えるのは最初のステージだけです。20,000件の注文に{estado: 1}のインデックスがあるとき、先頭の$matchは5,000個のキーと5,000件のドキュメントを検査します。同じフィルタを$groupのあとに置くと、キーは1つも使わず、20,000件のドキュメントを検査します。

$lookupはJOINであり、インデックスがなければ高くつく

同じデータベースの別のコレクションを結合し、見つかったものは配列として届きます。その配列はたいてい$unwindで開きます。

const cl = []
for (let i = 0; i < 499; i++)
  cl.push({ num: i, nombre: "Cliente " + i, pais: ["MX", "ES", "AR", "CO"][i % 4] })
db.clientes.insertMany(cl)

const pais = [
  { $match: { estado: "pagado" } },
  { $lookup: { from: "clientes", localField: "cliente", foreignField: "num", as: "c" } },
  { $unwind: "$c" },
  { $group: { _id: "$c.pais", gastado: { $sum: "$total" } } }
]
db.pedidos.explain("executionStats").aggregate(pais)
// collectionScans 5001 - totalDocsExamined 2495499 - indexesUsed []

db.clientes.createIndex({ num: 1 })
db.pedidos.explain("executionStats").aggregate(pais)
// collectionScans 0 - totalDocsExamined 5001 - indexesUsed num_1

このステージ自身のexplainを見れば疑いの余地はありません:clientes.numにインデックスがなければ、入力ドキュメントごとに全件を1回たどり、インデックスがあれば1回もたどりません。

再帰とウィンドウ

$graphLookupは階層をたどれるところまでたどり、depthFieldは各ステップがどこまで離れていたかを記録します。返す配列は並んでいません:ここでは、レベル2、0、1のAna、Caro、Betoの順で返ってきました。$setWindowFields(5.0以降)はSQLのウィンドウです:partitionByがPARTITION BY、sortByがORDER BYにあたります。

db.empleados.insertMany([{ _id: 1, nombre: "Ana", jefe: null },
  { _id: 2, nombre: "Beto", jefe: 1 }, { _id: 3, nombre: "Caro", jefe: 2 },
  { _id: 4, nombre: "Dora", jefe: 3 }])
db.empleados.aggregate([
  { $match: { nombre: "Dora" } },
  { $graphLookup: { from: "empleados", startWith: "$jefe", connectFromField: "jefe",
                    connectToField: "_id", as: "cadena", depthField: "nivel" } }
])   // cadena: Ana(2), Caro(0), Beto(1)

db.pedidos.aggregate([
  { $match: { cliente: 42 } },
  { $setWindowFields: { partitionBy: "$estado", sortBy: { fecha: 1 },
      output: { acumulado: { $sum: "$total", window: { documents: ["unbounded", "current"] } },
                puesto: { $rank: {} } } } },
  { $match: { estado: "pagado" } },
  { $limit: 3 }
])   // acumulado 559.5 - 1113 - 1660.5

1回で複数の答え

$facetは同じ入力に対してサブパイプラインを実行し、そのすべてを含む1つのドキュメントを返します。その中では、もうどのインデックスも効きません。$unionWithはUNION ALLです。

db.devoluciones.insertMany([{ cliente: 42, total: 9.5 }, { cliente: 7, total: 4.25 }])
db.pedidos.aggregate([
  { $match: { cliente: 42, estado: "pagado" } },
  { $project: { _id: 0, cliente: 1, importe: "$total" } },
  { $unionWith: { coll: "devoluciones", pipeline: [
      { $match: { cliente: 42 } },
      { $project: { _id: 0, cliente: 1, importe: { $multiply: ["$total", -1] } } } ] } },
  { $group: { _id: "$cliente", neto: { $sum: "$importe" }, filas: { $sum: 1 } } }
])   // { _id: 42, neto: 5495.5, filas: 11 }

結果を書き込む

$mergeは出力先のコレクションに結合し、$outはそれを丸ごと置き換えます。

db.pedidos.aggregate([
  { $match: { estado: "pagado" } },
  { $group: { _id: "$cliente", gastado: { $sum: "$total" } } },
  { $merge: { into: "resumen", whenMatched: "merge", whenNotMatched: "insert" } }
])
db.pedidos.aggregate([
  { $match: { estado: "enviado" } },
  { $group: { _id: "$cliente", enviado: { $sum: "$total" } } },
  { $merge: { into: "resumen", whenMatched: "merge", whenNotMatched: "insert" } }
])   // resumen: { _id: 42, gastado: 5505, enviado: 515 }

db.pedidos.aggregate([
  { $match: { estado: "nuevo" } },
  { $group: { _id: "$cliente", nuevo: { $sum: "$total" } } },
  { $out: "resumen" }
])   // resumen: { _id: 42, nuevo: 525 } - 以前の内容は消える

上限

ブロッキングするステージ($group、$sort、$facet、$lookupの中間結果)にはそれぞれ104,857,600バイトが割り当てられ、6.0以降は超えた分が自動的にディスクにあふれます。ただし、アキュムレータの配列はあふれません:大きなドキュメントに対する$pushは146 ExceededMemoryLimitで失敗し、allowDiskUse: trueでも救えません。

キーワード: 集計、パイプライン、ステージ、$match、$group、$project、$lookup、JOIN、$graphLookup、setWindowFields、ウィンドウ、$facet、$unionWith、$merge、$out、$unwind、explain、allowDiskUse

トランザクション:必要なときと競合112

1つのドキュメントはトランザクションなしで丸ごと書き込まれます。複数ならトランザクションと、レプリカセットが必要です。セッション、readConcernとwriteConcern、WriteConflict 112、そして測定した3つの上限。

対象: MongoDB 7.0+

MongoDBではドキュメントは丸ごと書き込まれるか、まったく書き込まれないかのどちらかで、updateOneが10個のフィールドと入れ子の配列に触れる場合でもそれは変わりません。一度に複数のドキュメントを変更するにはトランザクションが必要で、トランザクションにはレプリカセットが必要です:スタンドアロンのノードでは拒否され、そのメッセージはトランザクションに触れさえしません。デプロイメントが再試行可能な書き込みをサポートしていない、と言うのです。

db.cuentas.insertMany([{ _id: "A", saldo: 100 }, { _id: "B", saldo: 100 }])
db.cuentas.updateOne({ _id: "A" },
  { $inc: { saldo: -30 }, $set: { ultimo: new Date("2026-09-12") } })
db.cuentas.findOne({ _id: "A" })
// { _id: "A", saldo: 70, ultimo: 2026-09-12 } - los dos campos, o ninguno

トランザクションはセッションの上で実行される

中で行うことはすべてセッションオブジェクトを通します:外側のdb.cuentasは、名前がまったく同じでもトランザクションには含まれません。commitTransaction()までは、書き込んだ内容は内側からしか見えません。

const s = db.getMongo().startSession()
const c = s.getDatabase(db.getName()).cuentas
s.startTransaction({ readConcern: { level: "snapshot" },
                     writeConcern: { w: "majority" } })
c.updateOne({ _id: "A" }, { $inc: { saldo: -10 } })
c.updateOne({ _id: "B" }, { $inc: { saldo: 10 } })

c.find().toArray()           // dentro:  A 60, B 110
db.cuentas.find().toArray()  // fuera:   A 70, B 100

s.commitTransaction()
db.cuentas.find().toArray()  // fuera:   A 60, B 110
s.endSession()

abortTransaction()はそのすべてを取り消します。頼む必要さえありません:セッションがなくなるかサーバが再起動すると、トランザクションは中止された状態で終わります。

readConcernとwriteConcernは別々の問い

readConcernは何を読むかを表します:localはここにあるもの、majorityはもう失われることのないもの、snapshotはある一瞬の一貫した姿です。writeConcernはいつ書き込まれたとみなすかを表します:w: 1はプライマリ、w: "majority"はセットの過半数で、j: trueはジャーナルを加えます。出荷時の値はgetDefaultRWConcernから来ます:読み取りはlocal、書き込みはmajorityです。

書き込みの競合

同じドキュメントに対する2つのトランザクションは待ちません:2つ目はその場で失敗し、112 WriteConflictを返します。メッセージもはっきりそう言います。再試行は前提のうちで、だからドライバには自動で再試行するwithTransactionが付いています。

const s1 = db.getMongo().startSession()
const s2 = db.getMongo().startSession()
s1.startTransaction(); s2.startTransaction()
s1.getDatabase(db.getName()).cuentas.updateOne({ _id: "A" }, { $inc: { saldo: 1 } })
s2.getDatabase(db.getName()).cuentas.updateOne({ _id: "A" }, { $inc: { saldo: 1 } })
// 112 WriteConflict - Write conflict during plan execution ... Please retry

s1.commitTransaction()   // 1つ目は通る
s2.abortTransaction()
s1.endSession(); s2.endSession()

トランザクションの外からの書き込みは112を受け取りません:待ちます。測定では76秒待ち、ドキュメントを押さえていたトランザクションが存続期間の上限で中止されるまで続きました。

上限

トランザクションの寿命は60秒で、その線を越えるとコミットはhas been abortedとともに251 NoSuchTransactionを返します。中では、ロックの要求はそれぞれ5ミリ秒しか待ちません:トランザクションはロックで固まるくらいなら失敗するほうを選びます。そして、セットが満たせないwriteConcernは、試す前に失敗します。

db.adminCommand({ getParameter: 1, transactionLifetimeLimitSeconds: 1 })
// 60
db.adminCommand({ getParameter: 1, maxTransactionLockRequestTimeoutMillis: 1 })
// 5
db.adminCommand({ getDefaultRWConcern: 1 })
// defaultReadConcern local - defaultWriteConcern { w: "majority", wtimeout: 0 }

db.cuentas.updateOne({ _id: "A" }, { $inc: { saldo: 0 } },
  { writeConcern: { w: 2, wtimeout: 1000 } })
// 100 UnsatisfiableWriteConcern - Not enough data-bearing nodes

実用上のルール:2つのドキュメントを1日に何度も一緒に変更しなければならないなら、たいてい間違っているのはモデルで、本来は埋め込むべきでした。トランザクションは、本当に1つのドキュメントに収まらないものの逃げ道です。

キーワード: トランザクション、セッション、startTransaction、コミット、中止、WriteConflict、112、251、readConcern、writeConcern、MAJORITY、SNAPSHOT、レプリカセット、原子性、ロック

パフォーマンス:explain、プランキャッシュ、プロファイラ

explainの3つの詳細度とそれぞれが加えるもの、プランキャッシュと計画が無効になるとき、WiredTigerのキャッシュの中のワーキングセット、プロファイラの3つのレベル。

対象: MongoDB 7.0+

何かに手を付ける前に測ります。そのための道具がexplainです。explainには3つの詳細度があり、あとのものほどコストがかかります:queryPlannerは計画するだけで実行はせず、採用された計画と却下された計画を表示します。executionStatsは採用された計画を実行し、そのコストを加えます。allPlansExecutionはさらに、試行期間中に各候補にかかったコストを加えます。採用された計画がなぜ勝ったのかは、そこでわかります。

const est = ["nuevo", "pagado", "enviado", "cerrado"]
const docs = []
for (let i = 0; i < 20000; i++)
  docs.push({ cliente: i % 499, estado: est[i % 4], total: (i % 997) + 0.5 })
db.pedidos.insertMany(docs)
db.pedidos.createIndex({ cliente: 1 })
db.pedidos.createIndex({ cliente: 1, total: -1 })
const q = { cliente: 42, total: { $gt: 100 } }

db.pedidos.find(q).explain()
// queryPlanner:winningPlanはFETCH、rejectedPlansに候補が1つ

db.pedidos.find(q).explain("executionStats")
// + executionStats: nReturned 20, docs 20, claves 20, 0 ms

db.pedidos.find(q).explain("allPlansExecution")
// + allPlansExecution:試した2つの計画、FETCH 20とFETCH 10

大事な3つの数字はnReturned、totalDocsExamined、totalKeysExaminedです。2つ目が1つ目よりずっと大きければ、サーバは捨てるためにドキュメントを読んでいます。

プランキャッシュ

プランナはクエリーのたびに決め直すわけではありません。最初に候補を試し、勝ったものをplanCacheKeyのもとに保存して、以後はそれを再利用します。explainではisCached: trueとして現れます。エントリーにはかかった作業量worksが保存されていて、あとの実行でその10倍を費やすと、その計画は無効になり、候補がもう一度競います。インデックスの作成や削除でもキャッシュは消去されます。

db.pedidos.getPlanCache().clear()
db.pedidos.getPlanCache().list()     // []

db.pedidos.find(q).toArray()
db.pedidos.find(q).toArray()
db.pedidos.getPlanCache().list()     // 1 entrada: works 21, isActive true

db.pedidos.find(q).explain().queryPlanner.winningPlan.isCached   // true

ワーキングセットとWiredTigerのキャッシュ

MongoDBは結果を保持しません。保持するのはページで、WiredTigerのキャッシュの中にあります。パフォーマンスは、ワーキングセット(実際に触れるデータとインデックス)がそこに収まるかどうかで決まります。デフォルトでは、このキャッシュはRAMから1 GBを差し引いた残りの半分を使います。測定では、要求された486,864ページのうち、ディスクから読み込む必要があったのは261ページだけでした:1,865ページに1ページです。

const w = db.serverStatus().wiredTiger.cache
w["maximum bytes configured"]        // 3621781504 - (RAM - 1 GB)の半分
w["bytes currently in the cache"]    // 18640438 - サーバ全体で
w["pages requested from the cache"]  // 486864
w["pages read into cache"]           // 261 - 1865ページに1ページ

db.pedidos.stats().size              // 1385000:データ
db.pedidos.stats().totalIndexSize    // 499712:インデックス

プロファイラ

レベルは3つです:0はオフ、1はslowmsを超えたものだけ、2はすべてです。測定してわかった、よく引っかかる点が2つあります:setProfilingLevelは、いま設定したレベルではなく変更前のレベルを返すので、新しいレベルは読み直す必要があります。そしてsystem.profileは1 MiBの上限付きコレクションなので大きくならず、自分の尾をかじります。

db.getProfilingStatus()      // { was: 1, slowms: 100, sampleRate: 1 }
db.setProfilingLevel(2)      // { was: 1, ... }  <- 変更前のレベル

db.pedidos.find({ total: { $gt: 900 } }).toArray()
db.system.profile.find().sort({ ts: -1 }).limit(1)
// query - COLLSCAN - docsExamined 1901 - nreturned 101 - 0 ms

db.system.profile.stats().capped    // true, maxSize 1048576
db.setProfilingLevel(1, { slowms: 100 })

各エントリーにはplanSummary、docsExamined、nreturned、millisが入っていて、インデックスを張る価値があるかを判断するのにまさに必要なものです。Calíopeはプロファイリングのツールでこのコレクションを読みます。レベル2は本番環境では高くつきます:しばらくオンにしたら元に戻すもので、そのままにはしません。

キーワード: パフォーマンス、explain、queryPlanner、executionStats、allPlansExecution、プランキャッシュ、planCacheKey、isCached、WiredTiger、ワーキングセット、プロファイラ、system.profile、slowms

サーバの設定:ファイルと実行中に変わるもの

getCmdLineOptsによると実際に何が動いているか、mongod.confの大事な5つのセクション、そして3種類のパラメータ:実行中に変わるもの、起動時だけのもの、そもそもパラメータではないもの。

対象: MongoDB 7.0+

知らないサーバについて最初に問うべきは、設定ファイルに何が書いてあるかではなく、実際に何で動いているかです。getCmdLineOptsはその両方に一度に答えます:argvはコマンドラインで渡されたもの、parsedはそれをファイルの語彙に翻訳したものです。

db.adminCommand({ getCmdLineOpts: 1 })
// argv:   ["mongod", "--profile", "1", "--slowms", "100",
//          "--auth", "--bind_ip_all"]
// parsed: { net: { bindIp: "*" },
//           operationProfiling: { mode: "slowOp", slowOpThresholdMs: 100 },
//           security: { authorization: "enabled" } }

大事な5つのセクション

storageはデータの場所とキャッシュが使うメモリーの量、netは待ち受けるアドレス、securityは認証が必要かどうか、operationProfilingは低速なトラフィックの何を記録するか、replicationはどのセットに属するかを表します。ファイルはYAMLなので、インデントも構文の一部です。

# mongod.conf - 上と同じ内容を、残る場所に書いたもの
storage:
  dbPath: /data/db
  wiredTiger:
    engineConfig:
      cacheSizeGB: 3.37
net:
  bindIp: 127.0.0.1,10.0.0.5
  port: 27017
security:
  authorization: enabled
operationProfiling:
  mode: slowOp
  slowOpThresholdMs: 100
replication:
  replSetName: rs0
setParameter:
  cursorTimeoutMillis: 300000

パラメータには3つの種類がある

setParameterで実行中に変えられるもの、起動時にしか読まれないもの、そしてどれほどパラメータに見えてもそうではないものです。3つはサーバの応答で見分けられます:1つ目は変更前の値とともにwasを返します(新しい値ではないので、結果を知るには読み直す必要があります)。2つ目は20 IllegalOperationになります。そしてportは起動オプションでありパラメータではないので、unrecognized parameterとともに72 InvalidOptionsになります。

db.adminCommand({ setParameter: 1, cursorTimeoutMillis: 300000 })
// { was: 600000, ok: 1 }   <- 新しい値ではなく、変更前の値を返す

db.adminCommand({ setParameter: 1, wiredTigerEngineRuntimeConfig: "cache_size=512M" })
db.serverStatus().wiredTiger.cache["maximum bytes configured"]   // 536870912

db.adminCommand({ setParameter: 1, authenticationMechanisms: ["SCRAM-SHA-256"] })
// 20 IllegalOperation - not allowed to change [...] at runtime

db.adminCommand({ setParameter: 1, port: 27020 })
// 72 InvalidOptions - attempted to set unrecognized parameter [port]

setParameterは残らない

setParameterが有効なのは次の再起動までで、1秒も長くはありません:cursorTimeoutMillisを300,000に設定したサーバは、再起動すると600,000に戻っていました。残すには、ファイルのsetParameter:セクション(例のいちばん下にあるもの)に書く必要があります。

キャッシュと互換性バージョン

WiredTigerのキャッシュは誰もが最初に手を伸ばすもので、そしてほとんどの場合、触れるべきでないものです:デフォルトでは、RAMから1 GBを差し引いた残りの半分を使います。測定したノードでは、7,933 MBのRAMから3,621,781,504バイトのキャッシュになりました。そして、ファイルにない6つ目のものがあります:featureCompatibilityVersionで、バイナリのどの機能を有効にするかを決めます。アップグレードのあとに手で上げ、元に戻す前に下げます。

db.hostInfo().system.memSizeMB   // 7933
db.serverStatus().wiredTiger.cache["maximum bytes configured"]
// 3621781504 = (7933 MB - 1 GB)の半分

Object.keys(db.adminCommand({ getParameter: "*" })).length   // 780
db.adminCommand({ getParameter: 1, featureCompatibilityVersion: 1 })
// { version: "8.2" }

キーワード: 設定、mongod.conf、getCmdLineOpts、setParameter、cacheSizeGB、WiredTiger、bindIp、認可、operationProfiling、replSetName、featureCompatibilityVersion、FCV

セキュリティ:ユーザ、ロール、localhostの例外

ユーザはデータベースの中に存在し、それが名字になること。組み込みロールがadminとほかの場所で同じではないこと。権限のない書き込みが受け取る13。そして、セキュリティを有効にしたばかりのサーバが開けておく唯一の扉。

対象: MongoDB 7.0+

security.authorization: enabledがなければ、ここに書いたことは何も存在しません:サーバはやって来た人を誰でも受け入れ、bindIp: "*"なら誰でもやって来られます。有効にしたら、最初に問うのは「自分は誰か」です。

db.adminCommand({ connectionStatus: 1 }).authInfo
// { authenticatedUsers:     [{ user: "caliope", db: "admin" }],
//   authenticatedUserRoles: [{ role: "root",    db: "admin" }] }

db.adminCommand({ getParameter: 1, authenticationMechanisms: 1 })
// ["MONGODB-X509", "SCRAM-SHA-1", "SCRAM-SHA-256"]

デフォルトの方式はSCRAM-SHA-256で、サーバは両方のバージョンを保持します:測定したユーザのパスワードは、SHA-256で15,000回、SHA-1で10,000回の反復を持ち、ソルトは40文字でした。パスワードは暗号化されていても一切送られません:SCRAMは、パスワードを言わずに知っていることを証明します。3つ目の方式MONGODB-X509はパスワードの代わりにクライアント証明書を使い、その場合ユーザの名前は証明書のサブジェクトそのものです。

ユーザはデータベースの中に存在し、それが名前のもう半分

lectorはユーザではありません:ventasのlectorがユーザです。作成されたデータベースがそのユーザの認証データベースで、接続するときにそれを指定する必要があります(--authenticationDatabase)。間違ったデータベースを指定しても、サーバはユーザが別の場所にいるとは言いません:Authentication failedと言うだけです。

ロールはどのデータベースでも同じではない

普通のデータベースには組み込みロールが6つあります。adminには21あります。サーバ全体に及ぶロールがそこに置かれているからです:…AnyDatabaseのロール、root、backup、restore、クラスタのロールです。ロールはアクションのリストです:readは11個のアクションで、findはそのうちの1つにすぎません。

db.getSiblingDB("admin").runCommand({ rolesInfo: 1, showBuiltinRoles: true })
// 21 roles: backup, clusterAdmin, clusterManager, clusterMonitor, dbAdmin,
// dbAdminAnyDatabase, dbOwner, read, readAnyDatabase, readWrite,
// readWriteAnyDatabase, restore, root, userAdmin, userAdminAnyDatabase ...

db.getSiblingDB("ventas").runCommand({ rolesInfo: 1, showBuiltinRoles: true })
// 6: dbAdmin, dbOwner, enableSharding, read, readWrite, userAdmin

権限が足りないとき何が起きるか

空の答えも、欠けた行もありません:返ってくるのは13 Unauthorizedで、メッセージにはデータベース、コマンド、さらにコレクションの名前まで入ります。足りないルールを読み取れるエラーです。

db.getSiblingDB("ventas").createUser({
  user: "lector", pwd: "lectorpass",
  roles: [{ role: "read", db: "ventas" }]
})
// mechanisms: ["SCRAM-SHA-1", "SCRAM-SHA-256"]

// 読み取り用ユーザとして接続済み(--authenticationDatabase ventas):
db.datos.findOne()              // { _id: 1, v: 1 }
db.datos.insertOne({ _id: 2 })
// 13 Unauthorized - not authorized on ventas to execute command { insert: ... }

localhostの例外

--auth付きでユーザが1人もいないサーバは、そのマシン自身から来た人に1つのことだけを許します:最初のユーザを作ることです。読むことはもうできず、2人目を作ることもできません。使い始めるための入口で、ユーザが1人できた時点で自動的に閉じます。

// --auth付きでユーザが1人もいないノードで、ノード自身から:
db.getSiblingDB("prueba").c.findOne()
// 13 Unauthorized

db.getSiblingDB("admin").createUser({ user: "primero", pwd: "x", roles: ["root"] })
// OK:許されるのはこれだけ

db.getSiblingDB("admin").createUser({ user: "segundo", pwd: "x", roles: ["root"] })
// 13 Unauthorized - Command createUser requires authentication

キーワード: セキュリティ、認証、SCRAM、SCRAM-SHA-256、x.509、TLS、ユーザ、ロール、組み込みロール、READ、readWrite、dbAdmin、userAdmin、root、13、Unauthorized、localhost、authSource

バックアップ:mongodump、oplog、扱わないもの

mongodumpに入るものと入らないもの、復元にかかる時間とその理由、--oplogの用途、oplogのウィンドウが実際にどれだけ続くか、そしてダンプが保証しない2つのこと。

対象: MongoDB 7.0+

mongodumpは論理バックアップです:ほかのクライアントと同じように接続し、ドキュメントを読んでBSONとして書き出します。そこから、数字に表れる2つの結果が生じます。1つ目は、ファイルの大きさがディスク上の容量ではなくドキュメントの大きさになることです:ディスク上では圧縮されているコレクションで、.bsonが2,420,000バイトでした。2つ目は、サーバの通常の作業とキャッシュを奪い合うことで、大きなデータベースのダンプは体感できるほど影響します。

mongodump -u caliope -p ... --authenticationDatabase admin \
  --db ventas --out /vol
// writing `ventas.pedidos` to `/vol/ventas/pedidos.bson`
// done dumping `ventas.pedidos` (20000 documents)      26 ms

ls -l /vol/ventas
// pedidos.bson           2420000   <- ドキュメントの論理的な大きさ
// pedidos.metadata.json      255   <- インデックス(データなし)

インデックスは定義だけが.metadata.jsonに入って運ばれます。だから復元はダンプよりずっと高くつきます(この測定では91 ms対26 ms):時間はインデックスの再構築に費やされ、実際のコレクションでは待ち時間のほとんどがそれです。

mongorestore -u caliope -p ... --authenticationDatabase admin \
  --nsFrom "ventas.*" --nsTo "copia.*" /vol
// restoring `copia.pedidos` from `/vol/ventas/pedidos.bson`
// finished restoring `copia.pedidos` (20000 documents, 0 failures)
// restoring indexes for collection `copia.pedidos` from metadata
// 20000 document(s) restored successfully.             91 ms

db.getSiblingDB("copia").pedidos.getIndexes()   // _id_とcliente_1

--archiveはフォルダのツリーの代わりに1つのファイルを残し、--gzipを付けると2,420,000バイトから133,572バイトに縮みました。どちらもパイプで送れるので、途中のディスクに一度も触れずに、あるマシンから別のマシンへデータベースをコピーできます。

mongodump ... --db ventas --archive=/vol/ventas.gz --gzip
// 133572バイト(通常のBSONでは2420000)

バックアップを一時点に変えるのはoplog

ダンプには時間がかかり、その間もデータベースは変わり続けます:コレクションAをダンプする前とBをダンプしたあとに書き込まれたものは噛み合いません。--oplogはダンプ中に起きた操作も保存し、mongorestore --oplogReplayがそれを最後に適用するので、復元されるのはある瞬間、つまりダンプの終わりの状態です。oplogはレプリカセットのものなので、レプリカセットに対してしか使えません。

// これはレプリカセットにしか存在しない:
db.getSiblingDB("local").oplog.rs.stats()
// capped true - maxSize 45822903296 - size 3708051130 - count 318107

rs.printReplicationInfo()
// oplog first event time   Sat Aug 08 2026 17:56:09
// oplog last event time    Sat Sep 12 2026 07:41:07

mongodump --port 27018 --oplog --out /vol
// writing captured oplog to `` - dumped 1 oplog entry

oplogは上限付きコレクションなので、そのウィンドウはバイトではなく時間で測ります。そしてその時間は、どれだけ書き込まれるかで決まります。測定したノードでは、42 GiBの上限で8月8日から9月12日までのウィンドウでした。負荷が10倍なら3日です。週末に出かける前に見ておくべき数字です。

ダンプが扱わないもの

2つあります。数百ギガバイトのデータベースは、ドキュメントを1件ずつ読んでバックアップするものではありません:それにはファイルシステムのスナップショットが必要で、ジャーナルを含めて取るか、fsyncLockでデータベースをロックして取る必要があります。そしてシャードクラスタでは、ルータに対するmongodumpは、シャードの間で共通の時点を与えません:バランサを止め、シャードごとに1つ、さらに設定サーバのスナップショットを1つ取る必要があります。

キーワード: バックアップ、mongodump、mongorestore、oplog、oplogReplay、アーカイブ、gzip、スナップショット、fsyncLock、復旧、復旧ウィンドウ、BSON

スキーマ:ALTERはなく、バリデータがある

スキーマとはドキュメントが持っているもののことなので、変えるとは書き込むことです。$jsonSchemaのバリデータ、121とそのerrInfo、validationLevelとvalidationActionの4つの組み合わせ、そしてドキュメントのバージョンによる移行。

対象: MongoDB 7.0+

テーブルがないのでALTER TABLEもありません:コレクションのスキーマとは、文字どおり、そのドキュメントが持っているもののことです。新しいドキュメントにフィールドを加えるのはタダで、古いドキュメントは変わりません。そこが落とし穴です:誰かが過去をそろえるまで、読む側は両方の形に対処しなければなりません。

バリデータはスキーマではなく扉

存在するのは$jsonSchemaを使ったvalidatorです:書き込み時にチェックされる条件で、読み込み時にも、さかのぼってもチェックされません。拒否は121 DocumentValidationFailureで、ありがたいのはerrInfo.detailsです。「無効」と言う代わりに、破られたルールの名前を挙げます。

db.createCollection("socios", {
  validator: { $jsonSchema: {
    bsonType: "object",
    required: ["nombre", "correo"],
    properties: {
      nombre: { bsonType: "string", minLength: 2 },
      correo: { bsonType: "string", pattern: "^.+@.+$" },
      edad:   { bsonType: "int",    minimum: 18 }
    } } },
  validationLevel: "strict", validationAction: "error"
})

db.socios.insertOne({ nombre: "Ana", correo: "ana@ej.com", edad: 30 })   // entra
db.socios.insertOne({ nombre: "Caro" })
// 121 DocumentValidationFailure
// errInfo.details: required -> missingProperties: ["correo"]

型に注意してください:mongoshは整数をintとして保存するので、edad: 30はbsonType: "int"を通過し、edad: 30.5は違反します。こちらは本当にdoubleだからです。

validationLevelとvalidationActionは別々のつまみ

レベルはどのドキュメントに及ぶかを表します:strictはすべて、moderateはもともと有効だったものだけです。古いがらくたでいっぱいのコレクションに、更新を詰まらせずにバリデータを付けるにはこれを使います。アクションは失敗したときに何が起きるかを表します:errorは拒否し、warnは書き込みを通してログに記録します。そしてcollModでバリデータを設定しても、すでにあったものには何も触れません。

db.viejos.insertMany([{ _id: 1, nombre: "Uno" }, { _id: 2, nombre: "Dos" }])
db.runCommand({ collMod: "viejos",
  validator: { $jsonSchema: { bsonType: "object", required: ["nombre", "correo"] } },
  validationLevel: "moderate", validationAction: "error" })
db.viejos.countDocuments()                                  // 2 - 何も削除しない

db.viejos.updateOne({ _id: 1 }, { $set: { nombre: "Uno bis" } })   // moderate:通る
db.viejos.insertOne({ _id: 3, nombre: "Tres" })                    // 121 igual

db.runCommand({ collMod: "viejos", validationLevel: "strict" })
db.viejos.updateOne({ _id: 2 }, { $set: { nombre: "Dos bis" } })   // 121

db.runCommand({ collMod: "viejos", validationAction: "warn" })
db.viejos.insertOne({ _id: 4, nombre: "Cuatro" })      // 入り、警告するだけ

移行とは書き込むこと

ALTERがないので、新しい列にあたるのは$setを使ったupdateMany、列の削除にあたるのは$unsetです。安く済みます(20,000件のドキュメントで61 msと51 ms)が、アトミックではありません:ドキュメントを1件ずつ処理するので、移行中は両方の形が共存します。

db.pedidos.updateMany({ _v: 1 }, { $set: { moneda: "MXN", _v: 2 } })
// 61 msで20000件を変更

db.pedidos.updateMany({}, { $unset: { moneda: "" } })
// 51 msで20000件を変更

だから、持ちこたえるパターンは、バージョンを各ドキュメントの中に持たせることです(_v):アプリケーションは両方の読み方を知っていて、移行はまとまりごとに、あるいは各ドキュメントに触れるたびに進み、{_v: 1}が何も返さなくなった日に古いコードを捨てます。SQLの移行がしていることも同じですが、ここでは中間の状態が目に見え、それを書き留めておく必要があります。

キーワード: スキーマ、検証、バリデータ、$jsonSchema、121、DocumentValidationFailure、validationLevel、validationAction、STRICT、MODERATE、WARN、collMod、移行、ドキュメントのバージョン

シャーディング:すべてはキーで決まる

シャードクラスタの3つの部品、ハッシュキーが分散し単調なキーが偏る理由、対象を絞ったクエリーと全体に送るクエリーの測定した差、ゾーン、そしてキーの変更にかかるコスト。

対象: MongoDB 7.0+

シャーディングとは、1つのコレクションを複数のマシンに分割することで、3つの部品が必要です:データを保持し、それ自体がレプリカセットであるシャード。どのチャンクがどこにあるかの地図を保持する設定サーバ。そして何も保持せず、アプリケーションの接続先となるルータのmongosです。

# 3つの別々の部品で、ルータはデータを保持しない
mongod --configsvr --replSet cfg --port 27019
mongod --shardsvr  --replSet sh1 --port 27018
mongod --shardsvr  --replSet sh2 --port 27018
mongos --configdb cfg/qa-cfg:27019

# ya conectado al mongos:
sh.addShard("sh1/qa-sh1:27018")
sh.addShard("sh2/qa-sh2:27018")   // config.shards: ["sh1", "sh2"]

意味のある決定はシャードキーだけ

そこから3つのことが一度に決まります:データがどう分散するか、どのクエリーを1つのシャードに向けられるか、そしてホットスポットができるかどうかです。シャードキーに求められるのは、カーディナリティ(異なる値が多いこと)、偏りのない頻度(1つの値が全体の半分を占めないこと)、そして単調でないことです。常に増え続けるキーは、新しい書き込みをすべて同じ場所に送るからです。

最後の点は理論ではありません。同じ60,000ドキュメントのコレクションで、顧客をhashedキーにすると一方のシャードに52.11 %、もう一方に47.88 %が残りました。常に増え続けるObjectIdの_idでは、100 %が一方に、それも1つのチャンクに残りました。

sh.enableSharding("ventas")

sh.shardCollection("ventas.pedidos", { cliente: "hashed" })
db.pedidos.getShardDistribution()
// 60000 documentos - sh1 52.11 %, sh2 47.88 % - 2 trozos

sh.shardCollection("ventas.eventos", { _id: 1 })
db.eventos.getShardDistribution()
// 60000 documentos - sh2 100 % - 1 trozo

対象を絞るか、全体に送るか

キーを含むクエリーは1つのシャードに行き、それで終わりです。含まないクエリーはすべてのシャードに問い合わせ、答えをマージします:SINGLE_SHARDとSHARD_MERGEの違いです。測定した差は、調べたドキュメント121件対60,000件でした。

db.pedidos.find({ cliente: 42 }).explain()
// SINGLE_SHARD - shards: ["sh2"]
// examinados 121, devueltos 121

db.pedidos.find({ estado: "pagado" }).explain()
// SHARD_MERGE  - shards: ["sh2", "sh1"]
// examinados 60000, devueltos 15000

ゾーンと、キーの変更

ゾーンはキーの範囲をシャードに結び付けるもので、現実に役立つ用途が2つあります:ある国のデータをその国のマシンに置いておくことと、よく使うデータとあまり使わないデータを分けることです。また5.0からはreshardCollectionでキーを変更できますが、コレクション全体をコピーするので、それが目に見えます:60,000ドキュメントで2分後もまだ処理中で、一時コレクションが丸見えでした。

sh.addShardToZone("sh1", "MX")
sh.addShardToZone("sh2", "EU")
// config.shards: sh1 tags ["MX"], sh2 tags ["EU"]

db.adminCommand({ reshardCollection: "ventas.eventos", key: { _id: "hashed" } })
// コレクション全体をコピーする:2分後もまだ実行中で、
// config.collectionsにsystem.resharding.<uuid>が見えていた

もう本当ではない3つのこと

キーのないupdateOneは失敗する、キーの値は変更できない、シャーディングの前にインデックスが存在している必要がある、という話がいまも語られています。8.2では、3つとも何の文句もなく通りました。

キーワード: シャーディング、シャードキー、ハッシュ、チャンク、mongos、設定サーバ、ゾーン、バランサ、reshardCollection、SINGLE_SHARD、SHARD_MERGE

よくあるエラー:8つの番号とその中身

日々出会う8つのコードを、サーバが返すテキストとともに1つずつ起こしたもの。そして番号以上のものを中に持つ2つ:キーを持つ11000と、errInfoを持つ121。

対象: MongoDB 7.0+

MongoDBのエラーは番号を持ち、ほとんどの場合は名前も持ち、ときにはその両方より価値のあるものを中に持っています。日々出会う8つを、サーバに対して1つずつ起こし、出てきたとおりに写しました。

コード名前何が起きたか
11000—一意インデックスでのキーの重複
13Unauthorizedそのデータベースでのアクションがユーザに足りない
18AuthenticationFailedユーザ、パスワード、または認証データベース
26NamespaceNotFoundコレクションが存在しない
50MaxTimeMSExpired与えた時間を超えた
112WriteConflict別のトランザクションがそのドキュメントに触れた
121—ドキュメントがバリデータを通らなかった
251NoSuchTransactionトランザクションはすでに中止されていた

名前のない2つは、もっと良いものを持っている

11000と121はcodeNameなしで届きますが、問題ありません:どちらも、直すのに必要な事実を1つ持っています。11000はコレクション、インデックスと値を挙げるので、3つの一意インデックスのどれが反応したのか推測する必要はありません。

db.correos.createIndex({ correo: 1 }, { unique: true })
db.correos.insertOne({ correo: "a@b.c" })
db.correos.insertOne({ correo: "a@b.c" })
// 11000 :: E11000 duplicate key error collection: ventas.correos
//          index: correo_1 dup key: { correo: "a@b.c" }

121のメッセージはDocument failed validationとだけ言いますが、e.errInfo.detailsは破られたルールを、その名前と期待される値とともに持っています。「無効」と「correoが足りない」の違いです。

db.createCollection("socios", {
  validator: { $jsonSchema: { bsonType: "object", required: ["correo"] } } })
db.socios.insertOne({ nombre: "sin correo" })
// 121 :: Document failed validation
// e.errInfo.details:
// { operatorName: "$jsonSchema", schemaRulesNotSatisfied: [
//     { operatorName: "required", specifiedAs: { required: ["correo"] },
//       missingProperties: ["correo"] } ] }

権限に関する2つは別物

18は「あなたが誰かわからない」です:パスワードの誤り、あるいは(いちばん多いのは)認証データベースの誤りです。MongoDBのユーザは、名前と作成されたデータベースの組だからです。13は「あなたが誰かはわかるが、許可されていない」です。メッセージにはデータベース、コマンド、さらにコレクションの名前まで入るので、足りないロールがそのまま読み取れます。

そして構文上の注意を2つ

50はサーバのエラーではなく、maxTimeMSで自分で設定した上限です。そして26は思いがけないところで現れます:存在しないものに対するcollModやrenameCollectionは確かに失敗しますが、存在しないコレクションに対するdrop()は「falseを返すだけ」です。エラーではないので、それを当然と思っているスクリプトは、名前を間違えていたことに決して気づきません。

db.gordo.find({ s: /x{100}/ }).maxTimeMS(1).toArray()
// 50 MaxTimeMSExpired :: operation exceeded time limit

db.runCommand({ collMod: "no_existe", validationLevel: "strict" })
// 26 NamespaceNotFound :: ns does not exist

db.no_existe.drop()   // false、エラーなし

キーワード: エラー、コード、11000、重複キー、13、Unauthorized、18、AuthenticationFailed、26、NamespaceNotFound、50、MaxTimeMSExpired、112、WriteConflict、121、251、NoSuchTransaction、errInfo

制限:8つの上限とそれぞれの知らせ方

実際に突き当たる8つの上限を、サーバに対して1つずつ起こしたもの。それぞれが返すコードと、言い伝えの場所にない3つ。

対象: MongoDB 7.0+

どれもサーバに対して実際に起こしたものなので、左の数字は言い伝えの数字ではなく、実際に拒否した数字です。

上限測定した値知らせ方
ドキュメントの大きさ16,777,216バイト10334
入れ子の深さinsertOneで179レベル15 Overflow
コレクションあたりのインデックス64(_id_を含む)67 CannotCreateIndex
複合インデックスのフィールド3213103
データベースの名前63文字73 InvalidNamespace
データベース + コレクション255文字73 InvalidNamespace
インデックスを張った値の大きさ実質的な上限なし—
パイプラインのブロッキングするステージ104,857,600バイト146

うっかり突き当たるのは16 MBの上限だけ

そして、ほとんどいつも同じ理由です:ドキュメントの中で配列が際限なく増えることです。メッセージにはドキュメントの大きさと最大値の両方の数字が入っているので、どれだけ超えたかがひと目でわかります。

db.c.insertOne({ _id: 1, s: "x".repeat(16 * 1024 * 1024 - 200) })   // entra
db.c.insertOne({ _id: 2, s: "x".repeat(16 * 1024 * 1024 + 100) })
// 10334 :: object to insert too large.
//          size in bytes: 16777338, max size: 16777216

インデックスに関する2つは数え間違えられる

コレクションあたり64のインデックスは「_id_を含む」ので、自分で作れるのは63です。しかも、健全な状態で達する上限ではありません:インデックスが64あれば、書き込みのたびに64本のツリーを維持します。複合インデックスの32フィールドも目標ではありません:6つか7つを超えたら、必要なのはおそらく、より幅の広い1つではなく2つのインデックスです。

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

言い伝えの場所にない3つ

入れ子はドキュメントでは100レベルとされていますが、サーバが拒否したのは180レベル目でした。上限はコマンド全体のBSONにかかるもので、insertの外側の包みがその一部を使うからです。長い名前はコレクションではなく、データベースとコレクションの合計、つまり名前空間で失敗します。そして、古いバージョンでは1,024バイトだったインデックスキーの大きさの上限はもうありません:50,000の値にインデックスを張ったものが入りました。

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) })   // 入る:キーの上限はない

そして上限のないもの

コレクションの数にも、データベースの数にも、コレクション内のドキュメントの数にも上限はありません。最初に尽きるのはこの表にはないもの、ディスクです。そして本当に16 MBに収まらないもの(ファイル)にはGridFSがあり、255 KBの断片に切って、それぞれを普通のドキュメントとして保存します。

キーワード: 制限、上限、16 MB、ドキュメントの大きさ、10334、入れ子、Overflow、コレクションあたりのインデックス、67、CannotCreateIndex、13103、名前空間、73、InvalidNamespace、インデックスキー

ベストプラクティス:数字に支えられた7つ

MongoDBハンドブックのまとめ:持つ価値のある7つの習慣とそれぞれを裏付ける測定、そして引き継いだばかりのサーバの脈を取る5行。

対象: MongoDB 7.0+

これはハンドブックの最後で、新しいことは何もありません:各トピックで示したことを、日々役立つ形にまとめたものです。どの習慣にも裏付けとなる数字があり、その数字はすべて写したものではなく、実際のサーバで測定したものです。

1. テーブルに似せるのではなく、どう読むかに合わせてモデリングする。一緒に読むものは一緒に保存します。このルールの限界は厳しく、測定済みです:ドキュメントは16,777,216バイトを超えられないので、際限なく増える配列は、ある普通の火曜日に10334で終わります。

2. よく使うクエリーごとにインデックスを1つ、それ以上は張らない。インデックスはどれも、書き込みのたびに維持するツリーです:同じ20,000件の挿入が、インデックスなしで60 ms、5つで101 ms、10個で160 msかかりました。しかも容量を取ります:サンプルのデータベースでは、データが1,148,000バイトのコレクションに622,592バイトのインデックスがありました。

3. クエリーは時計ではなく、totalDocsExaminedとnReturnedの比較で判断する。時計が示すのは、温まったキャッシュと静かなマシンで今日かかった時間です。この2つの数字の比は、コレクションが10倍になったときに何が起きるかを示します。

4. 失えないものにはwriteConcern: majority。レプリカセットではこれがすでに出荷時の値なので、習慣とは設定することではなく、速くするために外さないことです。

5. 認証なしでは絶対に動かさない。ファイルの1行です。そして、起動したばかりのサーバが残す唯一の扉(マシン自身から最初のユーザを作ること)は、そのユーザができた時点で自動的に閉じます。

6. oplog付きでバックアップし、そのウィンドウを時間で測る。mongodump --oplogが、ダンプを一瞬の状態に変えます。そしてoplogの大きさはバイトではなく日数で読みます:測定したノードでは34日でしたが、それはどれだけ書き込むかで変わるので、負荷が変わったら見直すべき数字です。

7. ワーキングセットはキャッシュに収まらなければならない。小細工のない唯一のパフォーマンスのルールです。サンプルのデータベースでは、データ1,150,242バイトに対してキャッシュが3,621,781,504バイト:3,000倍以上の余裕です。余裕がなくなった日には、あらゆるところに一度に表れます。

引き継いだばかりのサーバの脈を取る

何かに触れる前に知っておくべきことには、5行で答えられます:パスワードを求めるか、何をもって書き込みが書き込まれたとみなすか、誰かがスロークエリーを見ているか、使えるメモリーはどれだけか、動かすデータはどれだけかです。

db.adminCommand({ getCmdLineOpts: 1 }).parsed.security
// { authorization: "enabled" }, o undefined - que es la respuesta mala

db.adminCommand({ getDefaultRWConcern: 1 }).defaultWriteConcern
// { w: "majority", wtimeout: 0 }
// スタンドアロンのノードには存在すらしない:"not supported on standalone nodes"

db.getProfilingStatus()      // { was: 1, slowms: 100, sampleRate: 1 }

db.serverStatus().wiredTiger.cache["maximum bytes configured"]   // 3621781504
db.stats().dataSize                                              // 1150242

そしてもう1つ、ディスクがどこに使われているかを示し、ついでにどのコレクションがデータよりも多くのインデックスを抱えているかも示すものです。

db.getCollectionInfos({ type: "collection" }).map(i => {
  const s = db.getCollection(i.name).stats()
  return { c: i.name, indices: s.nindexes, datos: s.size, indice: s.totalIndexSize }
})
// { c: "eventos", indices: 3, datos: 1148000, indice: 622592 }
// typeによる絞り込みが必要:ビューに対するstats()は失敗する

キーワード: ベストプラクティス、まとめ、アクセスパターン、インデックス、writeConcern、MAJORITY、認証、oplog、ワーキングセット、キャッシュ、explain、サーバの健全性

データ構造とエンコーディング

Redisの6つの型、内部での保存のしかた(OBJECT ENCODING)、エンコーディングが変わるとき、そして本当の制限です。

対象: Redis 7.0+ Valkey 8.0+

Redisにはテーブルも列もありません。各キーは1つの型の1つの値を持ち、型はそのキーに最初に書き込むコマンドで決まります。SETは文字列を、HSETはハッシュを、RPUSHはリストを、SADDはセットを、ZADDはソート済みセットを、XADDはストリームを作ります。TYPEでどれかがわかり、そのキーに別の型のコマンドを送ると、何も変えずにWRONGTYPEと応答します。

6つの型(テストベッドで)

型テストベッドのキー用途
stringapp:sesion:10011つの値:テキスト、数値、バイト列
hashapp:usuario:1フィールドを持つオブジェクト(ここでは6つ)
listapp:cola:pendientes届いた順のキューまたはスタック(500項目)
setapp:etiquetas:activas順序のない一意のメンバー(300)
zsetapp:ranking:globalスコアで順序付けた一意のメンバー(100)
streamapp:eventos:stream時刻にもとづくIDを持つエントリーのログ(50)

OBJECT ENCODING:内部での保存のしかた

型は見えるもので、エンコーディングはサーバがそれをどう保存するかです。Redisはコンパクトな形で始め、コレクションが大きくなると一般的な形に切り替えます。その切り替えが、数バイトと多くのバイトの違いになります。一時的なハッシュで測定した結果:

CONFIG GET hash-max-listpack-entries   # 512
HLEN qa:h                      # 512
OBJECT ENCODING qa:h           # listpack
MEMORY USAGE qa:h SAMPLES 0    # 5176
HSET qa:h f512 v
OBJECT ENCODING qa:h           # hashtable
MEMORY USAGE qa:h SAMPLES 0    # 28824

フィールドが1つ増えただけで、サイズは5倍以上になります。1つの値が64バイトを超えた場合も同じです(hash-max-listpack-value)。65バイトのフィールドを1つ持つハッシュは、もうhashtableです。そして切り替えは元に戻りません。513番目のフィールドを削除しても、ハッシュはhashtableのままです。

Valkey

Valkeyでは、ハッシュテーブルをよりコンパクトなものに置き換えた8.1から、この跳ね上がりが小さくなっています。同じ512フィールドのハッシュで測定した結果:8.0.11ではRedisと同じく5168から28816バイトに、9.1.2では5144から17024に、3倍強です。しきい値と、元に戻らない切り替えは同じです。

ほかの型も同じ考え方で、テストベッドでのしきい値は次のとおりです。整数のセットはintsetで、テキストのメンバーが入るとすぐにlistpackになり、128を超えるとhashtableになります(set-max-listpack-entries)。小さなリストはlistpack、大きなリストはquicklistです。ソート済みセットは、メンバーが128を超えるとlistpackからskiplistになります(zset-max-listpack-entries)。そのため、300個のタグのセットはhashtableで、100個のランキングはまだlistpackです。

文字列はテキストではなくバイト列です

文字列は任意のバイトの並びを保持し、サーバはそれを解釈しません。整数に収まればintとして保存され(metricas:diarias:2026-09-01)、44バイトまではembstr、45バイトからはrawとして保存されます。改行を含むこともでき(binario:datosは4バイトのa\r\nbです)、UTF-8でないバイトを含むこともできます。\xff\x00\xfeをSETすると3バイトが保存され、Calíopeはテキストをでっち上げる代わりに、それを16進数で表示します。

Valkey

Valkey 9.1.2では、embstrとrawの境目は44バイトではありません。値はキーの名前の隣に保存され、問題になるのは両方を合わせたサイズです。測定結果:キーがkなら、110バイトの値はまだembstrで、115バイトの値はもうrawです。29文字のキーでは、境目は80〜90バイトのあいだに下がります。8.0.11ではRedisと同じく44バイトです。

本当の制限

文字列は512 MBまで大きくなれます。それがproto-max-bulk-lenで、テストベッドでは536870912です。それを超えるSETRANGEにはstring exceeds maximum allowed sizeと応答します。コマンドリファレンスによれば、コレクションは最大2³²−1個の要素を持てます。先に制限になるのはサーバのメモリーと、それを一度に全部読むコストです。

HyperLogLog、ビットマップ、GEOは新しい型ではありません

上の6つの型に対するコマンドで、TYPEでそれがわかります。PFADDはHyperLogLog(異なる要素の数のおおよそのカウント)を文字列に保存します。要素が4つなら30バイトです。SETBITは文字列にビットを書き込み、1000番目のビットで126バイトになります。GEOADDは座標をZSETのスコアとして保存するので、GEOのキーは、ほかのソート済みセットと同じように削除され、期限切れになり、測定されます。

キーワード: Redis、型、String、ハッシュ、リスト、セット、ZSET、ストリーム、OBJECT ENCODING、LISTPACK、HASHTABLE、INTSET、QUICKLIST、SKIPLIST、HyperLogLog、ビットマップ、GEO、制限

キー、名前、有効期限

サーバがプレフィックスを知らない理由、TTLの−1と−2の意味、キーが期限切れになるしくみ、そしてKEYSとDELではなくSCANとUNLINKを使う理由です。

対象: Redis 7.0+ Valkey 8.0+

名前は慣習であって、階層ではありません

キーはふつうコロンを使って名付けられ(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

-1と-2を取り違えると、もう存在しないキーを永続的なものとして表示することになります。PTTLは同じものをミリ秒で返し、INFO keyspaceのexpiresフィールドは、データベース内で有効期限を持つキーの数を数えます(テストベッドでは40)。

キーが期限切れになるしくみ

2つの方法があります。期限が過ぎたあとに誰かが読むと、その読み取りで期限切れが見つかって削除されます。これが遅延期限切れです。誰も読まなければ、アクティブなサイクルが回収します。1秒に10回(hz 10)、有効期限を持つキーのサンプルを調べます。テストベッドでは、PX 200で書き込んだ2つのキーが、誰も読まないうちに0.5秒後には消えていて、expired_keys(INFO statsにあり、起動時からの累計)は0から2になりました。expire_cycle_cpu_millisecondsは、そのサイクルがどれだけのCPU時間を使ったかを示します。

KEYSではなくSCAN

KEYS *は1つのコマンドですべてのキーを返し、実行中、サーバはほかの誰にも応答しません。Redisはコマンドを1つずつ実行するからです。テストベッドの266キーなら49 µsで、誰も気づきません。そのコストはデータベースのキーの数に比例して増え、何百万ものキーがあると数秒間ブロックします。SCANは同じ走査を分割して行います。テストベッドでは、1回約12 µsの呼び出しが27回で、そのあいだにサーバはほかのすべてのクライアントに応答します。その代わり、同じキーが2回返ってくることがあるので、重複を取り除く必要があります。そのため、CalíopeのコンソールはKEYSを拒否し、ブラウザはSCANで走査します。

DELではなくUNLINK

どちらも削除しますが、DELはその場でメモリーを解放し、UNLINKは別のスレッドで解放します。200,000メンバー(11 MB)のセットで測定した結果:DELは83 msかかり、そのあいだサーバは誰にも応答しませんでした。UNLINKは0.3 msでした。小さなキーでは違いはありません。大きなキーでは、サーバを止めないのはUNLINKのほうで、Calíopeが削除に使うのもこちらです。

RENAMEとハッシュタグ

RENAMEは、変更先のキーが存在すればそれを置き換え、その古い値は失われます。RENAMENXは、変更先が存在すれば何もせずに0を返します。クラスタでは、両方のキーが同じスロットに入らなければならず、そうでなければコマンドはCROSSSLOTと応答します。テストベッドでは、qa:t67:aはスロット7222に、qa:t67:zzは9705に入ります。クラスタがスロットを選ぶときに見る名前の部分はハッシュタグだけです。キーに波かっこで囲まれた部分があれば、その部分だけが数えられます。usuario:1とusuario:2は3014と15269に入りますが、{usuario}:1と{usuario}:2はどちらも15774に入ります。clave:con:{etiqueta}:hashは5693に入り、etiquetaと同じです。空の波かっこ(foo{}bar)はタグとして数えられません。

キーワード: Redis、キー、名前、プレフィックス、TTL、EXPIRE、PERSIST、KEEPTTL、有効期限、SCAN、KEYS、UNLINK、DEL、RENAME、ハッシュタグ、スロット

メモリーと追い出し

INFO memoryの各数値が何を測るか、キーの合計がサーバのメモリーにならない理由、そしてメモリーがいっぱいになったときに各maxmemory-policyが何をするか。

対象: Redis 7.0+ Valkey 8.0+

いくつかの数値があり、同じものではありません

INFO memoryはいくつかの数値を返し、テストベッドでは次のとおりです。

フィールド何かテストベッド
used_memoryRedisがアロケータに要求した量1.6 MB
used_memory_startup空のサーバが使う量0.9 MB
used_memory_datasetデータにあたる部分0.6 MB
used_memory_rssオペレーティングシステムがプロセスに割り当てている量13 MB
used_memory_peakこれまでに使った最大量16.6 MB

mem_fragmentation_ratioはused_memory_rssをused_memoryで割ったもので、テストベッドでは8.56です。故障ではありません。少し前に11 MBのセットがピークを16.6 MBまで押し上げ、それを削除したときに、アロケータ(jemalloc)がページを手放さなかったのです。MEMORY PURGEはそれを返すように求め、比率は8.13に下がりました。使用中のメモリーが少ないのに比率が高いのはこれで、使用量が多いのに比率が高いのは本当の断片化です。そして比率が1未満なら、システムがRedisの一部をスワップに追い出しているということで、そのほうが深刻です。

なぜMEMORY USAGEを足してもused_memoryにならないか

MEMORY USAGEはひとつのキーを測ります。その値、名前、それを取り巻くものです。テストベッドの266個のキーを足すと54,048バイトで、used_memoryは1.6 MBです。差はサーバ自体(空のときに948,744バイト)、クライアントとAOFのバッファ、ほかのデータベース、そしてキーを索引するテーブルです。だからCalíopeのメモリーアナライザは、各プレフィックスがどれだけの容量を占めるかを表示し、合計がサーバのメモリーだとは約束しません。

SAMPLES:コレクションでは、数値は推定値

コレクションの場合、MEMORY USAGEは5個の要素を測って掛け算します(SAMPLES 5、デフォルト)。SAMPLES 0はすべてを測り、正確ですが高くつきます。要素が似ていれば違いはありません。300個のタグのセットは、どちらの方法でも16,216バイトです。似ていなければ間違います。50個に1個のフィールドが4,000バイトある600フィールドのハッシュは、サンプルでは32,304、SAMPLES 100では56,832、すべて数えると81,360です。

maxmemoryと、それに達したときに起こること

maxmemoryがなければ(テストベッドのように0)、Redisはシステムが許すかぎり大きくなります。上限があれば、そこに達したときにRedisはmaxmemory-policyを適用します。値は8つあります。

ポリシーいっぱいになったときの動作
noeviction書き込みをOOMで拒否します。読み取りは引き続きできます
allkeys-lruすべてのキーから、最近いちばん使われていないキーを追い出します
allkeys-lfuすべてのキーから、いちばん使用頻度の低いキーを追い出します
allkeys-randomすべてのキーから、ランダムに追い出します
volatile-lruallkeys-lruと同じですが、有効期限のあるキーだけが対象です
volatile-lfuallkeys-lfuと同じですが、有効期限のあるキーだけが対象です
volatile-random有効期限のあるキーだけから、ランダムに
volatile-ttl有効期限がいちばん近いキー

上限を使用中の量のすぐ上に設定して測定しました(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のどれかです。

LRUは近似です。サーバはmaxmemory-samples個(5個)のキーを比べ、いちばん長く使われていないものを追い出します。OBJECT IDLETIMEはキーが何秒触れられていないかを示し、OBJECT FREQはLFUのポリシーのときだけ答えます。

MEMORY STATSとMEMORY DOCTOR

MEMORY STATSは上の内訳を示します。起動時に使う量、クライアント、AOF、各データベース、キーあたりのバイト数です。MEMORY DOCTORはわかりやすい文章で診断を返しますが、使用中のメモリーが少ないときは別です。テストベッドでは、インスタンスが空か、ごくわずかなメモリーしか使っていないと答えます。

キーワード: Redis、メモリー、used_memory、RSS、断片化、MAXMEMORY、MAXMEMORY-POLICY、追い出し、LRU、LFU、NOEVICTION、OOM、MEMORY USAGE、SAMPLES、MEMORY STATS、MEMORY DOCTOR、MEMORY PURGE

永続化:RDBとAOF

Redisがメモリーにあるものをどうディスクに保存するか、プロセスを強制終了したときに各組み合わせが何を失うか、そしてloading:1の意味。

対象: Redis 7.0+ Valkey 8.0+

Redisはメモリーの中で動きます。ディスクに書くのは起動時に読み込み直すためで、その方法は2つあり、組み合わせることもできます。

RDB:データベース全体のスナップショット

BGSAVEは、サーバを止めずに、子プロセスからデータベース全体をdump.rdbに書きます。SAVEは同じことをサーバを止めて行うので、使われません。saveのルールはこれらを自動で起動します。テストベッドのルールは3600 1 300 100 60 10000で、1時間に少なくとも1回、5分間に100回、または1分間に1万回の変更があればスナップショットを撮ります。INFO persistenceは、最後のスナップショットがいつだったか(rdb_last_save_time、UNIX時間の秒)、成功したか(rdb_last_bgsave_status)、そして今この瞬間に失われる変更の数(rdb_changes_since_last_save)を示します。BGSAVEのあと、これは71から3になりました。

AOF:すべての書き込みのログ

appendonly yesでは、Redisはすべての書き込みをファイルに追記し、起動時にそれを再生します。テストベッドはこの設定で動いています(aof_enabled:1)。Redis 7では、AOFはappendonlydir/の中の複数のファイルです。RDB形式のベース、それ以降のコマンドを入れた増分、そしてそれらを結ぶマニフェストです。BGREWRITEAOFはそれを新しいベースに圧縮します。aof_rewritesはその回数を数え、aof_rewrite_in_progressは実行中かどうかを示します。

ディスクに届くタイミングはappendfsyncが決めます。

値いつ同期するか失われうるもの
always書き込みのたびなし。いちばん遅い
everysec1秒に1回マシンが落ちれば、最大1秒分
noシステムが決めたときシステムがまだ書いていないもの

テストベッドは、デフォルトのeverysecを使います。

プロセスを強制終了したときに失われるもの(測定結果)

AOFとeverysecで、2秒前に書いたキーと、予告なくプロセスを止めるdocker killの直前に書いたキーは、どちらも起動時に残っていました。プロセスを止めても、すでにAOFに入ったものは失われません。Redisは応答する前にそれをファイルに書くからです。everysecが危険にさらすのは、オペレーティングシステムがまだディスクにフラッシュしていないもので、それが失われるのはプロセスではなくマシンが落ちたときだけです。

AOFがなくRDBだけなら、危険にさらされるのはrdb_changes_since_last_saveが示すものです。最後のスナップショット以降に書いたものは、プロセスを止めるとすべて失われます。テストベッドのルールでは、変更が100回未満なら、それは1時間近くの変更になりえます。

loading:1:サーバが起動中

起動時、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です。

キーワード: Redis、永続化、RDB、AOF、BGSAVE、SAVE、APPENDONLY、APPENDFSYNC、EVERYSEC、BGREWRITEAOF、dump.rdb、読み込み中、起動、データの消失

クライアントとレイテンシ

誰が接続していて何をしているか、1つの遅いコマンドが全員を待たせる理由、そしてサーバが自分のレイテンシを測るしくみです。

対象: Redis 7.0+ Valkey 8.0+

Redisはコマンドを1つのスレッドで1つずつ実行します。1つが実行されているあいだ、ほかのすべてのクライアントは順番を待ちます。そのため、ここでのレイテンシは1つのクエリーの問題ではなく、全員の問題です。

誰が接続しているか:CLIENT LIST

接続1つにつき1行で、field=valueの組が並びます。特に多くを語るもの:

フィールド内容
id接続の番号。CLIENT KILL IDが求めるもの
addr接続元
nameCLIENT SETNAMEで設定した名前。設定していなければ空
age / idle接続してからの秒数と、最後のコマンドからの秒数
flagsNは通常の接続、bは待機中でブロックされている接続(BLPOP)、Sはレプリカ、Mはそのプライマリ
cmd最後のコマンド
tot-memバッファを含めて接続が使うメモリー

Calíopeも一覧に表示されます。その接続はCaliopeという名前で(name=Caliope、lib-name=caliope)、セッションは作業用に最大4つ、それに加えて長い走査用に別の1つを開きます。「クライアント」はそれらをこのセッションのものとして示し、接続解除を表示しません。

INFO clientsは合計を示します:connected_clients、blocked_clients、そして上限のmaxclients(テストベッドではデフォルトの10000)。timeoutが0(これもデフォルト)なら、サーバはアイドルの接続を決して閉じません。

1つの遅いコマンドが全員を待たせる:測定結果

テストベッドで、1.7秒かかるスクリプトと、その直後に別の接続から送ったPING:PINGがPONGと応答するまでに1.65秒かかりました。スクリプトとは何の関係もないのに、そのあいだずっとキューで待っていたのです。スクリプトが5秒を超えたときに何が起こるかは「トランザクションとスクリプト」で説明しています。

CLIENT PAUSE:意図的にクライアントを止める

CLIENT PAUSE <ms> WRITEは、その時間だけすべてのクライアントの書き込みを保留し、読み取りは通します。WRITEがなければすべてを保留します。2秒で測定した結果:読み取りは7 msで応答し、書き込みは2000 msまるまる待ちました。これは書き込みを失わずにプライマリを切り替えるためのもので、CLIENT UNPAUSEで早めに解除できます。

サーバのレイテンシモニタ

Redisは、latency-monitor-thresholdでミリ秒単位のしきい値を与えると、遅かった出来事を記録します。デフォルトの0では何も記録しません。テストベッドで20 msにして25秒置くと、LATENCY LATESTにはcommand型の出来事が7件(負荷ジェネレータのスクリプト)表示され、最悪は76 msでした。LATENCY HISTORY commandはそれぞれを時刻とともに示し、LATENCY DOCTORは助言付きの文章でまとめ、LATENCY RESETは記録したものを消去します。

モニタが示すのは出来事がいつあったかです。それがどのコマンドだったかはスローログSLOWLOGにあり、Calíopeはそれを「スローログ」で表示し、「パフォーマンス」で説明しています。

キーワード: Redis、クライアント、CLIENT LIST、接続、レイテンシ、LATENCY DOCTOR、LATENCY HISTORY、CLIENT PAUSE、MAXCLIENTS、タイムアウト、ブロッキング、シングルスレッド

パフォーマンス

スローログとマイクロ秒単位のしきい値、パイプラインで節約できるもの、大きなキーとその読み方、そしてINFOのカウンタが示すこと。

対象: Redis 7.0+ Valkey 8.0+

Redisでは、ほとんどのコマンドがマイクロ秒で終わります。コストがかかるのは、何度も行うことか、大きなものに一度に行うことです。

スローログ:SLOWLOG

サーバは、slowlog-log-slower-thanより長くかかったすべてのコマンドを記録し、最後のslowlog-max-len個を保持します。しきい値はマイクロ秒単位です(ほかのエンジンのようにミリ秒や秒ではありません)。デフォルトの10000は10ミリ秒です。しきい値を負にするとログは止まり、0にするとすべてのコマンドを記録します。各エントリーには、番号、時刻、かかった時間(マイクロ秒)、引数付きのコマンド(長い場合は切り詰め)、クライアントのアドレスと名前が入ります。

Valkey

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では、それがすべてです。

これは履歴ではなく、最新の分だけを残す枠です。最大の128では、129番目のエントリーがいちばん古いものを押し出します。テストベッドでは、50〜250ミリ秒かかる負荷ジェネレータのスクリプトで埋まります。そしてこれは、コマンドがサーバの中でかかった時間を測ります。キューでの待ち時間やネットワークの往復は数えません。Calíopeはこれを「スローログ」に表示します。

往復:パイプライン

単独のコマンドは、それぞれネットワークの往復をまるごと払います。ネットワーク越しにテストベッドのサーバで測定しました。TYPEを1つずつ500回送ると1.6〜2.1秒かかり、同じ500回をパイプラインで(すべて送ってから応答を読む)送ると約6ミリ秒でした。サーバの仕事は同じで、節約できたのはネットワークを500回待つことです。Calíopeは、キーの各ページの型と有効期限をこの方法で問い合わせます。

大きなキー

redis-cli --bigkeysはSCANでデータベースを走査し、型ごとにいちばん大きなものを報告します。テストベッドでは、500要素のリスト、300メンバーのセット、100メンバーのソート済みセット、50エントリーのストリームです。MEMORY USAGE <key>はひとつが占める量を返します。セットは16216バイト、リストは6208、ユーザのハッシュは168です。Calíopeは「メモリー」で、プレフィックスごとにメモリーを合計します。

大きなキーは、それ自体が遅いわけではありません。遅いのは、それを丸ごと読むことです。HGETALL、SMEMBERS、LRANGE 0 -1、ZRANGE 0 -1はコレクション版のKEYSです。すべてを一度に返し、それを組み立てる間サーバを止めます。読む前にサイズを問い合わせ(HLEN、SCARD、LLEN、ZCARD)、HSCAN、SSCAN、ZSCANで少しずつ、またはLRANGEで範囲を区切って読みます。

INFO statsの2つの数値

instantaneous_ops_per_secはサーバが直近の数秒で計算するもので、テストベッドでは13です。ヒット率はkeyspace_hitsとkeyspace_missesから出し、これらは起動時から積算されます。テストベッドでは5110と1040で、83%です。何週間も積算された数値は今日の様子を示さないので、Calíopeは「サーバ」でそのように表示します。

OBJECT FREQ

LFUの追い出しポリシー(allkeys-lfuかvolatile-lfu)では、Redisはキーごとに対数的なアクセスカウンタを持ち、OBJECT FREQがそれを読みます。測定結果:ハッシュを30回読むと、0から7になりました。テストベッドのLRUのポリシーでは、そう伝えるエラーで答えます。

キーワード: Redis、パフォーマンス、SLOWLOG、スローコマンド、マイクロ秒、パイプライン、BIGKEYS、MEMORY USAGE、HGETALL、SMEMBERS、LRANGE、毎秒の操作数、ヒット率、OBJECT FREQ、LFU

トランザクションとスクリプト

コマンドをまとめても何も取り消さないMULTIとEXEC、楽観的ロックとしてのWATCH、EVALと関数、そしてスクリプトがサーバを止める理由。

対象: Redis 7.0+ Valkey 8.0+

Redisには、リレーショナルデータベースのようなトランザクションはありません。あるのは、ほかのクライアントのものが間に入り込まずに複数のコマンドをまとめて実行する2つの方法、MULTIとスクリプトです。

MULTIとEXEC:まとめて、ただし後戻りなし

MULTIのあと、サーバはコマンドを実行しません。それぞれにQUEUEDと答えて保持します。EXECはそれらを続けてすべて実行し、DISCARDは捨てます。テストベッドで測定しました。

- SET a 1、LPUSH a x、SET b 2をキューに入れると、EXECはOK、WRONGTYPE、OKと答えました。LPUSHは失敗し、ほかの2つは書き込まれました。ロールバックはありません。実行中のエラーは何も取り消しません。
- キューに入れるときに見つかったエラー(引数の間違ったコマンド)は、トランザクション全体を取り消します。EXECはEXECABORTと答え、どれも実行しませんでした。
- DISCARDのあと、キーは存在しませんでした。

MULTIの前のWATCH <key>は楽観的ロックです。EXECの前にほかのクライアントがそのキーを変えると、EXECは何も実行せずにnullを返します。測定結果:間にほかのクライアントがキーを書いたとき、EXECはnullを返し、キーはほかのクライアントの値のままでした。間に誰もいないときは、INCRが通りました。WATCHを使う側は、nullを受け取ったらやり直します。

トランザクションは、ひとつの接続の中にあります。だから「コンソール」はMULTI、EXEC、DISCARD、WATCH、UNWATCHを実行しません。各行を空いている接続で送るので、トランザクションがある接続で開いたまま、そのEXECが別の接続に行ってしまうおそれがあるからです。

スクリプト:EVAL、EVALSHA、関数

EVALはLuaのスクリプトを、サーバの上で丸ごと途切れずに実行します。その間、ほかのコマンドは実行されません。「コンソール」から複数のコマンドをまとめる方法です。キーは引数とは別に渡します(EVAL "return redis.call('INCRBY', KEYS[1], ARGV[1])" 1 key 5)。クラスタでは、サーバがどのスロットに行くかを知る必要があるからです。

Valkey

Valkeyでは、スクリプトはredis.callの代わりにserver.callを呼べます。9.1.2で測定したところ、どちらも同じキーに加算しました。関数も同じで、redis.register_functionを使うFUNCTION LOADとFCALLです。

SCRIPT LOADはスクリプトを保存してそのSHA1ダイジェストを返し、EVALSHA <digest>はテキストを送り直さずにそれを実行します。サーバがそのダイジェストを持っていなければNOSCRIPTと答え、EVALに戻ります。Redis 7の関数は、名前の付いた同じものです。FUNCTION LOADはライブラリを読み込み、FCALLはその関数のひとつを呼び、FUNCTION LISTは一覧を表示し、FUNCTION DELETEは削除します。測定結果:数を2倍にする関数を、値が16のキーにFCALLで呼ぶと、32を返しました。

スクリプトは実行中、サーバを止めます

テストベッドで測定しました。1.7秒かかるスクリプトでは、別の接続からのPINGが1.65秒待ちました。負荷ジェネレータのスクリプトはそれぞれ約50ミリ秒かかり、だから「スローログ」を埋めます。

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で測定しました。

SCRIPT KILLが効くのは、スクリプトが何も書いていない場合だけです。ループの前にキーをひとつ書くスクリプトで測定すると、UNKILLABLEと答え、スクリプトは最後まで続きました。そうなると、待つか、保存せずにサーバを止めるSHUTDOWN NOSAVEしかありません。

キーワード: Redis、トランザクション、MULTI、EXEC、DISCARD、WATCH、ロールバック、EVAL、EVALSHA、SCRIPT LOAD、関数、FCALL、Lua、BUSY、SCRIPT KILL、LUA-TIME-LIMIT

レプリカとクラスタ

レプリカの見方とその遅延がバイト単位である理由、16384のスロット、MOVEDとASK、クラスタを停止状態にするもの、そしてSentinelやSSHトンネル越しのクラスタがない理由。

対象: Redis 7.0+ Valkey 8.0+

レプリカと、その見方

レプリカは、プライマリが書くものをすべてコピーする別のサーバです。REPLICAOF <host> <port>でサーバをレプリカにし、REPLICAOF NO ONEで解放します。レプリカになるとき、サーバは自分のデータを捨ててプライマリのデータを読み込みます。だからCalíopeのコンソールは、送信する前にコマンドの名前を入力するよう求めます。ROLEは各サーバが何であるかを、ひとつの応答で示します。オフセットとレプリカの付いたmasterか、プライマリとリンクの状態(connected)の付いたslaveです。INFO replicationはすべてを示します。

遅延はバイト単位

プライマリとレプリカは、それぞれレプリケーションストリームのバイトカウンタを持っています。プライマリはmaster_repl_offset、レプリカはslave_repl_offsetです。その差が、レプリカがまだ適用しなければならない分です。テストベッドのクラスタでは、何もしていないとき3つのレプリカすべてで差は0で、どちらのカウンタも10秒ほどごとに14バイト進みます。プライマリがレプリカに送るPINGです。

プライマリはレプリカごとにoffsetとlagも表示しますが、どちらも今この瞬間の遅延ではありません。

- offsetは、レプリカが前回確認応答した分で、確認応答は1秒に1回です。測定結果:2 MBを書いたあと、プライマリはそのレプリカがlag=0のまま1秒間2,000,070バイト遅れていると表示し、次に読んだときには追いついていました。
- lagは最後の確認応答からの秒数で、どれだけ遅れているかではありません。レプリカが追いついているとき、0と1の間を行き来します。

Calíopeは各レプリカのカウンタをそのレプリカ自身で読み、プライマリのカウンタは2回読んで、それがひとりでにどれだけ動くかを知ります。それ以下では警告しません。レプリカがプライマリに従っていることは、両者が共有するmaster_replidでわかります。レプリカが報告するmaster_hostではありません。テストベッドではそれはDockerのネットワークのアドレス(172.18.0.32)で、クライアントが使うアドレスではないからです。再起動したプライマリは、新しいmaster_replidで0から戻ってきます。レプリカが再同期するまでは差に意味がなく、Calíopeは追いついていると言う代わりに、遅延を未測定のままにします。

min-replicas-to-writeを設定すると、min-replicas-max-lag秒(デフォルトは10)以内に確認応答するレプリカが少なくともその数だけないかぎり、プライマリは書き込みを拒否します。テストベッドのレプリカのないノードで1にして測定すると、SETはNOREPLICAS Not enough good replicas to write.と答え、読み取りは続きました。テストベッドでは、デフォルトの0です。

クラスタ:16384のスロット

クラスタはキーを16384のスロットに分散し、各プライマリがその範囲を持ちます。テストベッドでは0-5460、5461-10922、10923-16383で、それぞれにレプリカが1台あります。スロットはキーから決まります(CLUSTER KEYSLOT cl:a:1 → 10042)。キーがタグを持っていれば、波かっこの中だけから決まります。{usuario:1}:perfilと{usuario:1}:carritoは、どちらも3014に入ります。

CLUSTER INFOは状態(cluster_state)、カバーされているスロット、ノードの数を示します。CLUSTER SHARDSは、各範囲をそのプライマリとレプリカ、それぞれが通知するアドレスとともに示します。CLUSTER NODESは同じものを、ノードごとに1行で示します。

自分のものではないキーを求められたノードはMOVED <slot> <address>と答えます。測定結果:0-5460のプライマリでGET cl:a:1を実行すると、MOVED 10042 10.100.37.234:7002と答えました。Calíopeはそのリダイレクトに従い、対応表を読み直します。ASKは移動の途中にあるスロットのためのリダイレクトです。そのコマンドにだけ効き、前にASKINGが付き、対応表は変わりません。異なるスロットのキーを含むコマンドはCROSSSLOTと答えます。MGET cl:a:1 cl:a:2で測定すると、スロットは10042と5977でした。

Valkey

Valkey 9.0はCLUSTER MIGRATESLOTSを加えます。スロットの範囲を一度に移動するもので、所有者に送ると自分で進み、CLUSTER GETSLOTMIGRATIONSがその状態を示します。9.1.2で、スロット0〜100に101,000個のキーがある状態で測定すると、0.6秒かかり、その間ASKはまったくありませんでした。移動先は移動元へのMOVEDを返し、切り替わった瞬間に、移動元が移動先へのMOVEDを返し始めました。CLUSTER SHARDSには、移動途中のものは何も表示されません。CLUSTER SETSLOTとMIGRATEを使うキー単位の移行も残っており、ASKを返すのはこちらです。

cluster_state:fail(測定結果)

スロットがひとつでも所有者を失うと、クラスタは応答をやめます。テストベッド(cluster-node-timeoutは5秒)でプライマリを止めると、次のようになりました。

- 約5秒間は何も変わりません。ほかのノードは、まだ落ちたと見なしていません。
- そのあとcluster_state:failになり、約2秒後にそのレプリカが昇格すると、状態はokに戻ります。
- そのレプリカも止めると、5462個のスロットが所有者を失い、クラスタ全体が、ほかの2つのプライマリのキーに対してもCLUSTERDOWN The cluster is downと答えました。これがデフォルトのcluster-require-full-coverage yesの動作です。

戻ってきたとき、古いプライマリは新しいプライマリのレプリカになっていて、そこでCLUSTER FAILOVERを実行すると元の位置に戻ります。Calíopeは「トポロジ」で各ノード、その役割、遅延を表示します。

SentinelはCalíopeにありません

Sentinelはプライマリを監視してフェイルオーバーを決めますが、データを持たず、自分のレプリカもないので、Calíopeはそのトポロジを読みません。プライマリとそのレプリカ、遅延を見るには、プライマリを指すプロファイルを開いてください。

クラスタはSSHトンネル越しには開きません

クラスタの各ノードには、それが通知するアドレスで接続し、MOVEDはクライアントをそのアドレスに送ります。SSHトンネルはひとつのノードにしか通じません。リダイレクトはトンネルを出て、今いる場所からは届かないかもしれないアドレスに向かってしまいます。だからCalíopeは、クラスタだとわかったプロファイルをトンネル越しには開きません。接続するときにそう伝え、エラーからこのトピックに来られます。開くには、プロファイルからトンネルを外し、各ノードにそれが通知するアドレスで届くネットワークから接続してください。

キーワード: Redis、レプリケーション、レプリカ、REPLICAOF、ROLE、遅延、オフセット、MIN-REPLICAS-TO-WRITE、クラスタ、スロット、MOVED、ASK、CROSSSLOT、CLUSTERDOWN、FAIL、Sentinel、SSHトンネル

セキュリティとACL

サーバがどこで待ち受けるか、保護モード、パスワードと権限付きのユーザ(ACL)、rename-commandがACLより劣る理由、TLS、そしてSLOWLOGとMONITORにパスワードの何が残るか。

対象: Redis 7.0+ Valkey 8.0+

新しくインストールしたRedisサーバは、誰にもパスワードを求めません。外側から順に4つの層がサーバを守ります。どこで待ち受けるか、保護モード、各ユーザが誰で何ができるか、そして接続が暗号化されているかです。このトピックの内容は、すべてテストベッド(Redis 7.4.11)で測定しました。

待ち受ける場所:bindと保護モード

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です。

パスワードとユーザ:requirepassとACL

requirepassはdefaultユーザのパスワードで、誰もほかのユーザを作らなければ、存在するのはこのユーザだけです。Redis 6以降は名前付きのユーザ(ACL)があり、それぞれが独自のコマンド、キー、チャネルを持ちます。ACL LISTがその一覧を表示します。テストベッドにはdefaultしかなく、~*(すべてのキー)、&*(すべてのチャネル)、+@all(すべてのコマンド)を持っています。パスワードがなければ、同じユーザがnopassとして表示され、ポートに届いた人は誰でもそのユーザになります。ACL WHOAMIは、どのユーザとして接続しているかを示します。

ユーザはACL SETUSERで作成します。測定結果:

ACL SETUSER lector on >lectorpass ~app:* &canal:* -@all +@read +ping +acl|whoami

- 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。そのコマンドは許可されていませんでした。

権限はカテゴリ単位です。ACL CATは21個を一覧表示し(read、write、admin、dangerous、blocking…)、危険なもの(ACL CAT dangerous)には75個のコマンドがあります。ACL LOGは、拒否のたびに、ユーザ、拒否されたもの、クライアントを記録します。上の3つのあとには3件のエントリーがあり、reasonはcommandかkeyでした。ACL DELUSERはユーザを削除し、ACL LOG RESETはログを消去します。

Valkey

Valkeyではカテゴリは同じ21個ですが、危険なものが増えます。8.0.11では75個、9.1.2では83個のコマンドです。増えた8個は、スロットを移動するもの(CLUSTER MIGRATESLOTS、SYNCSLOTS、GETSLOTMIGRATIONS、CANCELSLOTMIGRATIONS、FLUSHSLOT)と、COMMANDLOGの3つです。

rename-commandと、それがACLより劣る理由

rename-commandはサーバの起動時にコマンドの名前を変え、""を指定するとそのコマンドを取り除きます。--rename-command FLUSHALL ""と--rename-command CONFIG CFG_7f3aで起動したサーバで測定すると、FLUSHALLとCONFIG GETはERR unknown commandと答え、CFG_7f3a GET maxmemoryは動きました。これはすべてのユーザに一度に効き、再起動しないと変わらず、本当の名前を使うツールはどれも動かなくなります。Calíopeも例外ではありません。ACLなら、あるユーザからしてはいけないことだけを取り上げ、ほかの人はそのまま使い続けられます。

TLS

TLSを使うサーバはtls-portで待ち受け、port 0にすると暗号化されていないポートを閉じます。テストベッドのredis-tlsノードは、そのように6380で起動します。測定結果:6379へはConnection refused、TLSなしで6380へはI/O error、TLSでも認証局の証明書がなければcertificate verify failed、テストベッドの認証局を使うとPONGでした。サーバの証明書にはlocalhost、127.0.0.1、テストベッドのマシンのIPが書かれています。そこにない名前で接続すると、やはり検証に失敗します。Calíopeは、プロファイルで設定した認証局に照らして証明書を確認します。

SLOWLOGとMONITORの中のパスワード

RedisはAUTHとHELLO … AUTHの引数を隠します。すべてが記録されるようにslowlog-log-slower-than 0にして測定すると、SLOWLOGのAUTHのエントリーには、パスワードの代わりに(redacted)が入っていました。同時に開いていたMONITORには、"AUTH" "(redacted)"と"HELLO" "3" "AUTH" "(redacted)" "(redacted)"が見えました。Calíopeは「コマンドログ」で同じことをします。AUTHはAUTH ****として残ります。

キーワード: Redis、セキュリティ、ACL、ユーザ、権限、REQUIREPASS、AUTH、NOPERM、PROTECTED-MODE、BIND、RENAME-COMMAND、TLS、証明書、SLOWLOG、MONITOR、パスワード

サーバの設定

CONFIG GET、CONFIG SET、CONFIG REWRITE、再起動のあとに残るものと残らないもの、最初に見るべき設定、INFO serverとINFO stats、そしてDEBUGが無効の状態で出荷される理由。

対象: Redis 7.0+ Valkey 8.0+

Redisサーバは、設定をファイル(redis.conf)、起動時の引数、またはその両方から取り、コマンドラインがファイルより優先されます。テストベッドのノード(Redis 7.4.11)は、ファイルなしで引数だけで起動します(redis-server --requirepass … --appendonly yes …)。INFO serverには空のconfig_file:が表示されます。

CONFIG GETとCONFIG SET

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は、今変えたものだけを保存するのではありません。サーバが動いている設定のすべてで、ファイルを書き直します。

最初に見るべき設定

値はテストベッドのもので、注記したもの以外はRedisのデフォルトです。

設定値何か
databases16番号付きのデータベース、0〜15。クラスタでは1
maxmemory0メモリーの上限なし
maxmemory-policyallkeys-lru上限に達したときに削除されるもの。Redisの出荷時はnoevictionで、redis-tlsはこれです
maxclients10000同時接続数
timeout0接続を閉じるまでの非アクティブな秒数。0は閉じないことを意味します
tcp-keepalive300相手がまだいるかを確かめる間隔(秒)
enable-debug-commandnoDEBUGが無効

Valkey

Valkey 9.0以降のクラスタでは、databasesは16のままで、効くのはcluster-databasesです。デフォルトは1です(valkey-cluster-1で測定)。8.0.11では、Redisと同じくdatabasesは1です。

INFO serverとINFO stats:最初に見るもの

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回で、拒否された接続も追い出されたキーもありませんでした。「サーバ」はこの要点を表示し、読むだけです。

Valkey

Valkeyでは、バージョンはvalkey_version(テストベッドでは9.1.2)、モードはserver_modeです。redis_versionはどのバージョンでも7.2.4と表示します。元になったRedisのバージョンだからで、redis_modeは存在しません。ファイルはvalkey.conf、サーバはvalkey-serverという名前で、テストベッドのvalkeyノードもファイルなしで起動します。

DEBUGと、それが「コンソール」にない理由

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.と答えました。「コンソール」は、サーバで有効になっていても実行しません。

キーワード: Redis、設定、CONFIG GET、CONFIG SET、CONFIG REWRITE、redis.conf、DATABASES、MAXMEMORY、MAXCLIENTS、TIMEOUT、TCP-KEEPALIVE、ENABLE-DEBUG-COMMAND、INFO SERVER、INFO STATS、DEBUG

よくあるエラー

Redisの各エラーのプレフィックス(WRONGTYPE、NOAUTH、NOPERM、OOM、READONLY、MOVED、CROSSSLOTなど)の意味、テストベッドで何が原因だったか、そしてどうすればよいかです。

対象: Redis 7.0+ Valkey 8.0+

Redisのエラー応答は、その種類を示す大文字の単語(WRONGTYPE、NOAUTH…)で始まり、より正確なものがなければERRで始まります。これはサーバからの応答で、接続の障害ではありません。接続はそのまま続き、次のコマンドも同じように通ります。Calíopeはそれを届いたとおりに表示します。「コンソール」では、赤で、記号とプレフィックスを前に付けて表示します。接続時のアクセスのエラー(NOAUTH、WRONGPASS)は、アクセス拒否として「エラー」に届きます。

この表のエラーは、どれもテストベッド(Redis 7.4.11)に対して発生させたものです:

プレフィックス発生させたもの対処
WRONGTYPE文字列のキーに対するLPUSHTYPEで型を確認する。型ごとに専用のコマンドがある
NOAUTHパスワードのあるサーバで、先にAUTHをせずに送ったコマンドプロファイルにパスワードを設定する
WRONGPASS間違ったパスワードでのAUTHユーザとパスワードを確認する。ユーザがonかどうかはACL LISTでわかる
NOPERMそのコマンド(SET)やキーの権限がないACLユーザ何が拒否されたかはACL LOGでわかる
OOMmaxmemory 1とnoevictionポリシーでのSETメモリーを空ける、maxmemoryを上げる、または追い出しをするポリシーを選ぶ
READONLYREPLICAOFでレプリカにしたサーバでのSETプライマリに書き込む
MASTERDOWNプライマリとのリンクがなくreplica-serve-stale-data noのレプリカでの、PINGを含むあらゆるコマンドリンクを復旧するか、レプリカが手持ちのデータで応答するようにする
NOREPLICASmin-replicas-to-write 1でレプリカがない状態でのSETレプリカを復旧するか、設定を下げる
LOADING起動時にサーバが150万キーを読み込んでいたあいだ(約4秒)のPING待つ。読み込みが終わればすぐに応答する
BUSYスクリプトが5秒より長く実行されていたあいだの、あらゆるコマンドスクリプトがまだ何も書き込んでいなければSCRIPT KILL。書き込んでいれば待つ
MOVEDキーのスロットを持っていないクラスタのノードでのGET何もしない。Calíopeが正しいノードでコマンドを繰り返す
ASK2つのノードのあいだで移行中のスロットにあるキー何もしない。CalíopeがASKINGを付けてもう一方のノードで繰り返す
CROSSSLOT別々のスロットにある2つのキーのMGET{usuario:1}のように、波かっこで共通のタグを付ける
CLUSTERDOWNプライマリとそのレプリカが同時に停止した状態での、あらゆるコマンドノードを復旧する。cluster-require-full-coverage yesでは、スロットが1つでも欠けるとどれも応答しない
ERR存在しないコマンド(FOO bar)、またはテキストに対するINCRメッセージの残りを読む。理由はそこにある

クラスタでは、レプリカへの書き込みにはREADONLYではなくMOVEDで、スロットのプライマリのアドレスとともに応答します。

メッセージ(原文のまま)

サーバはこう応答しました。Redisが書いているので、どの言語でも英語です:

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なしで移動します。「レプリカとクラスタ」のトピックで説明しています。

MOVEDとASKはあなたの間違いではありません

クラスタでは、各キーは16384個のスロットのどれかにあり、各スロットは1つのプライマリにあります。スロットを持っていないノードは、そのスロットと、持っているノードのアドレスを付けてMOVEDと応答し、Calíopeはそこでコマンドを繰り返し、クラスタの対応表を読み直します。ASKはスロットがあるノードから別のノードへ移動中に同じことを伝えるもので、そのときは対応表はまだ変わっていないので読み直しません。「コンソール」では、どちらも目にすることはありません。正しいノードの応答が届くからです。

キーワード: Redis、エラー、WRONGTYPE、NOAUTH、WRONGPASS、NOPERM、OOM、READONLY、MASTERDOWN、NOREPLICAS、LOADING、BUSY、MOVED、ASK、CROSSSLOT、CLUSTERDOWN、ERR

やってはいけないこと

本番環境でのKEYS、コレクション全体の読み取り、巨大なキー、開いたままのMONITOR、SAVE、FLUSHALL、スキーマごとのデータベース、DEBUG、そして時間制限のない待機:それぞれのコスト(測定結果)と、Calíopeが実行しないものです。

対象: Redis 7.0+ Valkey 8.0+

Redisはコマンドを1つのスレッドで1つずつ実行します。1つのコマンドにかかる時間だけ、ほかのすべてのクライアントが待ちます。この一覧のほとんどすべてはそこに行き着きます。数値はテストベッド(Redis 7.4.11)で、あとで削除した100万個のテスト用のキーを使って測定したものです。

KEYS *

KEYSはすべてのキーを一度に走査します。100万個あると、1つも見つからないKEYS nada:*がサーバで244 msかかり、同時に送ったPINGは220〜280 ms待ちました。同じ走査をSCAN … COUNT 1000で行うと、1回0.46 msの呼び出しが1000回でした。同じ仕事を、あいだにほかの全員のコマンドを挟みながら分割して行うのです。「コンソール」はKEYSを実行せず、「キー」はSCANで走査します。

コレクション全体の読み取り、または巨大なキー

HGETALL、SMEMBERS、LRANGE 0 -1、ZRANGE 0 -1は、1つのキーにとってのKEYSです。500,000フィールド、MEMORY USAGEで32 MBのハッシュで測定した結果:HGETALLは届くまでに1.4秒かかり、同時に送ったPINGは425 ms待ちました。やるべきことは、まずサイズを問い合わせ(HLEN、SCARD、LLEN、ZCARD)、HSCAN、SSCAN、ZSCAN、または区間ごとのLRANGEで分けて読むことです。「キー」のビューアは、そうやって100件ずつ読みます。

削除にもコストがかかります。そのハッシュのDELはサーバで516 msかかり、PINGは557 ms待ちました。同じハッシュのUNLINKは、メモリーをバックグラウンドで解放するので15 µsでした。「キー」はUNLINKで削除します。

開いたままのMONITOR

MONITORは、サーバが受け取るすべてのコマンドを、すべてのクライアントの分について、それを求めた人に送り、求めた人が閉じるまで開いたままです。redis-benchmarkと100,000回のGETで測定した結果:ほかに何もなければ1秒あたり20,417回で中央値は1.46 ms、MONITORを開いていると1秒あたり17,634回で1.86 msでした。「コンソール」はこれを実行しません。止める方法がないからです。

BGSAVEの代わりにSAVE

SAVEはコマンドと同じスレッドでダンプを書き出し、BGSAVEは別のプロセスで書き出します。100万キーで測定した結果:SAVEは2.5秒かかり、PINGはその2.5秒を待ちました。BGSAVEではPINGはすぐに応答し、ダンプは3秒後に問題なく完了しました。

よく考えずにFLUSHALLやFLUSHDB

FLUSHALLは、選択しているデータベースだけでなく、サーバのすべてのデータベースを空にします。測定では、データベース0と2にキーがある状態で、INFO keyspaceが空になりました。ゴミ箱も取り消しもありません。「コンソール」は、サーバとデータベースを示す確認の中でコマンドの名前を入力したときだけ、これらを実行します。

「スキーマ」ごとに1つのデータベース

番号付きのデータベースはスキーマではなく、アプリケーションを分ける方法でもありません。メモリー、パスワード、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ミリ秒を超える場合)は実行しません。

「コンソール」が実行しないものと、その理由

- KEYS:すべてのキーを一度に走査します。
- MONITOR:サーバの性能を落とし、コンソールはそれを閉じられません。
- SUBSCRIBE、PSUBSCRIBE、SSUBSCRIBE:メッセージが応答に混ざって届いてしまいます。
- SHUTDOWN:サーバを停止します。
- DEBUG:サーバを止めたりクラッシュさせたりします。
- RESET、HELLO、CLIENT REPLY、SYNC、PSYNC:Calíopeが使っている接続のプロトコルを変えてしまいます。
- SELECT:インターフェイスがあるデータベースを表示しながら別のデータベースを読むことになります。
- MULTI、EXEC、DISCARD、WATCH、UNWATCH:トランザクションは1つの接続の上にあります。
- データを待つコマンドで、タイムアウトが0か10秒より長いもの。

また、FLUSHDB、FLUSHALL、CLUSTER RESET、CLUSTER FAILOVER、REPLICAOF、SLAVEOFの前には、コマンドの名前の入力を求めます。この一覧はCalíopeのコードから来ています。一覧が変われば、このトピックも一緒に変わります。

キーワード: Redis、ベストプラクティス、KEYS、SCAN、HGETALL、巨大なキー、DEL、UNLINK、MONITOR、SAVE、BGSAVE、FLUSHALL、FLUSHDB、SELECT、データベース、DEBUG、BLPOP、ブロッキング、コンソール

Valkey:Redisとの違い

Valkeyはどこから来たか、バージョンの読み方、Redisにないもの、そしてCalíopeがValkeyについて表示しないもの。

対象: Valkey 8.0+

Valkeyは、プロトコルでもそれ以外のほとんどすべてでもRedisです。このハンドブックのRedisの12のトピックはValkey 8.0以降に当てはまり、何かが違うところでは独自のブロックがそう書いています。ここにあるのは、そのどれにも収まらないことです。2026年10月22日に、テストベッドのvalkey(9.1.2)とvalkey-cluster-1のノード、そして境界を確かめるための8.0.11のコンテナで測定しました。

生まれ

Valkeyは、RedisがBSDライセンスを離れた2024年3月に、Redis 7.2.4のフォークとして生まれました。Linux Foundationが運営し、3条項BSDライセンスを維持しています。

どのバージョンか:redis_versionではなくvalkey_version

INFO server
redis_version:7.2.4
server_name:valkey
valkey_version:9.1.2
valkey_release_stage:ga
server_mode:standalone

redis_versionは、どのValkeyでも(8.0.11でも)7.2.4と表示します。元になったRedisのバージョンで、それを見るクライアントのために残されています。本当のバージョンはvalkey_versionです。モードはserver_modeにあり、redis_modeは存在しません。8.0以降、HELLO 3はserver valkeyと答えます。7.2はまだserver redisと答え、正体を明かすのはINFO serverだけです。

ValkeyにあってRedisにないもの

- COMMANDLOG(8.1):スローコマンドのほかに、要求か応答が大きいコマンド(large-requestとlarge-reply、デフォルトでは1 MB超)を記録します。SLOWLOGは残っており、そのslowの部分です。
- スロットのアトミックな移行(9.0):CLUSTER MIGRATESLOTSは範囲を一度に、ASKなしで移動し、CLUSTER GETSLOTMIGRATIONSが進み具合を示します。
- 書き込み時のフィールド単位の有効期限(9.0):HSETEXは、フィールドを有効期限付きで書きます。測定結果:HSETEX h EX 100 FIELDS 1 f vのあとHTTL h FIELDS 1 f → 100。8.0.11では、どちらもunknown commandと答えます。
- クラスタでの番号付きデータベース(9.0)。cluster-databasesで設定し、デフォルトは1です。
- よりコンパクトなハッシュテーブル(8.1):ハッシュがlistpackを離れるとき、Redisの半分しか大きくなりません。「データ構造とエンコーディング」で扱っています。

CalíopeがValkeyについて表示しないもの

- 「スローログ」はSLOWLOGを読みます。COMMANDLOGの大きなコマンドは、そこには出ません。「コンソール」でCOMMANDLOG GET 10 large-requestと問い合わせてください。
- 「キー」のビューアは、ハッシュのフィールドごとの有効期限を表示しません。同じ機能を持つRedis 7.4でも表示しないのと同じです。
- クラスタでは、サーバにデータベースがもっとあってもセッションはデータベース0のままで、「コンソール」はSELECTを実行しません。
- 「トポロジ」は各スロットの所有者を表示し、進行中の移行は表示しません。アトミックな移行では切り替えは一瞬で、途中の状態はCLUSTER GETSLOTMIGRATIONSからしかわかりません。

CalíopeがValkeyを見分ける方法

接続するとき、HELLOがvalkeyと言えば、セッションはValkeyのものになります。「サーバ」のフッタはバージョンの前に製品名を置き、「コンソール」はvalkey>のプロンプトを表示します。「接続をテスト」はINFO serverにも問い合わせるので、7.2も見分けます。プロファイルはRedisファミリー、Valkey製品として保存され、Valkeyを知らないバージョンのCalíopeは、それをRedisとして開きます。約束しているのはValkey 8.0以降です。7.2は開きますが、測定していません。

キーワード: Valkey、Redis、valkey_version、redis_version、server_mode、server_name、HELLO、COMMANDLOG、LARGE-REQUEST、LARGE-REPLY、CLUSTER MIGRATESLOTS、CLUSTER GETSLOTMIGRATIONS、HSETEX、HTTL、CLUSTER-DATABASES、ライセンス、BSD、Linux Foundation、フォーク

データモデル:パーティションキーがすべてを決める

パーティションキーとクラスタリング列の役割、サーバが受け付けるクエリ、そしてALLOW FILTERINGが許可ではなく警告である理由。

対象: Cassandra 4.1+ ScyllaDB 2025.1+

主キーは2つの部分からなり、役割が違う

PRIMARY KEY ((cliente_id), fecha, pedido_id)では、最初の部分がパーティションキーです。サーバはこれをパーティショナ(デフォルトはMurmur3)に通し、得られた数値、つまり*トークン*で、その行がどのノードに置かれるかが決まります。同じパーティションキーを持つ行はすべて、同じノードにまとまって並んで置かれます。残りはクラスタリング列で、パーティションの中での行の順序を決めます。テストベッドのdemoキースペースでは、pedidosはcliente_idで分散され、fechaの降順、次にpedido_idの順に並びます。

SELECT DISTINCT cliente_id, token(cliente_id) FROM pedidos;
-- 1 → -4069959284402364209   2 → -3248873570005575792

クエリで何を指定できるかは、キーが決める

効率のよい読み取りは1つのパーティションに向かいます。パーティションキー全体を=かINで指定し、そのうえでクラスタリング列を定義順に、どれも飛ばさずに絞り込めます。

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テーブル」です。

ALLOW FILTERINGは許可ではなく警告

キーに含まれない列で絞り込むと、すべてのノードのすべてのパーティションを読むことになるため、サーバは拒否します。

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(*)は警告しません。

静的列

static列はパーティションごとに1つの値を持ち、そのパーティションのすべての行で共有されます。categoriasでは、raiz_nombreはraizごとに1回書かれ、そのルートの各行に繰り返し現れます。

Calíopeで見られること

「キースペース」では、各テーブルの詳細に、CQLで書くとおりの「主キー」と、各列の役割(「パーティションキー」、順序付きのクラスタリング、静的、通常)が表示されます。「CQLクエリー」では、サーバが拒否したものはその文面のままエラーとして、警告付きで受け付けたものは「サーバーからの警告」の帯に表示されます。エクスプローラの行のサンプルは、WHEREもALLOW FILTERINGも使わずに、テーブルをページごとにたどります。

キーワード: Cassandra、ScyllaDB、データモデル、パーティションキー、partition key、クラスタリング、clustering、主キー、ALLOW FILTERING、ORDER BY、token、static、クエリごとに1テーブル

整合性とレプリケーション

キースペースがいくつコピーを持つか、リクエストごとにいくつ応答が必要か、そしてUnavailableの意味:コーディネータが何も書かないうちに拒否したということ。

対象: Cassandra 4.1+ ScyllaDB 2025.1+

レプリケーション係数はキースペースのもの

キースペースごとに、各行のコピーをいくつ、どこに置くかを決めます。NetworkTopologyStrategyはそれをデータセンターごとに指定し({'class': 'NetworkTopologyStrategy', 'datacenter1': 3})、データセンターが1つでもこれを使うべきです。SimpleStrategyはラックもデータセンターも考慮しません。名前はノードが公開しているもの(SELECT data_center FROM system.local)でなければならず、存在しない名前は「Unrecognized strategy option {dc_que_no_existe} passed to NetworkTopologyStrategy」で拒否されます。

テストベッドのクラスタ(3ノード、係数3のdemo_anillo)では、各ノードがすべてのコピーを持ち、nodetool statusでは各ノードの実効所有率が100 %になります。

整合性レベルはリクエストごとのもの

レプリケーションはコピーがいくつ存在するかを決め、整合性は読み取りや書き込みが成立するためにいくつ応答が必要かを決めます。係数3なら、ONEは1つのレプリカを、QUORUMは過半数(2)を、ALLは3つすべてを待ちます。LOCAL_ONEとLOCAL_QUORUMはコーディネータのデータセンターにあるものだけを数え、EACH_QUORUMは各データセンターで過半数を求めます。読み取りも書き込みもQUORUMなら、2つの過半数は少なくとも1つのレプリカを共有し、読み取りは確定した最新の書き込みを見ます。

Unavailable:コーディネータは試す前に拒否する

3ノードのうち1つを止めた状態で:

CONSISTENCY ALL;
SELECT sensor, valor FROM demo_anillo.lecturas WHERE sensor = 23;
-- code=1000 [Unavailable exception] Cannot achieve consistency level ALL
-- required_replicas: 3, alive_replicas: 2
CONSISTENCY QUORUM;   -- 読み書きできる:3つのうち2つが残る
CONSISTENCY ONE;      -- 読み書きできる

Unavailable(コード0x1000)は、コーディネータが何かを送る前に、生きているレプリカが足りないとわかっていたという意味です。何も書かれていないので、ノードが戻るかレベルを下げるまで、再試行しても無駄です。タイムアウトは別物で(Read_timeout(0x1200)とWrite_timeout(0x1100))、コーディネータがリクエストを送ったのに、時間内に応答したレプリカが足りなかったことを意味します。タイムアウトした書き込みは、一部のレプリカには書かれているかもしれません。3つのレプリカではTHREEはALLと同じく失敗し、TWOは通ります。

ノード数より大きいレプリケーション係数

Cassandra

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なしで作られます。

Calíopeで見られること

「CQLクエリー」は各文を「整合性レベル」セレクタのレベルで送り、その初期値はLOCAL_ONEです。Unavailableは、レベル、必要なレプリカ数、稼働中のレプリカ数(「整合性レベル ALL:必要なレプリカ 3、稼働中 2」)とともに表示され、「解決方法を表示」でこのトピックに来られます。タイムアウトも同様です。「クラスタ」ではどのノードが稼働中かがわかり、Unavailableのときにまず見る場所です。「キースペース」では、各キースペースの詳細に「レプリケーション」が表示されます。

キーワード: Cassandra、ScyllaDB、整合性、consistency、整合性レベル、レプリケーション、レプリケーション係数、NetworkTopologyStrategy、SimpleStrategy、QUORUM、LOCAL_QUORUM、LOCAL_ONE、ALL、Unavailable、Read_timeout、Write_timeout、レプリカ

コンパクション:STCS、LCS、TWCS

CassandraとScyllaDBがコンパクションを行う理由、テーブルごとに合う戦略、そしてcompaction_historyと実行中のコンパクションからわかること。

対象: Cassandra 4.1+ ScyllaDB 2025.1+

コンパクションが必要な理由

データファイルが書き換えられることはありません。書き込みは*コミットログ*とメモリ上のテーブル(memtable)に入り、それがいっぱいになると(あるいはnodetool flushで指示すると)、変更できないファイル、つまりSSTableとしてディスクに書き出されます。何度も更新された行は複数のSSTableに分かれて残り、読み取りはそのすべてを見て、各列の最新版を選ばなければなりません。コンパクションは複数のSSTableを1つにまとめて最新版を残し、gc_grace_secondsが過ぎていれば、削除されたものの*tombstone*を捨てます。

戦略

各テーブルはWITH compaction = {'class': …}で戦略を選びます。

- SizeTieredCompactionStrategy(STCS)は、似たサイズのSSTableがmin_threshold個(デフォルトは4)たまるとまとめます。ディスクへの書き込みは少ない代わりに、1つの行が複数のSSTableに分かれたまま残ることがあります。
- LeveledCompactionStrategy(LCS)は、SSTableを重ならないレベルに整理するので、読み取りが見るSSTableは少なくて済みます。ディスクへの書き込みは多くなるので、書き込みよりずっと読み取りが多いテーブルに向いています。
- TimeWindowCompactionStrategy(TWCS)はTTL付きの時系列データ向けです。時間の窓ごとにまとめ、窓全体が期限切れになると丸ごと捨てます。

テストベッドで、50行のバッチを5回、そのたびにnodetool flushして測定しました。

STCS  4つ目のSSTableのあと1つにまとめた → 残り2、nodetool compact → 1
LCS   フラッシュのたびにコンパクションした → 残り1

Cassandra

Cassandraでは、何も指定せずに作ったテーブルはSTCSになります。

Cassandra 5.0+

5.0からはUnifiedCompactionStrategy(UCS)もあり、3つのどれとも同じように動くよう調整できます。

ScyllaDB

ScyllaDBにもSTCS、LCS、TWCSがありますが、デフォルトの戦略は別のもの、IncrementalCompactionStrategy(ICS)です。コンパクション中に空きディスクをそれほど必要としないよう、STCSを少しずつ行うものです。UCSはありません(「Unable to find compaction strategy class 'UnifiedCompactionStrategy'」)。新しいテーブルはそれぞれタブレットに分割され(テストベッドのノードでは8つ)、タブレットごとに個別にコンパクションされます。nodetool compactのあとには、タブレットごとに1つ、8つのSSTableが残ります。テストベッドの2025.1では、各テーブルは1つのタブレットで作られました。

履歴からわかること

system.compaction_historyには、完了したコンパクションごとに1行が残ります。いつ、どのテーブルか、入力と出力のバイト数、そしてrows_mergedです({1: 200}は、それぞれ1つのSSTableにしかなかった200行という意味です)。bytes_outがbytes_inよりずっと小さければ、コンパクションで古い版かtombstoneが捨てられたということです。いまコンパクション中のものはsystem_views.sstable_tasks(kind、progress、total、completion_ratio)に出て、nodetool compactionstatsは保留中のタスクとあわせてそれを示します。

ScyllaDB

ScyllaDBでは、履歴にさらにcompaction_type、shard_id、sstables_in、sstables_outがあり、実行中のものはsystem.compactions_in_progressから読みます。system_viewsはありません。2025.1にはこの4つの列がなく、Calíopeはある列で履歴を表示します。

Calíopeで見られること

「ノード」では、「実行中のコンパクション」にいまコンパクション中のものが(サーバが示せば進捗とともに)、「コンパクションの履歴」に最近完了したものがバイト数とともに並びます。各テーブルの戦略は、「キースペース」のそのテーブルの「DDL」で読めます。

キーワード: Cassandra、ScyllaDB、コンパクション、compaction、SSTable、memtable、flush、STCS、LCS、TWCS、UCS、ICS、SizeTiered、Leveled、TimeWindow、Incremental、compaction_history、sstable_tasks、compactionstats

tombstoneとgc_grace_seconds

削除が書き込みである理由、tombstoneが読み取りを遅くするとき、そしてtombstoneが消えるとき。

対象: Cassandra 4.1+ ScyllaDB 2025.1+

削除は書き込みである

SSTableが書き換えられることはないので、DELETEは何も取り除きません。「この時点から削除済み」という印、tombstoneを書き込みます。印は他の書き込みと同じようにレプリカへ送られ、コンパクションが両者をまとめるまで古い版を覆い隠します。列をnullにするINSERTやUPDATE、TTLが切れた列、コレクションからの要素の削除もtombstoneを書き込みます。

テストベッドで、1つのパーティションの1,200行を1行ずつ削除し、さらに列をnullのままにするINSERTを実行して測定しました。

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

読み取りが遅くなる理由

何が生きているかを知るために、読み取りは走査する範囲のすべてのtombstoneを乗り越えなければなりません。最終的に何も返さない場合でも同じです。削除を重ねて空になったパーティション(キューやタスクのリスト)は、コンパクションされるまで読み取りのコストが変わりません。

Cassandra

Cassandraはtombstone_warn_threshold(1,000)から警告し、tombstone_failure_threshold(100,000)から読み取りを中断します。警告は応答と一緒に届きます。

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

ScyllaDB

ScyllaDBは応答と一緒に警告しません。同じ1,200行を削除しても警告は届かず、トレースは1200 clustering row(s) (0 live, 1200 dead)と数えます。代わりにページを区切ります。query_tombstone_page_limit(10,000)個のtombstoneのあと、それまでの結果を返し、クライアントが次のページを要求します。1つのパーティションに100,600個あっても、読み取りはエラーなしで、10,000ずつのページで終わりました。

消えるとき:gc_grace_seconds

tombstoneが捨てられるのはコンパクションのときだけで、しかもgc_grace_seconds(デフォルトは864,000秒、10日)より前には捨てられません。これは印がすべてのレプリカに届くまでの猶予です。あるレプリカがそれより長く印を受け取らないまま(停止していて修復もされずに)いると、他のレプリカが印を捨てたとき、そのデータを持っているのはそのレプリカだけになり、データを返してしまいます。削除が「復活」するのです。だから猶予期間と修復は一体です(「修復とヒント」を参照)。

同じ構成の2つのテーブルで同じ1,200行を削除し、nodetool flushとnodetool compactを実行して測定しました。

gc_grace_seconds = 864000   →  Read 0 live rows and 1200 tombstone cells
gc_grace_seconds = 0        →  Read 0 live rows and 0 tombstone cells

猶予期間はALTER TABLE … WITH gc_grace_seconds = 3600で変更します。レプリカが2つ以上あるテーブルで下げても安全なのは、期限が切れる前に修復が走る場合だけです。

ScyllaDB

ScyllaDBのテーブルはtombstone_gc = {'mode': 'repair'}で作られます。tombstoneは、gc_grace_secondsが切れたときではなく、修復がそれをカバーしたときに捨てられます。テストベッドのノード(レプリカは1つ)では、猶予期間が10日でもコンパクションが1,200個すべてを捨てました。tombstone_gc = {'mode': 'timeout'}のテーブルはCassandraと同じく残しました。これは2026.3.3の場合です。2025.1ではテーブルは{'mode': 'timeout'}で作られ、Cassandraと同じく猶予期間に従います。

Calíopeで見られること

「CQLクエリー」では、「サーバーからの警告」がサーバーの警告を届いたとおりに表示します。「トレース」をオンにすると文がトレースを残し、結果から「トレース」で開くと、各レプリカが読んだ生きている行とtombstoneの数がわかります。各テーブルの「tombstone の猶予 (gc_grace_seconds)」は「キースペース」で見られ、その「DDL」には他のオプション(ScyllaDBではtombstone_gc)が含まれます。「ノード」では、「設定」がノードのしきい値を、「大きなパーティション」が最も大きいパーティションを表示します。

キーワード: cassandra、scylladb、tombstone、削除、delete、null、gc_grace_seconds、tombstone_warn_threshold、tombstone_failure_threshold、tombstone_gc、復活、コンパクション、read_too_many_tombstones

修復とヒント

停止していたレプリカが追いつく仕組み:ヒント、nodetoolによる修復、そしてCalíopeが表示することとしないこと。

対象: Cassandra 4.1+ ScyllaDB 2025.1+

遅れるレプリカ

書き込みはすべてのレプリカに送られますが、整合性レベルが待つのは一部だけです。RF 3なら、QUORUMは2つで書き込みを受け入れます。3つ目が停止していれば、そのデータを持たないままになります。それを追いつかせる仕組みは2つあります。自動で短期的な*ヒント*と、実行する必要がある修復です。

ヒント

コーディネーターがレプリカに届かないとき、その書き込みを*ヒント*として保持し、レプリカが戻ったときに届けます。これはレプリカの停止がヒントの窓(デフォルトは3時間)を超えていない間だけです。それを過ぎると保持をやめ、足りない分は修復でしか戻りません。

テストベッドの3ノードのリング(RF 3)で、1つのノードを止めて測定しました。

200 × INSERT …(CONSISTENCY QUORUM)     -- 200件すべて受け入れ
SELECT …(CONSISTENCY ALL)              -- Unavailable: 必要3、稼働2
-- ノードが戻る: pending_hintsにそのアドレスのファイルが1つ、status UP
-- 30秒以内に: HintsDispatcher completed_tasks 2、pending_hintsは空、
-- 戻ったノードは修復なしで200行を持っている

ヒントは整合性レベルには数えられません。QUORUMの書き込みが受け入れられたのは2つのレプリカが応答したからで、3つ目のためのヒントが残ったからではありません。

Cassandra

Cassandraは、待機中のヒントをsystem_views.pending_hints(アドレス、ファイル、最新と最古)で、設定をsystem_views.settingsで公開しています。hinted_handoff_enabled(true)とmax_hint_window(3h)です。

ScyllaDB

ScyllaDBでは設定はsystem.configにあります。hinted_handoff_enabledとmax_hint_window_in_ms(10,800,000、同じ3時間)です。

修復

nodetool repairは各範囲のレプリカを比較し、それぞれに足りないものをコピーします。ヒントがカバーしなかったものを取り戻す唯一の方法で、各テーブルについてgc_grace_secondsごとに少なくとも1回実行する必要があります。そうしないと、tombstoneがあるレプリカで捨てられてから別のレプリカに届き、削除したデータが戻ってくることがあります(「tombstoneとgc_grace_seconds」を参照)。

Cassandra

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付きの行が残ります。

Cassandra

Cassandraでは、各修復がsystem_views.repairsにも表示されます。テストベッドではcompletedがTrue、duration_millisが995でした。

ScyllaDB

ScyllaDBでは、タブレットを使うキースペース(デフォルトのモード)はnodetool repairでは修復できません。「nodetool repair repairs only vnode keyspaces! To repair tablet keyspaces use nodetool cluster repair.」nodetool cluster repair <keyspace>がテーブルごとに修復します。これは2026.3.3の場合です。2025.1ではnodetool repairでもtabletを使うキースペースを修復できます。

Calíopeで見られること、しないこと

Calíopeは修復もヒントの配送もしません。nodetoolはJMX(ScyllaDBではポート10000のREST API)を使い、CalíopeはCQLプロトコルしか話さないからです。表示するのは次のものです。「クラスタ」では、どのノードが稼働しているか(1つが停止している間、他のノードがそのためのヒントを保持します)。「ノード」では、「設定」に窓の長さとヒントが有効かどうかが出ます。

Cassandra

「ノード」の「スレッドプール」には、ヒントを届けるHintsDispatcherプールがあります。戻ったレプリカがヒントを受け取ると、その完了タスク数が増えます。

修復とヒントのビューは、「CQLクエリー」で他のテーブルと同じように読めます。

キーワード: cassandra、scylladb、修復、repair、nodetool repair、ヒント、hints、hinted handoff、max_hint_window、pending_hints、アンチエントロピー、gc_grace_seconds、ノード停止

軽量トランザクション:IF NOT EXISTS、[applied]、SERIAL

1ステップで確認して書き込む方法、サーバーの応答、そのコスト、そして受け入れる整合性レベル。

対象: Cassandra 4.1+ ScyllaDB 2025.1+

1ステップで確認して書き込む

通常の書き込みは事前に読みません。既に存在するキーへの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」)。

コスト

条件はレプリカ間のPaxosで決まり、通常の書き込みが1往復のところ、書き込みごとに何往復もかかります。UPDATE … IFのトレースがそれを示しています。

Promising ballot …
CAS precondition is met; proposing client-requested updates for …
Accepting proposal …
Committing proposal …
CAS applied successfully

本当に確認が必要なもの(名前の予約、他人の変更を上書きしないこと)のためのもので、すべての書き込みに使うものではありません。

SERIALとLOCAL_SERIAL

条件のフェーズには独自のレベルがあります。SERIAL(クラスタ全体のレプリカ)かLOCAL_SERIAL(データセンターのレプリカ)です。読み取りのレベルとしてはどちらも使えます。読み取りが条件と同じくPaxosを通るからです。書き込みのレベルとしては、条件付きでも通常でも、どちらも使えません(両製品で測定)。

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

マテリアライズドビュー、セカンダリインデックス、SAI

キーではない列で検索する3つの方法、それぞれのコスト、そして初期状態で無効になっているもの。

対象: Cassandra 4.1+ ScyllaDB 2025.1+

キー以外で検索する

テーブルはパーティションキーで検索するのが基本です。別の列で検索する方法は3つあり、どれもサーバーが維持します。マテリアライズドビュー、セカンダリインデックス、そしてCassandra 5.0のSAIインデックスです。どれもなければ、両方の製品ともクエリーを拒否します。

SELECT * FROM pedidos WHERE estado = 'abierto';
-- Cannot execute this query as it might
--   involve data filtering … use ALLOW FILTERING

マテリアライズドビュー

マテリアライズドビューは別のキーを持つもう1つのテーブルで、ベーステーブルへの書き込み時にサーバーが埋めていきます。他のテーブルと同じようにキーで検索でき、ベースへの書き込みはビューごとに1回ずつ書き込みが増えます。

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';

ビューはバックグラウンドで構築されます。テストベッドでは、作成直後の読み取りは2行のうち1行を返し、数秒後には2行とも返しました。構築が終わったかどうかはsystem.built_viewsでわかります。

Cassandra

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

値の種類が少ない列に向いており、クラスター全体から1行を探す用途には向きません。作成直後の最初のクエリーはReadFailureで失敗し、数秒後には2行を返しました。

ScyllaDB

ScyllaDBの標準のインデックスはグローバルです。その下にはインデックス化した値でパーティション分割されたマテリアライズドビュー(<インデックス>_index)があります。クエリーはまずインデックスのパーティションを読み、見つかった行ごとに1回ずつ読み取ります。

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.」)。

Calíopeで見られること

「キースペース」では、各キースペースが「マテリアライズドビュー」と「インデックス」をまとめて表示します。ビューは「ベーステーブル」と「条件」を、インデックスは「種類」(COMPOSITESまたはCUSTOM)、「インデックスクラス」(sai)、「対象」を表示します。ScyllaDBでは、各インデックスの下にあるビューはビューとして表示しません。ユーザーのものではないからです。クエリーがインデックスを使ったかどうかはトレースでわかります。「CQLクエリー」で「トレース」をオンにしてください。

Cassandra

ビューとSASIが有効かどうかは「ノード」の「設定」でわかります。materialized_views_enabledとsasi_indexes_enabledです。

キーワード: cassandra、scylladb、マテリアライズドビュー、materialized view、セカンダリインデックス、secondary index、sai、storage-attached index、sasi、allow filtering、ローカルインデックス、グローバルインデックス、materialized_views_enabled

トレースの読み方

トレースが記録するもの、各イベントの時間の読み方、遅いクエリーで探すもの、そして文に手を加えずにトレースする方法。

対象: Cassandra 4.1+ ScyllaDB 2025.1+

トレースとは

トレースはリクエストの日誌です。リクエストに関わる各ノードが、何をしたかを時刻と、そのリクエストに費やしていたマイクロ秒数とともに記録します。文ごとに要求し(cqlshではTRACING ON)、サーバーはsystem_tracesに保存します。リクエストはsessionsに、各ステップはeventsに入ります。保存期間は24時間です。

読み方

各イベントには4つの情報があります。何をしたか(「アクティビティ」)、どのノードか(「ノード」)、そのノードがリクエストに何マイクロ秒費やしていたか(「経過」)、どのスレッドか(「スレッド」)。時間はノードをまたいで引き算しません。各ノードはリクエストを受け取ったときから数えます。

Cassandraでキーによる読み取りをテストベッドで測定した例です。

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つのイベントの間の大きな飛び。

文に手を加えずにトレースする

nodetool settraceprobability 0.01は、そのノードが調整するリクエストの100件に1件を、クライアントが求めなくてもトレースします。0で止まり、これが初期値です。両方の製品で1にして測定したところ、TRACING ONなしのクエリーがsystem_traces.sessionsにセッションを残しました。トレースはどれもクラスターへの書き込みなので、オンのままにしないでください。

Calíopeで見られること

「CQLクエリー」では「トレース」が文をトレースし、そのトレースは「トレース」で開きます。そこでは「トレースしたセッション」が過去24時間のトレースを、Calíopeのものも他のクライアントのものも一覧にし、それぞれをイベントとともに開けます。

ScyllaDB

「スロー」はScyllaDBのスロークエリーログを読みます。

Cassandra

「実行中」はノードでいま実行中のものを表示します。

キーワード: cassandra、scylladb、トレース、tracing、tracing on、system_traces、sessions、events、source_elapsed、settraceprobability、nodetool、sstables、tombstone、遅いクエリー

ロールと権限

認証と認可の有効化、ロールとは何か、どの権限がどのリソースに使えるか、そしてロールの保存場所。

対象: Cassandra 4.1+ ScyllaDB 2025.1+

認証と認可

初期状態ではどちらも無効です(cassandra.yamlとscylla.yamlのAllowAllAuthenticatorとAllowAllAuthorizer)。ポートに届いた人は誰でも入れて、何でもできます。authenticator: PasswordAuthenticatorとauthorizer: CassandraAuthorizer、そして再起動で有効になり、サーバーはスーパーユーザーcassandra(パスワードcassandra)を作ります。最初に置き換えるべきものです。認証がないと、LIST ROLESは次のように答えます。

Unauthorized: You have to be logged in and
  not anonymous to perform this request

ロール

ロールはユーザーでもありグループでもあります。LOGIN = trueとパスワードがあればログインでき、なければ権限をまとめるだけです。別のロールに付与されたロールは、自分の権限をそのロールに渡します。

CREATE ROLE equipo;
GRANT SELECT ON KEYSPACE ventas TO equipo;
CREATE ROLE lector WITH PASSWORD = '…' AND LOGIN = true;
GRANT equipo TO lector;

lectorは直接の権限を持たず、equipoを通じてventasを読みます。できないことは、サーバーが権限とリソースを示して答えます。

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は、そのリソースが受け付けるものを付与します。

Cassandra

CREATE、ALTER、DROP、SELECT、MODIFY、AUTHORIZE。

Cassandra 5.0+

5.0では、列のマスキングのためのUNMASKとSELECT_MASKEDも付きます。

ScyllaDB

ALTER、AUTHORIZE、CREATE、DROP、MODIFY、SELECT。

変更はすぐには反映されない

各ノードはロールと権限について読んだ内容をしばらく保持するので、GRANTやREVOKEが反映されるまで少し時間がかかることがあります。

Cassandra

permissions_validity、roles_validity、credentials_validityは2,000ミリ秒です。

ScyllaDB

permissions_validity_in_msは10,000で、permissions_update_interval_in_ms(2,000)ごとに更新されます。

ロールの保存場所

Cassandra

キースペースsystem_authです。テストベッドのノードではSimpleStrategy、係数1でレプリケートされています。ロールを持つノードが落ちると、そのロールはログインできません。ノードが複数あるなら係数を上げ(ALTER KEYSPACE system_auth WITH replication = {'class': 'NetworkTopologyStrategy', '<データセンター>': 3})、そのあとキースペースを修復します。

ScyllaDB

systemのテーブル(system.rolesとその周辺)です。ScyllaDBはこれをRaftですべてのノードにレプリケートします。system_authはありますがロールのテーブルはなく(「unconfigured table roles」)、上げるべきレプリケーション係数もありません。

Calíopeで見られること

「ロール」はLIST ROLESでロールを一覧にし、各ロールの「直接の権限」、「所属するロールから継承した権限」、「所属するロール」を表示します。「権限を付与…」と「ロールに追加…」はGRANTを送り、送った内容はパスワードを隠して「コマンドログ」に残ります。認証がなければ、「ロール」はそう伝えて何も提供しません。ノードが使う認証方式と認可方式は「ノード」の「設定」で見られます。system_authのALTER KEYSPACEは「CQLクエリー」で書きます。

キーワード: cassandra、scylladb、ロール、権限、grant、revoke、create role、login、スーパーユーザー、passwordauthenticator、cassandraauthorizer、system_auth、unauthorized、認証、認可

TTL、writetime、カウンター

データの失効のしかた、各セルが持つタイムスタンプと大きい方が勝つ理由、カウンターが受け付けることと受け付けないこと。

対象: Cassandra 4.1+ ScyllaDB 2025.1+

TTL:セルごとに失効する

TTL(秒)はUSING TTLで書き込む内容に付け、行ではなくセルごとに効きます。ttl(c)は列cに残る秒数を返し、行はすべてのセルが失効した時点で出てこなくなります。テーブルにはデフォルトのTTL(default_time_to_live)を持たせられ、USING TTLなしで書いたものに適用されます。USING TTL 0はその書き込みについて取り消します。

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の章にあります。

writetime:勝つのは最後ではなく最大のタイムスタンプ

各セルは書き込まれたときのタイムスタンプを1970年からのマイクロ秒で持ち、writetime(c)がそれを返します。同じセルへの2つの書き込みでは、届いた順に関係なくタイムスタンプが大きい方が勝ちます。

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

タイムスタンプはクライアントまたはコーディネーターが自分の時計で付けるため、時計がずれた2つのクライアントは何のエラーもなく書き込みを失うことがあります。遅れた時計の方が後から届いて負けるのです。

カウンター

counter列は加算と減算しかできず、ほかの列がすべて主キーであるテーブルに置きます。それ以外はサーバーが拒否します。

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」)。

また、インクリメントは冪等ではありません。カウンターのUPDATEがタイムアウトすると(種類COUNTERのWrite_timeout)、適用されたかどうかは分からず、繰り返すと二重に数えることがあります。

Calíopeで見られること

「キースペース」は各テーブルの「デフォルト TTL」を表示し、「行」では「書き込み時刻と TTL の対象」の選択で、選んだ列のwritetimeとttlを加えます。対象はカウンターでもコレクションでもない通常の列だけです。カウンターのテーブルはそこでは編集できず、「CQLクエリー」でUPDATEを使って加算します。

キーワード: cassandra、scylladb、ttl、using ttl、default_time_to_live、失効、writetime、using timestamp、タイムスタンプ、カウンター、counter、インクリメント、tombstone

よくあるエラー

サーバーが返すエラーをコード別に:それぞれの意味、付いてくる情報、どうすればよいか。

対象: Cassandra 4.1+ ScyllaDB 2025.1+

サーバーの応答か、切断か

エラーはどれも4桁の16進コードとメッセージを伴い、一部は後ろにデータ(レプリカの数、どのテーブルか)が付きます。サーバーがエラーで応答したということは、接続は正常だということです。直すのは文かサーバーで、ネットワークではありません。0x000A(Protocol error:サーバーが受け取ったものを理解できない)だけが切断で、Calíopeは接続を失われたものとして扱います。

0x2000 — Syntax error:理解できない

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を付ければエラーになりません。

0x1000 — Unavailable、0x1100 / 0x1200 — タイムアウト

要求した整合性に足りるだけの稼働中レプリカがなかったか、時間内に応答しませんでした。エラーには必要な数と実際の数が付きます。レプリケーション係数3のキースペースを1ノードで使うと、QUORUMは2を要求し、1しかありません。整合性とレプリケーションの章で説明しています。

0x1300 — Read failure

レプリカが読み取れなかったと応答し、理由はメッセージにあります:READ_TOO_MANY_TOMBSTONES(読み取りがtombstoneのしきい値を超えた:tombstoneの章)またはINDEX_NOT_AVAILABLE(構築中のSAIインデックス)。

0x2100 — Unauthorized

ロールに権限がありません。メッセージがどの権限か、どのリソースかを示します。ロールと権限の章で説明しています。

0x1400 — Function failure

ユーザー定義関数が例外を投げ、エラーにはキースペース、関数、その型が付きます。

Cassandra

ゼロで割る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では、そのレプリケーション係数は警告だけです。

Cassandra

Cassandraは同じことに0x2200(Invalid)で応答するか、警告付きでキースペースを作成します。

Calíopeで見られること

「CQLクエリー」はサーバーが送ってきたとおりにエラー(コードとメッセージ)を表示し、続けて付いてきた情報を1文で示します(「整合性レベル QUORUM:必要なレプリカ 2、稼働中 1」)。この章、整合性の章、ロールと権限の章で説明するエラーでは、「解決方法を表示」がその章を開きます。文のエラーは「エラー」には行かず、要求した場所に表示されます。そこに行くのは、接続を妨げるものや接続を切るものです。

キーワード: cassandra、scylladb、エラー、syntax_error、invalid、unavailable、read_timeout、write_timeout、read_failure、unauthorized、already_exists、overloaded、function_failure、config_error、allow filtering、コード

ScyllaDB:どこが違うか

ScyllaDBがCassandraと違うやり方をすること:バージョン、shard、tablet、独自のシステムテーブル、スロークエリーログ、ないもの。

対象: ScyllaDB 2025.1+

同じCQL、別のサーバー

ScyllaDBはCassandraのプロトコルとCQLを話しますが、別のプログラムであり、自分について公開する内容が実態と一致するとは限りません。system.localはrelease_versionに3.0.8と答えます。これはCassandraのバージョンを尋ねるドライバー向けの便宜上のバージョンで、本当のバージョンはsystem.versionsにあります。

SELECT release_version FROM system.local;
-- 3.0.8
SELECT version FROM system.versions;
-- 2026.3.3-0.20261004.d80b3d6ca26b

「クラスタ」はsystem.versionsのバージョンを表示します。

shard

ScyllaDBに与えたコア1つが1つの*shard*で、それぞれが自分のメモリーとデータの持ち分を持ち、shard同士は何も共有しません。テストベッドのノードは--smp 1で動いており、shardは1つです。system.clientsは各接続がどのshardに入ったか(shard_id)を、system.compaction_historyはどのshardがコンパクションしたかを示します。

tablet

新しいキースペースでは、ScyllaDBは各テーブルをノードごとの固定トークン範囲ではなく、ノード間を移動する断片である*tablet*に分けます(tablets_mode_for_new_keyspaces = enabled)。テストベッドのどのテーブルも8つで作られました(system.tablets)。tabletがあると:

- レプリケーション係数はノード数を超えられず、CREATE KEYSPACEは0x2300(「… doesn't have enough token-owning nodes…」)を返します。
- SimpleStrategyは使えません。
- 修復はnodetool cluster repairです。
- キースペースが「RF-rack-valid」でないと、ビューとインデックスが警告します。

tabletなしのキースペースはAND tablets = {'enabled': false}で作ります。

これは2026.3の場合です。2025.1では、多すぎるレプリケーション係数は警告だけで、nodetool repairもtabletを使うキースペースを修復し、tabletを使うキースペースではビュー、インデックス、軽量トランザクション、カウンターが使えません。求めるとサーバーがそう答えます。CUSTOMインデックスもなく、system.compaction_historyにshard_idはありません。

ScyllaDBだけがsystemで公開するもの

- system.config:ノードの設定と、その出どころ(name、value、source)。
- system.cluster_status:各ノードの状態、稼働しているか、負荷、トークン、リングの持ち分(owns)。
- system.large_partitions(とlarge_rows、large_cells):ディスクに書かれるときにしきい値を超えたパーティション。しきい値はsystem.configにあり、パーティションあたり1000 MB、パーティションあたり100,000行、行あたり10 MB、セルあたり1 MBです。100,500行のパーティションは、ディスクに書き出した(nodetool flush)直後に現れました。
- system.runtime_info:キャッシュの数値。

スロークエリーログ

ScyllaDBは遅いクエリーをsystem_traces.node_slow_logに記録し、24時間保持します。オンかどうか、どの長さからかはCQLでは読めず、ノードのREST API(ポート10000)で読み書きします。しきい値はマイクロ秒です。

URL=http://<node>:10000/storage_service
curl "$URL/slow_query"
-- {"enable": true, "ttl": 86400,
-- "threshold": 500, "fast": false}
curl -X POST \
"$URL/slow_query?enable=true&threshold=500"

thresholdが500なら0.5ミリ秒を超えるものはすべて記録され、1,983 µsのエントリーがあります。

ないもの、違うやり方をすること

- 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は拒否します。

Calíopeで見られること

「クライアント」は「シャード」列を表示します。「ノード」は「設定」をsystem.configから、「大きなパーティション」をsystem.large_partitionsから、「稼働中の数値」をsystem.runtime_infoから読みます。「トレース」には「スロー」タブがあり、node_slow_logを読んでログのオンにしかたを示します。CalíopeはREST APIを呼ばないためです。

キーワード: scylladb、scylla、shard、tablets、system.config、cluster_status、large_partitions、node_slow_log、slow_query、rest api、system.versions、seastar、違い

型:アフィニティと、列が強制しない理由

型は列ではなく値にある:5つのアフィニティとそれぞれが何を変換するか、ストレージクラスの間の順序、STRICTが本当に防ぐもの、そしてNOCASEがアクセントを何も知らない理由。

対象: SQLite 3.35+

SQLiteでは、型は列ではなく値に属します。列が宣言するのは*アフィニティ*です。保存するときに適用される好みで、変換できるときは変換し、できないときはそのまま通します。

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

5つが1行にそろっています。INTEGERとREALは数値に見えるテキストを変換し、TEXTは数値をテキストに変換し、NUMERICは値を見て決めるので、同じ列にintegerとrealが入ります。そして「BLOBはアフィニティのないもの」です。与えられたものを、来たとおりに保存します。

ストレージクラスは5つあり、順序がある

型を宣言しない列は正当で、5つすべてを受け付けます。つまり1つの列に、整数、実数、テキスト、BLOB、NULLを入れられます。そしてそれらは並べ替えられます。クラスの間には決まった順序があるからです。NULLが先、次に数値、次にテキスト、BLOBが最後です。つまり、汚れた列に対するORDER BYは失敗しません。誰にも知らせずに型ごとにまとめます。

CREATE TABLE libre (v);            -- 型の宣言なし:受け付けられる
INSERT INTO libre VALUES (1), (1.5), ('hola'), (x'0001'), (NULL);

SELECT typeof(v) FROM libre ORDER BY v;
-- null | integer | real | text | blob   <- これがクラスの間の順序

STRICTは見た目ほど防がない

3.37からはテーブルをSTRICTと宣言でき、そうするとINT、INTEGER、REAL、TEXT、BLOB、ANYの数個の型だけを受け付け、保存できないものを拒否します。しかし変換は続けます。'42'は何も失われないのでINTEGER列に入り、42はTEXT列に入って'42'として保存されます。拒否するのは変換できないものです。そして思わぬ利点が1つあります。作り話の型は、通常のテーブルなら黙って受け付けますが、ここでは作成の時点で拒否されます。

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"

日付もbooleanもなく、照合順序はアクセントを何も知らない

TRUEは値が1のintegerです。日付は自分で決めるものです。date()はtextを返し、julianday()はrealを返します。選んだものが、その値の一生のあいだ並べ替えや比較に使うものになります。照合順序はBINARY、NOCASE、RTRIMの3つしかありません。NOCASEが大文字と小文字を同一視するのはASCIIだけなので、caféとCAFÉは別の値で、upper('café')はCAFéを返します。テキストはちゃんとUTF-8です。lengthは文字を数え、BLOBに対してはバイトを数えます。

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

キーワード: 型、アフィニティ、TYPEOF、INTEGER、REAL、TEXT、BLOB、NUMERIC、動的型付け、STRICT、BOOLEAN、日付、JULIANDAY、COLLATE、NOCASE、UTF-8、ストレージクラス

トランザクション:書き込むのは1つだけ、そしてそれを示す5

書き込むのが1つだけなので分離レベルはシリアライザブル:3つのBEGINモード、SQLITE_BUSYの5とbusy_timeoutがそれを解決する理由、解決しない517、そしてWALが本当に変えるもの。

対象: SQLite 3.35+

SQLiteには選べる分離レベルがありませんが、それは欠けているのではありません。データベース全体で一度に書き込むのは1つだけなので、分離レベルはシリアライザブルです。ほかのすべてはそこから導かれます。

これを左右する設定は2つあり、どちらも考えうる最悪の値で出荷されています。

PRAGMA journal_mode;    -- delete:初期設定で、WALではない
PRAGMA busy_timeout;    -- 0:まったく待たない

PRAGMA journal_mode = WAL;
PRAGMA busy_timeout = 3000;

3つのBEGINモード

DEFERRED(デフォルト)は、必要になるまで何も取りません。最初の読み取りでスナップショットを取り、最初の書き込みでロックを求めます。IMMEDIATEは、BEGINの行そのもので、すぐに書き込みロックを求めます。EXCLUSIVEはさらに誰にも読ませないことを求めますが、WALモードではIMMEDIATE以上のことはほとんどしません。実際の決まり:トランザクションが書き込むなら、BEGIN IMMEDIATE。最初に待ち時間がかかりますが、SQLiteで最も厄介なエラー(下で扱うもの)を避けられます。

SAVEPOINTは途中の印で、ROLLBACK TOはトランザクションを閉じずにそこへ戻ります。

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を返す

busy_timeoutでは直らないもの:517

DEFERREDのトランザクションが読み取り、そのあと書き込もうとしたとき、その間に誰かがコミットしていると、読み取りのときに取ったスナップショットはもう使えず、SQLiteはSQLITE_BUSY_SNAPSHOT、つまり517を返します。待っても無駄です。そのスナップショットを返してくれるものは何もありません。唯一の出口はROLLBACKしてやり直すこと、あるいは、もっとよいのは最初からIMMEDIATEで開いておくことです。

-- 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;                       -- 唯一の出口:手放してやり直す

WALが変えるものと、変えないもの

ロールバックジャーナルでは、1つが書き込んでいるあいだ誰も読めません。journal_mode = WALでは、書き込む側が作業しているあいだも、読む側は最後にコミットされたバージョンを読み続けます。2つの接続で測定すると、読む側は一瞬もブロックされずに以前の値を得ました。変わらないのは書き込む側の数です。やはり1つだけで、2つ目はやはり5を受け取ります。

キーワード: トランザクション、BEGIN、DEFERRED、IMMEDIATE、EXCLUSIVE、SAVEPOINT、ROLLBACK TO、SQLITE_BUSY、5、517、BUSY_SNAPSHOT、busy_timeout、WAL、journal_mode、ロック、シリアライザブル

プラグマ:ファイルのもの、接続のもの、そしてコマンド

プラグマは1種類ではありません。ファイルに書き込まれるもの、接続が続くあいだだけ有効なもの、そしてコマンドであるものがあります。どれがどれか、測定した初期値、そしてオフになっているがそうあるべきではないもの。

対象: SQLite 3.35+

SQLiteには設定ファイルがありません。あるのはプラグマです。そして最初に解いておくべき混乱は、それがすべて同じ種類のものではないということです。ファイルの中に書き込まれ、あとから開く人にも有効なものがあり、接続が続くあいだだけ有効で毎回繰り返さなければならないものがあり、そして設定ではまったくなく、何かをして終わるコマンドであるものがあります。

これが初期値で、作成したばかりのデータベースから読んだものです。

PRAGMA journal_mode;   -- delete
PRAGMA synchronous;    -- 2 (FULL)
PRAGMA foreign_keys;   -- 0   <- apagadas
PRAGMA cache_size;     -- 2000 paginas
PRAGMA mmap_size;      -- 0
PRAGMA auto_vacuum;    -- 0
PRAGMA page_size;      -- 4096

オフになっていて、ほとんど誰も予想しないもの

foreign_keysは0です。外部キーは宣言でき、スキーマに保存され、CREATE TABLEにも表示されますが……チェックはされません。孤立した子は何も言わずに入ります。オンにするのは1行ですが、それは接続のものです。接続ごとに設定する必要があり、オンにしても過去にはさかのぼりません。そのためにforeign_key_checkがあり、すでにすり抜けたものを一覧にします。

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

残るものと残らないもの

journal_modeとuser_versionはファイルのヘッダに書き込まれ、閉じて開いても残ります。page_sizeとauto_vacuumも残りますが、最初のテーブルを作成する前に設定した場合だけです。すでにページのあるデータベースでは、エラーなしに受け付けられて何も変わりません。これは両方向で確かめました。foreign_keys、cache_size、busy_timeout、mmap_sizeは接続のもので、別の接続を開くとすぐに初期値に戻ります。

-- ファイルに書き込まれたまま残る:
PRAGMA journal_mode = WAL;
PRAGMA user_version = 7;
PRAGMA page_size    = 8192;   -- まだ空のデータベースでだけ
PRAGMA auto_vacuum  = FULL;   -- idem

-- 接続のもので、接続ごとに繰り返す必要がある:
PRAGMA foreign_keys = ON;
PRAGMA cache_size   = -8000;  -- 負の値はページではなくキビバイト
PRAGMA busy_timeout = 3000;

戸惑わせる細部が1つあります。journal_mode = WALを設定したあと、誰も触っていないのにsynchronousは「1 (NORMAL)」になっていました。これは意図的なものです(WALでは、コミットしたものを失わないにはNORMALで十分です)。しかし、プラグマを読んでも、その値がどこから来たのかはわからないことを示しています。

そしてコマンドであるもの

wal_checkpointはジャーナルをデータベースに流し込み、TRUNCATEを付けると0バイトにします。4,716,016バイトの-walが0になるのを測定しました。ANALYZEは、プランナがインデックスを選ぶのに使う情報でsqlite_stat1を満たします。そしてPRAGMA optimizeは、長く使った接続を閉じるときに実行する価値のあるものです。十分に変更されたテーブルを見て、必要なANALYZEを何も言わずに実行します。

PRAGMA wal_autocheckpoint;         -- 1000 paginas
PRAGMA wal_checkpoint(TRUNCATE);   -- 0 | 0 | 0、そして-walは0バイトになる

ANALYZE;
SELECT * FROM sqlite_stat1;        -- t | iv | 20000 20000
PRAGMA optimize;                   -- 何も返さない

キーワード: プラグマ、設定、journal_mode、WAL、SYNCHRONOUS、foreign_keys、cache_size、mmap_size、auto_vacuum、page_size、user_version、wal_checkpoint、OPTIMIZE、ANALYZE、sqlite_stat1

インデックス:あるのはBツリーだけ、そしてプランの3つの語

SCAN、SEARCH、COVERINGがEXPLAIN QUERY PLANの語彙のすべてです。部分インデックスと式インデックス、そしてそれを使わせるために繰り返さなければならない条件、WITHOUT ROWIDが節約するもの、ANALYZEが書き込むもの。

対象: SQLite 3.35+

SQLiteにあるインデックスは1種類だけ、Bツリーです。ハッシュもビットマップもなく、選ぶものはありません。単語検索はありますが、インデックスではありません。それはFTS5という独立した仮想テーブルです。これでテーマ全体が単純になります。残る決定は、どの列にとどの順序でだけだからです。

そして確認にはEXPLAIN QUERY PLANを使います。その語彙は3つの語に収まります。SCANはテーブル全体を読むこと、SEARCHはインデックスから入ること、COVERINGは行にまったく触れなかったことを意味します。

-- 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=?)

複合インデックスは、どのエンジンでもそうであるようにプレフィックスでたどります。(cliente, estado)はclienteだけの場合にも両方の場合にも役立ちますが、estadoだけの場合には役立ちません。

カバリング

クエリーが読む列をすべてインデックスが持っていれば、行には触れません。前と同じインデックスです。変わるのは、何を求めるかです。

EXPLAIN QUERY PLAN
  SELECT cliente, estado FROM pedidos WHERE cliente = 42;
-- SEARCH pedidos USING COVERING INDEX i_cli_est (cliente=?)

部分インデックスと式インデックス:どちらも繰り返す必要がある

部分インデックスは条件を満たす行だけをインデックス化するので、ほとんど場所を取りません。その代わり、クエリーがその条件を一字一句繰り返さなければならず、そうしないとプランナはそれを使えません。条件がなければ、同じクエリーはSCANに戻ります。式インデックスも同じです。lower(correo)をインデックス化しているので、WHEREにはlower(correo)と書かなければなりません。ただのcorreoではまったく役に立ちません。

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>=?)

WITHOUT ROWIDは間接参照を1つ減らす

通常のテーブルは行を隠れたrowidで保存するので、その主キーは別のインデックスで、そこから行を取りに行かなければなりません。WITHOUT ROWIDでは、テーブルが主キーのツリーそのものです。その1歩が省け、場所も省けます(同じ20,000行のテーブルで1,675,264バイトに対して1,335,296バイト、20 %少ない)。そしてプランは、自動インデックスの名前を挙げる代わりにUSING PRIMARY KEYと言うことで、それを示します。

ANALYZEがくれるのは数字であって、奇跡ではない

行数と、インデックスの値ごとに何行あるかでsqlite_stat1を満たします。2つ目の数字が、インデックスに価値があるかどうかを示すものです。値ごとに20,000では、何も区別していないということです。しかし、いつも選択が変わるわけではありません。測定では、プランナは実行前からすでに正しく選んでいました。統計がなくても妥当な推測を使うからです。ANALYZEを実行すると推測がなくなりますが、別のプランになると約束するものではありません。

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行

キーワード: インデックス、Bツリー、EXPLAIN QUERY PLAN、SCAN、SEARCH、COVERING INDEX、部分インデックス、式インデックス、WITHOUT ROWID、ANALYZE、sqlite_stat1、FTS5

パフォーマンス:トランザクションはほかのすべての440倍の価値がある

同じ20,000件の挿入を6通りで測定:最良と最悪の差はどのプラグマにもなく、BEGINがあるかどうかにあります。そしてWAL、VACUUM、ページサイズが本当にもたらすもの。

対象: SQLite 3.35+

重要なことは1つだけで、それはプラグマではありません。同じ20,000件の挿入、同じスキーマ、同じマシンで:

方法時間
1件ずつ、トランザクションなし3,963 ms
1件ずつ、synchronous = OFFで2,442 ms
1件ずつ、WALモードで258 ms
20,000件を1つのBEGINの中で9 ms
1つのBEGINの中で、WALモードで10 ms

440倍です。その理由は、BEGINがないと、INSERTの1つ1つがそれぞれのトランザクションになるからです。2万回のコミットが、それぞれディスクを待ちます。

-- 20 000件のINSERT、それぞれ独自のトランザクションで:  3963 ms
-- 同じ20 000件をこの中で:                          9 ms

BEGIN;
  INSERT INTO t (v) VALUES ('...');   -- x 20 000
COMMIT;

中間の層にある落とし穴に注意してください。ドライバが「一括挿入」の呼び出しを提供していても、トランザクションを開くとはかぎりません。Pythonのexecutemanyは、明示的なBEGINなしでは4,128 msかかりました。手で書いたループとまったく同じです。

ほかのものがもたらすもの

synchronousをオフにすると38 %節約でき、その代わりに停電でコミット済みのデータを失う可能性を差し出します。一覧の中で最悪の取引です。トランザクションなしのWALは258 msまで下がりました(15倍)。コミットでデータベースを書き直さなくなるからで、こちらはそのままにしておける変更です。しかし両方を組み合わせると、ほとんどすべてをBEGINが持っていきます:WALなしで9 ms、ありで10 ms。まずまとめ、調整はそのあとです。

場所を返すのはVACUUM

削除してもファイルは小さくなりません。ページは再利用のために空きリストに残ります。10,813,440バイトのファイルで行の半分を削除して測定すると、まったく同じサイズのままでした。VACUUMはファイル全体を書き直し、5,410,816にしました。データベースのコピー1つ分と排他ロックのコストがかかるので、毎晩の作業ではありません。大きな削除でファイルが本来の2倍のサイズになったときに実行するものです。

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

ページサイズはほとんど触る必要がない

3つを測定しました。512に下げると、ファイルが20 %大きくなり、ほかの2つでは1ミリ秒に届かなかったスキャンに測れる時間がかかりました。65,536に上げても何も得られませんでした。初期値の4,096がそのままにしておくべきもので、そのうえ変更できるのは最初のテーブルを作成する前か、あとでVACUUMを通す場合だけです。

PRAGMA page_size = 512;     -- 5215744バイト、スキャンは3 ms
PRAGMA page_size = 4096;    -- 4333568バイト、0 ms   <- 初期設定
PRAGMA page_size = 65536;   -- 4390912バイト、0 ms

そして自分で測れるものがもう2つ

インデックスはどれも、書き込みのたびにコストがかかります。同じ20,000行に、専用のインデックスなしで7 ms、1つで12、2つで16、3つで20かかり、ファイルは458,752バイトから1,277,952バイトになりました。そしてパラメータ付きの文は再利用されます。?を使った5,000件のクエリーは18 ms、同じものを値をSQLに直接埋め込んで実行すると26でした。しかも、それはインジェクションが入ってくる扉でもあります。

キーワード: パフォーマンス、トランザクション、BEGIN、COMMIT、一括、EXECUTEMANY、SYNCHRONOUS、WAL、VACUUM、freelist_count、page_size、断片化、プリペアドステートメント

DDL:ALTERにできる4つのことと、それ以外の回り道

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   <- 存在せず、存在したこともない

ADD COLUMNが拒否するもの

3つの拒否には同じ原因があります。新しい列はすでにある行に触れずに追加されるので、その行が受け取る値は、行を見ずに決められなければなりません。変化するDEFAULT、チェックが必要になるUNIQUE、値のないNOT NULLは、どれもそれを満たしません。

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

DROP COLUMNが拒否するもの、そしてもっと悪いこと:許してしまうもの

3.35からは列を削除できますが、主キーの一部である場合、UNIQUEである場合、インデックスや生成列がその列を指している場合はできません。ここまではよいのです。問題は、それが通してしまうケースです。ビューが使っている列は何も言われずに削除され、ビューは壊れたまま残り、PRAGMA integrity_checkはビューの中を見ないので相変わらずokと言います。誰かがクエリーを実行するまで、誰も警告してくれません。

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

いつもの回り道

それ以外のことはすべて、新しいテーブルを作成し、コピーし、古いテーブルを削除して、名前を変更するという手順になります。危険に聞こえますが、ここでは危険ではありません。MySQLにはないものがあるからです:SQLiteのDDLはトランザクションに対応しています。測定しました:BEGINの中のCREATE TABLEとADD COLUMNは、最後にROLLBACKすると、テーブルも列も残しませんでした。つまり回り道全体が1つのトランザクションに収まり、途中で何かがうまくいかなくても、中途半端に残るものは何もありません。

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を返します。

キーワード: DDL、ALTER TABLE、RENAME TO、RENAME COLUMN、ADD COLUMN、DROP COLUMN、3.25、3.35、壊れたビュー、integrity_check、トランザクション対応のDDL、12の手順、CAST

バックアップ:ファイルをコピーすると失う

WALモードのデータベースは3つのファイルで、データが最初のファイルにあることはほとんどありません。それをコピーすると、正常だと言い張る空のデータベースが残ります。本当に使える3つの方法を測定し、それぞれの整合性チェックが何を見つけるかを示します。

対象: SQLite 3.35+

SQLiteのデータベースはファイルのように見えます。そして、そこから問題が始まります。WALモードではファイルは3つあり、名前の付いたファイルにはデータがまったくないこともあります。30,000行を書き込んだあと、.sqliteは4,096バイト(ヘッダとわずかなもの)で、-walは3,366,072バイトでした。

最初のファイルだけをコピーしても、壊れたデータベースにはなりません。もっと悪いものになります。何の問題もなく開き、テーブルが1つもなく、PRAGMA integrity_checkがokと言うデータベースです。そのようなバックアップはあらゆるチェックを通り、中身は何もありません。

PRAGMA journal_mode = WAL;
-- 30 000行のあと、3つのファイルのサイズ:
--   base.sqlite         4096   <- ヘッダだけ
--   base.sqlite-shm    32768
--   base.sqlite-wal  3366072   <- データはここにある

-- base.sqliteだけをコピーすると、開けるが空のデータベースになる:
SELECT count(*) FROM sqlite_schema;   -- 0
PRAGMA integrity_check;               -- ok

本当に使える3つの方法

VACUUM INTOは、データベースを使用中のまま、断片化を解消したきれいなコピーを別のファイルに書き込みます。30,000行で3,338,240バイト、4 msでした。バックアップAPI(コマンドラインの.backupや、ドライバのConnection.backup)は、ページをコピーして同じことを行い、分割して進めることもできます。3,338,240バイト、3 msでした。そして.dumpは、データベースを作り直すSQLを書き出します。4,008,968バイトのテキストと30,003の文で、バイナリより20 %大きいですが、目で読める唯一のもので、形式の変更を乗り越えられる唯一のものです。

VACUUM INTO '/ruta/copia.sqlite';
-- 3338240バイト、4 ms、30 000行が入っている
-- コピーはWALではなくjournal_mode deleteで出てくる

うれしい細部が1つあります。VACUUM INTOのコピーはWALではなくjournal_mode deleteで出てきます。1つのファイルで、まさにバックアップに求めるものです。

データベースを止めた状態で3つのファイルを同時にすべてコピーすれば、うまくいきます。問題は「同時に」と「止めた状態で」です。誰かが書き込んでいるあいだは3つがそろう瞬間はなく、それを保証するコピーツールもありません。

手元にあるものを確かめる

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

そして、もう手遅れのとき

.dumpを復元するとは、そのSQLを空のデータベースに対して実行することです。バイナリのコピーを復元するとは、それを元の場所に戻すことで、そのときVACUUM INTOが上書きを拒否することを知っておくと役に立ちます。すでにあるファイルに対してはoutput file already existsと応答するので、うっかり昨日のバックアップを踏みつぶすことはありません。そして手元にあるのが壊れたデータベースだけでバックアップがない場合は、コマンドラインの.recoverがあります。まだ意味の通るページをたどり、作り直せるものを作り直すSQLを書き出します。すべてを約束するものではなく、残っているものを約束します。

キーワード: バックアップ、VACUUM INTO、バックアップAPI、ダンプ、WAL、-wal、-shm、integrity_check、quick_check、破損、復旧

エラー:8つのコード、そして4桁のものほど多くを語る

実際に出てくる8つを1つずつ発生させてテキストとともに示し、拡張コードの計算を説明します。制約違反の19は、何に違反したかによって275、787、1299、1555、2067になります。

対象: SQLite 3.35+

SQLiteには2組のコードがあります。1桁か2桁の基本コードと、同じことをもっと詳しく言う拡張コードです。そして両者の関係は算術的です。拡張コードは基本コードにサブタイプの256倍を足したものなので、code & 255はいつでも基本コードを返します。基本コードしか見せないドライバは、半分を隠しています。

コード名前何が起きたか
5SQLITE_BUSY別の接続が書き込みロックを持っている
6SQLITE_LOCKED別の文で、自分がロックを持っている
8SQLITE_READONLYファイル、ディレクトリ、または接続が書き込みを許さない
11SQLITE_CORRUPTファイルが意味をなさなくなった
13SQLITE_FULL収まらない:ディスク、またはmax_page_count
19SQLITE_CONSTRAINTここで拡張コードを見る
21SQLITE_MISUSEAPIの使い方が間違っている
26SQLITE_NOTADBそもそもデータベースではない

19は5つの別々のエラー

基本コードは役に立つことを何も言いません。違反した制約は5つのどれでもありうるからです。拡張コードは言い、メッセージも助けになります……例外が1つあります。INTEGER PRIMARY KEYの主キーは1555になりますが、そのテキストはUNIQUE constraint failedです。そこでは、文よりも番号のほうが正確です。

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

5と6は混同されるが、同じではない

5は外から来ます。別の接続が書き込んでいて、待てば解決します。そこでbusy_timeoutが役目を果たします。6は内から来ます。同じ接続が、変更したいテーブルにカーソルを開いたままにしていて、待っても無駄です。ブロックしているのは自分だからです。カーソルを閉じれば解決します。

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

キーワード: エラー、コード、SQLITE_BUSY、5、SQLITE_LOCKED、6、SQLITE_READONLY、8、SQLITE_CORRUPT、11、SQLITE_FULL、13、SQLITE_CONSTRAINT、19、275、787、1299、1555、2067、SQLITE_MISUSE、21、SQLITE_NOTADB、26

制限:8つの上限と、それが書かれている場所

SQLiteの上限は形式のものではなくバイナリのもので、PRAGMA compile_optionsで読めます。実際にぶつかる8つを1つずつ発生させたものと、何も言わずに通り過ぎる1つ。

対象: SQLite 3.35+

SQLiteの上限には、ほかのどのエンジンにもない特徴があります:それは形式のものではなく、たまたま話している相手のバイナリのものです。コンパイル時に固定されるので、「最大はいくつか?」への答えは、それをすべて表示するPRAGMA compile_optionsから始まります。プログラムは実行時にsqlite3_limitで下げることもできますが、上げることは決してできません。

これはmacOSに付属するバイナリのもので、最初の5つは実際に発生させました。

上限値知らせ方
テーブルあたりの列数2,000too many columns on b
複合SELECTの項の数500too many terms in compound SELECT
アタッチしたデータベース10too many attached databases - max 10
テキストまたはBLOBの長さ1,000,000,000—
ページサイズ65,536何も言わない。そこがまずい
文の中のパラメータ250,000—
式の深さ1,000—
データベースのページ数1,073,741,823SQLITE_FULL
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

警告しないもの

PRAGMA page_size = 131072はエラーにならず、おかしなものも返さず、何も変えません。ページは4,096のままです。最大は65,536で、それを超えて求めたものは何も言われずに捨てられるので、設定されたかどうかを知る唯一の方法は読み返すことです。テーブルのあるデータベースでpage_sizeとauto_vacuumがすでに見せている失敗の仕方と同じです:受け付けて、何もしない。

PRAGMA page_size = 131072;   -- ni error ni aviso
PRAGMA page_size;            -- 4096   <- 受け付けなかった
PRAGMA page_size = 65536;    -- este si

実際にどれだけ入るか

最大ファイルサイズは定数ではありません。max_page_countにページサイズを掛けたものです。初期値(4,096バイトのページが1,073,741,823)では4 TiBになり、ページを65,536に上げると64 TiBになります。そこに近づくずっと前に、ほかのものが尽きます。テキストは1,000,000,000バイトを超えられず、250,000を超えるパラメータを持つSELECTは準備すらできません。

そして上の表についての注意:それはこのバイナリのものです。電話のもの、組み込みライブラリのもの、誰かが独自のフラグでコンパイルしたものは別の数字を持っているかもしれません。だから役に立つ答えは決して値ではなく、値を尋ねるコマンドです。

PRAGMA compile_options;
-- MAX_COLUMN=2000          MAX_COMPOUND_SELECT=500   MAX_ATTACHED=10
-- MAX_LENGTH=1000000000    MAX_PAGE_SIZE=65536       MAX_EXPR_DEPTH=1000
-- MAX_VARIABLE_NUMBER=250000                         MAX_FUNCTION_ARG=1000

PRAGMA max_page_count;   -- 1073741823
-- x 4096(ページ)= 4 TiBのファイル、x 65536 = 64 TiB

下げるのは防御

sqlite3_limitが下げることしかできないのは欠点ではありません。そのためにあるのです。他人が書いたSQLを受け付けるアプリケーションは、LENGTH、COMPOUND_SELECT、EXPR_DEPTHを本当に必要なところまで下げます。そうすれば、悪意のあるクエリーがギガバイト単位のメモリーを要求することはもうできません。エラーのトピックのmax_page_countと同じ考え方です。自分で設定した上限はシステムの上限より早く、しかも自分で直せることについて警告してくれます。

キーワード: 制限、上限、compile_options、MAX_COLUMN、MAX_COMPOUND_SELECT、MAX_ATTACHED、MAX_LENGTH、MAX_PAGE_SIZE、max_page_count、sqlite3_limit、最大サイズ、列、ATTACH

ベストプラクティス:7つ、そのうち4つは開くときに設定する

SQLiteハンドブックのまとめ:それぞれ測定に裏付けられた7つの習慣。そのうち4つは接続を開いた直後の行に書くもので、他人のファイルの状態を確かめる6つのクエリーも付けています。

対象: SQLite 3.35+

これはハンドブックの最後で、新しいことは何もありません。各トピックが測定して残したものを集めたものです。目を引くのは、7つのうち4つがどこに来るかです:接続を開いた直後に書く行です。ほとんどのプログラムが書かない行です。

1. PRAGMA journal_mode = WAL。ファイルに書き込まれたまま残るので、一度で十分です。これを使うと、別の接続が書き込んでいるあいだも、読む側は一瞬もブロックされずに読み続けました。直さないのは書き込む側の数です。やはり1つだけです。

2. 接続ごとにPRAGMA foreign_keys = ON。この一覧の中で、データベースが受け付けるものを変える唯一のもので、オフの状態で出荷されています。オフだと、孤立した子が何も言わずに入ります。しかも残らないので、busy_timeoutと同じ場所に置きます。

3. PRAGMA busy_timeout、そして自分で設定する。エンジンは0で出荷します(その場でSQLITE_BUSYと応答します)が、多くのドライバは接続時に変更します。Pythonのドライバは何も言わずに5,000にします。だから重要な数字はドキュメントにあるものではなく、自分の接続でPRAGMA busy_timeoutが返すものです。

4. 書き込みをトランザクションにまとめる。最も大きな違いを生むのは断然これです。同じ20,000件の挿入が、1件ずつでは3,963 ms、BEGINの中では9 msでした。そして中間の層に注意してください。ドライバの「一括挿入」の呼び出しは、それだけではトランザクションを開きません。

PRAGMA journal_mode;    -- wal, o delete si nadie lo ha tocado
PRAGMA foreign_keys;    -- ほとんどいつも0で、ほとんどいつも間違い
PRAGMA synchronous;     -- ジャーナルでは2、WALでは1
PRAGMA busy_timeout;    -- エンジンのものではなく、自分の接続のもの
PRAGMA page_count;      -- x page_size = 占める容量
PRAGMA quick_check;     -- ok

5. ファイルをコピーせず、VACUUM INTOでバックアップする。WALモードではデータは-walにあるので、.sqliteをコピーすると、開けて、テーブルが1つもなく、integrity_checkがokと言うデータベースになります。あらゆるチェックを通って空のバックアップは、ないよりも悪いものです。

6. よく使うクエリーごとにインデックスを1つ、そしてその重さを見る。dbstatがオブジェクトごとに教えてくれ、それは意外なものです。測定したデータベースでは、インデックスが2,056,192バイトで、それがインデックス化していたテーブルは1,826,816バイトでした。

SELECT name, SUM(pgsize) AS bytes
  FROM dbstat
 GROUP BY name
 ORDER BY bytes DESC;
-- iv             2056192   <- インデックスがテーブルより重い
-- t              1826816
-- sqlite_schema     4096

7. セキュリティはファイルのもの。ユーザもロールもGRANTもありません。ファイルを読める人はそのすべてを読め、書ける人は削除もできます。保護するのはシステムのアクセス権、ディスクの暗号化、そして(電話では)データ保護クラスです。このハンドブックのほかの部分はすべてパフォーマンスの話ですが、ここだけは代わりになるものがありません。

そして習慣ではなく境界であるもの

SQLiteは評判よりはるかに多くを受け止めますが、どんな習慣でも動かせない境界が1つあります:書き込むのは一度に1つだけ。書き込みが1つのプロセスから来るか、交代で書き込む複数のプロセスから来るかぎり、ファイルはほとんど誰も届かない上限まで伸びます。本当に同時に書き込む2つが必要になった日には、変えるべきものはプラグマではなく、エンジンです。

キーワード: ベストプラクティス、まとめ、WAL、foreign_keys、busy_timeout、一括トランザクション、VACUUM INTO、バックアップ、DBSTAT、インデックス、アクセス権、暗号化、セキュリティ

セキュリティ、ユーザ、ロール

最小権限、ロール、暗号化された接続、そしてサーバを公開する前のチェックリスト。

対象: MySQL 5.7+ MariaDB 10.5+ Aurora 2+

MySQLとMariaDBでは、ユーザの識別は2つのものでできています。名前と、接続元のホストです。'app'@'10.0.%'と'app'@'%'は別のアカウントで、パスワードも権限も別です。セキュリティ上のトラブルの多くは、それを忘れることから始まります。

最小権限
アプリケーションが使うものだけを与え、それ以上は1つも与えません。そしてホストはできるだけ狭くします。アプリケーションのアカウントがDROPを必要とすることはまずなく、SUPER、FILE、GRANT OPTIONは決して必要ありません:

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.%';

ロール

MySQL 8.0+MariaDB 10.0.5+

ロールは、複数のアカウントに与える権限のまとまりです。ロールを1回変えれば、すべてのアカウントが変わります。数人より多いユーザを管理する方法として、筋が通っているのはこれだけです:

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.%';

暗号化された接続
TLSがないと、パスワードとデータはネットワーク上を読める状態で流れます。アカウントごとに、またはrequire_secure_transportでサーバ全体に要求できます。Calíopeは接続プロファイルでTLSに対応し、サーバが公開されていない場合のSSHトンネルにも対応しています:

ALTER USER 'app'@'10.0.%' REQUIRE SSL;

SHOW VARIABLES LIKE 'require_secure_transport';

SELECT user, host, ssl_type FROM mysql.user;

手早い監査
引き継いだサーバで実行する価値のある3つのクエリーです。パスワードのないアカウント、どのホストからでも接続できるアカウント、そして危険な権限が与えられているもの:

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');

サーバを公開する前に
1. 匿名のアカウントやパスワードのないアカウントがないこと。サンプルのtestデータベースもないこと。
2. rootはlocalhostからだけ。それ以外のことには別の管理用アカウントを使います。
3. bind-addressを正しいインターフェイスに — 外から誰も届くべきでないなら0.0.0.0にしない。
4. マシンの外に出る接続にはTLSを必須にします。
5. パスワードはコードの外で管理します — Calíopeはキーチェーンに保管し、平文では決して保存しません。
6. アプリケーションごとに別のアカウントを使い、1つが侵害されてもほかを巻き込まないようにします。
7. GRANTを定期的に見直します:権限は積み重なり、誰も取り除きません。

推奨事項
与えることより取り消すことから始めます。何も持たないアカウントを作り、アプリケーションが動くまで権限を足していきます。Calíopeの「ユーザ」ツールは、データベースごと、テーブルごとに与えられた権限を表示します。意外なものが見つかるのは、たいていそこです。

キーワード: セキュリティ、ユーザ、ロール、権限、GRANT、REVOKE、最小権限、SSL、TLS、REQUIRE SSL、ハードニング、mysql.user、user_privileges、監査

バックアップとポイントインタイムリカバリ

論理バックアップと物理バックアップの違い、バイナリログの役割、DELETEの1分前に戻る方法。

対象: MySQL 5.7+ MariaDB 10.5+ Aurora 2+

一度も復元したことのないバックアップはバックアップではなく、ただの意図です。ここで物を言うのは2つの数値です:RPO(どれだけのデータを失っても許せるか)とRTO(どれだけの時間止まっていられるか)。ほかのすべてはそこから導かれます。

論理と物理
- 論理(mysqldump、Calíopeの「バックアップ」) — SQLを生成します。バージョンやエンジンをまたいで移せて、1つのテーブルだけを復元でき、量が多いと復元が遅くなります。
- 物理(ボリュームのスナップショット、Percona XtraBackup、サーバを止めてのディレクトリのコピー) — ファイルをコピーします。復元はきわめて速いですが、サーバのバージョンとアーキテクチャに縛られます。

目安:数十ギガバイトまでは論理。それを超えたら、完全なコピーには物理、個々の部分には論理を使います。

バイナリログは欠けているもう半分
バックアップは、それを作った時点に戻してくれます。その後に起きたことはすべてバイナリログにあり、そこから災害の1秒前まで進めるのはバイナリログのおかげです。log_binが有効でなければ、ポイントインタイムリカバリはできず、最後のコピーに戻るだけです:

SHOW VARIABLES LIKE 'log_bin';
SHOW VARIABLES LIKE 'binlog_format';
SHOW VARIABLES LIKE 'binlog_expire_logs_seconds';

SHOW BINARY LOGS;

特定の時点まで戻す
手順です。必ず別のサーバで行い、本番環境のサーバでは決して行いません:
1. 障害の前に取った最新の完全なコピーを復元します。
2. バイナリログの中で、誤りの正確な時点を見つけます:削除しすぎた文と、その位置またはタイムスタンプです。
3. mysqlbinlogとその--start-position、--stop-positionオプション(または--start-datetimeと--stop-datetime)で、コピーが終わった位置からその文の直前までバイナリログを再生します。
4. データがそろっていることを確認し、それから、そのサーバを昇格させるか、欠けた部分をそこから書き出すかを決めます。

Calíopeのバイナリログビューアは手順2のためのものです。各ファイルをイベントごとに位置とともに読み、種類(DDL、DML、システム)やテキストで絞り込みます。手作業では難しい部分です。

位置を見つける
SHOW MASTER STATUS(現在のMySQLではSHOW BINARY LOG STATUS。古い形はもう構文エラーになります)は現在のファイルと位置を返します。あるバイナリログのイベントは次のように一覧表示します:

SHOW MASTER STATUS;

SHOW BINLOG EVENTS IN 'binlog.000042'
LIMIT 20;

復元を検証する
確認せずに復元するのは、問題に気づくのが遅れるいつものパターンです。データベースごとの件数と、重要なテーブルのCHECKSUM TABLEを元と比べれば、安心して眠れます:

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;

推奨事項
バックアップをスケジュールし(Calíopeでは保持期間を設定してできます)、コピーをマシンの外に置き、少なくとも2回分のバックアップサイクルをカバーする保持期間でlog_binを有効にし、少なくとも一度は完全な復元を練習します。障害の当日は、手順を覚える日ではありません。

Aurora

Amazon Auroraには独自の仕組みがあります。クラスタはストレージへ継続的にコピーし、バイナリログに触れずに保持期間内の任意の1秒に戻せます。これはマネージドなPITRで、既存のクラスタの上ではなく新しいクラスタに復元します。バックトラックはさらに進んで、何も作らずにクラスタをその場で数秒巻き戻します。どれもmysqldumpの代わりにはなりません。AWSのコピーは同じアカウントの中にあるので、アカウントを失うことからは守ってくれず、ほかのプロバイダへ移せるものも残りません。

キーワード: バックアップ、復元、リカバリ、PITR、ポイントインタイム、バイナリログ、mysqldump、mysqlbinlog、RPO、RTO、CHECKSUM TABLE、スナップショット

オンラインDDL:止めずにスキーマを変更する

ALGORITHM、LOCK、メタデータロック、そして外部ツールが必要になる場面。

対象: MySQL 5.7+ MariaDB 10.5+ Aurora 2+

大きなテーブルへのALTER TABLEは何時間もかかり、アプリケーションを待たせることがあります。MySQL 5.6とMariaDB 10.0からは、変更をどのように行うかを指定できるので、それが痛手になるかどうかを前もって知ることができます。

運に任せず、アルゴリズムを指定する
アルゴリズムを宣言してサーバがそれを使えない場合、文は3時間テーブルをロックする代わりにすぐに失敗します。いつも書いておくべき一番の理由はそれです:

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;
アルゴリズム行うこと典型的なコスト
INSTANTメタデータだけミリ秒
INPLACEその場で再構築数分から数時間
COPYテーブル全体をコピー数時間、ロックあり

MySQL 8.0+MariaDB 10.3+

ALGORITHM=INSTANTが対応するのは、末尾への列の追加、長さを表すバイト数が変わらない範囲でのVARCHARの拡張(MariaDB 10.4以降のみ。MySQLではテーブルを再構築しないINPLACEになり、INSTANTを求めるとエラー1845になります)、列名の変更、デフォルト値の変更です。データに一切触れないのはこれだけです。

LOCK句
- LOCK=NONE — 変更中も読み取りと書き込みが続きます。それができなければエラーになります。
- LOCK=SHARED — 読み取りはできますが、書き込みはできません。
- LOCK=EXCLUSIVE — 誰もテーブルに触れられません。

LOCK=NONEを宣言すれば、移行が本番環境を止めないことを保証できます。ブロックせずに実行されるか、実行されないかのどちらかです。

意外なのはメタデータロック
瞬時に終わるALTERでも、開始時と終了時に排他的なメタデータロックが必要です。そのテーブルで古いトランザクションが開いたままだと、ALTERは待ちます。そしてその後に届くすべてのクエリーが、その後ろに並びます。1ミリ秒で終わるはずだったALTERのせいで、テーブルが固まります。スキーマに触れる前に、長いトランザクションがないことを確認します:

SELECT object_name, lock_type, lock_status, owner_thread_id
FROM performance_schema.metadata_locks
WHERE object_schema = DATABASE();

SELECT @@lock_wait_timeout;

進行状況を見る
何時間も実行されているALTERは、それだけでは何の反応も見せません。performance_schemaなら見えます:

SELECT stage, work_completed, work_estimated,
       ROUND(work_completed / work_estimated * 100, 1) AS pct
FROM performance_schema.events_stages_current;

SHOW PROCESSLIST;

外部ツールが必要になるとき
変更が数十ギガバイトのテーブルでALGORITHM=COPYを強いる場合、どんなLOCKも助けになりません。そこで登場するのがpt-online-schema-change(Percona)とgh-ost(GitHub)です。新しいテーブルを作り、少しずつコピーし、トリガーやバイナリログの読み取りで同期を保ち、最後に一瞬で入れ替えます。サーバには同梱されていないので、別にインストールしてコマンドラインから実行します。

推奨事項
移行には常にALGORITHM=とLOCK=を書き、まず実際のデータのコピーで試して、どれだけ時間がかかるかを把握します。1秒後に失敗するALTERは、午前中の途中でテーブルをロックするALTERに比べれば良い知らせです。

キーワード: オンラインDDL、ALTER TABLE、ALGORITHM、INSTANT、INPLACE、COPY、LOCK=NONE、メタデータロック、MDL、PT-ONLINE-SCHEMA-CHANGE、GH-OST、スキーマ移行

文字セットと照合順序

utf8がUTF-8ではない理由、照合順序が何を決めるか、インデックスを壊さずに変換する方法。

対象: MySQL 5.7+ MariaDB 10.5+ Aurora 2+

いつも混同される2つの概念です。文字セットはどの文字を格納できるかを決め、照合順序はそれをどう比較して並べるかを決めます。前者は何が入るかに、後者はWHEREが何を返すかに影響します。

utf8はUTF-8ではない
MySQLのutf8はutf8mb3の歴史的な別名で、1文字あたり3バイトしか使いません。そのため絵文字を格納できず、現代の中国語、日本語、韓国語の多くの文字も格納できません。本当のUTF-8はutf8mb4です。この製品で最もよく繰り返される罠で、MySQL 8.0でも互換性のために残っています。今の状態を確認してください:

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%';

照合順序が決めること
名前の読み方がわかれば、名前がすべてを語っています。utf8mb4_0900_ai_ciでは、0900がUnicodeのバージョン、aiがアクセントを区別しない(accent-insensitive)、ciが大文字と小文字を区別しない(case-insensitive)という意味です。反対はas(アクセントを区別する)とcs(大文字と小文字を区別する)です。utf8mb4_binというものもあり、バイト単位で比較し、言語のことは何も知りません。

デフォルトは製品によって違います。MySQL 8.0はutf8mb4_0900_ai_ci、MariaDBはバージョンによってutf8mb4_general_ciかutf8mb4_uca1400_ai_ciです。2つの間でデータを移すなら、同じように並ぶとは考えないでください。

実際に変わること
ai_ciの照合順序では、caféとcafeは同じ値です。UNIQUEは2つ目を拒否し、WHEREは両方を見つけます。名前を検索するにはまさにそれが望みかもしれませんが、識別子を格納するには大惨事です:

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;

推奨事項
サーバ、データベース、テーブル、列、クライアントの接続まで、すべてutf8mb4にし、スキーマ全体で照合順序を1つにそろえます。変換の前に、長い文字列の列のインデックスを確認してください。utf8mb3からutf8mb4にすると1文字あたり1バイト増えることがあり、収まっていたインデックスが収まらなくなることがあります。

キーワード: 文字セット、CHARACTER SET、照合順序、COLLATION、utf8、utf8mb4、LATIN1、絵文字、アクセント記号、大文字と小文字の区別、CONVERT TO CHARACTER SET、照合順序の混在エラー

よくあるエラーとその意味

最もよく出会うコード(1045、1062、1213、2006)と、それぞれへの対処。

対象: MySQL 5.7+ MariaDB 10.5+ Aurora 2+ TiDB 8.5+

2000未満のコードはサーバから、2000〜2999はクライアントライブラリから、3000以上は再びサーバから来ます。この区別だけで、どこを見ればいいかがわかります。2xxxなら、問題はSQLではなく接続にあります。

コードメッセージよくある原因
1045Access denied for userユーザ、パスワード、またはホストが一致しない
1049Unknown databaseデータベースが存在しないか、ユーザから見えない
1040Too many connectionsmax_connectionsを使い切った
1062Duplicate entryUNIQUEまたは主キーとの衝突
1146Table doesn't exist名前の綴りの間違い、またはLinuxでの大文字と小文字の違い
1213Deadlock foundロックの循環。答えは再試行
1205Lock wait timeoutほかのトランザクションがロックを持っている
1215Cannot add foreign key型が違う、または参照先にインデックスがない
1267Illegal mix of collations照合順序の違う2つの列
1406Data too long for column値が宣言した型に収まらない
1235This version doesn't yet supportサーバのバージョンにその機能がない
2002Can't connect through socketサーバが起動していないか、ソケットが違う
2006MySQL server has gone awaywait_timeoutまたはmax_allowed_packet
2013Lost connection during queryクエリーが強制終了された、ネットワークが落ちた、またはサーバが再起動した

1045と1040:接続
1045の原因がパスワードであることはまずありません。アカウントが別のホスト用に存在しているのです。'app'@'localhost'と'app'@'%'は別のアカウントであることを思い出してください。1040は接続を使い切ったという意味で、よくある原因はプールのサイズではなく、誰も閉じない接続です:

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';

1062:重複エントリー
メッセージには、衝突したキーの名前が出ます。重複が想定どおりなら(再実行した読み込み、アップサート)、エラーとして扱わないための構文があります:

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);

1215:外部キーを作成できない
何も教えてくれないことで有名なメッセージです。本当の原因はいつも同じ4つです。2つの列の型が完全には一致していない(符号と長さを含む)、文字セットが一致していない、参照される列にインデックスがない、あるいは制約が許さない孤立した行がすでにある:

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;

エラーを正しく読む
コードをネットで検索する前に、全文を読んでください。MySQLはたいてい、テーブル、列、値を正確に挙げています。文がエラーではなく警告を返したときは、直後にSHOW WARNINGSを実行すると、サーバが自分で決めたこと(たとえば黙った切り詰め)がわかります。これはきれいな失敗よりたちが悪いものです。

推奨事項
Calíopeは、サーバのコードとメッセージを包み隠さずそのまま表示します。そのテキストが一番の手がかりで、助けを求めるときは全文をコピーする価値があります。「クエリーログ」には、原因となった文も、その時刻と所要時間とともに残ります。

TiDB

TiDB:ないものは、そのコードで伝える
TiDBにできないことを文が求めると、TiDBは8000以上の独自のコードで応答します。これはサーバの応答です。接続は開いたままで、エディタはそのテキストをこのリンクとともにそのまま表示します。

コードメッセージよくある原因
8048The isolation level … is not supportedSERIALIZABLEまたはREAD UNCOMMITTED。TiDBにはREPEATABLE READとREAD COMMITTEDしかない
8108Unsupported typeTiDBが実行しない文(CREATE PROCEDUREなど)
8200OPTIMIZE TABLE is not supportedTiDBは自分でデータを再編成する
8212(リージョン)操作にTiKVのリージョンが必要なのに見つからなかった
1235doesn't yet supportMySQLにあってTiDBにないもの(SHOW REPLICA STATUSなど)
1105… is not implemented yet同じことを汎用のコードで(CREATE TABLE … SELECTなど)

TiDBの1064は行と列も示し(line 1 column 15 near "FROM x")、Calíopeはエディタで、解析が止まった語を選択します。ヒントの置き場所を間違えてもエラーにはなりません。警告8066として届き、文はヒントなしで実行されます。

キーワード: エラー、エラーコード、1045、1049、1062、1146、1213、1205、1215、1267、1406、2002、2006、2013、1040、接続数の上限、接続の切断、アクセス拒否、重複エントリー、1235、8048、8108、8200、8212、TiDB、サポートされていない、分離レベル