MySQL 1226 max_connections_per_hour:共用サーバーでの対処
Python cronがMySQL 1226(1時間500接続)で落ちたのに、localhost経由のPHP APIは1時間約1,500接続でも無事だった。上限はuser@host単位。同じサーバーのジョブはlocalhostで接続し、リトライは絞る。
共用サーバーで、アプリ・cron・PHPページが1つのMySQLユーザーを使い回していて、(1226, "User '<db_user>' has exceeded the 'max_connections_per_hour' resource") に出会った人向けの記録です。結論から書くと、この上限はアカウント単位、つまり「ユーザー名@ホスト」単位で数えられるので、同じユーザー名でもlocalhostから入るかリモートホスト名から入るかで別の枠になりえます。実際、localhost経由のアプリAPIは1時間約1,500接続でも1件も失敗せず、リモートホスト名経由で接続していたcronだけが500の壁に当たりました。効いた対策は、DBと同じサーバーで動くジョブはlocalhostで接続すること。狭いリトライと接続ログがそれを補います。
追記(2026-11-10 夜): 初版では「このユーザーを使うものはすべて1時間500接続の枠1つを分け合っている」と書き、アプリの利用ピークを疑っていました。ところが同じ日の夜、接続ログはアプリAPIだけで1時間約1,500接続を記録し、それでもリクエストは成功していました。枠が1つだけなら説明がつきません。診断はリモート接続経路へと変わり、以下の各節を書き直しました。初版の誤りは該当箇所で訂正しています。
まだ分かっていない部分もあり、そこは分からないと書きます。
何が落ちたか
環境はHostingerのWebホスティング(LiteSpeed)で、サーバー時刻はUTCです。別のWebサイトの更新処理がPython 3.11+PyMySQLで書かれていて、DBと同じサーバー上のcronで1日2回、UTCの12:00と21:00に動きます。MySQLから宝くじの抽選結果を読んでページを作り直す処理です。
2026年11月9日、UTC 12:00の回(日本時間21:00、月曜の抽選直後の夜)が、最初のステップであるDB接続で落ちました。
pymysql.err.OperationalError: (1226, "User '<db_user>' has exceeded the
'max_connections_per_hour' resource (current value: 500)")
cron自身のログから分かったこと:
- 約1か月分、1日2回の実行記録があり、失敗はこの1回だけ。
- 次のUTC 21:00の回は、何も変えずに成功。
ジョブが壊れていたわけではありません。UTC 12:00直前の1時間で何かが500の枠を使い切り、たまたまcronが壁に当たってログに残した。問題は「どの枠を、ほかに誰が使っていたか」です。
同じユーザー名、2つの入り口
同じMySQLユーザー名を使っているのは、このcronだけではありません。初版で見落としていたのは、それぞれがどこからDBに入っているかでした。
| 利用者 | 頻度 | 接続経路 |
|---|---|---|
| モバイルアプリ8本のPHP JSON API | リクエストごとに1接続 | localhost |
| WebView時代の古いPHPページ | 旧版アプリがまだ開く | localhost |
| 広告設定エンドポイント | 16本のアプリが起動ごとに呼ぶ | 同じサーバー、おそらく localhost |
| プッシュ通知cron | 5分ごと | 未確認 |
| 別サイトの更新cron(落ちたもの) | UTC 12:00と21:00 | DBサーバーのリモートホスト名(下の修正まで) |
| 外部のクローラーPC | リモートMySQLで結果を書き込む | リモートホスト名 |
cronは同じマシン上のPythonスクリプトなのに、設定ファイルには外部から接続するためのリモートホスト名が入っていました。ありがちな話で、リモートホスト名は手元のPCからもサーバーからもつながるので、ローカルで開発してサーバーにコピーしたスクリプトはそのまま残ります。最初からサーバー用に書かれたPHPは、素直に localhost です。
診断を変えた数字
後述の対策2の接続ロガーは、2026年11月10日の16:17(日本時間)から動いています。数えているのはlocalhostで接続するアプリのJSON APIだけです。
| 時間帯(日本時間) | 記録された接続数 | 500と比べて |
|---|---|---|
| 16時台(16:17から) | 559 | 超えている |
| 17時台 | 592 | 超えている |
| 18時台 | 491 | わずかに下 |
| 19時台 | 約1,557 | 約3倍 |
| 20時台 | 約1,503 | 約3倍 |
全員が500の枠1つを分け合っているなら、夜の2時間は1226だらけになっていたはずです。そうはなりませんでした。20:52に4つのAPIエンドポイントを抜き取りで叩くと、すべて200で、JSONも欠けずに返ってきました。localhost側は「上限」の3倍で普通に動いていたわけです。
残るのは、確実に失敗したことが分かっている1本の接続、つまりリモートホスト名経由のcronの接続で、メッセージには current value: 500 とありました。
分かっていること・分かっていないこと
MySQLの仕組み。 MySQLはアカウントを 'ユーザー'@'ホスト' で識別し、MAX_CONNECTIONS_PER_HOUR のようなリソース上限はアカウントに付きます。ユーザー名が同じでも、localhostからのログインと外部からのTCPログインが別のアカウント行に当たることがあります。
観測したこと。 localhost経由の接続は1時間約1,500でも成功しました。リモート経路の接続は上限500で1226になりました。ホスト側のアカウント設定は見られないので、各アカウントがどう設定されているかは主張しません。言えるのは、経路ごとにこう振る舞った、ということだけです。
最も可能性が高い説明(未確認):リモート経路のアカウント('<db_user>'@'%')に1時間500の上限があり、月曜夜にそれを使い切ったのは、抽選前後にリモートMySQLで結果を書き込む外部クローラーと、cron自身だった。クローラーの接続ログがないので、その1時間に何百回つないだかは示せません。分かっている大口のリモートクライアントはそれだけで、抽選直後というタイミングとも合いますが、あくまで推論です。
初版の訂正。 初版はアプリの利用ピークを疑っていました。上の数字はそれを否定します。アプリはlocalhostで接続しており、localhostは500をはるかに超えてもエラーを出していません。11月9日のPHPエラーログに1226が1件もなかったことも、今では怪しい点ではなく辻褄の合う点です。ただし一つ注意は残ります。古い接続関数は
if(!($Conn = mysqli_connect(HOST, USER, PASS))) { die; }
で、PHP 8.1以降の失敗時の動きはmysqliのレポートモード次第です。黙って失敗した場合はエラーログに出ません。今夜の抜き取り確認でlocalhost側の可能性は低くなりましたが、11月9日のその1時間にアプリを確認したわけではありません。
除外できたことは初版どおり、その日の自分たちの作業です。回帰テストは午後に実行していて20〜21時ではなく、APIの移設でリクエストあたりの接続数も変わっていません。
ユーザーは分けられない、でもlocalhostなら使える
上限がアカウント単位なら、教科書どおりの解決策は利用者ごとにMySQLユーザーを分けることです。しかしHostingerのWeb・クラウドホスティングの今のプランでは、DB1つにユーザー1つで、phpMyAdminにも CREATE USER や GRANT の権限がなく、SQLで回り込むこともできません。ここは初版のとおりです。
見落としていたのは、新しいユーザーなしでできるもっと安い手でした。ジョブがDBと同じサーバーで動くなら、リモートホスト名ではなくlocalhostで接続する。 そうすればクローラーのような外部クライアントとリモート側の枠を取り合わずに済みます。外で動くクローラーにはこの手は使えませんが、cronには使えます。
その前に入れた2つの変更、落ちたジョブが枠切れを乗り越えるためのリトライと、接続の行き先を知るためのログは、そのまま残しています。
対策1:cronの接続だけをリトライする
2026年11月10日に導入しました。ルールは次のとおりです。
- リトライするのはDB接続だけ、それもcronの入口からだけ。
- 対象は MySQLエラー1226(1時間あたり上限)と1040(同時接続数超過)だけ。
- 900秒待って最大3回。最長でも45分。
- 認証エラー(1045)やホストに届かないといった他のエラーは即失敗。
- 同じヘルパーを使う手動ツールは
retry=Falseで即失敗させる。人がコマンドを打っているときは、黙って45分待たされるより、すぐエラーが出るほうがいい。
実際のコードを少し削ったものです。
RETRY_MYSQL_CODES = (1226, 1040)
def lotto_conn(retry=False, waits=(900, 900, 900)):
import time
import pymysql
for attempt in range(len(waits) + 1 if retry else 1):
try:
return _lotto_connect(pymysql)
except pymysql.err.OperationalError as e:
code = e.args[0] if e.args else None
if not retry or code not in RETRY_MYSQL_CODES or attempt >= len(waits):
raise
print(" DB connection limit (%s) - retry in %ds (%d/%d)"
% (code, waits[attempt], attempt + 1, len(waits)), flush=True)
time.sleep(waits[attempt])
数秒ではなく15分待つのは、枠が1時間単位だからです。5秒後に再試行しても、ほぼ確実にまだ満杯です。15分×3回なら、試行が1時間の大部分に散らばります。
待つようにすると新しい心配が出ます。寝ている間に次の回が始まって重なるかもしれません。これはジョブを包むシェルスクリプトで防いでいます。mkdir でロックを取り、開始前に最終実行時刻を書き込み、前回から6時間空いていなければ動きません。遅れた12:00の回が最後の接続を試みるのは約45分後で、待っている間に2本目が始まることはありません。
偽のエラーを差し込んで4パターン試しました。
| ケース | 期待 | 結果 |
|---|---|---|
| 1226が2回、その後成功 | 3回目で接続 | 接続した |
| 1226が4回 | あきらめて1226を投げる | 1226を投げた |
| 1045(認証エラー) | 待たずに即エラー | 即エラー |
retry=False で1226 |
即エラー | 即エラー |
ただし、本番で本物の1226が出たときに動くことはまだ証明されていません。導入後、1226は出ていないからです。それまでは「テスト済みのロジック」であって「実績のある動作」ではない、と考えています。
対策2:レスポンスを変えずに接続数を数える
2026年11月10日に開始しました。アクセスログが見られないので、共通のPHP接続関数で、接続1回につき1行をWebルートの外の日別ファイルに書くようにしました。
function conn_log() {
$dir = dirname(__FILE__) . '/logs'; // outside the web root
if (!is_dir($dir)) { @mkdir($dir, 0700); }
$uri = isset($_SERVER['REQUEST_URI']) ? strtok($_SERVER['REQUEST_URI'], '?') : '-';
$act = isset($_GET['action']) ? preg_replace('/[^A-Za-z0-9_]/', '', (string)$_GET['action']) : '';
$line = gmdate('Y-m-d H:i:s') . "\t" . substr((string)$uri, 0, 120) . "\t" . substr($act, 0, 40) . "\n";
@file_put_contents($dir . '/conn-' . gmdate('Ymd') . '.log', $line, FILE_APPEND | LOCK_EX);
}
設計上の判断:
- 記録するのはUTC時刻、クエリ文字列を除いたパス、
actionパラメータだけ。 検索キーワードなど他のパラメータはあえて残しません。ユーザーが入力した内容そのものなので保存したくないし、ファイルも無駄に大きくなります。エンドポイント別・時間帯別に数えるならパスとactionで足ります。 - エラーは
@で握りつぶす。 ディスクが一杯でもフォルダが作れなくても、APIは以前とまったく同じ応答を返します。レスポンス本文は変わりません。 - 外すのは1行。 接続関数の中の呼び出しを消すだけです。
大事な制約があります。広告設定エンドポイントとWebViewページは共通関数ではなく独自の接続コードを使っているので、数に入りません。さらに、リモート経路の接続はこのPHP関数を通らないので、cronや外部クライアントの分はまったく見えません。このログの数字はlocalhost経路の下限値であって、ユーザー全体の合計ではありません。
対策3:まずlocalhost、だめならリモートホスト名
2026年11月10日 21:29(日本時間)に導入しました。cronはまずローカルの経路を試します。Unixソケットがあればそれを、次に 127.0.0.1 を、それぞれタイムアウト5秒で。ローカルがだめなときだけ、タイムアウト20秒でリモートホスト名に切り替えます。どの経路を使ったか(local か remote)はログに出しますが、アドレスは出しません。実際のコードを簡略化したものです。
def connect():
tries = [("local (socket)", {"unix_socket": s}) for s in SOCKETS if os.path.exists(s)]
tries.append(("local (127.0.0.1)", {"host": "127.0.0.1", "port": 3306}))
tries.append(("remote", {"host": REMOTE_HOST, "port": 3306}))
for i, (label, where) in enumerate(tries):
last = i == len(tries) - 1
try:
conn = pymysql.connect(**where, user=USER, password=PASSWORD, database=DB,
connect_timeout=20 if last else 5)
print("DB path:", label) # the label only, never the address
return conn
except pymysql.err.OperationalError:
if last:
raise
SOCKETS はよくあるソケットの置き場所を並べた短いリストで、実在するものだけを試します。対策1の1226/1040リトライはこの関数全体を包んだままなので、最終的に使った経路で枠が切れていても、長い待ちはこれまでどおり効きます。
localhost決め打ちにせずフォールバックを残したのは、同じヘルパーが開発用のPCでも動くからです。手元にはMySQLがありません。そのケースは実際に試しました。PCではローカルの試行がエラー2003(接続できない)ですぐに失敗し、リモートでつながります。ローカルのタイムアウトを短くしてあるので、この回り道はほとんど時間を食いません。
経路名をログに出すのは、cronのログだけで「サーバーで本当にlocalを通ったか」を確かめるためです。結果(2026年11月11日追記):サーバーでの初回実行(2026年11月11日 6:00、日本時間)は local(ソケット) の経路ラベルを記録し、正常に終了しました。このサーバーにはローカルMySQLのUnixソケットがあり、同じユーザー名でパスワードも権限も変えずにログインできたということです。cronはもう、クローラーとリモート側の1時間あたりの枠を分け合っていません。
テストの300接続は、別の経路だった
ロガーを入れた直後、何も変わっていないことを確かめるために回帰テストを1回流しました。中身は、約60エンドポイントのAPIスナップショット比較、新しいスナップショットの保存、簡単なURLチェックです。
これで約2分間に約300接続、16時台の1時間全体では559接続でした。初版ではここから「テスト1回で500の枠の半分以上を食う。夜にテストしていたら、自分たちが原因になっていた」と結論づけていました。
この結論は間違いでした。テストが叩くのはAPIで、APIはlocalhostで接続します。300接続は実際に発生しましたが、落ちた枠には数えられていません。夜のlocalhost側はその3倍の負荷をこなしていました。夜のピーク(日本時間20〜22時)にフルの回帰テストを回さない、同じ1時間に2回回さないというルールは残しますが、理由は共用サーバーへの負荷に対する一般的な配慮で、1226の原因だったからではありません。
数字としては今も役に立ちます。フルの回帰テスト1回は約2分で約300接続。誰かがこれをループで回そうとする前に知っておく価値があります。
同じテストからもう1つ。日付が変わると、スナップショット比較は約30件のレスポンスを「差分あり」と判定しました。今日の星占い、新しい抽選結果など、日付で変わる内容です。これをハッシュでは判断しませんでした。ステータスコード、Content-Type、JSONの形(キーと値の型)を比べ、フラグが付いた全件で3つとも同一でした。ハッシュは「何かが変わった」ことしか教えてくれません。形を見れば「契約が変わったかどうか」が分かります。
どの対策をいつ使うか
| 対策 | 向いている場面 | 注意点 |
|---|---|---|
| 同じサーバーで動くジョブはlocalhostで接続 | DBと同じサーバーのcronやワーカーが、今リモートホスト名でつないでいる | いちばん安い。リモートをフォールバックに残し、使った経路をログに出す。サーバーでの結果はまだ |
| バッチで長い待ち時間のリトライ | 遅れて動いても困らないcronやワーカー | 1226/1040だけに限定。遅れた回が重ならないようロックが必要 |
| DBユーザーを分ける | VPS、またはDBごとに複数ユーザーを作れるコントロールパネル | 上限がアカウント単位なので本命。今のプランでは不可 |
| 読み取り専用GETをキャッシュ | 1日に数回しか変わらないデータの重いエンドポイント | 負荷は減るが、今回の構成では1226になったリモート側の枠には効かない |
| 持続的接続(persistent connection) | 共用PHPホスティングではまれ | 使えない・勧められないことが多い。既定の選択肢にはしない |
| ホストに上限について問い合わせる | ある経路の実測ピークが上限に近いとき | ホストとプラン次第。どのアカウント・経路の上限かを聞く |
次にやること
2026年11月11日 6:00(日本時間)の実行は、上に書いたとおり local(ソケット) を通りました。残るのは時間をかけた確認です。最初の失敗が起きた月曜夜の抽選前後を含めて、その後も1226が出ないかを見ます。1回の成功は良い兆しですが、証明ではありません。
重い読み取り専用GETエンドポイントのキャッシュは予定に残っていますが、これらはlocalhost経路なので、目的は1226ではなくサーバー負荷の削減に変わりました。cronの経路、次の1週間の時間帯別の数字、クローラーの関与を示す手がかりが見つかれば、この記事に追記します。
よくある質問
MySQLエラー1226「max_connections_per_hour」は何を意味する?
そのMySQLアカウントの「1時間あたりの接続回数」の枠を使い切った、という意味です。MySQLはリソース上限をアカウント単位で数え、アカウントは「ユーザー名+ホスト」で区別されます。'<db_user>'@'localhost' と '<db_user>'@'%' は別のアカウントです。今回は、リモートホスト名経由の接続が上限500に当たり、localhost経由は1時間約1,500接続でもエラーになりませんでした。
PHPのAPIは動いているのに、cronだけ1226になるのはなぜ?
接続経路が違うからだと考えるのがいちばん自然です。PHPのAPIはホストlocalhostで接続し、落ちたcronはDBサーバーのリモートホスト名、つまり外部のクローラーと同じ経路で接続していました。上限に当たったのはリモート経路だけです。ただしクローラーの接続ログがないので、確定ではなく最も可能性が高い説明です。
1226で接続に失敗したらリトライすべき?
バッチ処理なら、1226と1040に限ってリトライする価値があります。枠は1時間単位なので数秒後の再試行では意味がなく、900秒待ちを最大3回にしました。認証エラーやホストのエラーは即座に失敗させ、ユーザーに応答するリクエスト処理の中では何分も待たせません。
HostingerのWebホスティングでMySQLユーザーを分けて上限を分散できる?
今回のプランではできません(DB1つにユーザー1つ、phpMyAdminにCREATE USERやGRANTの権限もなし)。ただ、ジョブがDBと同じサーバーで動くなら、リモートホスト名ではなくlocalhostで接続するという安い手があります。外部クライアントとリモート側の枠を取り合わずに済みます。