今日は、DBで日付にはDATE型?VARCHAR型どっちの日。
初めに
この記事はMySQL Advent Calendar 2020の14日目の記事です。
アドベントカレンダー参加は、初めてなので至らないところもあるとは思いますが、お手柔らかにお願いします。
とても長い内容となってしまったので、結論だけご覧になりたい方は。
目次から〆や各まとめに飛んでください。
追記:2021年6月19日
公開後様々な声があったのでまとめて別の記事にしています。
目次
とある日
データベースで日付を格納するときに、VARCHAR型を使用するという話を聞いた。
初めて聞いたときは、耳を疑った。
日付を格納するDATE型が存在するのに、VARCHAR型で格納するのかと。
それについて気になったので、それぞれのメリット・デメリットなどを調べていきたいと思います。
ちなみに皆さんは、日付を格納するときのColumnにはどの型を使用しますか?
DATE型ですか?それとも、VARCHAR型ですか?
私は、DATE型です。
環境
今回それぞれ使用するアプリケーション等のバージョンを明記しています。
VirtualBox内の仮想環境下で、MySQLを構築しています。
Oracle VM VirtualBox
| 項目名 | 値 |
|---|---|
| バージョン | 6.0.14 r133895 (Qt5.6.2) |
DBeaver
| 項目名 | 値 |
|---|---|
| バージョン | 7.2.0.202008302047 |
ホストPC
| 項目名 | 値 |
|---|---|
| OS | Windows 10 Home |
| CPU | Intel(R) Core(TM) i7-8550U CPU @ 1.80GHz |
| メモリ | 8.0 GB |
ゲストPC
VirtualBoxの仮想環境内に、テスト環境を構築しています。
OS
mysql> \! cat /etc/os-release NAME="CentOS Linux" VERSION="8 (Core)" ID="centos" ID_LIKE="rhel fedora" VERSION_ID="8" PLATFORM_ID="platform:el8" PRETTY_NAME="CentOS Linux 8 (Core)" ANSI_COLOR="0;31" CPE_NAME="cpe:/o:centos:centos:8" HOME_URL="https://www.centos.org/" BUG_REPORT_URL="https://bugs.centos.org/" CENTOS_MANTISBT_PROJECT="CentOS-8" CENTOS_MANTISBT_PROJECT_VERSION="8" REDHAT_SUPPORT_PRODUCT="centos" REDHAT_SUPPORT_PRODUCT_VERSION="8"
メモリ
2048MB
ストレージ
20.00 GB
MySQL
mysql> status; -------------- mysql Ver 8.0.21 for Linux on x86_64 (Source distribution) Connection id: 27 Current database: hobby Current user: root@localhost SSL: Not in use Current pager: stdout Using outfile: '' Using delimiter: ; Server version: 8.0.21 Source distribution Protocol version: 10 Connection: Localhost via UNIX socket Server characterset: utf8mb4 Db characterset: utf8mb4 Client characterset: utf8mb4 Conn. characterset: utf8mb4 UNIX socket: /var/lib/mysql/mysql.sock Binary data as: Hexadecimal Uptime: 11 hours 56 min 31 sec Threads: 5 Questions: 1817 Slow queries: 0 Opens: 409 Flush tables: 3 Open tables: 312 Queries per second avg: 0.042
概要
まず、今回の主役であるDATE型とVARCHAR型について軽く調べます。
日付型(DATE型)
この
DATE型は、日付部分はあるが時間部分はない値に使用されます。MySQLはDATE値を取得し、 形式で表示 し ます。サポートされる範囲は 〜です。'*YYYY-MM-DD*'``'1000-01-01'``'9999-12-31'
※日付を扱う型は他にもありますが、今回はDATE型だけを題材にしたいと思います。
現在日付表示(DATE型)
SELECT CURDATE(); -- CURDATE() | -- ----------| -- 2020-11-05|
サーバーSQLモードについて
存在しない日付についての振る舞いにを判別するモードが存在します。
サーバーでは、月と日の値が有効である必要があり、それぞれ1から12および1から31の範囲だけではありません。strictモードを無効に
'2004-04-31'する'0000-00-00'と、などの無効な日付 がに変換され 、警告が生成されます。厳密モードを有効にすると、無効な日付でエラーが発生します。このような日付を許可するには、を有効にしALLOW_INVALID_DATESます。詳細については 、5.1.11項「サーバーSQLモード」を参照してください。
今回は、デフォルト状態で無効な日付でエラーを発生させます。
↓確認
SELECT @@GLOBAL.sql_mode; -- @@GLOBAL.sql_mode | -- ---------------------------------------------------------------------------------------------------------------------| -- ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION|
↓一部抜粋解説
NO_ZERO_IN_DATE
NO_ZERO_IN_DATEモードは、年の部分は非ゼロであるが月または日の部分が 0 である日付をサーバーが許可するかどうかに影響します。(このモードは'2010-00-01'や'2010-01-00'などの日付に影響しますが、'0000-00-00'には影響しません。サーバーが'0000-00-00'を許可するかどうかを制御するには、NO_ZERO_DATEモードを使用してください。)NO_ZERO_IN_DATEの影響は、厳密 SQL モードが有効かどうかにも依存します。
- このモードが有効でない場合、ゼロ部分を含む日付は許可され、挿入によって警告が生成されません。
- このモードが有効な場合、ゼロ部分を含む日付は
'0000-00-00'として挿入され、警告が生成されます。
create table no_zero_data ( test_date date ); INSERT into no_zero_data(test_date) values('2010-00-01'); -- SQLエラー [1292] [22001]: Data truncation: Incorrect date value: '2010-00-01' for column 'test_date' at row 1
NO_ZERO_DATE
NO_ZERO_DATEモードは、サーバーが'0000-00-00'を有効な日付として許可するかどうかに影響します。この影響は、厳密 SQL モードが有効かどうかにも依存します。
- このモードが有効でない場合、
'0000-00-00'は許可され、挿入によって警告が生成されません。- このモードが有効な場合、
'0000-00-00'は許可され、挿入によって警告が生成されます。
create table no_zero_data ( test_date date ); INSERT into no_zero_data(test_date) values('0000-00-00'); -- SQLエラー [1292] [22001]: Data truncation: Incorrect date value: '0000-00-00' for column 'test_date' at row 1
文字列型(VARCHAR型)
※文字列を扱う型は他にもありますが、今回はVARCHAR型だけを題材にしたいと思います。
今回は、バイト計算なども行いたいので最大長は、最低限の値を指定することにします。
VARCHARカラム内の値は可変長の文字列です。長さは 0 から 65,535 までの値で指定できます。VARCHARカラムの有効な最大長は、最大行サイズ (65,535 バイト、すべてのカラムで共有される) と使用される文字セットによって決まります。セクションD.10.4「テーブルカラム数と行サイズの制限」を参照してください。
VARCHAR カラムの場合、使用している SQL モードに関係なく、カラム長を超える末尾のスペースは挿入前に切り捨てられ、警告メッセージが表示されます。
現在日付表示(VARCHAR型)
今回VARCHAR型で値を保持する場合は、「'%Y%m%d'」この形式で値を保持します。
SELECT DATE_FORMAT( CURDATE() , '%Y-%m-%d' ),DATE_FORMAT( CURDATE() , '%Y%m%d' ); -- DATE_FORMAT( CURDATE() , '%Y-%m-%d' )|DATE_FORMAT( CURDATE() , '%Y%m%d' )| -- -------------------------------------|-----------------------------------| -- 2020-12-09 |20201209 |
サーバーSQLモードについて
厳密な SQL モードが有効でない場合に、
CHARまたはVARCHARカラムにその最大長を超える値を割り当てると、その値はカラムの最大長に合わせて切り捨てられ、警告メッセージが表示されます。スペース以外の文字の切り捨てに関しては、厳密な SQL モードを使用することで、警告ではなくエラーを発生させて、その値の挿入を抑制できます。セクション5.1.7「サーバー SQL モード」を参照してください。
create table varchar_data ( test_date varchar(5) ); INSERT into varchar_data(test_date) values('201000 '); -- SQLエラー [1406] [22001]: Data truncation: Data too long for column 'test_date' at row 1 INSERT into varchar_data(test_date) values('2010 '); INSERT into varchar_data(test_date) values('20100 '); INSERT into varchar_data(test_date) values('2'); select test_date,LENGTH(test_date) from varchar_data ; -- test_date|LENGTH(test_date)| -- ---------|-----------------| -- 2010 | 5| -- 20100 | 5| -- 2 | 1|
2つの型を比較(本題)
それぞれの型についての解説を終えたのでここから本題です。
DATE型とVARCHAR型どちらが日付を格納するのにふさわしいのかを調べます。
自分が考えついた比較の種類として下記の3つが挙げれます。
- 不正データ
- INDEX
- ストレージ要件
他にもあるとは思いますが、今回はこの3つについて考えます。
不正データ
不正データは、概要でも少し触れましたが、日付として不適切なデータを許可するかどうかについてです。
DATE型
成功時と失敗時に分かれてデータを挿入していく。
単純なテーブル構造。
create table Illegal( test date );
成功
区切り文字は、数字以外であれば使用可能。
もしくは、区切り文字なくても可能。
INSERT into Illegal(test) values('20200229');-- 区切り文字なし INSERT into Illegal(test) values('2020:03:29');-- 区切り文字を「:」 INSERT into Illegal(test) values('2020/04/29');-- 区切り文字を「/」 INSERT into Illegal(test) values('2020/05:29');-- 区切り文字を「/」と「:」複合 select * from Illegal; -- test | -- ----------| -- 2020-02-29| -- 2020-03-29| -- 2020-04-29| -- 2020-05-29|
失敗
他にも様々な、無効な日付があるとは思いますが3つを挿入。
INSERT into Illegal(test) values('2019-02-29');-- 無効な日付 -- SQLエラー [1292] [22001]: Data truncation: Incorrect date value: '2019-02-29' for column 'test' at row 1 INSERT into Illegal(test) values('2020229');-- 月を「0」paddingしていない -- SQLエラー [1292] [22001]: Data truncation: Incorrect date value: '2020229' for column 'test' at row 1 INSERT into Illegal(test) values('2020005029');-- 区切り文字を「0」 -- SQLエラー [1292] [22001]: Data truncation: Incorrect date value: '2020005029' for column 'test' at row 1
VARCHAR型
同じ用に、成功時と失敗時に分かれてデータを挿入していく。
単純なテーブル構造。
今回のテストでは、最大長を「8」と指定する。
今回VARCHAR型で値を保持する場合は、「'%Y%m%d'」この形式で値を保持します。
create table Illegal( test varchar(8) );
成功
DATE型ではできなかったデータたちも挿入可能データが存在する。
INSERT into Illegal(test) values('20200229');-- 有効な日付 INSERT into Illegal(test) values('20200230');-- 無効な日付 INSERT into Illegal(test) values('2020229');-- 月を「0」paddingしていない select * from Illegal; -- test | -- --------| -- 20200229| -- 20200230| -- 2020229 |
失敗
基本的は、文字制限を超えたものが失敗している。
INSERT into Illegal(test) values('2019-02-29');-- 無効な日付 INSERT into Illegal(test) values('2020:03:29');-- 区切り文字を「:」 INSERT into Illegal(test) values('2020/04/29');-- 区切り文字を「/」 INSERT into Illegal(test) values('2020/05:29');-- 区切り文字を「/」と「:」複合 INSERT into Illegal(test) values('2020005029');-- 区切り文字を「0」 -- SQLエラー [1406] [22001]: Data truncation: Data too long for column 'test' at row 1
不正データまとめ
DATE型に、軍配があがると思われる。
VARCHAR型を使用する場合は、アプリケーションかCHECK制約等で何かしらの制限を掛ける必要が出てくる。
しかし、すべてのシステムの日付情報が一定のフォーマットでおくられるともかぎらない。
なので、VARCHAR型だとそのあたりを柔軟に対応することが可能となる。
INDEX
やはり、外せないのが検索に関するINDEXについて。
行うのは単純な検索だが、INDEXが効くのかどうかや、どれぐらい時間がかかるかをテストしていく。
データ生成
今回は、DATE型とVARCHAR型の2つのテーブルを作成します。
両者で違うのは、日付を扱うColumnの型だけです。
DATE型のテストデータを作成後それを、そのままコピーしてVARCHAR型のテストデータを作成します。
今回はお試しなので、100万件ぐらい生成します。
まず、テストデータ生成するのに100件のナンバリングされたデータを作成します。
CREATE TABLE temp( id int8 PRIMARY KEY AUTO_INCREMENT ); INSERT into temp() select NULL from information_schema.COLUMNS limit 100; select id, COUNT(*) OVER () AS id_count from temp limit 5; -- id|id_count| -- --|--------| -- 1| 100| -- 2| 100| -- 3| 100| -- 4| 100| -- 5| 100|
DATE型テーブル作成
CREATE TABLE date_version( id int8 PRIMARY KEY AUTO_INCREMENT, start_date date NOT NULL, end_date date NOT NULL );
テストデータ作成。
ランダムな日付を生成します。
INSERT INTO date_version (start_date, end_date) SELECT CURDATE() - INTERVAL FLOOR(RAND() * 100000) DAY, CURDATE() - INTERVAL FLOOR(RAND() * 100000) DAY FROM temp, temp AS temp2, temp AS temp3;
約10秒ぐらいかかった。
挿入できたか確認。
SELECT start_date, COUNT(*) OVER () AS date_count FROM date_version dv LIMIT 5; -- start_date|date_count| -- ----------|----------| -- 1876-07-13| 1000000| -- 1911-01-06| 1000000| -- 1779-08-01| 1000000| -- 1983-10-09| 1000000| -- 1844-03-04| 1000000|
VARCHAR型テーブル作成
CREATE TABLE var_version( id int8 PRIMARY KEY AUTO_INCREMENT, start_var varchar(8) NOT NULL, end_var varchar(8) NOT NULL );
テストデータ挿入。
date_versionテーブルのデータをそのまま使用します。
型変換を忘れずに!
INSERT INTO var_version (id,start_var,end_var) SELECT id, DATE_FORMAT(start_date , '%Y%m%d'), DATE_FORMAT(end_date , '%Y%m%d') FROM date_version;
確認。
SELECT start_var, COUNT(*) OVER () AS var_count FROM var_version vv LIMIT 5; -- start_var|var_count| -- ---------|---------| -- 18760713 | 1000000| -- 19110106 | 1000000| -- 17790801 | 1000000| -- 19831009 | 1000000| -- 18440304 | 1000000|
INDEX作成
まずは、star_*のColumnに対して、INDEXを貼ります。
CREATE INDEX date_version_index_start_date ON date_version (start_date); CREATE INDEX var_version_index_start_var ON var_version (start_var);
date_version INDEX
mysql> show index from date_version\G; *************************** 1. row *************************** Table: date_version Non_unique: 0 Key_name: PRIMARY Seq_in_index: 1 Column_name: id Collation: A Cardinality: 0 Sub_part: NULL Packed: NULL Null: Index_type: BTREE Comment: Index_comment: Visible: YES Expression: NULL *************************** 2. row *************************** Table: date_version Non_unique: 1 Key_name: date_version_index_start_date Seq_in_index: 1 Column_name: start_date Collation: A Cardinality: 99310 Sub_part: NULL Packed: NULL Null: Index_type: BTREE Comment: Index_comment: Visible: YES Expression: NULL 2 rows in set (0.00 sec) ERROR: No query specified
var_version INDEX
mysql> show index from var_version\G *************************** 1. row *************************** Table: var_version Non_unique: 0 Key_name: PRIMARY Seq_in_index: 1 Column_name: id Collation: A Cardinality: 0 Sub_part: NULL Packed: NULL Null: Index_type: BTREE Comment: Index_comment: Visible: YES Expression: NULL *************************** 2. row *************************** Table: var_version Non_unique: 1 Key_name: var_version_index_start_var Seq_in_index: 1 Column_name: start_var Collation: A Cardinality: 98960 Sub_part: NULL Packed: NULL Null: Index_type: BTREE Comment: Index_comment: Visible: YES Expression: NULL 2 rows in set (0.00 sec)
いざ検索!!
statr_*2014年1月1日から2014年12月31日までのデータを取得します。
date_version 実行
SELECT * FROM date_version WHERE start_date BETWEEN '2014-01-01' AND '2014-12-31' ;
結果。
3773 rows in set (0.38 sec)
なかなかの速さですね。
date_version 実行計画
mysql> explain SELECT -> * -> FROM -> date_version -> WHERE -> start_date BETWEEN '2014-01-01' -> AND '2014-12-31'\G; *************************** 1. row *************************** id: 1 select_type: SIMPLE table: date_version partitions: NULL type: range possible_keys: date_version_index_start_date key: date_version_index_start_date key_len: 3 ref: NULL rows: 3773 filtered: 100.00 Extra: Using index condition 1 row in set, 1 warning (0.00 sec) ERROR: No query specified
type: rangeで範囲インデックスが使用されている。
possible_keys: date_version_index_start_dateで作成したINDEXが使用されているのが確認できる。
var_version 実行
SELECT * FROM var_version WHERE date_format(start_var, '%Y-%m-%d') BETWEEN '2014-01-01' AND '2014-12-31' ;
結果。
3773 rows in set (0.96 sec)
date_versionとはかなり差が出ました。
var_version 実行計画
mysql> explain SELECT -> * -> FROM -> var_version -> WHERE -> date_format(start_var, '%Y-%m-%d') BETWEEN '2014-01-01' -> AND '2014-12-31' -> \G; *************************** 1. row *************************** id: 1 select_type: SIMPLE table: var_version partitions: NULL type: ALL possible_keys: NULL key: NULL key_len: NULL ref: NULL rows: 997425 filtered: 100.00 Extra: Using where 1 row in set, 1 warning (0.00 sec) ERROR: No query specified
type: ALLなので、フルスキャンを行ってますね。
WHERE句で、型変換を行っているのでINDEXは効かない。
rows: 997425めっちゃ読み込んでる。
INDEXまとめ
DATE型 |
VARCHAR型 |
|
|---|---|---|
| 時間 | 3773 rows in set (0.38 sec) | 3773 rows in set (0.96 sec) |
| INDEX | type: range |
type: ALL |
100万行のテーブルに対して、検索列にINDEXを貼って検索すると、DATE型のほうが速く、INDEXも適切に使用されている。
今後データが増えても、検索時間の短縮を行える。
検索クエリにて、VARCHAR型では、date_formatによる型変換を行っているため毎回指定のフォーマットに変換する作業が存在する。
検索のINDEXに関しても、DATE型に軍配があがると結果となった。
ストレージ要件
日付型と文字列型のテーブルの容量はどうなるのかについて調べます。
文字列型と日付型のストレージ要件
文字列型のストレージ要件と日付と時間型の要件の違い。
| データ型 | 必要なストレージ |
|---|---|
VARCHAR(M)、VARBINARY(M) |
カラム値が 0 から 255 バイトを必要とする場合は、L + 1 バイト、値が 255 バイト以上を必要とする可能性のある場合は、L + 2 バイト |
DATE |
3 バイト |
VARCHAR型は、今回作成したvar_versionのstart_varでは「8+1」で9バイトとなる。
DATE型は、今回作成したdate_versionのstart_dateでは3バイトとなる。
それぞれのテーブルに対してのストレージ要件を導き出したいと思います。
mysql> show create table date_version\G; *************************** 1. row *************************** Table: date_version Create Table: CREATE TABLE `date_version` ( `id` bigint NOT NULL AUTO_INCREMENT, `start_date` date NOT NULL, `end_date` date NOT NULL, PRIMARY KEY (`id`), KEY `date_version_index_start_date` (`start_date`) ) ENGINE=InnoDB AUTO_INCREMENT=1048561 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci 1 row in set (0.00 sec)
mysql> show create table var_version\G; *************************** 1. row *************************** Table: var_version Create Table: CREATE TABLE `var_version` ( `id` bigint NOT NULL AUTO_INCREMENT, `start_var` varchar(8) NOT NULL, `end_var` varchar(8) NOT NULL, PRIMARY KEY (`id`), KEY `var_version_index_start_var` (`start_var`) ) ENGINE=InnoDB AUTO_INCREMENT=1000001 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci 1 row in set (0.00 sec)
INFORMATION_SCHEMATABLES
The INFORMATION_SCHEMA TABLES Table
この
TABLESテーブルは、データベース内のテーブルに関する情報を提供します。
このテーブルで、date_versionとvar_versionのテーブル容量を見ることができます。
INFORMATION_SCHEMA TABLES Table Columnの詳細。
| カラム名 | 備考 |
|---|---|
| TABLE_NAME | テーブルの名前。 |
| TABLE_ROWS | 行数。この値は概算値であり、実際の値から40%から50%も異なる場合があります。 |
| DATA_FREE | 割り当てられているが未使用のバイト数。 |
| INDEX_LENGTH | INDEX_LENGTH は非クラスター化インデックスに割り当てられたおおよそのスペースの量(バイト単位)です。 |
| DATA_LENGTH | DATA_LENGTH はクラスター化インデックスに割り当てられたおおよそのスペース量(バイト単位)です。 |
| AVG_ROW_LENGTH | 行の平均の長さ。 |
var_version と date_version のテーブルサイズ
SELECT TABLE_NAME, TABLE_ROWS, DATA_FREE, INDEX_LENGTH, DATA_LENGTH, AVG_ROW_LENGTH FROM information_schema.tables WHERE table_schema="hobby" AND (table_name = 'var_version' or table_name = 'date_version') ORDER BY (data_length+index_length) DESC; -- TABLE_NAME |TABLE_ROWS|DATA_FREE|INDEX_LENGTH|DATA_LENGTH|AVG_ROW_LENGTH| -- ------------|----------|---------|------------|-----------|--------------| -- var_version | 997425| 4194304| 0| 48840704| 48| -- date_version| 998547| 4194304| 0| 35192832| 35|
| 項目 | date_version | var_version |
|---|---|---|
| TABLE_ROWS | 997425 | 998547 |
| DATA_FREE | 41943040 | 4194304 |
| INDEX_LENGTH | 0 | 0 |
| DATA_LENGTH | 48840704 | 35192832 |
| AVG_ROW_LENGTH | 48 | 35 |
項目値を見る限り、var_versionのほうが少ない容量となっている。
ストレージ要件まとめ
それぞれの型のストレージ要件では、DATE型のほうがストレージ容量が少ないと思ったのですが、予想とは反対の結果が、INFORMATION_SCHEMA TABLES Tableのデータによって証明された。
行の平均の長さがわかるAVG_ROW_LENGTHの列と、クラスター化インデックスに割り当てられたおおよそのスペース量のDATA_LENGTHの2つとも、var_versionテーブルの方が少ない結果となった。
なぜ、予想と反した結果となったのかはわからない。
教えて下さい。m(_ _)m
〆
今回3つの視点からDATE型とVARCHAR型のメリット・デメリットを調べてみた。
- 不正データを、格納したくないなら、
DATE型を。 - INDEXを活用してクエリを速くしたいのであれば、
DATE型を。 - ストレージ要件を低くしたいのであれば、
VARCHAR型を。
という調査結果となった。
調べる前と変わらないが、日付を扱うのであれば、DATE型を使用することを推奨します。
もちろん、様々な要件によって決まることなので一概には言えないことではあります。
もし、日付を格納のためにVARCHAR型を使用している方がいるのでしたら、理由をぜひお聞かせ願いたいです。
長い内容となりましたが、読んでいただいてどうもありがとうございます!!!
12/21のRDBMS-GIS(MySQL,PostgreSQLなど) Advent Calendar 2020にも参加します。
もしご興味があれば、読んでください。
参考文献
https://dev.mysql.com/doc/refman/5.6/ja/storage-requirements.html
https://qiita.com/Kota0114/items/cd0a22424f5c80358aec
https://dev.mysql.com/doc/refman/5.6/ja/datetime.html
https://dev.mysql.com/doc/refman/8.0/en/information-schema-tables-table.html
https://dev.mysql.com/doc/refman/5.6/ja/storage-engines.html