mybook

キューとストリーミング — リアルタイムデータ処理

イベントが爆発する

Buzzが100万ユーザーを迎えた日、アキラは新しい問題と向き合っていた。

「1日に発生するイベントが1億件を超えた」とユイがダッシュボードを見ながら言った。

Buzz の1日あたりイベント数(100万ユーザー規模):
  投稿作成:         500万件   ( 58 件/秒)
  いいね:         3,000万件   (347 件/秒)
  フォロー:         200万件   ( 23 件/秒)
  コメント:         800万件   ( 93 件/秒)
  プロフィール閲覧: 5,000万件   (578 件/秒)
  ─────────────────────────────────────
  合計:         約 1億イベント/日   (平均1,157 件/秒)
  ピーク時:                        (最大5,000 件/秒)

「これをPostgreSQLに全部書き込んでいたら、秒間5,000INSERTがかかる」アキラは言った。「データベースが耐えられない」

これらを全て同期で処理していたら、データベースはあっという間にパンクする。イベント駆動アーキテクチャの出番だ。

INFO

イベント駆動アーキテクチャとは、「何かが起きた(イベント)」という情報をキューやストリームに流し、複数のサービスが独立して処理するアーキテクチャパターンだ。送信者と受信者が分離されるため、スケーリングがしやすくなる。

SQS vs Kinesis — 使い分け

Loading diagram...
比較項目SQS(Standard)SQS(FIFO)Kinesis Data Streams
順序保証なしあり(グループ内)シャード内で保証
配送保証At-Least-OnceExactly-OnceAt-Least-Once
スループット無制限300 TPS1MB/s または 1000 rec/s /シャード
保持期間最大14日最大14日最大365日(延長可)
コンシューマー1サービス1サービス最大20サービスが独立消費
用途タスクキュー決済処理ログ・分析・イベントソーシング

INFO

Kinesisの最大の特徴は同じデータを複数のサービスが独立して消費できること。いいねイベントを「通知サービス」「分析サービス」「ランキング更新サービス」が同時・独立して処理できる。SQSは1つのメッセージを1つのコンシューマーしか受け取れない。

Kinesis でイベントストリーミング

Kinesisストリームの作成と設定

# Kinesis ストリーム作成
aws kinesis create-stream \
  --stream-name buzz-events \
  --shard-count 10  # 10シャード = 10MB/s の書き込みキャパシティ
 
# 保持期間を7日に設定(デフォルトは24時間)
aws kinesis increase-stream-retention-period \
  --stream-name buzz-events \
  --retention-period-hours 168  # 7日間
 
# Enhanced Fan-Out を有効化(コンシューマーごとに2MB/sを保証)
aws kinesis register-stream-consumer \
  --stream-arn arn:aws:kinesis:ap-northeast-1:123456789:stream/buzz-events \
  --consumer-name buzz-analytics
 
# シャード数を増やす(スケールアップ)
aws kinesis update-shard-count \
  --stream-name buzz-events \
  --target-shard-count 20 \
  --scaling-type UNIFORM_SCALING
# Gemfile
gem 'aws-sdk-kinesis'
# app/services/event_publisher.rb
class EventPublisher
  KINESIS     = Aws::Kinesis::Client.new(region: 'ap-northeast-1')
  STREAM_NAME = 'buzz-events'
 
  # イベントを Kinesis に発行
  def self.publish(event_type:, data:, partition_key: nil)
    event = {
      event_type: event_type,
      event_id:   SecureRandom.uuid,
      timestamp:  Time.current.iso8601(3),  # ミリ秒精度
      version:    '1.0',
      data:       data
    }
 
    # partition_key でシャードを決定
    # 同じユーザーのイベントを同じシャードへ(順序保証)
    key = partition_key || data[:user_id]&.to_s || SecureRandom.hex
 
    KINESIS.put_record(
      stream_name:   STREAM_NAME,
      data:          JSON.generate(event),
      partition_key: key
    )
  rescue Aws::Kinesis::Errors::ProvisionedThroughputExceededException => e
    # スロットリング: 指数バックオフでリトライ
    Rails.logger.warn "Kinesis throttled, retrying: #{e.message}"
    Retryable.retryable(tries: 5, on: Aws::Kinesis::Errors::ProvisionedThroughputExceededException) do
      KINESIS.put_record(stream_name: STREAM_NAME, data: JSON.generate(event), partition_key: key)
    end
  end
 
  # バッチ発行(最大500件、コスト削減)
  def self.publish_batch(events)
    records = events.map do |event|
      {
        data:          JSON.generate(event),
        partition_key: event[:user_id]&.to_s || SecureRandom.hex
      }
    end
 
    records.each_slice(500) do |batch|
      response = KINESIS.put_records(
        stream_name: STREAM_NAME,
        records:     batch
      )
 
      # 失敗したレコードをリトライ
      if response.failed_record_count > 0
        failed = batch.zip(response.records).filter_map do |rec, result|
          rec if result.error_code
        end
        Rails.logger.warn "#{failed.count} records failed, retrying..."
        retry_batch(failed)
      end
    end
  end
 
  private
 
  def self.retry_batch(records, attempt: 0)
    return Rails.logger.error "Batch retry exhausted: #{records.count} records lost" if attempt >= 3
 
    sleep(2 ** attempt)  # 指数バックオフ: 1s, 2s, 4s
 
    response = KINESIS.put_records(stream_name: STREAM_NAME, records: records)
    if response.failed_record_count > 0
      retry_records = records.zip(response.records).filter_map { |r, res| r if res.error_code }
      retry_batch(retry_records, attempt: attempt + 1)
    end
  end
end
# モデルでイベント発行
class Post < ApplicationRecord
  after_commit :publish_kinesis_event, on: :create
 
  private
 
  def publish_kinesis_event
    EventPublisher.publish(
      event_type: 'post.created',
      data: {
        user_id:        user_id,
        post_id:        id,
        content_length: content.length,
        has_image:      image.attached?,
        hashtags:       extract_hashtags,
        language:       detect_language,
        created_at:     created_at.iso8601
      },
      partition_key: user_id.to_s
    )
  end
end
 
class Like < ApplicationRecord
  after_commit :publish_kinesis_event, on: [:create, :destroy]
 
  private
 
  def publish_kinesis_event
    EventPublisher.publish(
      event_type: destroyed? ? 'like.removed' : 'like.created',
      data: {
        user_id:  user_id,
        post_id:  post_id,
        actor_id: user_id,
        post_owner_id: post.user_id
      },
      partition_key: post_id.to_s  # 同じ投稿のいいねを同じシャードへ
    )
  end
end

Kinesis コンシューマー(Lambda)

# lambda/kinesis_processor/handler.py
import json
import base64
import boto3
from collections import defaultdict
 
dynamodb   = boto3.resource('dynamodb')
sqs        = boto3.client('sqs')
cloudwatch = boto3.client('cloudwatch')
 
NOTIFICATION_QUEUE = 'https://sqs.ap-northeast-1.amazonaws.com/123456789/buzz-notifications'
ACTIVITY_TABLE     = dynamodb.Table('buzz-activity-feed')
ANALYTICS_TABLE    = dynamodb.Table('buzz-analytics')
 
def lambda_handler(event, context):
    # バッチ処理(1つのLambda呼び出しで複数レコードを処理)
    processed = 0
    errors    = 0
 
    # イベントをタイプ別に集約(バッチ書き込みのため)
    likes_by_post  = defaultdict(int)
    activities     = []
    notifications  = []
 
    for record in event['Records']:
        try:
            payload    = json.loads(base64.b64decode(record['kinesis']['data']))
            event_type = payload['event_type']
            data       = payload['data']
 
            if event_type == 'post.created':
                handle_post_created(data, activities, notifications)
            elif event_type == 'like.created':
                likes_by_post[data['post_id']] += 1
                handle_like_created(data, activities, notifications)
            elif event_type == 'like.removed':
                likes_by_post[data['post_id']] -= 1
            elif event_type == 'follow.created':
                handle_follow_created(data, activities, notifications)
 
            processed += 1
        except Exception as e:
            print(f"Error processing record: {e}")
            errors += 1
 
    # バッチでDynamoDBに書き込み(コスト削減)
    if activities:
        batch_write_activities(activities)
 
    # バッチでSQSに通知メッセージを送信
    if notifications:
        batch_send_notifications(notifications)
 
    # いいね数をバッチ更新(DynamoDBのアトミック操作)
    for post_id, count in likes_by_post.items():
        if count != 0:
            update_like_count(post_id, count)
 
    # CloudWatchにメトリクスを送信
    cloudwatch.put_metric_data(
        Namespace='Buzz/Kinesis',
        MetricData=[
            {'MetricName': 'ProcessedRecords', 'Value': processed, 'Unit': 'Count'},
            {'MetricName': 'ErroredRecords',   'Value': errors,    'Unit': 'Count'}
        ]
    )
 
    return {'processed': processed, 'errors': errors}
 
 
def handle_like_created(data, activities, notifications):
    # アクティビティフィードに記録
    activities.append({
        'recipient_id': data['post_owner_id'],
        'activity_id':  generate_snowflake_id(),
        'actor_id':     data['user_id'],
        'action':       'like',
        'target_type':  'Post',
        'target_id':    data['post_id'],
        'created_at':   data.get('created_at'),
        'ttl':          int(time.time()) + 90 * 24 * 3600  # 90日後
    })
 
    # 通知キューへ
    notifications.append({
        'type':          'new_like',
        'recipient_id':  data['post_owner_id'],
        'actor_id':      data['user_id'],
        'post_id':       data['post_id']
    })
 
 
def batch_write_activities(activities):
    with ACTIVITY_TABLE.batch_writer() as batch:
        for activity in activities:
            batch.put_item(Item=activity)
 
 
def batch_send_notifications(notifications):
    # SQSに10件ずつバッチ送信(SQSの上限)
    for i in range(0, len(notifications), 10):
        batch = notifications[i:i+10]
        sqs.send_message_batch(
            QueueUrl=NOTIFICATION_QUEUE,
            Entries=[
                {
                    'Id':          str(idx),
                    'MessageBody': json.dumps(msg)
                }
                for idx, msg in enumerate(batch)
            ]
        )

EventBridge でサービス間連携

EventBridgeはAWSのサーバーレスイベントバスで、マイクロサービス間の疎結合な連携に使う。

Loading diagram...
# app/services/event_bridge_publisher.rb
class EventBridgePublisher
  EVENTS_CLIENT = Aws::EventBridge::Client.new(region: 'ap-northeast-1')
  EVENT_BUS_NAME = 'buzz-event-bus'
 
  EVENT_SOURCES = {
    posts:    'buzz.posts',
    social:   'buzz.social',
    users:    'buzz.users',
    payments: 'buzz.payments'
  }.freeze
 
  def self.publish(source:, detail_type:, detail:)
    EVENTS_CLIENT.put_events(
      entries: [{
        event_bus_name: EVENT_BUS_NAME,
        source:         EVENT_SOURCES.fetch(source),
        detail_type:    detail_type,
        detail:         JSON.generate(detail),
        time:           Time.current
      }]
    )
  end
 
  # バッチ発行(最大10件)
  def self.publish_batch(events)
    events.each_slice(10) do |batch|
      entries = batch.map do |event|
        {
          event_bus_name: EVENT_BUS_NAME,
          source:         EVENT_SOURCES.fetch(event[:source]),
          detail_type:    event[:detail_type],
          detail:         JSON.generate(event[:detail]),
          time:           Time.current
        }
      end
 
      response = EVENTS_CLIENT.put_events(entries: entries)
 
      if response.failed_entry_count > 0
        failed = response.entries.select { |e| e.error_code }
        Rails.logger.error "EventBridge: #{failed.count} events failed"
      end
    end
  end
end
 
# 使い方
EventBridgePublisher.publish(
  source:      :social,
  detail_type: 'UserFollowed',
  detail: {
    follower_id: current_user.id,
    followee_id: user.id,
    timestamp:   Time.current.iso8601
  }
)
# EventBridge ルール設定(CloudFormation)
NotificationRule:
  Type: AWS::Events::Rule
  Properties:
    EventBusName: buzz-event-bus
    EventPattern:
      source: ['buzz.social']
      detail-type:
        - 'UserFollowed'
        - 'PostLiked'
        - 'PostCommented'
        - 'UserMentioned'
    State: ENABLED
    Targets:
      - Arn: !GetAtt NotificationLambda.Arn
        Id: NotificationService
        # デッドレターキュー(失敗したイベントを保存)
        DeadLetterConfig:
          Arn: !GetAtt EventBridgeDLQ.Arn
        # リトライ設定
        RetryPolicy:
          MaximumRetryAttempts: 3
          MaximumEventAgeInSeconds: 3600
 
AnalyticsRule:
  Type: AWS::Events::Rule
  Properties:
    EventBusName: buzz-event-bus
    EventPattern:
      source: ['buzz.posts', 'buzz.social', 'buzz.users']
    State: ENABLED
    Targets:
      - Arn: !GetAtt AnalyticsKinesisStream.Arn
        Id: AnalyticsKinesis
        RoleArn: !GetAtt EventBridgeKinesisRole.Arn

Kinesis Data Firehose でデータレイク

大量のイベントをS3に保存して後から分析する。

# CloudFormation: Kinesis Firehose → S3 データレイク
BuzzFirehose:
  Type: AWS::KinesisFirehose::DeliveryStream
  Properties:
    DeliveryStreamName: buzz-events-firehose
    DeliveryStreamType: KinesisStreamAsSource
    KinesisStreamSourceConfiguration:
      KinesisStreamARN: !GetAtt BuzzKinesisStream.Arn
      RoleARN: !GetAtt FirehoseRole.Arn
 
    ExtendedS3DestinationConfiguration:
      BucketARN: !GetAtt DataLakeBucket.Arn
      RoleARN: !GetAtt FirehoseRole.Arn
 
      # 動的パーティショニング(年/月/日/イベントタイプ別)
      Prefix: "events/year=!{partitionKeyFromQuery:year}/month=!{partitionKeyFromQuery:month}/day=!{partitionKeyFromQuery:day}/event_type=!{partitionKeyFromQuery:event_type}/"
      ErrorOutputPrefix: "errors/year=!{timestamp:yyyy}/month=!{timestamp:MM}/!{firehose:error-output-type}/"
 
      DynamicPartitioningConfiguration:
        Enabled: true
        RetryOptions:
          DurationInSeconds: 300
 
      ProcessingConfiguration:
        Enabled: true
        Processors:
          - Type: MetadataExtraction
            Parameters:
              - ParameterName: MetadataExtractionQuery
                ParameterValue: '{year: .timestamp[0:4], month: .timestamp[5:7], day: .timestamp[8:10], event_type: .event_type}'
              - ParameterName: JsonParsingEngine
                ParameterValue: JQ-1.6
 
      BufferingHints:
        IntervalInSeconds: 300   # 5分ごとにS3に書き込み
        SizeInMBs: 128           # または128MB溜まったら書き込み
 
      CompressionFormat: GZIP   # gzip圧縮
 
      # Apache Parquet形式(列指向、Athenaで効率的)
      DataFormatConversionConfiguration:
        Enabled: true
        InputFormatConfiguration:
          Deserializer:
            OpenXJsonSerDe: {}
        OutputFormatConfiguration:
          Serializer:
            ParquetSerDe:
              Compression: SNAPPY
-- Amazon Athena でイベントデータを分析
-- S3のデータをSQLで直接クエリ(サーバーレス、低コスト)
 
-- テーブル作成(S3のデータを参照)
CREATE EXTERNAL TABLE buzz_events (
  event_type  STRING,
  event_id    STRING,
  timestamp   STRING,
  data        STRUCT<
    user_id:     BIGINT,
    post_id:     BIGINT,
    likes_count: INT
  >
)
PARTITIONED BY (
  year       STRING,
  month      STRING,
  day        STRING,
  event_type STRING
)
STORED AS PARQUET
LOCATION 's3://buzz-data-lake/events/'
TBLPROPERTIES ('parquet.compression' = 'SNAPPY');
 
-- パーティション情報を更新
MSCK REPAIR TABLE buzz_events;
 
-- 時間帯別のいいね数分析
SELECT
  DATE_TRUNC('hour', PARSE_DATETIME(timestamp, 'yyyy-MM-dd''T''HH:mm:ss.SSSZ')) as hour,
  COUNT(*) as total_likes,
  COUNT(DISTINCT data.user_id) as unique_users
FROM buzz_events
WHERE year = '2024'
  AND month = '12'
  AND event_type = 'like.created'
GROUP BY 1
ORDER BY 1;
 
-- コスト: $5/TBスキャン(Parquet + パーティショニングで大幅削減)

Action Cable でリアルタイム通知

Kinesisで処理したイベントをブラウザにプッシュする。

# app/channels/activity_channel.rb
class ActivityChannel < ApplicationCable::Channel
  def subscribed
    reject unless current_user
 
    stream_for current_user
    # Redis PubSub経由でブロードキャスト
 
    # 接続時に未読通知数を送る
    transmit({
      type:          'unread_count',
      unread_count:  current_user.unread_notifications_count
    })
  end
 
  def unsubscribed
    stop_all_streams
  end
 
  def mark_read(data)
    notification_ids = data['notification_ids']
    Notification.where(id: notification_ids, recipient_id: current_user.id)
                .update_all(read_at: Time.current)
  end
end
 
# Lambda から Rails に WebSocket メッセージを送る
# app/jobs/broadcast_activity_job.rb
class BroadcastActivityJob < ApplicationJob
  queue_as :critical
 
  def perform(recipient_id:, activity:)
    user = User.find(recipient_id)
    ActivityChannel.broadcast_to(user, {
      type:     'new_activity',
      activity: activity
    })
  end
end
// app/javascript/channels/activity_channel.js
import consumer from "channels/consumer"
 
const activityChannel = consumer.subscriptions.create("ActivityChannel", {
  connected() {
    console.log("ActivityChannel connected");
  },
 
  disconnected() {
    console.log("ActivityChannel disconnected");
    // 再接続を試みる
    setTimeout(() => this.consumer.connect(), 3000);
  },
 
  received(data) {
    switch (data.type) {
      case 'new_activity':
        this.handleNewActivity(data.activity);
        break;
      case 'unread_count':
        this.updateBadge(data.unread_count);
        break;
    }
  },
 
  handleNewActivity(activity) {
    // 通知バッジの数字を増やす
    const badge = document.getElementById('notification-badge');
    const count = parseInt(badge.dataset.count || '0') + 1;
    badge.dataset.count = count;
    badge.textContent  = count > 99 ? '99+' : count;
    badge.classList.remove('hidden');
 
    // トースト通知を表示
    this.showToast(this.formatActivityMessage(activity));
  },
 
  updateBadge(count) {
    const badge = document.getElementById('notification-badge');
    if (count > 0) {
      badge.textContent = count > 99 ? '99+' : count;
      badge.classList.remove('hidden');
    } else {
      badge.classList.add('hidden');
    }
  },
 
  showToast(message) {
    const toast = document.createElement('div');
    toast.className = 'toast-notification';
    toast.textContent = message;
    document.body.appendChild(toast);
    setTimeout(() => toast.remove(), 4000);
  },
 
  formatActivityMessage(activity) {
    const messages = {
      like:    `${activity.actor.username}さんがいいねしました`,
      follow:  `${activity.actor.username}さんがフォローしました`,
      comment: `${activity.actor.username}さんがコメントしました`
    };
    return messages[activity.action] || '新しいアクティビティ';
  }
});
 
export default activityChannel;

イベント駆動アーキテクチャの効果

Kinesis + EventBridge 導入後(導入3ヶ月後の実績):

処理能力:
  イベント処理スループット: 100,000 イベント/秒(ピーク)
  イベント処理レイテンシ: 平均 120ms(旧: 同期で2-5秒)
  イベントロスト: 0件(Kinesisが7日間保持)

通知:
  通知配信成功率: 99.7%(旧: 94.2%)
  通知レイテンシ: 平均 2秒(旧: 5-30秒)

コスト:
  Kinesis: $0.014/1M レコード × 3億イベント/日 × 30日 = $126/月
  EventBridge: $1/1M イベント × 1億イベント/日 × 30日 = $3,000/月
  Lambda: $0.0000002/リクエスト × 1億/日 × 30日 = $600/月
  合計追加コスト: 約 $3,726/月

  ※ Aurora への書き込み削減(オフロード)でDBコストが$800/月削減
  ※ 実質追加コスト: $2,926/月

WARNING

イベント駆動アーキテクチャではイベントの順序が保証されない場合がある(Kinesisはシャード内のみ保証)。「フォロー解除」が「フォロー」より先に処理される可能性を考慮した実装が必要。イベントにタイムスタンプを含め、古いイベントを無視する(Last-Write-Wins)か、バージョン番号で検証する。

「イベント処理が非同期になったことで、アプリが桁違いにスムーズになった」アキラは言った。「100万ユーザーを超えた。次は日本から出る——グローバル展開だ」

次章では、マルチリージョン展開とGlobal Acceleratorで世界中のユーザーに最速体験を届ける方法を学ぶ。