目次
はじめに
楽天市場のショップが抱える在庫管理の悩みは共通している。在庫数が変動するたびに RMS 管理画面を手動で更新し、ピーク時には更新が間に合わず売り越し(在庫数を超えた注文受付)が発生する。
RMS Web API を使えば在庫更新を自動化できるが、呼び出し頻度に制約があるため、ナイーブに実装するとすぐにエラーが返ってくる。さらに以下の問題が重なって、安全な自動化は意外と難しい。
- 在庫のみ更新する軽量なエンドポイントと、商品情報ごと更新する重いエンドポイントをどう使い分けるか
- 件数が多い場合、ファイル一括更新と API 更新をどう使い分けるか
- 非同期キューで更新を捌くとき、同一 SKU への二重更新をどう防ぐか
- API がエラーを返した在庫更新を、どう確実にリトライするか
本記事ではこれらを一つずつ解決し、売り越しゼロを目指す設計を実装コード付きで解説する。
RMS Web API の認証と基本構造
楽天 RMS Web API の認証は、他の記事でも触れている通り ESA serviceSecret:licenseKey 形式の Basic 認証ベースで統一されている。
function buildAuthHeader(serviceSecret: string, licenseKey: string): string {
const credentials = Buffer.from(`${serviceSecret}:${licenseKey}`).toString("base64")
return `ESA ${credentials}`
}
async function callRmsApi<T>(endpoint: string, params: Record<string, unknown>): Promise<T> {
const res = await fetch(`https://api.rms.rakuten.co.jp/es/${endpoint}`, {
method: "POST",
headers: {
Authorization: buildAuthHeader(SERVICE_SECRET, LICENSE_KEY),
"Content-Type": "application/json; charset=utf-8",
},
body: JSON.stringify(params),
})
if (!res.ok) throw new Error(`RMS API error: ${res.status}`)
return res.json() as Promise<T>
}エンドポイント選定: 在庫のみか、商品全体か
RMS の更新系エンドポイントには大きく分けて 2 種類ある。
| エンドポイント | 更新対象 | 呼び出しコスト | 用途 |
|---|---|---|---|
inventory/2.0/updateInventory 系 | 在庫数・フラグのみ | 軽い | リアルタイム在庫同期 |
item/2.0/update | 商品情報全体(在庫含む) | 重い | 商品マスタ変更に伴う更新 |
原則: 在庫数だけ変えるなら在庫専用エンドポイントを使う。item/2.0/update で在庫を更新すると商品情報全体を送信する必要があり、意図しないフィールドの上書きリスクもある。在庫自動化の文脈では在庫専用エンドポイント一本に絞るのが安全。
正確なエンドポイント名・パラメータは RMS Web サービス仕様書(利用契約後に参照可)で確認すること。API バージョンによって名称が異なる場合がある。
API vs ファイル一括更新: 件数で使い分ける
RMS には在庫を CSV/TSV ファイルで一括更新するインターフェースも存在する。件数と更新頻度によって使い分ける。
| 方法 | 向いているケース | 注意点 |
|---|---|---|
| API(逐次) | 数十〜数百件・頻度が高い | 呼び出し頻度制約あり |
| API(非同期キュー) | 数百〜数千件・頻度が高い | 本記事の主題 |
| ファイル一括更新 | 数千件以上・夜間バッチ向き | 反映までラグがある |
リアルタイムで在庫を反映させたい場合(注文が入るたびに在庫を減らす等)は API 経由が適している。1 日 1 回まとめて同期する夜間バッチであれば、ファイル一括更新の方がシンプルで安定することもある。
実装パターン 1: SQS キューでレート制限内に収める
在庫更新のトリガー(注文受付・入荷など)が短時間に集中すると、呼び出し頻度制約を超えてエラーが続発する。解決策は キューで更新リクエストを一本化し、ワーカーが制限内のペースで処理する設計だ。
在庫変動イベント(注文受付・入荷登録等)
↓
SQS に { itemUrl, skuId, newStock } を Enqueue
↓
SQS Worker: 200ms インターバルで 1 件ずつ処理
↓
RMS inventory API を呼び出し
↓
成功: メッセージを Delete / 失敗: リトライキューへ
// 在庫変動イベントをキューに積む(重複排除は後段で行う)
async function enqueueInventoryUpdate(
itemUrl: string,
skuId: string,
newStock: number
) {
await sqs.sendMessage({
QueueUrl: INVENTORY_QUEUE_URL,
MessageBody: JSON.stringify({ itemUrl, skuId, newStock, enqueuedAt: Date.now() }),
})
}
// SQS Worker: Lambda で定期起動(例: 1 分ごと)
export async function handler(event: SQSEvent) {
for (const record of event.Records) {
const { itemUrl, skuId, newStock } = JSON.parse(record.body)
try {
await updateRmsInventory(itemUrl, skuId, newStock)
} catch (err) {
// Lambda の自動リトライに任せるか、DLQ に送る
throw err
}
// 呼び出し間隔を開ける
await delay(200)
}
}
function delay(ms: number) {
return new Promise((resolve) => setTimeout(resolve, ms))
}SQS の MaximumBatchingWindowInSeconds を使えばメッセージをまとめて受信し、同一 SKU の最新値だけを処理するフィルタリングも入れやすい。
実装パターン 2: SKU 単位 upsert で二重更新を回避
キューには同一 SKU への複数イベントが積まれることがある(例: 注文が 2 件連続で入った場合)。古い在庫数で上書きする「追い越し」を防ぐため、キュー処理前に SKU ごとの最新状態を DynamoDB で管理する。
interface InventoryState {
skuId: string // PK
itemUrl: string
targetStock: number // キューに積まれた最新の在庫数
lastUpdatedAt: number // エポックミリ秒
}
// 在庫変動イベントを「最新状態」として DynamoDB に upsert
async function upsertInventoryState(
skuId: string,
itemUrl: string,
newStock: number,
enqueuedAt: number
) {
await dynamodb.update({
TableName: "inventory-states",
Key: { skuId },
// より新しいイベントのみ上書き(古いイベントで巻き戻さない)
UpdateExpression:
"SET targetStock = :stock, itemUrl = :url, lastUpdatedAt = :ts",
ConditionExpression:
"attribute_not_exists(lastUpdatedAt) OR lastUpdatedAt < :ts",
ExpressionAttributeValues: {
":stock": newStock,
":url": itemUrl,
":ts": enqueuedAt,
},
}).catch((err) => {
if (err.name === "ConditionalCheckFailedException") {
// より新しい状態がすでに書き込まれている → 何もしない(正常)
return
}
throw err
})
}SQS Worker は DynamoDB から SKU の最新 targetStock を読み取ってから RMS API を呼ぶ。キューに積まれた時点の値ではなく、処理時点での最新値を送信することで、順序が入れ替わっても常に正しい在庫数になる。
async function updateRmsInventory(itemUrl: string, skuId: string) {
// 処理時点での最新在庫数を取得
const state = await dynamodb.get({
TableName: "inventory-states",
Key: { skuId },
})
if (!state.Item) return // すでに別処理で消費済み
const { targetStock } = state.Item as InventoryState
await callRmsApi("inventory/2.0/updateInventory", {
updateInventoryRequest: {
inventoryList: [
{
itemUrl,
inventorySkuList: [
{
skuId,
inventoryStatus: "NORMAL",
inventoryCount: targetStock,
},
],
},
],
},
})
}実装パターン 3: 失敗分のリトライキューとデッドレターキュー
RMS API が一時的にエラーを返すことはある(サービスメンテナンス・瞬断等)。リトライは SQS の組み込み機能と DLQ(デッドレターキュー)の組み合わせで設計する。
メインキュー(INVENTORY_QUEUE)
↓ 処理失敗
SQS 自動リトライ(MaxReceiveCount: 3 回)
↓ 3 回失敗
DLQ(INVENTORY_DLQ)
↓ アラート + 手動確認
// SQS キューの設定(CDK / CloudFormation での定義イメージ)
const dlq = new sqs.Queue(this, "InventoryDLQ", {
retentionPeriod: Duration.days(14), // 2 週間保持
})
const mainQueue = new sqs.Queue(this, "InventoryQueue", {
visibilityTimeout: Duration.seconds(30),
deadLetterQueue: {
queue: dlq,
maxReceiveCount: 3, // 3 回失敗で DLQ へ
},
})DLQ に溜まったメッセージは、CloudWatch アラームで検知して Slack 通知する。在庫不一致は売り越しに直結するため、DLQ のメッセージは 手動確認→再試行か棄却かを判断する運用フローを必ず用意する。
実装パターン 4: 定期フルスキャンで在庫不一致を補正
キューイベント方式は「在庫変動イベントが正しく発行される」前提で動く。イベント取りこぼしや外部システムの直接書き込みによる不一致を検出するため、夜間バッチで在庫の突き合わせ補正を入れる。
// 夜間バッチ: 在庫マスタと RMS の在庫を突き合わせ
async function nightlyInventoryReconcile() {
const allSkus = await fetchAllSkusFromMaster() // 社内在庫マスタ
for (const sku of allSkus) {
const masterStock = sku.currentStock
const rmsStock = await fetchRmsInventory(sku.itemUrl, sku.skuId)
if (masterStock !== rmsStock) {
console.warn(`在庫不一致: ${sku.skuId} master=${masterStock} rms=${rmsStock}`)
// 在庫マスタを正として RMS を上書き補正
await enqueueInventoryUpdate(sku.itemUrl, sku.skuId, masterStock)
}
}
}夜間バッチの件数が多い場合は、このフルスキャンをファイル一括更新に切り替えるとレート制約を気にせず処理できる。
まとめ
| 問題 | 対策 |
|---|---|
| レート制限超過 | SQS キュー + ワーカーによる速度制御(200ms インターバル) |
| 同一 SKU への追い越し更新 | DynamoDB に最新状態を upsert し、処理時点の値で送信 |
| API 一時エラー | SQS 自動リトライ(最大 3 回)+ DLQ で手動確認 |
| イベント取りこぼしによる不一致 | 夜間フルスキャンで在庫突き合わせ補正 |
| 大量商品の一括更新 | ファイル一括更新と API を件数・頻度で使い分け |




