NULLは値ではなく状態
SQLのNULLは「0」でも「空文字」でもなく、「値が存在しない」という状態です。InnoDBはこの状態を、MySQL 8.4で標準的なCOMPACT系(COMPACT・DYNAMIC・COMPRESSED。既定の行フォーマットはDYNAMIC)では、データ領域を使わずに表現します。NULLのフィールドにはデータを1バイトも置かず、代わりに「このフィールドはNULLだ」という印だけを別の場所に立てます。値がないものにわざわざ領域を割り当てないので、オーバーヘッドは最小限で済みます。一方、古いREDUNDANT行フォーマットでは、可変長カラムのNULLはデータ部を使いませんが、固定長カラムのNULLでもその固定長分のデータ領域を予約します。
ここでフィールドという語を整理しておきます。フィールドとは、テーブルの1カラムに対応する値の格納領域です。レコードは、レコードヘッダーとメタデータ(NULLビットマップや可変長フィールドの長さ情報)に続いて、データ部が並ぶ構造です。データ部はORIGIN(データ先頭)から始まり、主キーや各カラムが定義順に置かれます。このデータ部の「1カラム分」が1フィールドです。id、name、emailの3カラムなら、データ部はフィールド0(id)、フィールド1(name)、フィールド2(email)の順に並びます。レコードヘッダーやNULLビットマップはフィールドには含まれず、フィールドの有無や長さを管理する補助情報です。
内部ではUNIV_SQL_NULLで表す
InnoDBの内部処理では、フィールドの長さを表す値として特殊な定数UNIV_SQL_NULLを使います。フィールドの長さがこの値になっていれば、そのフィールドはNULLだと解釈され、データへのポインタは無効として扱われます。比較やオフセット計算のたびに「長さがUNIV_SQL_NULLか」を見てNULLかどうかを判定するので、行フォーマットの違いをまたいで一貫した扱いができます。
NULLビットマップ
COMPACT系の行フォーマット(COMPACT・DYNAMIC・COMPRESSED)では、NULLになり得る(Nullableな)フィールドの分だけ、レコード内にNULLビットマップを持ちます。REDUNDANTはこのビットマップを持たず、各フィールドのオフセット情報にNULLかどうかの印を埋め込む別の方式です。ビットマップのサイズは、Nullableなフィールド数を8で割って切り上げたバイト数です。0個なら0バイト、1〜8個なら1バイト、9〜16個なら2バイトと増えます。
各Nullableフィールドにはビットが1つ割り当てられ、フィールドの定義順に並びます。ビットが1ならそのフィールドはNULL、0なら非NULLです。NULLのフィールドには長さ情報もデータも格納されません。レコードを走査するときは、Nullableフィールドに来るたびにビットマップを見て、ビットが1ならそのフィールドを読み飛ばします。
NULLビットマップは、ORIGIN(データ先頭)から見てレコードヘッダー側(アドレスの小さい側)にあります。ヘッダーは5バイトで、その直前にビットマップが置かれます。
図では、上へ行くほどアドレスが小さく、下へ行くほど大きくなります。record startとORIGINは、どちらも箱と箱のあいだの境界を指しています。record startはレコードの最もアドレスの小さい位置、つまり先頭バイトの手前の境界で、可変長フィールドがあればここから長さ情報が並び始めます。ORIGINは、メタデータ(可変長フィールドの長さ情報・NULLビットマップ・レコードヘッダー)とフィールドデータ本体とのあいだの境界で、フィールド1のデータはORIGINのすぐ下(アドレスの大きい側)から始まります。
図にある「可変長フィールドの長さ情報」は、VARCHARやTEXTのように値のバイト数が行ごとに変わるカラムの長さを記録したメタデータで、NULLビットマップよりもさらにアドレスの小さい側(record start寄り)に置かれます。可変長フィールドが0個なら、この領域そのものが存在せず、レコードはNULLビットマップから始まります。
なぜデータ領域を割り当てないのか
NULLは値がない状態なので、そもそも格納する値がありません。仮にデータ領域を割り当ててしまうと、「0が入っているフィールド」と「NULLのフィールド」の区別が難しくなり、解釈が曖昧になります。ビットやオフセットのフラグで「NULLである」と明示すれば、データ領域を一切使わずに状態を伝えられ、区別も明確です。加えて、NULLの多い行ではデータを省けるぶんレコードが小さくなり、1ページに載る行数を増やせます。
ALTER TABLE ADD COLUMNとビットマップ
ALTER TABLE ... ADD COLUMNでは、ALGORITHM=INSTANTによってテーブル全体の再構築を避けられる場合があります(MySQL 8.0.29以降は追加位置の制約も緩和されています)。この方式をInstant ADDと呼びます。既存の行は物理的に書き換えず、新カラムの値はデフォルトとして参照され、新しく挿入する行だけが新カラムを物理レコードに含めます。一方、再構築が行われる場合は、全レコードを新しい構造で書き直すため、NULLビットマップも新スキーマに合わせて作り直されます。
ここが少しややこしい点です。同じDEFAULT NULLの新カラムでも、その行がInstant ADDの前に挿入されたか後かで、NULLの表し方が変わります。ADD前からある既存の行には、物理レコードに新カラムの領域がなく、NULLビットマップにもそのカラム用のビットがありません。そのため、ビットマップではNULLを表せず、別の方法で補います。一方、ADD後に挿入された行には新カラムが物理レコードに含まれるので、通常のNullableカラムと同じくビットマップのビットでNULLを表します。
メタデータの登録
Instant ADDされたカラムのデフォルト値は、カラム定義に紐づくデフォルト情報として保持されます。保持場所は、永続的にはデータ辞書(テーブル定義を格納するシステム領域)、実行時にはテーブルを開いたときメモリへ読み込まれる辞書キャッシュ内のカラム定義です。DEFAULT NULLの場合は、長さをUNIV_SQL_NULL、データへのポインタを無効とする形で登録され、後続のオフセット構築やフィールド取得のときに参照されます。
既存レコード(Instant ADD前に挿入されたもの)
既存レコードの物理データには新カラムの領域がありません。レコードを読むたびに作られるオフセット配列(各フィールドの位置やNULLかどうかを保持するメモリ上の構造)を構築する処理で、そのカラムが物理レコード内にないと分かると、インデックス側のデフォルト情報を参照してオフセットを決めます。デフォルトがNULLなら、該当フィールドに「デフォルト値である」ことと「NULLである」ことを示すフラグ(REC_OFFS_DEFAULTとREC_OFFS_SQL_NULL)が付きます。物理レコード上のビットマップは書き換えず、オフセット配列のフラグで「デフォルトでありNULL」を表現し、フィールド取得時にNULL相当の値を返します。
このオフセット配列は永続化されず、SELECTで行を返すとき・レコード比較時・特定フィールドの取得時など、物理レコードにアクセスするたびにメモリ上に作り直されます。
新規レコード(Instant ADD後に挿入されたもの)
新規挿入では、新カラムを含めた物理レコードが作られます。InnoDBはページへ書き込む前に、挿入する行を、各フィールドの値と「その値がNULLかどうか」をまとめた中間的な形でメモリ上にいったん保持します(InnoDB内部ではタプルと呼ばれる構造で、ページ上の物理レコードとは別物です)。これをページ上の物理レコードへ変換するとき、Nullableなフィールドを先頭から順に見ていき、NULLのフィールドがあればNULLビットマップの対応するビットを1にします。DEFAULT NULLの新カラムがNULLで挿入される場合も、通常のNullableフィールドと同じように該当ビットが1になり、データ部にはそのフィールドを置かず、ビットマップだけでNULLを表します。
インデックスでのNULL
主キー(クラスタ化インデックス)
主キーのカラムには通常NOT NULL制約が課されます。主キーがNULLになり得ると、B-treeのキー順序や一意性の保証に支障が出るため、InnoDBは主キーにNULLを含むテーブル定義を基本的に許容しません。MySQLのCREATE TABLEでも、主キーはデフォルトでNOT NULLになります。
セカンダリインデックスでのNULL
セカンダリインデックスのキーには、Nullableなカラムを含められます。ここで誤解しやすいのは、UNIQUE制約が付いていればNULLも1行しか入れられない、と思ってしまう点です。実際にはそうではなく、UNIQUEなセカンダリインデックスでも、キー列のいずれかがNULLの行は何行でも挿入できます(キー列がすべてNULLの場合も含みます)。一意性チェックではNULL同士を「等しくない」とみなすため、NULLを含むキーは重複と判定されないからです。実装内部では、比較時にnulls_unequalやハンドラ側のHA_NULL_ARE_EQUALといったフラグで、NULL同士を等価とみなすかどうかが切り替わります。MySQLの通常のUNIQUE制約は、上記のとおりNULL同士を等しくない扱いにします。
レコード比較でのNULL
レコード同士を比較するとき、どちらかのフィールドがNULLだと特別な処理になります。両方がNULLなら、nulls_unequalが有効なときは「等しくない」とし、そうでなければ比較を続けます。一方がNULLで他方が非NULLのときは、インデックスカラムが昇順(ASC)ならNULLを最小として扱い、ソート順ではNULLが先に来ます。降順(DESC)で定義されているときは逆にNULLを最大として扱い、NULLが後ろに来ます。