この記事を書こうと思ったきっかけ
きっかけはチームリーダーから
「Bluetooth対応デバイスを連続で送信した時に2回目が動いてなさそう」
と言われ、そこから調査に入ったことでした。
実際に Bluetooth対応デバイスへのコマンド送信機能を試したところ、確かに
1秒以下を下回るスピードで連続送信すると、2回目以降が失敗する
という問題が発生していました。
一見するとデバイス側の仕様や制約に見えましたが、
ログや実装を追っていくと、実際には サーバー側の実装に起因するレースコンディション が原因であることが判明しました。
このようなケースは比較的レアだと感じたため、記事にまとめることにしました。
レース・コンディションとは?
レース・コンディションの仕組み
レース・コンディション(Race Condition)とは、
複数の処理が同時に動き、実行される順番によって結果が変わってしまう状態
のことです。
処理の順序を前提に書いたコードでも、
並行実行によって 想定外のタイミングでデータが扱われる ことがあります。
レース・コンディションの発生例
例えば、同じデータを使う処理が同時に走ると、
- 両方が「まだ更新されていない」と判断する
- 本来1回だけ行う処理が複数回実行される
といった問題が発生します。
特に、状態の確認と更新が分かれている処理 で起きやすいのが特徴です。
レース・コンディションの影響
レース・コンディションが発生すると、
- 動いたり動かなかったりする
- 再現しにくい不具合になる
といった影響があります。
今回はこの問題を、
どのように切り分け、修正したか をまとめました。
実際にどんな問題が起きていたのか?
問題の概要
Bluetooth対応デバイスに対して短時間に複数のコマンドを送信した場合、
- 1回目のコマンド → 成功
- 2回目以降のコマンド → 失敗
という挙動になっていました。
ユーザーがBluetooth対応デバイスの操作メニューを連打すると、
後続のコマンドが正しく処理されない 状態です。
根本原因:レース・コンディション
調査の結果、以下のようなレース・コンディションが発生していました。
- リクエスト①が
getLatestEndTime()を実行 →null - 即座実行と判定し、DBへのログ書き込みを開始(時間がかかる)
- DB書き込み完了前にリクエスト②が
getLatestEndTime()を実行 →null - リクエスト②も即座実行と判定
- 両方が同じ実行時刻として処理され、2回目が1回目を上書き
DB の状態が更新される前に次のリクエストが来てしまい、
同時実行を考慮していない設計 になっていたことが問題でした。
修正内容
① メモリキャッシュの導入
まず、IoTDeviceQueueManager に
roomId ごとの最新 END_TIME を保持するメモリキャッシュ を追加しました。
DBへの書き込み完了を待たずに、
END_TIME を即座にメモリへ保存することで、
次のリクエストはキャッシュから最新の END_TIME を参照できるようになります。
これにより、DB更新前に次のリクエストが到達した場合でも、
誤って「即座に実行可能」と判定してしまう状況を防げるようになりました。
// roomIdごとの最新END_TIMEをメモリキャッシュ
private val latestEndTimes = ConcurrentHashMap<String, Date>()
② 排他制御の強化
次に、roomId ごとに排他制御を行うための
ロックオブジェクトを追加しました。
// roomIdごとのロックオブジェクト
private val roomLocks = ConcurrentHashMap<String, Any>()
synchronized(lock) ブロック内で、
メモリキャッシュ上の END_TIME
DB 上の最新 END_TIME
を参照し、キュー状態の判定とキャッシュ更新を原子的に実行するようにしています。
これにより、複数のリクエストがほぼ同時に送られてきた場合でも、
同一 roomId に対する処理が同時に進んでしまうことを防ぎ、
常に一貫した状態をもとに判断できるようになりました。
③ 連打防止処理の削除
従来は、
1秒以内の連打を防ぐための制御が実装されていましたが、
今回の修正にあわせてこの処理は削除しました。
今回の修正により、
・メモリキャッシュ
・排他制御
・後述するキューイング処理
が正しく機能するようになったことで、
ユーザー操作を制限する必要がなくなったためです。
④ キューイング機能の実装
連続送信時の問題を根本的に解決するため、
IoTDeviceQueueManager を使用した
キューイング機能を実装しました。
この仕組みにより、
即座に実行できないコマンドは無理に送信せず、
順番に処理されるようになっています。
主な変更点
キュー状態の判定
determineQueueState() メソッドで、
・即座に実行すべきか
・キューに入れて順番を待つべきか
を判定します。
// キュー状態を判定する(例)
val state = determineQueueState(roomId)
即座実行とキューイングの分岐
・shouldSendImmediately == true の場合
→ Bluetooth対応デバイスサーバーに送信
・それ以外の場合
→ ログに記録し、後続のスケジュール処理へ委譲
if (shouldSendImmediately) {
// Bluetooth対応デバイスへ送信
} else {
// キューに積んで後続の処理へ
}
ログ記録の統一
送信成功時・キューイング時の両方で、
必ず recordLog() を呼び出すようにしました。
recordLog(roomId, command)
スケジュール実行処理
キューに入れられたコマンドは、
スケジュールタスクによって 1秒ごとに順次実行 されます。
これにより、Bluetooth対応デバイスが前のコマンドを処理中であっても、
後続のコマンドを安全なタイミングで送信できるようになっています。
🎯 期待される効果
・1秒以内の連続送信でも、すべてのコマンドが順番に実行される
・レース・コンディションが解消される
・ユーザー操作に対して安定した挙動を実現できる
まとめ
今回の問題は、一見すると Bluetooth対応デバイス側の仕様に見えましたが、
実際にはサーバー側の
状態管理と同時実行制御の設計不足が原因でした。
メモリキャッシュ・排他制御・キューイングを組み合わせることで、
連続送信を前提とした安全な構成に改善できたと考えています。
同じように、
外部デバイス連携や非同期処理を扱う場面で悩んでいる方の
参考になれば幸いです。
