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で結果を書き込む リモートホスト名
1つのデータベースに入る2つの経路を描いた構成図。上の経路は「localhost・1時間約1,500接続・OK」で、アプリのPHP API、古いWebViewページ、同じサーバーで動く(おそらくlocalhost経由の)広告設定がここを通る。下の経路は「リモートホスト名・1時間500・1226」で、修正前のPython cronと、関与が未確認のため点線で描いた外部クローラーPCがここを通る。cronからlocalhostの経路へ「修正後」と書いた点線の矢印がある。2つの経路はどちらも同じデータベースを指している。
同じDB・同じユーザー名でも、入り口は2つ。localhost経由は1時間約1,500接続でもエラーなし。リモートホスト名経由のcronは1時間500接続の上限(1226)に当たった。クローラーの関与は未確認。

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])
フローチャート:ジョブがMySQLに接続する。エラーがなければ処理を続ける。エラーならコードを調べ、1226か1040なら15分待って(最大3回)接続をやり直す。それ以外のコード、または3回の待ちを使い切ったときは即座に失敗する。
cronの接続は1226と1040のときだけ、15分待ちを最大3回リトライする。それ以外のエラーは即失敗。

数秒ではなく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で接続するという安い手があります。外部クライアントとリモート側の枠を取り合わずに済みます。

  • #MySQL
  • #共用サーバー
  • #PHP
  • #Python
  • #cron
  • #レート制限
  • #Hostinger

t.laboratory

実際に作って運用しているアプリ・サーバー・サービスで起きた問題と、その解決に使ったコードを記録する技術ブログです。

運営者情報 · アプリ

↑