タグ: SQL

  • DBスキーマ駆動のJavaコード生成ツールを自作した(2) 〜DB標準機能に敗れる〜

    前回、個人的な好みと、カラムデフォルト値問題からツールを作成した経緯を書きました。
    今回はもう一つのきっかけである作成日時・更新日時問題について記載します。


    更新日時・作成日時カラム問題

    これらのカラムを全テーブルに入れることは、多くのプロジェクトで行われるのではないでしょうか。
    私が初めて参画したJava プロジェクトもこの方式でした。
    そこではORマッパーを使っていたため、更新日時と作成日時をJava でセットする必要がありました。
    それは言い換えると、開発者全員がUpdate 前に更新日時をセットするコードを必ず実装することが求められていた状態です。

    残念ながらプロジェクトの途中で、実装漏れがいくつも見つかりました。
    そして、更新日時がそれほど重要な位置付けでなかったことと、工数がかかるので問題は放置されました。

    また、作成日時が更新されてしまう問題も起きていました。
    Java のUpdate 処理をInsert 処理と同じように new Entity() から書き始めた場合、作成日時をわざわざデータベースから取得してセットするのが面倒なのでサボりがちです。
    作成日時カラムに現在日付でも入れておけば処理は完了すると、実装者は考えてしまいます。
    気持ちは良く分かります。

    私はこの問題もツールで解消しようとしました。


    ツールでの解決

    前回、DBのカラム定義がnot null で、デフォルト値を持っている場合に、Java から指定された値がnull の場合、Insert やUpdate の対象から外す機能をつけたと書きました。
    しかし、これだけでは足りません。誤ってnull 以外の値をJava で入れた場合にデータが書き変わってしまうからです。
    そこで、Update 時はJava で何を書いてもUpdate されない仕組みが必要と思いました。( 正確にはRepository 層以外のJava コードの記載に影響されないという意味です)

    このように書いた時に、

    Java
    var account = new Account();
    account.setName("グリーン");
    var now = LocalDateTime.now();
    account.setCreatedAt(now);
    repository.update(account);

    下のようなcreated_at を含まないUpdate のSQL を実行したいです。

    SQL
    update account set name = 'グリーン';

    そのため、ツールでは設定でUpdate 対象外とするカラムを指定できる機能を付けました。
    ついでに、Insert 対象外とするカラムを指定する機能と、更新する値を常に now() とするカラムを指定する機能も付けました。

    例を書いてみます。

    DDL(再掲) とツールの設定は以下のとおりです。

    SQL
    create table account (
      name text not null,
      created_at timestamp not null default now(),
      updated_at timestamp not null default now()
    )

    ツールの設定

    YAML
    # param.yml
    excludeUpdateColumnsByTable:
       "*":
          - created_at
    excludeInsertColumnsByTable:
       "*":
          - created_at
          - updated_at
    setNowColumnsByTable:
       "*":
          - updated_at

    (”*” は全てのテーブルを意味します)

    Insert 時にこのように書いても、

    Java
    var account = new Account();
    account.setName("グリーン");
    var createdAt = LocalDateTime.of(2001,1,1,1,1,1);
    account.setCreatedAt(createdAt); // 無視される
    var updatedAt = LocalDateTime.of(2002,2,2,2,2,2);
    account.setUpdatedAt(updatedAt); // 無視される
    repository.insert(account);

    発行されるSQL はこちらになります。

    SQL
    insert into account (name) values ('グリーン');

    createdAt とupdatedAt はInsert 時に除外されるため、DBがカラム定義に従ってデフォルト値のnow() を入れてくれます。

    Update はこのようになります。

    Java
    var account = new Account();
    account.setName("グリーングリーン");
    var createdAt = LocalDateTime.of(2003,3,3,3,3,3);
    account.setCreatedAt(createdAt); // 無視される
    var updatedAt = LocalDateTime.of(2004,4,4,4,4,4);
    account.setUpdatedAt(updatedAt); // 無視される
    repository.update(account);
    SQL
    update account set name = 'グリーングリーン', updated_at = now();

    Insert と同様に、created_at は除外されます。また、updated_at の値はnow() で置き換えられます。
    呼び出す側は更新日時・作成日時を考えなくても、もし誤って変な値をセットしても、Repository 層以下でよろしくやってくれるところが便利な点です。(Java から更新できなくなるとも言える)

    これで解決したように思えました。
    これで解決ならツールを作った甲斐もあったものです。
    しかしですね。。。


    トリガーで全て解決

    残念ながらツールを作った後に、トリガーで全て解決することに気づきました。
    トリガーでUpdate 時に作成日時として時刻で上書きすることが可能です。
    同時に、作成日時を更新前の値に強制的に戻すこともできます。
    また、Insert 時だけ作成日時を時刻で上書きすることもできます。
    アプリケーションの実装漏れを、DBレイヤーで強制的に防げる点が大きいです。

    PostgreSQL の例を挙げます。

    トリガーの定義

    SQL
    CREATE OR REPLACE FUNCTION refresh_created_updated_columns()
    RETURNS TRIGGER AS $$
    BEGIN
        -- 更新時は常に updated_at を現在時刻にする
        NEW.updated_at = NOW();
    
        IF (TG_OP = 'INSERT') THEN
            -- 新規作成時は created_at を現在時刻にする
            NEW.created_at = NOW();
        
        ELSIF (TG_OP = 'UPDATE') THEN
            -- 更新時は OLD の値を維持して上書きを防止する
            NEW.created_at = OLD.created_at;
        END IF;
    
        RETURN NEW;
    END;
    $$ LANGUAGE plpgsql;

    トリガーの適用

    SQL
    CREATE TRIGGER trigger_account_created_updated_columns
    BEFORE INSERT OR UPDATE ON account FOR EACH ROW
    EXECUTE FUNCTION refresh_created_updated_columns();

    これでこの問題はほぼ解決です。
    ツールを作った後に気づいたので、悲しい。本当に悲しい。

    1点だけ気になるのは、トリガーと言えば速度が問題になりがちですが、実際どうなのでしょうか。


    トリガーの速度を試す

    自分のPC にDocker でPostgreSQL を起動して、大量Update を行ってみました。

    シェルで簡易に行ったもので、DB 接続とコミットを毎回行っています。

    結果
    10,000 回Update
    トリガーなし 99秒
    トリガーあり 103秒 (+4秒)

    20,000 回Update
    トリガーなし 208秒
    トリガーあり 214秒 (+6秒)

    3%から4% の速度劣化といったところです。
    Update を2回行うよりはずっと少ない負担のようです。
    トリガー適用時にBEFORE を指定したため、データベースとしては更新対象のレコードが把握できている状態からトリガーで指定された処理を行うため、高速なようです。

    この程度であれば多くのプロジェクトで許容されるのではないでしょうか。
    ただ、ツールを使えばトリガーを入れなくても良いという分があるにはあります。

    参考 負荷投入shell

    ShellScript
    date
    for i in $(seq 1 20000)
    do
      psql -Upostgres <<! >/dev/null 2>&1
    update account set name = 'グリーングリーン', created_at='2000-01-01', updated_at='2000-02-02';
    !
    done
    date

    最後に感想

    多くのプロジェクトはトリガーで解決できるため、ツールが活きるのは以下2つの場合でしょう。

    • トリガーの負荷を許容できない
    • Java からDB アクセスをライブラリの好みが私と近い(Spring JDBC が好き)

    いずれにせよ、あまりないかもしれません。ツールはこちらに残しておきます。

    別の角度の話ですが、私はJava でJava のコードを作るというのが初めてだったのですが、やってみて自分のコードの抽象度を大きく高めることができると感じました。AOP とは違う角度で、場合によってはより強力に、抽象化ができると思いました。

    これを使えば、作成者ID をSpring Security のLoginUserDetail から取得して強制的にセットするという流れを実装することもできそうです。記事では触れませんでしたが、作成者ID・更新者IDも同じ話になります。
    思想に反するという反論があるかもしれませんが、実装漏れを必ず食い止めるという視点で、手段の一つとして持っておくと何かの役に立つかもしれないです。

    【広告】

  • DBスキーマ駆動のJavaコード生成ツールを自作した(1)

    いろいろと思うところがあり、Spring JDBC ベースのコード生成ツールを自作しました。
    なぜ今更このようなものを作ったのか、個人的な好みと、カラムデフォルト値問題、更新日時・作成日時問題(次回)から記載します。


    個人的な好み

    Java からDB アクセスを行う仕組みは歴史がある枯れた領域です。
    無料で使えるライブラリが多数あるのですが、個人的にはどれも一長一短だと感じています。
    私が使ったことのあるライブラリについて独断と偏見を列挙します。
    多分私はマイノリティなので、反論は当然あると思います。

    JOOQ

    始めは好印象だった。Java でSQL の全てを書くことができる。ORマッパーとしては最強だと今も思っている。PostgreSQL、MySQL は無償版でも使えるが、Oracle は有償版でないと使えない点は気になる。今後料金体系が変わったりしたら一大事かも。ORマッパーなのでJava のコードからSQL を推測するスキルが必要。私は自分で書いたクエリーを時間が経ってから読んだら意味が分からなかった。

    JPA

    こちらも始めは使いやすいと思っていた。しかし、一次キャッシュにより更新されない問題を踏んだ時に、二度と使わないと思えるほど嫌になった。私の頭では一次キャッシュを使いこなせない。ライブラリの容量が大きいので起動が遅くなる、Boot jar のサイズが大きくなる、といった問題もある。

    MyBatis

    XML にSQL を書くのが嫌。CDATA がたくさん出てきて読みづらくなる。if foreach OGNL を覚えるのが面倒。Java ならすんなり書けるのに使い方を調べながら書くのがストレス。実質業界標準なので業務で使うことは多い。

    Spring JDBC

    機能が薄くてシンプル。SQL をJava で書くのでMyBatis のように独自の文法を覚える必要がない。欠点は簡単なCRUD を書くだけでも、Entity とRepository を手で作成する必要がありそこそこ手間がかかる。Java の文字列結合でSQL を書くとセキュリティ上の問題が起きると信じている人がいる気がする。

    DBFlute

    SQL を書いたらEntity を作ってくれるという、他にはない思想が良い。開発速度向上につながる。Generator が吐き出すjava コードをEclipse で開くと、警告が沢山出たことを当時(10年以上前)気にしていた。後述の問題は解消できない。

    Exposed (kotlin)

    Kotlin 良さと相まって、ものすごく少ない行数でDB アクセスができる点が素晴らしい。後述の問題にも対応できる。だが、ORマッパーの苦しさはJOOQ と同じ。あと、少し前にメジャーバージョンに変わった辺りでIF が変わり書き直しが必要になったのが辛かった。

    よくある、ORマッパー派か生SQL派で言うと、私は生SQL派になります。生SQL を効率よく書きつつ、後述の問題を回避したいというのが私の願いです。

    次に、多くのライブラリでカラムのデフォルト値が使えないという問題があると思うので触れてみます。


    カラムデフォルト値問題

    こちらは業務で影響が出ることはほとんどない重箱の隅のような話です。多くのライブラリは抽象化を優先している(そうあるべき)ため、この問題を孕んでいると思います。

    例えばこのように、create_at カラムにnot null 制約と初期値としてnow() がついているとします。

    SQL
    create table account (
      name text not null,
      created_at timestamp not null default now()
      updated_at timestamp not null default now()
    )

    ORマッパーでInsert を行います。

    Java
    var account = new Account();
    account.setName("グリーン");
    account.setCreatedAt(null);
    account.setUpdatedAt(null);
    repository.insert(account);

    このコードでは not null 制約に抵触してエラーになると思います。
    発行されるSQLがこのようになるためです。

    SQL
    insert into account (name, created_at, updated_at) values ('グリーン', null, null);
    --> ERROR:  リレーション"account"の列"created_at"のNULL値が非NULL制約に違反しています

    DB でデフォルト値を持っているので、エラーにならなくてもよい気がします。
    この問題が業務に影響することはほとんどありません。
    こうするだけです。

    Java
    var now = LocalDateTime.now();
    account.setCreatedAt(now);

    一応、これに対する反論として、Java のサーバとDB サーバのマシン時刻が合っていない可能性があるというものがあります。
    ですが、NTP を使えばずれはその差はほぼありません。僅かな差が許されない要件であることは稀でしょう。

    さらに、初期値をカラムの定義に委ねるのはよろしくない、アプリで初期値を保つべきだという思想もあると思います。

    思想に反する上に業務上困ることはないので気にしなければ良いのですが、私はDB が持つデフォルト値という機能をJava から使えなくなるという点にどうしても違和感を感じます。データはシステムの中心であり、データを管理するデータベースの機能がミドルウェアにより制限されるのは違うと思います。


    ツールで解消

    私が作成したツールは、DBのカラム定義がnot null で、デフォルト値を持っている場合に、Java から指定された値がnull の場合、Insert やUpdate の対象から外すようにしました。

    そのため、先ほどのサンプルのSQL はこの形で実行されます。

    SQL
    insert into account (name) values ('グリーン');

    ツールはこちら

    このツールは、万人向けではありません。ただ、生SQL派で、DBのデフォルト値を大事にしたい人にはそれなりに刺さるかもしれません。
    次回は、このツールを作るもうひとつのきっかけになった「作成日時・更新日時問題」について書きます。

    【広告】

  • Spring Boot + Nginx でセキュリティ対策を大雑把に実施する

    業務でセキュリティ診断を受けました。
    数回の診断を経て、最終的には「指摘なし」との高評価をいただくことができました。
    この記事では、高評価に至るまでに実施した事を残したいと思います。

    なお、お約束ではありますが、セキュリティは要件次第で対策内容が変わるものです。
    以下の内容は、大雑把にセキュリティ対応を行うためのもので、万全を保証するものではありません。


    今回の構成

    • Spring Boot (3.4.1)
    • Spring Security
    • Thymeleaf
    • バニラJavaScript (react やvue などを使わない)
    • MyBatis
    • Nginx

    Spring Boot

    application-prd.yml に以下の設定を入れます。

    YAML
    server:
      servlet:
        session:
          cookie:
            secure: true   # (1)
            name: __Host-SESSION   # (2)

    これらの設定はHTTPS 環境でないと動かないので、application-prd.yml に記載してapplication.yml (-prdなし)に記載しないと良いです。
    こうすることでローカル環境でもSpring Boot に直接アクセスできます。

    (1) Cookie中のセッションID をSecure 属性で保護します

    付けなかった場合は、HTTP で通信を行った時にもcookie が送付されるため、セッションID が漏えいしてしまい、セッションを乗っ取られる可能性があります。
    とても重要な設定です。

    (2) Cookie中のセッションIDをホストオンリーとします

    セッションIDの名前がデフォルトのJSESSIONID から変更されます。
    __Host- が先頭につくことでサブドメインからのCookie 注入を防いでくれます。
    こちらを設定した場合、Cookie にドメインを指定できなくなります。(する必要がない)


    Spring Security

    以下の設定を入れます。

    Java
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http.headers((headers) -> headers
            .contentSecurityPolicy(config -> config
                .policyDirectives("default-src 'self'")
            ) // (3)
        ).csrf(csrf -> csrf
            .csrfTokenRepository(new HttpSessionCsrfTokenRepository())
        ); // (4)
        return http.build();
    }

    (3) Content security policy (CSP)の適用

    CSP を適用することで、ブラウザに意図しない外部サイトのスクリプト実行を制限させます。
    クロスサイトスクリプティング対策として有用です。
    Nginx で設定することも可能ですが、開発時にSpring Boot へ直接アクセスすることがあるので、Spring Security で設定した方が良いと思います。加えて、nonce という例外を書く機能が使えるようになるメリットがありますが、私の場合は最後まで使うことはありませんでした。

    設定した場合に、開発上の注意点が2つあります。

    ひとつは、JS、CSS、画像が外部から読み込めなくなることです。
    CDN など外部から読み込みが必要な場合は、例外の設定が必要になります。
    私の場合、Google recaptcha が必要だったため、以下のような例外設定を追記しました。

    default-src 'self’;
    script-src 'self' https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/;
    frame-src https://www.google.com/recaptcha/ https://recaptcha.google.com/recaptcha/;
    connect-src 'self' https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/;

    もうひとつは、onclick= やstyle= などのインライン属性が使えなくなります。
    こちらはフロント側の開発に大きく影響します。

    onclick についてはイベント形式に書き直します。

    ❌ CSP で制限される書き方

    HTML
    <span onclick="alert('クリックされた!')">クリック可能な文字列</span>

    ✅ CSP 対応済みの書き方

    HTML
    <span data-action="alert">クリック可能な文字列</span>
    JavaScript
    document.querySelectorAll('[data-action="alert"]').forEach(btn => {
      btn.addEventListener('click', () => {
        alert('クリックされた!')
      })
    })

    style= についてはCSS に必要なクラスを作成して対応します。

    ❌ CSP で制限される書き方

    HTML
    <span style="display:none">非表示</span>

    ✅ CSP 対応済みの書き方

    HTML
    <span class="app-display-none">クリック可能な文字列</span>
    CSS
    .app-display-none {
    	display: none;
    }

    当初私は style が使えないことを面倒だと感じましたが、外観をCSS で全て管理できるので綺麗な書き方になると考え直しました。

    (4) CSRF 対策

    攻撃者がXSS などで任意のJavaScript を実行できる場合に、被害者のログイン済みサイトから送金操作などのデータ操作を行うことを防ぎます。
    この設定を入れると、form タグにトークンが自動的に追加されます。

    HTML
    <form action="/action" method="post"> 
      <input type="hidden" name="_csrf" value="...長いトークン...">
      ...
    </form>

    2行目をSpring Security が自動的に挿入します。

    同時に、Spring Securityがトークンの検証を行うため、意図しないページからのPOST アクセスを防ぐことができます。
    注意点として、GETリクエストには適用されないことが挙げられます。
    HTTP の原則に従って、GET でデータ操作を行わないというルールを守る必要があります。

    バニラJavaScript(フレームワークを使わないJavaScript のこと)でサーバへリクエストを投げる(fetch)場合は、このトークンを付与することで投げることができます。
    これは、攻撃者も同じことを行うことが可能なのですが、前述のCSP 対応と後述のThymeleaf によるXSS 対策で防ぐことになります。

    今回の構成から外れますが、SPA の場合は、cookie を使った対策をフレームワークで行います。
    私が経験のあるAngular について、リンクを置いておきます。


    Nginx

    以下の設定を入れます。

    # (5)
    server_tokens off;
    # (6)
    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
    # (7)
    add_header Referrer-Policy "same-origin, origin-when-cross-origin" always;

    (5) Nginx のバージョンを表示しない

    デフォルトではエラー画面やレスポンスヘッダーにNginx のバージョンが記載されます。
    万一、使っているNginx のバージョンに脆弱性が見つかった場合、バージョン番号が攻撃者に有用な情報となってしまいます。

    (6) Strict-Transport-Security ヘッダを設定

    HTTPS を使っており、HTTP を使わないことをブラウザに覚えさせる設定です。
    一度このヘッダーを受信したブラウザは、タイムアウトするまで(2年間)常にHTTPS でアクセスします。

    (7) Referrer にパスを入れない

    パスに大切な情報が含まれると情報漏洩につながる恐れがあるので、念の為外部サイトにパスを送付しない設定を入れます。

    origin-when-cross-origin を指定すると、同一オリジンにはReferrer を送るが、クロスオリジンにパスを送付しなくなります。この機能は一部のブラウザで未対応らしです。
    same-origin を指定すると、外部サイトにReferrer を送らなくなります。origin-when-cross-origin 未対応のブラウザはこちらが有効になります。


    Thymeleaf

    クロスサイトスクリプティング(XSS)対策として、文字列を代入するときに、th:text の使用を徹底します。

    ✅ 安全な書き方

    HTML
    <span th:text="${name}">ユーザ名</span>

    th:utext を使うとHTML を代入することができますが、問題を起こす可能性が上がるので極力使いません。
    要件次第ではありますが、私が携わったプロジェクトでは一切使わずに済みました。

    ❌ 問題の可能性が上がる書き方

    HTML
    <span th:utext="${html}">動的HTML</span>

    その他のフレームワークを使っている場合でも、似たような機能があるはずです。


    MyBatis

    SQL インジェクション対策として、基本的には$ を使わず# を使うように徹底します。
    しかし、order by だけは # で直接指定することができません。
    代わりに<choose>を使います。

    ✅ 安全な書き方

    SQL
    SELECT
      id,
      name,
      email,
      created_at
    FROM users
    <choose>
      <when test="orderBy == 'name'">
        ORDER BY name
      </when>
      <when test="orderBy == 'createdAt'">
        ORDER BY created_at
      </when>
      <otherwise>
        ORDER BY id
      </otherwise>
    </choose>

    くれぐれもユーザが指定する値を直接$ で渡すことは避けてください。

    ❌ 問題の可能性が上がる書き方

    SQL
    SELECT ... FROM users ORDER BY ${orderBy}

    その他

    IPA の「安全なウェブサイトの作り方」を見ると、上で触れていない問題があります。

    触れてはいませんが、構成上問題になりにくいものと、私の場合要件にないため問題にならないものがありましたので、分類しておきます。

    上で触れたもの
    1 SQLインジェクション
    4 セッション管理の不備
    5 クロスサイト・スクリプティング
    6 CSRF(クロスサイト・リクエスト・フォージェリ)

    構成上問題になりにくいもの
    9 クリックジャッキング (Spring Security 使用のため)
    10 バッファオーバーフロー (Java 使用のため)

    要件にないので問題にならなかったもの
    2 OSコマンド・インジェクション
    3 パス名パラメータの未チェック/ディレクトリ・トラバーサル
    7 HTTPヘッダ・インジェクション
    8 メールヘッダ・インジェクション

    こちらは今回の記事の対象外とさせてください。
    11 アクセス制御や認可制御の欠落


    感想

    脆弱性診断で高評価を得られたことで、今回の記事を書く動機が湧きました。

    勝因は、自前でセキュリティ対策のコードを書かずに、Spring、Nginx、Mybatis といった巨人に委ねた点だと考えています。

    実装にあたって特に面倒だと感じたのは CSP、Thymeleaf、Mybatis のところでした。ですが、一度正しく設定して、雛形をチーム内で共有するところまでたどり着いた後は、スムーズに開発できたと思っています。チーム内で不満の声は上がらなかったです。

    くどいですが、これらの対策を行えば万全というものではなく、何もしないよりこれらをやっておいた方が無難というものになります。
    抜け道を作ることや設定を緩めることは可能ですが、その際は要件と設計とセキュリティをよく検討の上実施ください。

    【広告】

  • 大量更新SQL の性能改善

    業務で大量更新処理の速度改善を行ったので、大切な点を残しておきます。
    プロジェクトを離れた前任者の一括処理が、20時間以上経っても終わらないという状況で相談を受けました。
    結果としては複雑なチューニングよりも、業務要件の洗い出しと、基本的なSQLの組み方が最も効果的でした。

    状況調査

    まず、処理内容とSQL に登場するテーブル件数を確認しました。
    一括処理は、1億レコードのテーブルを、その他のテーブルから集めた情報で更新するというものでした。
    1億件ものレコードを全件洗い替えするため、20時間要していました。

    SQL のイメージはこちらです。

    SQL
    -- 1億レコードを全件更新する処理(改善前)
    UPDATE heavy_table t
    INNER JOIN (
        SELECT ...
    ) info ON ...
    SET t.column = info.value;

    対象レコードを業務要件から絞り込み、大幅高速化

    業務要件を確認したところ、全件の洗い替えではなく、その日に更新が入ったデータだけを洗い替えれば十分だと分かりました。
    そのため、本当に更新が必要な対象は、1億ではなく数千件程度でした。
    SQL のwhere 句で更新対象を絞り込むことで、約10分の1まで短縮されました。
    この時点で、20時間かかっていた処理が2時間まで短縮されました。

    SQL
    -- 1億レコードのうち、本日更新分だけに絞り込む(約10分の1に短縮)
    UPDATE heavy_table t
    INNER JOIN (
        SELECT ...
    ) i ON ...
    SET t.column = i.value
    WHERE t.id IN (
        SELECT id
        FROM other_table ...
    );

    update 文でサブクエリを使わない

    その日に更新が入ったデータの抽出をサブクエリで行っていたのですが、update 文とサブクエリを同時に使うよりも、サブクエリ結果を一時テーブルに入れてからupdate を行った方が速いことが分かりました。
    私にはデータベース内部の挙動を正確に説明できないのですが、update 文にサブクエリを含めるとデータベースに適切な最適化を難しくさせたり、不必要なロックが発生するようです。

    こちらの対策を行うことで、処理時間が30分になりました。

    SQL
    -- 本日分のIDを事前に一時テーブルへ投入(サブクエリの事前計算)
    INSERT INTO tmp_id
    SELECT id
    FROM other_table ...;
    
    -- 一時テーブルと結合して高速に更新(最終的に30分まで短縮)
    UPDATE heavy_table t
    INNER JOIN (
        SELECT ...
    ) i ON ...
    INNER JOIN tmp_id tmp ON t.id = tmp.id
    SET t.column = i.value;

    まとめ

    以前、SQLチューニングについて、先輩から教わったことを思い出しました。
    「業務影響がある対策が一番効果があり、業務影響がない対策では大きな改善は難しい。」
    今回は、はじめに業務要件を確認したのが良かったと思います。
    チームでは並行して、ヒント句を入れたりクラウドなのでRDB のスケールアップを試みたりしましたが、おそらくそれらだけでは、1億件の更新を30分で終わらせることはできなかったと思います。
    改めて先輩の知見に感謝です。

    【広告】