MySQLの正規表現でタイムアウト(Error Code 3699)が発生する原因と対処法
作成日:2026.10.17
MySQLの正規表現で発生する「Error Code 3699」の原因と対処法を解説します。SQLやパターンの見直し、一時テーブルを使った検索と整形の分離、最終手段としてのregexp_time_limitの変更と注意点を紹介します。
目次
MySQLで検索や文字列の整形に正規表現を使っていると、次のエラーが発生することがあります。
Error Code: 3699. Timeout exceeded in regular expression match.
私は、最初の検索と最終出力に正規表現を多用したSQLで遭遇しました。対象はインデックスのないテーブルでしたが、それだけで原因を決められるわけではありません。
今回は、エラーが発生する一般的な原因と、SQLの見直し、一時テーブルに抜き出してから整形する方法を説明します。設定値の変更は、最後の選択肢として扱います。
3699は正規表現の処理制限によるエラー
3699のエラー名はER_REGEXP_TIME_OUTです。正規表現のマッチ処理が制限に達したことを示します。SQLクライアントの待ち時間や、SQL全体の実行時間の上限とは別の制限です。
MySQLでは、regexp_time_limitで正規表現の処理の上限を制御します。名前にtimeとありますが、MySQL公式資料ではマッチエンジンの処理ステップ数による制限と説明されています。「32ならSQL全体を32ミリ秒以内にする」という設定ではありません。詳しくは、MySQL公式の正規表現のリソース制御を参照してください。
正規表現をたくさん書いたから、その回数の合計で必ず3699になる、というものでもありません。まずは、どの入力とパターンの組み合わせで処理が重くなっているかを調べます。
一致しない文字列の判定に時間がかかる場合がある
正規表現は、長い文字列や複雑なパターンを扱うと処理が重くなることがあります。特に、繰り返しを重ねたパターンでは、一致しないことを確かめるまでに多くの組み合わせを試す場合があります。
例えば、次のようなパターンです。
(A+)+B
A+はAの1回以上の繰り返しで、その外側にも+があります。つまり、Aが1文字以上続き、その直後にBがある部分にマッチします。具体例は次のとおりです。
| 対象文字列 | マッチする部分 | 理由 |
|---|---|---|
AB | AB | Aが1文字あり、その直後にBがある。 |
AAAB | AAAB | Aが3文字続き、その直後にBがある。 |
XAAABY | AAAB | 先頭・末尾を指定していないため、文字列の途中にもマッチする。 |
AAAAC | なし | Aは続いているが、その直後がBではなくC。 |
B | なし | Bの直前に必要なAがない。 |
AAABのAの部分は、内側のA+でまとめてAAAと読むことも、外側の繰り返しを使ってA / AAやA / A / Aと分けて読むこともできます。Aが長く続いた後にBではない文字が来ると、このような分け方を何度も試す可能性があります。
対象行が多ければSQL全体の負荷も増えますが、1行の入力だけでも重いパターンは問題になります。「件数を減らせば必ず解決する」とは考えない方がよいでしょう。
まずはSQLと正規表現を見直す
検索と出力のどちらで失敗するか分ける
WHERE句で正規表現による検索を行い、SELECT句でも抽出や置換をしているなら、最初から全部まとめて調べると原因を追いにくくなります。
- 出力用のREGEXP_SUBSTRやREGEXP_REPLACEを外し、IDなどだけを取得して検索部分を調べます。
- 検索できたら、出力用の正規表現を一つずつ戻します。
- 問題が起きる処理は、IDの範囲などで対象を分け、入力の長さや特徴を調べます。
長い入力だけでなく、一致しない入力、NULL、空文字、日本語や改行を含む入力も確認します。単にLIMITを付けるだけでは、どの行が評価されるか、重い処理を避けられるかまでは保証できません。
正規表現を使わなくてよい処理を減らす
固定の状態コードを選ぶだけならWHERE status = 'ready'のような等価比較、決まった文字の置換ならREPLACE、区切り文字での抽出ならSUBSTRING_INDEXで書けないか検討します。
SELECT
REPLACE('BOOK-2026-001', '-', '/') AS replaced_code,
SUBSTRING_INDEX('BOOK-2026-001', '-', 1) AS prefix;
期待結果は、replaced_codeがBOOK/2026/001、prefixがBOOKです。
ただし、書き換え前後で大小文字、照合順序、末尾空白、NULLの扱いが一致するかは確認します。例えば「空白文字をまとめる処理」を「半角スペース一つを置換する処理」に変えても、同じ結果にはなりません。文字列関数の基本は、TRIM・SUBSTRING_INDEX・REGEXP_SUBSTRの記事でも説明しています。
パターンの曖昧さと、評価する対象を減らす
先ほどの(A+)+Bで単に「Aが1回以上続いてBが来る部分」を探したいだけなら、A+Bで足りないか検討できます。キャプチャの使い方なども含め、必要な結果を保てるか確認してから変更します。
.*を広く使うより、区切り文字や文字種が分かっている部分を具体的に書くことも検討します。非貪欲に変えるだけで、常に速くなるわけではありません。また、既存データの末尾を勝手に切り捨てると、検索や抽出の結果が変わるので注意が必要です。
SQL側では、状態や日付など、正規表現を使わない条件で対象を絞れるか調べます。インデックスは、その条件に使えるかを実行計画で確認します。文字列の列へインデックスを付ければ、そのまま任意の正規表現検索が速くなるわけではありません。
WHERE句に条件を書く順番を変えても、その順に評価されるとは保証されません。実行計画の読み方は、MySQL公式のEXPLAINの説明を参照してください。SQL全体の走査コストと、問題のある文字列に対する正規表現の処理は分けて調べましょう。
一時テーブルへ抜き出してから整形する
検索と出力用の整形を分けたい場合は、検索結果を一時テーブルへ保存し、次のSQLで整形する方法があります。どの行を整形するかが確定し、検索と整形を別々に確認できるのが利点です。
MySQLの一時テーブルはCREATE TEMPORARY TABLEで作成します。作成した接続からだけ参照でき、接続が閉じると自動的に削除されます。作成にはCREATE TEMPORARY TABLES権限が必要です。基本構文と制約は、MySQL公式のCREATE TEMPORARY TABLEの説明を参照してください。
説明用データを用意する
以下は説明用の例です。状態がreadyで、BOOKから始まる所定のコードがある行を選び、出力時に空白文字の連続を半角スペース一つにそろえる処理を行ないたいとします。あくまでも例なので、3699を再現するものではありません。
専用の検証用DBで、以下のテーブル名が未使用であることを確認してください。元データのテーブルも説明用の一時テーブルとして作ります。業務テーブルへの書き込みは必要ありません。
CREATE TEMPORARY TABLE regexp_demo_source (
id INT UNSIGNED NOT NULL PRIMARY KEY,
status VARCHAR(20) NOT NULL,
raw_label VARCHAR(200) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
INSERT INTO regexp_demo_source (id, status, raw_label)
VALUES
(1, 'ready', 'BOOK-2026-001 Keyboard'),
(2, 'pending', 'BOOK-2026-002 Cable'),
(3, 'ready', 'OTHER-003 Mouse'),
(4, 'ready', 'BOOK-2026-004 Monitor');
以降は同じ接続で実行します。作成・追加に失敗した場合は、そのまま後続のSQLへ進まないでください。
検索結果を保存し、その後で整形する
一つのSQLにまとめるなら、検索側と出力側の両方で正規表現を使う次の形になります。
SELECT
id,
REGEXP_REPLACE(raw_label, '[[:space:]]+', ' ') AS formatted_label
FROM regexp_demo_source
WHERE status = 'ready'
AND REGEXP_LIKE(
raw_label,
'^BOOK-[0-9]{4}-[0-9]{3}[[:space:]]',
'c'
)
ORDER BY id;
^は先頭、[0-9]{4}は半角数字4桁、[[:space:]]は空白文字です。検索ではBOOKの大小文字を区別するため'c'を指定しています。整形側の[[:space:]]+は、空白文字の連続を表します。
これを検索と整形の二つのSQLに分けます。まず、整形前の文字列をそのまま保存します。
CREATE TEMPORARY TABLE regexp_demo_matches
ENGINE=InnoDB
AS
SELECT id, raw_label
FROM regexp_demo_source
WHERE status = 'ready'
AND REGEXP_LIKE(
raw_label,
'^BOOK-[0-9]{4}-[0-9]{3}[[:space:]]',
'c'
);
SELECT COUNT(*) AS matched_rows
FROM regexp_demo_matches;
今回のデータでの期待件数は2件です。検索結果が入ったことを確認してから、出力用の整形を行います。
SELECT
id,
REGEXP_REPLACE(raw_label, '[[:space:]]+', ' ') AS formatted_label
FROM regexp_demo_matches
ORDER BY id;
| id | formatted_labelの期待結果 |
|---|---|
| 1 | BOOK-2026-001 Keyboard |
| 4 | BOOK-2026-004 Monitor |
後半のSQLは、保存済みの2行だけを参照して整形します。整形処理を調整して再実行するときも、最初の検索からやり直す必要がありません。なお、CREATE TABLE … SELECTでは元テーブルのインデックスは自動的に作られません。後続で結合や検索に使うなら、用途に応じて定義を検討します。
確認が終わったら、この例で作成した一時テーブルを削除します。
DROP TEMPORARY TABLE regexp_demo_matches;
DROP TEMPORARY TABLE regexp_demo_source;
一時テーブルにしても解決しないケース
抽出時のREGEXP_LIKE自体で3699になるなら、整形を分けるだけでは解決しません。まず正規表現を使わない条件だけで候補を別の一時テーブルへ保存し、その候補に検索用の正規表現を適用する、といった分け方の検討も選択肢に入ります。ただし、候補に残った1行のマッチ処理が重ければ、パターンや入力の見直しは必要です。
一時テーブルには作成と書き込みのコストもあります。元から問題のないSQLまで分割しても、速くなるとは限りません。分割前の結果と件数が同じか、作成から取得完了までの合計時間がどう変わったかを確認します。また、元テーブルが更新されても、既に保存した一時テーブルの内容は自動更新されません。
アプリケーションから使う場合は、作成から参照・削除まで同じDB接続を使います。接続プールで接続が残る場合もあるので、終了時やエラー時の後片付けも必要です。
なお、WITH句やサブクエリでSQLを分けて書くだけでは、同じように結果が保存されるとは限りません。MySQLのオプティマイザは、それらを外側のSQLへ統合する場合があります。処理の段階を明確に分けたいときは、派生テーブル・CTEの最適化の仕様も確認しておきましょう。
設定値の変更は最後に検討する
SQLとパターンを見直しても必要な処理が制限に達する場合は、regexp_time_limitの変更を検討します。まず、バージョンと現在値を確認します。
SELECT VERSION();
SELECT @@GLOBAL.regexp_time_limit;
SELECT @@GLOBAL.regexp_stack_limit;
MySQL 8.0のregexp_time_limitの公式定義では、既定値は32、スコープはGlobalです。このSQLだけの変更としてSET SESSIONを使うことはできず、SET_VARヒントの対象でもありません。
例えば専用の検証環境で64を試すなら、管理者と影響範囲を確認したうえで、元の値を保存してから変更します。64は説明用の値で、推奨値ではありません。
SET @saved_regexp_time_limit = @@GLOBAL.regexp_time_limit;
SELECT @saved_regexp_time_limit;
SET GLOBAL regexp_time_limit = 64;
-- ここで対象SQLの結果、実行時間、負荷を確認する
SET GLOBAL regexp_time_limit = @saved_regexp_time_limit;
SELECT @@GLOBAL.regexp_time_limit;
保存と復元は同じ接続で行います。接続断に備えて元の値を別途控え、対象SQLが失敗したときも復元を確認してください。SET GLOBALで変えた値は、クライアントの接続を閉じただけでは元に戻りません。
変更にはSYSTEM_VARIABLES_ADMINなどの管理権限が必要です。ほかの接続から実行する正規表現にも関係するため、本番で気軽に変更する設定ではありません。制限を緩めると、重いマッチ処理をより長く許すことになります。CPU負荷や同時実行への影響まで確認します。この例では、再起動後も変更を残すSET PERSISTは使っていません。
regexp_stack_limitは正規表現の内部スタック用メモリの上限で、regexp_time_limitとは役割が異なります。スタック不足のエラー3698と、今回の3699を混同して、両方をまとめて引き上げることは避けましょう。
まとめ
3699が出たら、まず検索と整形を分け、重くなるパターンと入力を調べます。別の手段で書けるところは正規表現を減らし、必要な処理は対象とパターンを見直します。
一時テーブルに検索結果を保存してから整形する方法は、処理の切り分けや再実行にも使えます。ただし、残った入力そのものが重い場合の解決策にはなりません。設定変更は最後に回し、エラーが消えたかだけでなく、結果と処理全体の負荷を確かめましょう。
参考資料
奈良市を拠点に、27年以上の経験を持つフリーランスWebエンジニア、阿部辰也です。
これまで、ECサイトのバックエンド開発や業務効率化システム、公共施設の予約システムなど、多彩なプロジェクトを手がけ、企業様や制作会社様のパートナーとして信頼を築いてまいりました。
【制作会社・企業様向けサポート】
Webシステムの開発やサイト改善でお困りの際は、どうぞお気軽にご相談ください。小さな疑問から大規模プロジェクトまで、最適なご提案を心を込めてさせていただきます。
ぜひ、プロフィールやWeb制作会社様向け業務案内、一般企業様向け業務案内もご覧くださいね。
MySQLで文字列を整形・抽出する:TRIM・SUBSTRING_INDEX・REGEXP_SUBSTRの使い方
2026.10.11
MySQLで取得する文字列から不要な空白を除いたり、区切り文字や正規表現を使って必要な部分を取り出したりする方法を紹介します。TRIM・SUBSTRING_INDEX・REGEXP_SUBSTRの基本構文と使い分けを、具体的なSQL例で解説。NULLや空文字、想定した形式に合わない値を扱う際の注意点も整理します。
MySQLのビュー(VIEW)入門:テーブルとの違いと使いどころ
2026.10.18
MySQLのビュー(VIEW)とテーブルの違い、ビューが役立つ場面を解説します。CREATE VIEWによる作成方法を、注文データの抽出・集計例で紹介。元データの変更との関係や、更新・性能・参照権限の注意点も整理します。
MySQLのCREATE TABLE LIKEでテーブル構造をコピーする:使い方と注意点
2026.10.16
MySQLのCREATE TABLE LIKEで、既存テーブルの構造を基に空テーブルを作る方法を解説します。列やインデックスの確認、外部キーが引き継がれない点に加え、INSERT SELECTによるデータのコピー方法も紹介します。
MySQLのDATE_FORMATとTIME_TO_SEC:日時の表示と時間の数値化
2026.10.13
MySQLのDATE_FORMATで日時の表示を整える方法と、TIME_TO_SECで時間を秒数へ変換する方法を解説します。入力と結果を比較しながら、月と分の書式指定、NULL、負の時間、24時間を超える時間の扱いも紹介します。