【実務・中級編】 Hash型:数値演算 – Redis

Redisハッシュ数値演算の極意:`HINCRBY`と`HINCRBYFLOAT`で競合を駆逐せよ

システム開発の現場で、こんなコードレビューをしたことはないだろうか?

> 「おい、このカウンタ更新処理、一度アプリケーション層で `HGET` して、数値をインクリメントして `HSET` し直してるぞ。これじゃあ高負荷時に完全にレースコンディション(競合)が起きる。Redisを使っている意味がない。今すぐ `HINCRBY` に書き換えろ」

私のチームのコードレビューでは、これらは一発レッドカードだ。
RDB脳のままRedisを触ると、トランザクションやロックの概念を持ち込み、結果としてRedisのシングルスレッドモデルが持つ真のポテンシャルを殺してしまう。

今回は、Redisのハッシュ型における数値演算コマンド `HINCRBY` と `HINCRBYFLOAT` に焦点を当て、実務の現場でシステムを堅牢に保ち、極限のパフォーマンスを引き出すための設計思想を伝授しよう。

—

1. なぜ「GETしてSETするな」なのか?(アトミック性の本質)

アプリケーション側で以下のような処理を書いた瞬間、そのシステムはスケールする資格を失う。

1. `val = HGET user:1000 views`
2. `val = val + 1`
3. `HSET user:1000 views val`

複数のリクエストが同時に走った時、ステップ1とステップ3の間で値が書き換わる。いわゆる「読み取り・変更・書き込み(Read-Modify-Write)」のアンチパターンの完成だ。これを防ぐために分散ロックを持ち出すのは本末転倒である。

Redisはシングルスレッドでコマンドを処理する。つまり、「単一のコマンド内で完結する操作は、完全にアトミック(不可分)であることが保証される」。
この原則を体現しているのが、`HINCRBY` および `HINCRBYFLOAT` である。

—

2. コマンドの深層:実務での振る舞いとエッジケース

それぞれのコマンドの仕様と、実務でハマりがちなポイントを整理しておこう。

`HINCRBY key field increment`

指定したハッシュのフィールドに整数値を加算(または減算)する。

  • 自動キー・フィールド生成: キーが存在しない、あるいはフィールドが存在しない場合、勝手に値を `0` とみなして、そこから加算を開始してくれる。初回の初期化処理(`EXISTS` チェックなど)をわざわざ書く必要はない。
  • オーバーフローの挙動: 内部的には64符号付き整数(signed 64-bit integer)として処理される。つまり、約 $-9\times 10^{18}$ から $+9\times 10^{18}$ の範囲だ。この範囲を超えることは実アプリケーションではないはずだが、もし超えた場合はRedisがエラーを返す。
  • 返り値: 演算後の「新しい値」を返す。これを利用して、インクリメントと同時に最新値に基づいたビジネスロジックを後続で回すことも容易だ。

`HINCRBYFLOAT key field increment`

浮動小数点数版。こちらはIEEE 754倍精度浮動小数点数として処理される。

  • 実務での最大の注意点: 浮動小数点の計算には宿命的な誤差が伴う。例えば、残高管理や厳密な金融計算でこれを安易に使うと、数百万回の演算の果てに「1円合わない」という悪夢を見る。厳密な通貨計算には、数値を整数(セント単位やミリ単位)に変換して `HINCRBY` を使うのが鉄則だ。小数点を扱うのは、ゲームのパラメータ調整や、累積の統計データ(確率、レート等)に限定すべきである。

—

3. 実践:コードと実行結果で見る挙動

では、実際にRedisクライアント(ここではイメージしやすいようredis-cli構文)での振る舞いを見てみよう。ECサイトの商品在庫・閲覧数管理を想定した実務的なシナリオだ。

1. 商品ID “item:42” の初期データは何も無い状態からスタート
いきなりインクリメントを叩く(閲覧数を1増やす)
127.0.0.1:6379> HINCRBY item:42 view_count 1
(integer) 1 # 存在しなかったため、0 + 1 で 1 が返る

2. さらに3増やす
127.0.0.1:6379> HINCRBY item:42 view_count 3
(integer) 4 # 1 + 3 = 4

3. 減算したい場合は負の数を渡す(在庫のデクリメント)
127.0.0.1:6379> HINCRBY item:42 stock -1
(integer) 99 # 初めて作成された stock フィールドから 1 引いた値(0 – 1 = -1 ではなく、初期値0からの演算なので注意!)
※待て、初期値0から “-1” を引くと -1 になる。
在庫管理で「初期値がない状態で減算する」とマイナス在庫になるというバグを生む。

最後の例に気づいただろうか?これが実務で最もやりがちな罠だ。
「まだ在庫データが登録されていない商品」に対して `-1` を加算すると、`0 + (-1) = -1` になり、マイナス在庫が誕生してしまう。

—

4. 堅牢な設計パターン:マイナス在庫を防ぐアトミックガード

「初期データが存在しない場合に減算してはいけない」という要件を、Redisでどうエレガントに解決するか。

単純な `HINCRBY` だけでは、フィールドが存在しない場合のデフォルト値が `0` であるため、制御が難しい。ここで、Luaスクリプトの登場だ。Redisの真価は、カスタムの原子操作をLuaスクリプトでアトミックに流し込める点にある。

以下は、「指定したフィールドが存在し、かつ減算後の値が0以上になる場合のみデクリメントする」アトミックなLuaスクリプトの設計パターンだ。

— KEYS[1]: ハッシュのキー (e.g., “item:42”)
— ARGV[1]: フィールド名 (e.g., “stock”)
— ARGV[2]: 減算する値 (e.g., 1)

local current = redis.call(‘HGET’, KEYS[1], ARGV[1])

— フィールドが存在しない、または数値に変換できない場合はエラー(または初期化不足)
if not current then
return {err = “Field does not exist”}
end

local val = tonumber(current)
local decr = tonumber(ARGV[2])

if (val – decr < 0) then return {err = "Out of stock"} end -- 問題なければ HINCRBY 相当の処理を実行 return redis.call('HINCRBY', KEYS[1], ARGV[1], -decr) これを `EVAL` コマンドでアトミックに実行すれば、アプリケーション層での複雑な排他制御なしに、整合性を完璧に担保できる。 ---

5. パフォーマンス上のアーキテクチャ設計における注意点

最後に、インフラおよびアーキテクチャの観点から、`HINCRBY` 系を使う際の重要な知見を共有する。

1. ビッグハッシュのアンチパターン
1つのハッシュキーの中に、数万〜数百万のフィールドを詰め込んではならない。Redisのハッシュは、フィールド数が少ないうちはメモリ効率の良いエンコーディング(`ziplist` 改め `listpack`)を採用しているが、要素数が大きくなるとハッシュテーブルに移行し、メモリフットプリントが跳ね上がる。
「1ユーザーあたりのメタデータ」や「1日単位の特定統計」など、数件〜数百件程度に収まる粒度でキーを設計すること。

2. クラスタ環境におけるハッシュタグの活用
Redis Cluster環境では、キー名に基づいてどのシャード(ノード)にデータが配置されるかが決まる(CRC16ハッシュ)。
もし複数のハッシュキーを一つのトランザクションやLuaスクリプトで操作したい場合は、ハッシュタグ `{…}` を使って、同じシャードにルーティングさせなければならない。

  • 例: `{user:1000}:stats` と `{user:1000}:profile` は同じシャードに配置される。

—

チーフアーキテクトからの総括

`HINCRBY` と `HINCRBYFLOAT` は、単なる「数値を足す便利なコマンド」ではない。
これらは、「RDB的なロック文化を捨て、Redisのシングルスレッドアトミック性を最大限に活かして高スループットなシステムを構築するためのキーストーン」である。

設計レビューでこれらのコマンドを見かけたら、単に正しく動くかだけでなく、「初期値の扱いは安全か」「競合を完全に排除できているか」「データの粒度(ビッグハッシュになっていないか)」まで踏み込んでチェックしてほしい。

君たちの手で、美しく、そして猛烈に速いシステムを組み上げてくれ。

コメント

タイトルとURLをコピーしました