JavaでハイパフォーマンスなREST APIを構築する
目次
APIの遅さは単なる悪いユーザー体験ではありません。それはお金の損失です。Amazonの試算では、レイテンシが100ms増加するごとに収益が1%減少します。Googleは、500ms遅くなることで20%のトラフィック減少につながることを発見しました。Shopifyはフラッシュセール中に1分あたり数十万件のリクエストを処理します。これらの数字は、より速いマシンから生まれたものではありません。システムのどこにボトルネックがあるかを正確に理解し、それを意図的に排除することから生まれています。
パフォーマンスは後から追加するものではありません。それはすべての設計上の決断の結果です。この記事は、すべてにキャッシュを追加したり、スレッドプールを1000に増やしたりする方法を教えるものではありません。システムを読み解き、本当のボトルネックを見つけ、正しいものを修正する方法を教えます。それがシニアエンジニアの問題へのアプローチです。
第1部:なぜAPIが遅いのか
リクエストのフルライフサイクル
何かを最適化する前に、リクエストがどこを通り、どこで時間を消費するかを理解する必要があります。
Client
|-- DNS lookup (1-100ms, cached after first request)
|-- TCP handshake (1 RTT ~= 0.5-50ms depending on geography)
|-- TLS handshake (1-2 additional RTTs)
v
Load Balancer
|-- Health check routing
|-- SSL termination (if not offloaded)
|-- Connection overhead (negligible with keep-alive)
v
API Server (JVM)
|-- Thread pool wait (0 if available, up to seconds if exhausted)
|-- Request deserialization -- JSON parsing (1-50ms depending on payload size)
|-- Authentication/Authorization (token validation, database check)
|-- Business logic (depends on complexity)
|-- Serialization -- response to JSON
v
Database
|-- Connection pool acquisition (0-30s if exhausted)
|-- Query execution (1ms-10s depending on query complexity and indexes)
|-- Network round-trip (0.5-2ms within the same datacenter)
v
Cache (Redis)
|-- Network round-trip (0.5-1ms)
|-- Cache hit: return data
|-- Cache miss: fall through to DB
v
External Services
|-- DNS + TCP + TLS (if not reusing connection)
|-- External API latency (10ms-5s, out of your control)
|-- Timeout handling
リクエストのレイテンシは、パス上のすべてのステップの合計です。最も遅いステップがすべてを決定します。外部の決済APIが800msかかる場合、データベースを50msから5msに最適化しても、総レイテンシはまだ約850msです。
これが、推測よりもプロファイリングが重要な理由です。 シニアエンジニアはボトルネックを推測しません。測定します。
本番環境における現実的なレイテンシの内訳
データベースとキャッシュから読み込むAPIエンドポイントの典型的なレイテンシ内訳を示します。
P50 latency = 45ms
Breakdown:
Network (client to LB): 8ms (18%)
Auth token validation: 3ms (7%)
Cache lookup (Redis): 2ms (4%) -- cache hit
Serialization: 5ms (11%)
Business logic: 2ms (4%)
DB query (on cache miss): 25ms (56%)
Network (LB to client): 8ms (18%)
データベースがほとんどのAPIエンドポイントを支配しています。そこから最適化を始めましょう。
スレッド競合:隠れたボトルネック
// Real production code that caused an outage:
@RestController
public class ReportController {
private static final Map<String, Report> reportCache = new HashMap<>(); // NOT thread-safe!
@GetMapping("/reports/{id}")
public Report getReport(@PathVariable String id) {
// ConcurrentModificationException in production
// Or worse: stale reads silently returning wrong data
return reportCache.computeIfAbsent(id, this::generateReport);
}
}
スレッド競合は、複数のスレッドが同じリソース(ロック、I/O、CPU)を奪い合うときに発生します。症状としては、CPUは低いがスループットも低い、スレッドダンプに多くのBLOCKED状態のスレッドが見られるなどが挙げられます。
ガベージコレクション:予測不可能なレイテンシスパイク
GCの停止は、一貫した低レイテンシの敵です。G1GC(Spring Bootのデフォルト)では、マイナーGCは通常5ms未満です。フルGCはJVM全体を数百ミリ秒停止させることがあります。
Timeline:
| request | request | request | request | request | request |
| 45ms | 42ms | 48ms | [GC 200ms pause] | 44ms |
^
This request sees 5x latency with no code change
P50と比較してP99レイテンシが異常に高い場合は、ほぼ常にGCプレッシャーまたはスレッドプール競合のサインです。
第2部:パフォーマンス指標の正しい理解
平均レイテンシが無意味な理由
Scenario: 100 requests in one minute
- 95 requests: 50ms
- 4 requests: 200ms
- 1 request: 5,000ms (database query missing an index)
Average: (95*50 + 4*200 + 1*5000) / 100 = 100ms
You report "average latency is 100ms". The reality:
- 1% of users wait 5 seconds
- With 1 million requests per day: 10,000 requests daily take 5 seconds
平均値は外れ値によって歪められ、ユーザー体験を代表していません。ユーザーは平均ではなく分布を体験します。
パーセンタイル:シニアエンジニアの考え方
P50(中央値): リクエストの50%がこの値以下で完了します。「典型的なユーザー」の体験です。
P95: リクエストの95%がこの値以内で完了します。5%はより遅いです。SLAの設定に使用します。
P99: リクエストの99%がこの値以内で完了します。ヘビーユーザー(最も多くのリクエストを送る)、ピークトラフィック、ファンアウトを伴う操作において重要です。
System A: P50=50ms P95=100ms P99=200ms -- consistent and predictable
System B: P50=40ms P95=500ms P99=3000ms -- bimodal, serious problems
System B looks better at P50 but is completely unacceptable at P99.
マイクロサービスでP99が特に重要な理由:
Service A calls Service B calls Service C calls Service D
If each service has P99 latency = 100ms:
P99 of the full chain = 1 - (0.99^4) ~= 4% of requests exceed 100ms at some step
P99 latency of the chain ~= 400ms+ (4 services in the worst-case path)
With 10 services: P99 of the full chain approaches 1 second even though each service only takes 100ms
Microservices amplify P99 latency.
スループット対レイテンシ:根本的なトレードオフ
// Scenario: batch vs individual processing
// Option A: process each request immediately -- low latency, lower throughput
@PostMapping("/orders")
public OrderResponse createOrder(@RequestBody OrderRequest request) {
Order order = orderService.create(request); // commit immediately
return OrderResponse.from(order);
// Latency: 50ms, Throughput: 200 RPS
}
// Option B: buffer and batch -- higher latency, much higher throughput
@PostMapping("/orders")
public OrderResponse createOrder(@RequestBody OrderRequest request) {
orderQueue.enqueue(request);
return OrderResponse.accepted(); // return immediately, process async
// Latency: 5ms, Throughput: 5000 RPS
// Trade-off: the user does not get immediate success or failure confirmation
}
良いスループットと良いレイテンシは無料では得られません。ユースケースに基づいて何を優先するかを決断しなければなりません。
サチュレーション:クラッシュ前のシグナル
サチュレーションは、リソースがその容量に対してどれだけ使用されているかを示します。コネクションプールが90%いっぱいになっている場合、現在問題がなくても危険なシグナルです。
HikariCP pool: 10 connections
8 in use (80% saturation) -- still has buffer
9 in use (90% saturation) -- warning: a small traffic spike will exhaust the pool
10 in use (100% saturation) -- requests are queuing for a connection
現在の使用量だけでなく、サチュレーションを監視してください。どのリソースでもサチュレーションが70-80%を超えたら注意が必要です。
第3部:データベースは最も一般的なボトルネック
N+1クエリ:HibernateがパフォーマンスをKillする方法
// Controller returning merchants with their orders
@GetMapping("/merchants")
public List<MerchantResponse> getMerchants() {
List<Merchant> merchants = merchantRepo.findAll(); // 1 query
return merchants.stream()
.map(merchant -> MerchantResponse.builder()
.id(merchant.getId())
.name(merchant.getName())
.orderCount(merchant.getOrders().size()) // N queries! LAZY loads each merchant
.totalRevenue(merchant.getOrders().stream()
.mapToDouble(o -> o.getTotal().doubleValue())
.sum())
.build())
.toList();
// With 100 merchants: 1 + 100 = 101 queries
// Each query at 2ms: 202ms of pure N+1 overhead
}
開発環境でのN+1検出:
// Use datasource-proxy to count queries per request
@Bean
public DataSource dataSource(DataSourceProperties properties) {
HikariDataSource ds = properties.initializeDataSourceBuilder()
.type(HikariDataSource.class).build();
return ProxyDataSourceBuilder.create(ds)
.name("Query-Counter")
.countQuery()
.logSlowQueryBySlf4j(50, TimeUnit.MILLISECONDS)
.afterQuery((execInfo, queryInfoList) -> {
if (queryInfoList.size() > 10) {
log.warn("N+1 suspected: {} queries in one request, first: {}",
queryInfoList.size(),
queryInfoList.get(0).getQuery());
}
})
.build();
}
修正:データベースで集約する:
// One query that fetches all required data
@Query("""
SELECT new com.example.dto.MerchantStats(
m.id,
m.name,
COUNT(o.id),
COALESCE(SUM(o.total), 0)
)
FROM Merchant m
LEFT JOIN m.orders o
GROUP BY m.id, m.name
""")
List<MerchantStats> findAllWithStats();
// 1 query, the database aggregates, no lazy loading needed
修正2:完全なエンティティが必要な場合はJOIN FETCHを使用する:
@Query("""
SELECT DISTINCT m FROM Merchant m
LEFT JOIN FETCH m.orders o
WHERE m.active = true
""")
List<Merchant> findActiveWithOrders();
SELECT *:単なる無駄ではない
// Imagine a Product entity with 30 fields including:
@Entity
public class Product {
// ... 25 normal fields ...
@Lob
private byte[] fullDescription; // HTML content, 50KB per product
@Lob
private byte[] technicalManual; // PDF, 5MB per product
}
// API listing 50 products:
List<Product> products = productRepo.findAll(pageable); // SELECT * loads 5MB * 50 = 250MB!
// Fix: projection fetches only what is needed
public interface ProductSummary {
Long getId();
String getName();
String getSku();
BigDecimal getPrice();
// fullDescription and technicalManual excluded
}
List<ProductSummary> products = productRepo.findAllProjectedBy(pageable);
// SELECT id, name, sku, price FROM products -- under 1KB per product
Quarkus Panacheによるプロジェクション:
@ApplicationScoped
public class ProductRepository implements PanacheRepository<Product> {
public List<ProductSummaryDTO> findSummaries(int page, int pageSize) {
return find("active = true")
.page(page, pageSize)
.project(ProductSummaryDTO.class)
.list();
}
}
API設計におけるオーバーフェッチングとアンダーフェッチング
オーバーフェッチング: クライアントが必要以上のデータを受け取ります。影響:帯域幅、シリアライゼーション時間、メモリ。
アンダーフェッチング: クライアントが十分なデータを取得するために複数のリクエストを行う必要があります。影響:追加のネットワークラウンドトリップ、より高いレイテンシ。
// Over-fetching: one endpoint returns everything
@GetMapping("/users/{id}")
public User getUser(@PathVariable Long id) {
return userRepo.findById(id).orElseThrow();
// Returns: profile, settings, 200 orders, 50 reviews, payment methods...
// A mobile app only needs name and avatar for the list view
}
// Better pattern: sparse fieldsets or separate endpoints
@GetMapping("/users/{id}")
public UserResponse getUser(
@PathVariable Long id,
@RequestParam(required = false) Set<String> fields
) {
User user = userRepo.findById(id).orElseThrow();
return UserResponse.of(user, fields); // Only include requested fields
}
// Client calls: GET /users/123?fields=id,name,avatarUrl
第4部:コネクションプールの最適化
データベースコネクションを開くことが高コストな理由
PostgreSQLへの新しいJDBC接続の作成には以下が含まれます。
- TCPハンドシェイク(0.5-2ms)
- 暗号化されている場合のTLSハンドシェイク(追加1-2ms)
- PostgreSQL認証プロトコル(1-2ラウンドトリップ)
- セッションパラメータのネゴシエーション
- セッションのサーバーサイドメモリ割り当て
合計:1つのコネクションを取得するのに5-15msのオーバーヘッド。1秒あたり1000リクエストで、各リクエストに新しいコネクションを作成すると、1秒あたり5-15秒のオーバーヘッドが発生します。システムは追いつくことができません。
コネクションプーリングはコネクションをウォームな状態に保ち、再利用することで、このオーバーヘッドを完全に排除します。
HikariCPのサイジング:実用的な公式
HikariCPのドキュメントより:
pool_size = (core_count * 2) + effective_spindle_count
With SSD: effective_spindle_count = 1
4-core server with SSD: pool_size = (4 * 2) + 1 = 9
「コネクションが多い = 良い」が間違っている理由:
PostgreSQL server: max_connections = 100
Application: 20 instances, each with pool_size = 20
Total connections = 400 -- PostgreSQL rejects connections!
Correct: pool_size = 100 / 20 instances = 5 connections per instance
(Reserve 20 connections for admin, monitoring, and migrations)
# application.yml -- Production-ready HikariCP config
spring:
datasource:
hikari:
maximum-pool-size: 10
minimum-idle: 5
connection-timeout: 5000 # 5s -- fail fast, do not make users wait
idle-timeout: 300000 # 5 minutes
max-lifetime: 1800000 # 30 minutes -- recycle before firewall timeout
keepalive-time: 60000 # 1-minute ping
leak-detection-threshold: 10000 # 10s -- warn on connection leaks
validation-timeout: 3000 # 3s timeout to validate a connection
プール枯渇:症状と診断
Symptoms:
- API latency spikes from 50ms to 5+ seconds
- Error: "Unable to acquire JDBC Connection" or
"Connection is not available, request timed out after 5000ms"
- Database CPU is low (the DB is not busy, the app is waiting for connections)
- hikaricp_connections_pending > 0
Typical timeline:
T=0s Traffic spike (flash sale, new deploy)
T=30s Pool hits max, requests start queuing
T=35s connection-timeout begins expiring -- 503 errors appear
T=40s Error rate above 50%
T=45s Traffic self-reduces (clients give up) -- or worse, a retry storm begins
// Detect pool exhaustion before it becomes an incident
@Component
public class ConnectionPoolHealthCheck {
@Autowired private HikariDataSource dataSource;
@Scheduled(fixedRate = 5000)
public void checkPool() {
HikariPoolMXBean pool = dataSource.getHikariPoolMXBean();
int pending = pool.getThreadsAwaitingConnection();
double utilization = (double) pool.getActiveConnections()
/ dataSource.getMaximumPoolSize();
if (pending > 0) {
log.error("CONNECTION POOL: {} requests waiting. Active={}, Idle={}, Max={}",
pending,
pool.getActiveConnections(),
pool.getIdleConnections(),
pool.getTotalConnections());
}
Metrics.gauge("hikaricp.utilization", utilization);
}
}
PgBouncer:データベースレベルのコネクションプーリング
多くのアプリケーションインスタンスがPostgreSQLが処理できる以上のコネクションを必要とする場合:
Without PgBouncer:
20 app instances * 10 connections = 200 connections to PostgreSQL
PostgreSQL overhead: ~5-10MB memory per connection = 1-2GB just for connections
With PgBouncer (transaction pooling mode):
20 app instances * 10 = 200 connections to PgBouncer
PgBouncer: 10-20 connections to PostgreSQL
PostgreSQL sees only 10-20 connections, scaling to thousands of app connections
# pgbouncer.ini
[databases]
mydb = host=localhost dbname=mydb
[pgbouncer]
pool_mode = transaction # Reuse connection after each transaction
max_client_conn = 1000 # Max client connections to PgBouncer
default_pool_size = 20 # Connections from PgBouncer to PostgreSQL
min_pool_size = 5
reserve_pool_size = 5 # Emergency connections
server_idle_timeout = 300
トレードオフ: トランザクションプーリングモードは、一部のPostgreSQL機能(プリペアドステートメント、アドバイザリーロック、SETパラメータ)と互換性がありません。採用前に互換性を確認してください。
第5部:シリアライゼーションとJSONパフォーマンス
スケール時にシリアライゼーションが高コストな理由
1秒あたり10,000リクエスト、各リクエストが10KBのレスポンスをシリアライズする場合:
10,000 RPS * 10KB = 100MB per second of JSON serialization
+ Jackson uses reflection to read field names and values
+ Object allocation for intermediate representation
+ GC pressure from short-lived objects
Jacksonのリフレクションは、高スループットサービスではCPUの10-30%を消費することがあります。
Jacksonの内部動作とチューニング
Jacksonはシリアライズ時に2つの層を持ちます。
ObjectMapper:高レベルAPI、型情報をキャッシュJsonSerializer:型ごとのシリアライザー、生成またはリフレクションベース
// ObjectMapper is EXPENSIVE to create -- create once and reuse
// Spring Boot manages this automatically, but understanding it matters
@Configuration
public class JacksonConfig {
@Bean
@Primary
public ObjectMapper objectMapper() {
return JsonMapper.builder()
// Performance: skip null fields to reduce payload size
.serializationInclusion(JsonInclude.Include.NON_NULL)
// Performance: fail fast instead of ignoring unknown fields
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false)
// Performance: do not serialize dates as arrays
.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false)
// Register type handlers
.addModule(new JavaTimeModule())
// Performance: AfterburnerModule uses bytecode generation instead of reflection
.addModule(new AfterburnerModule())
.build();
}
}
AfterburnerModuleはパフォーマンスにおいて最も重要です。リフレクションをバイトコード生成に置き換え、シリアライゼーションのスループットを2-5倍向上させます。
BeanUtilsの代わりにMapStructを使用:
// BeanUtils.copyProperties: uses reflection, slow
BeanUtils.copyProperties(order, orderDto);
// MapStruct: compile-time code generation, zero reflection overhead
@Mapper(componentModel = "spring")
public interface OrderMapper {
OrderDto toDto(Order order);
Order toEntity(OrderDto dto);
}
// MapStruct generates code like:
public OrderDto toDto(Order order) {
OrderDto dto = new OrderDto();
dto.setId(order.getId());
dto.setStatus(order.getStatus().name());
// ... pure Java, no reflection
return dto;
}
循環参照とオブジェクトグラフの爆発
// Circular reference: causes StackOverflowError or infinite JSON output
@Entity
public class Order {
@ManyToOne
private Customer customer; // Customer has List<Order>
}
@Entity
public class Customer {
@OneToMany
private List<Order> orders; // Orders have Customer -- infinite loop
}
// Jackson annotation fix:
@Entity
public class Customer {
@OneToMany
@JsonManagedReference // serialize this side
private List<Order> orders;
}
@Entity
public class Order {
@ManyToOne
@JsonBackReference // do not serialize this side
private Customer customer;
}
// Better: create a dedicated DTO and break the circular reference explicitly
public record OrderDto(Long id, String status, Long customerId) {}
// Never serialize entities directly
レスポンス圧縮:帯域幅を60-90%削減
# application.yml -- Enable compression
server:
compression:
enabled: true
mime-types: application/json,text/html,text/plain
min-response-size: 1024 # Only compress responses larger than 1KB
Without compression: 100KB JSON response -> 100KB transferred
With gzip: 100KB JSON response -> ~10-15KB (85-90% smaller)
With brotli: 100KB JSON response -> ~8-12KB (88-92% smaller)
トレードオフ: 圧縮はCPUを使用します。1KB未満のレスポンスでは、圧縮のオーバーヘッドが帯域幅の節約を上回ります。CDNは一般的に圧縮をより効率的に処理するため、可能であればそこにオフロードしてください。
大きなレスポンスのストリーミング
// Instead of loading everything into memory then serializing:
@GetMapping("/reports/export")
public ResponseEntity<List<SalesRecord>> exportReport() {
List<SalesRecord> allRecords = reportRepo.findAll(); // 1GB into heap!
return ResponseEntity.ok(allRecords);
}
// Use StreamingResponseBody: write directly to the output stream
@GetMapping(value = "/reports/export", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<StreamingResponseBody> exportReport() {
StreamingResponseBody stream = outputStream -> {
JsonGenerator generator = objectMapper.getFactory()
.createGenerator(outputStream);
generator.writeStartArray();
reportRepo.findAllAsStream().forEach(record -> { // stream from DB
try {
objectMapper.writeValue(generator, record);
} catch (IOException e) {
throw new UncheckedIOException(e);
}
});
generator.writeEndArray();
generator.close();
};
return ResponseEntity.ok()
.contentType(MediaType.APPLICATION_JSON)
.body(stream);
}
// Memory usage: constant, independent of data size
第6部:パフォーマンスを意識したREST API設計
オフセットページネーション:スケールしない理由
-- Page 1: fast
SELECT * FROM orders ORDER BY created_at DESC LIMIT 20 OFFSET 0;
-- Page 500: PostgreSQL must:
-- 1. Scan from the beginning (may use an index)
-- 2. Skip 9,980 rows
-- 3. Return 20 rows
SELECT * FROM orders ORDER BY created_at DESC LIMIT 20 OFFSET 9980;
-- Cost is linear with offset -- page 5000 scans and discards 100,000 rows
キーセットページネーション:ページ番号に関わらずO(log n):
// Request: GET /orders?limit=20&after_created=2026-06-01T10:00:00Z&after_id=9876
@GetMapping("/orders")
public PageResponse<OrderDto> getOrders(
@RequestParam int limit,
@RequestParam(required = false) Instant afterCreated,
@RequestParam(required = false) Long afterId
) {
List<Order> orders;
if (afterCreated == null) {
// First page
orders = orderRepo.findFirstPage(limit + 1);
} else {
// Subsequent pages -- cursor-based
orders = orderRepo.findNextPage(afterCreated, afterId, limit + 1);
}
boolean hasMore = orders.size() > limit;
List<Order> page = hasMore ? orders.subList(0, limit) : orders;
String nextCursor = hasMore
? buildCursor(page.get(page.size() - 1))
: null;
return PageResponse.of(page.stream().map(mapper::toDto).toList(), nextCursor);
}
// Repository:
@Query("""
SELECT o FROM Order o
WHERE (o.createdAt < :afterCreated)
OR (o.createdAt = :afterCreated AND o.id < :afterId)
ORDER BY o.createdAt DESC, o.id DESC
""")
List<Order> findNextPage(
@Param("afterCreated") Instant afterCreated,
@Param("afterId") Long afterId,
Pageable pageable
);
// Index: (created_at DESC, id DESC) -- O(log n) traversal
カーソル付きレスポンスフォーマット:
{
"data": [...],
"pagination": {
"hasMore": true,
"nextCursor": "eyJjcmVhdGVkQXQiOiIyMDI2LTA2LTAxVDEwOjAwOjAwWiIsImlkIjo5ODc2fQ==",
"limit": 20
}
}
フィルタリングとソーティング:データベースへの直接的な影響
// Dynamic filter builder -- no string concatenation (SQL injection risk!)
@GetMapping("/orders")
public Page<OrderDto> searchOrders(
@RequestParam(required = false) String status,
@RequestParam(required = false) Long merchantId,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate dateFrom,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate dateTo,
@RequestParam(defaultValue = "createdAt") String sortBy,
@RequestParam(defaultValue = "DESC") String sortDir,
Pageable pageable
) {
Specification<Order> spec = Specification.where(null);
if (status != null) spec = spec.and(OrderSpecs.hasStatus(status));
if (merchantId != null) spec = spec.and(OrderSpecs.forMerchant(merchantId));
if (dateFrom != null) spec = spec.and(OrderSpecs.createdAfter(dateFrom));
if (dateTo != null) spec = spec.and(OrderSpecs.createdBefore(dateTo));
// Validate sort column -- prevent injection, prevent sorting on non-indexed columns
Sort sort = buildSafeSort(sortBy, sortDir);
return orderRepo.findAll(spec, PageRequest.of(pageable.getPageNumber(),
pageable.getPageSize(), sort))
.map(mapper::toDto);
}
private Sort buildSafeSort(String sortBy, String sortDir) {
Set<String> allowedFields = Set.of("createdAt", "totalAmount", "status");
if (!allowedFields.contains(sortBy)) {
sortBy = "createdAt"; // default safe fallback
}
Sort.Direction direction = sortDir.equalsIgnoreCase("ASC")
? Sort.Direction.ASC : Sort.Direction.DESC;
return Sort.by(direction, sortBy);
}
動的フィルターのパフォーマンス考慮事項: 各フィルターの組み合わせには独自のインデックスが必要になる場合があります。5つのフィルタ可能フィールドがある場合、多くの組み合わせが生じる可能性があります。解決策:
- カーディナリティが最も高く、最も頻繁に使用されるフィルター列にインデックスを付与する
- 最も一般的な組み合わせに複合インデックスを作成する
- 複雑なクエリにはElasticsearchまたはPostgreSQLフルテキスト検索を使用する
スパースフィールドセット:ペイロードとデータベースフェッチの削減
// Client requests only the fields it needs
// GET /orders?fields=id,status,totalAmount
@GetMapping("/orders/{id}")
public Map<String, Object> getOrder(
@PathVariable Long id,
@RequestParam(required = false) Set<String> fields
) {
Order order = orderRepo.findById(id).orElseThrow();
if (fields == null || fields.isEmpty()) {
return mapper.toFullMap(order);
}
return mapper.toPartialMap(order, fields);
}
スパースフィールドセットはペイロードサイズを削減し、DBプロジェクションがインデックスと一致する場合にカバリングインデックスを有効にできます。
第7部:キャッシュ戦略
キャッシュが存在する理由
データベースは高コストです:ディスクI/O、クエリプランニング、ロック取得。読み込みが多いワークロード(ほとんどのWeb APIは80-95%が読み取り)では、ディスクの代わりにメモリからデータを提供することは、最もROIの高い最適化の1つです。
キャッシュは、データが書き込まれるよりも読み込まれる頻度が高い場合にのみ意味があります。 頻繁に変更されるデータをキャッシュすると、有意義なメリットをもたらさずに整合性の問題が生じます。
キャッシュアサイド(遅延ロード):最も一般的なパターン
@Service
public class ProductService {
@Autowired private ProductRepository repo;
@Autowired private RedisTemplate<String, Product> redisTemplate;
private static final Duration TTL = Duration.ofMinutes(10);
public Product getProduct(Long id) {
String key = "product:" + id;
// 1. Check cache
Product cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
return cached; // Cache hit -- ~1ms
}
// 2. Cache miss: load from DB
Product product = repo.findById(id).orElseThrow(); // ~5-50ms
// 3. Populate cache
redisTemplate.opsForValue().set(key, product, TTL);
return product;
}
public void updateProduct(Product product) {
repo.save(product);
// Invalidate cache
redisTemplate.delete("product:" + product.getId());
// Alternative: write the new data to cache immediately (write-through)
}
}
Spring Cacheの抽象化(よりクリーン):
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(
new GenericJackson2JsonRedisSerializer()
))
.disableCachingNullValues();
return RedisCacheManager.builder(factory)
.cacheDefaults(config)
.withCacheConfiguration("products", config.entryTtl(Duration.ofHours(1)))
.withCacheConfiguration("orders", config.entryTtl(Duration.ofMinutes(5)))
.build();
}
}
@Service
public class ProductService {
@Cacheable(value = "products", key = "#id")
public ProductDto getProduct(Long id) {
return mapper.toDto(repo.findById(id).orElseThrow());
}
@CacheEvict(value = "products", key = "#product.id")
@Transactional
public ProductDto updateProduct(ProductDto product) {
return mapper.toDto(repo.save(mapper.toEntity(product)));
}
@CacheEvict(value = "products", allEntries = true)
@Scheduled(fixedRate = 3600000) // Clear all entries every hour
public void evictAllProducts() {}
}
ライトスルー:より高い整合性
// Write-through: update cache and DB at the same time
@Transactional
public Product updateProductWriteThrough(Product product) {
Product saved = repo.save(product); // DB first
redisTemplate.opsForValue().set(
"product:" + saved.getId(),
saved,
Duration.ofMinutes(10)
); // Cache updated immediately after DB
return saved;
}
// Advantage: cache is always fresh after a write
// Disadvantage: write latency increases (DB + Redis), cache holds data that is rarely read
キャッシュスタンピード:キャッシュミスがデータベースを圧倒するとき
Cache key "popular-products" expires at 14:00:00.000
14:00:00.001: 1000 concurrent requests hit cache -- all miss
14:00:00.002: 1000 requests simultaneously query DB -- DB overloaded
// Fix 1: Probabilistic Early Recomputation
public Product getProductWithPER(Long id) {
String key = "product:" + id;
ValueOperations<String, CachedValue<Product>> ops = redisTemplate.opsForValue();
CachedValue<Product> cached = ops.get(key);
if (cached != null) {
// Probabilistically recompute before the actual expiry
long remainingTtl = redisTemplate.getExpire(key, TimeUnit.SECONDS);
double beta = 1.0; // tuning parameter
double logFetchTime = Math.log(cached.getFetchTimeMs() / 1000.0);
double threshold = -beta * logFetchTime * remainingTtl;
if (Math.random() > Math.exp(threshold)) {
return cached.getValue(); // cache hit, still fresh
}
// Probabilistically recompute early to warm the cache before expiry
}
return recomputeAndCache(id);
}
// Fix 2: Distributed lock -- only one request recomputes, rest wait
public Product getProductWithLock(Long id) {
String key = "product:" + id;
String lockKey = "lock:product:" + id;
Product cached = redisTemplate.opsForValue().get(key);
if (cached != null) return cached;
// Try to acquire lock
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", Duration.ofSeconds(10));
if (Boolean.TRUE.equals(locked)) {
try {
Product product = repo.findById(id).orElseThrow();
redisTemplate.opsForValue().set(key, product, Duration.ofMinutes(10));
return product;
} finally {
redisTemplate.delete(lockKey);
}
} else {
// Wait for the lock holder to finish
Thread.sleep(100);
return getProductWithLock(id); // retry
}
}
キャッシュペネトレーション:存在しないデータのクエリ
Attacker or bug: continuously queries IDs that do not exist
Each request: cache miss -> DB query -> not found -> not cached (null)
Every request hits the DB -> DB overloaded
// Fix: cache null results with a short TTL
@Cacheable(value = "products", key = "#id", unless = "#result == null")
public ProductDto getProduct(Long id) {
return repo.findById(id).map(mapper::toDto).orElse(null);
}
// Will not cache null because of unless="#result == null"
// Better: explicitly cache null with a short TTL
public Optional<Product> getProduct(Long id) {
String key = "product:" + id;
Object cached = redisTemplate.opsForValue().get(key);
if (cached != null) {
return cached instanceof NullMarker ? Optional.empty() : Optional.of((Product) cached);
}
Optional<Product> product = repo.findById(id);
if (product.isPresent()) {
redisTemplate.opsForValue().set(key, product.get(), Duration.ofMinutes(10));
} else {
redisTemplate.opsForValue().set(key, new NullMarker(), Duration.ofMinutes(1));
// Cache the miss with a shorter TTL
}
return product;
}
キャッシュアバランシェ:すべてのキーが同時に期限切れになる
// With a fixed TTL:
redisTemplate.opsForValue().set(key, value, Duration.ofMinutes(10));
// If all keys were cached at 2:00 AM, they all expire at 2:10 AM -- stampede
// Fix: TTL with jitter
Duration baseTtl = Duration.ofMinutes(10);
Duration jitter = Duration.ofSeconds(ThreadLocalRandom.current().nextInt(0, 300));
redisTemplate.opsForValue().set(key, value, baseTtl.plus(jitter));
// Keys expire spread across a 10-15 minute window, not simultaneously
ローカルキャッシュ:Redisの前にCaffeine
めったに変更されない読み取りの多いデータ(設定、通貨、カテゴリ)の場合、ローカルインメモリキャッシュはRedisより100倍高速です。
@Configuration
public class LocalCacheConfig {
@Bean
public CacheManager localCacheManager() {
CaffeineCacheManager manager = new CaffeineCacheManager();
manager.setCaffeine(Caffeine.newBuilder()
.maximumSize(1000)
.expireAfterWrite(5, TimeUnit.MINUTES)
.recordStats() // enable metrics
);
return manager;
}
}
// Layered caching: L1 (local Caffeine) -> L2 (Redis) -> DB
@Service
public class CurrencyService {
@Caching(cacheable = {
@Cacheable(cacheManager = "localCacheManager", value = "currencies", key = "#code"),
@Cacheable(cacheManager = "redisCacheManager", value = "currencies", key = "#code")
})
public Currency getCurrency(String code) {
return currencyRepo.findByCode(code);
}
}
トレードオフ: ローカルキャッシュデータはノード間で異なる時刻に古くなる可能性があります。設定が変更された場合、すべてのインスタンスで無効化する必要があります。Redis pub/subを使用して無効化をブロードキャストしてください。
@Component
public class CacheInvalidationListener {
@Autowired private CacheManager localCacheManager;
@RedisListener(topics = "cache:invalidate")
public void handleInvalidation(String cacheKey) {
// Invalidate local cache when Redis receives the message
String[] parts = cacheKey.split(":");
localCacheManager.getCache(parts[0]).evict(parts[1]);
}
}
第8部:スレッディングと並行処理
Tomcatスレッドプール:デフォルトのメカニズム
Tomcatを使用したSpring Bootは、各HTTPリクエストをプールの1つのスレッドで処理します。I/O(DBクエリ、外部呼び出し)を待機している間、スレッドはブロックされます。
Max threads = 200 (Tomcat default)
Each request holds a thread for 100ms
Max throughput = 200 threads / 100ms = 2,000 RPS
But if a request takes 500ms due to an external service call:
Max throughput = 200 threads / 500ms = 400 RPS
Thread pool exhausts at 400 RPS even though the server is not busy
# application.yml -- Tomcat thread pool tuning
server:
tomcat:
threads:
max: 200 # Increase if workload is I/O-bound
min-spare: 20 # Minimum threads always ready
accept-count: 100 # Queue size when all threads are busy
connection-timeout: 5000 # 5s to complete request
max-connections: 8192 # Max TCP connections
なぜmax threadsを1000に増やさないのか?
各スレッドはスタックメモリとして約512KBから1MBを使用します。1000スレッドはスタックだけで500MBから1GBを意味します。コンテキストスイッチのオーバーヘッドも大幅に増加します。これが非同期I/Oが存在する正確な理由です。
バーチャルスレッド(Java 21):ゲームチェンジャー
バーチャルスレッド(Project Loom)は、プラットフォームスレッドのオーバーヘッドなしに数百万のスレッドを可能にします。
// Spring Boot 3.2+ -- Enable virtual threads
# application.yml
spring:
threads:
virtual:
enabled: true
// With virtual threads:
// Each request still uses a "thread" but it is a virtual thread
// When a virtual thread blocks (waiting for DB, an HTTP call), it unmounts from the carrier thread
// The carrier thread can serve another virtual thread
// Result: as efficient as non-blocking I/O with traditional blocking code style
// Before virtual threads -- Reactive style:
@GetMapping("/orders/{id}")
public Mono<OrderDto> getOrder(@PathVariable Long id) {
return orderRepository.findById(id) // Returns Mono
.map(mapper::toDto)
.switchIfEmpty(Mono.error(new NotFoundException()));
// Hard to debug, stack traces are meaningless
}
// With virtual threads -- simpler:
@GetMapping("/orders/{id}")
public OrderDto getOrder(@PathVariable Long id) {
return orderRepository.findById(id) // Blocking style
.map(mapper::toDto)
.orElseThrow(NotFoundException::new);
// Normal blocking-style code that scales like non-blocking
}
// Custom executor with virtual threads when needed
@Bean
public Executor virtualThreadExecutor() {
return Executors.newVirtualThreadPerTaskExecutor();
}
// Async with virtual threads
@Async("virtualThreadExecutor")
public CompletableFuture<Report> generateReport(Long merchantId) {
// Runs on a virtual thread -- does not block a platform thread
Report report = expensiveReportGeneration(merchantId);
return CompletableFuture.completedFuture(report);
}
バーチャルスレッドが不十分な場合:
- CPUバウンドタスク(I/Oブロッキングなし):バーチャルスレッドは役立ちません
- 明示的なバックプレッシャーが必要なタスク:リアクティブストリームの方が適切です
- synchronizedブロックでのピニング問題:JVMフラグで検出してください
バックプレッシャー:過負荷の回避
// Without backpressure: the server accepts as many requests as the client sends
// Result: memory exhaustion, GC pressure, OOM
// Rate limiting at the API level (Resilience4j):
@Bean
public RateLimiter rateLimiter() {
RateLimiterConfig config = RateLimiterConfig.custom()
.limitRefreshPeriod(Duration.ofSeconds(1))
.limitForPeriod(1000) // Max 1000 requests per second
.timeoutDuration(Duration.ofMillis(100))
.build();
return RateLimiter.of("api-rate-limiter", config);
}
@GetMapping("/orders")
@RateLimiter(name = "api-rate-limiter", fallbackMethod = "rateLimitFallback")
public Page<OrderDto> getOrders(Pageable pageable) {
return orderService.findAll(pageable);
}
public Page<OrderDto> rateLimitFallback(Pageable pageable, RequestNotPermitted e) {
throw new TooManyRequestsException("Rate limit exceeded. Retry after 1 second.");
}
第9部:非同期処理
すべてがリクエスト内で発生する必要はない
// Synchronous: user waits for everything:
@PostMapping("/orders")
public OrderResponse placeOrder(@RequestBody OrderRequest request) {
Order order = orderService.create(request); // 50ms
emailService.sendConfirmation(order); // 300ms -- SMTP
pushNotification.send(order.getUserId(), order); // 200ms -- FCM
analyticsService.track("order_placed", order); // 100ms -- analytics DB
// Total: 650ms, user waits for all of it
return OrderResponse.from(order);
}
// Async: user gets a response immediately:
@PostMapping("/orders")
public OrderResponse placeOrder(@RequestBody OrderRequest request) {
Order order = orderService.create(request); // 50ms -- critical path
// Fire and forget -- does not block the response
CompletableFuture.runAsync(() -> emailService.sendConfirmation(order));
CompletableFuture.runAsync(() -> pushNotification.send(order.getUserId(), order));
CompletableFuture.runAsync(() -> analyticsService.track("order_placed", order));
// Total user-facing time: 50ms, the rest happens in the background
return OrderResponse.from(order);
}
CompletableFuture.runAsync()の落とし穴: デフォルトではForkJoinPool.commonPool()を使用し、JVM内のすべてのコードで共有されます。1つの重いタスクが他のタスクを飢餓状態にする可能性があります。
// Dedicated executor:
@Bean(name = "asyncExecutor")
public Executor asyncExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(50);
executor.setQueueCapacity(500);
executor.setThreadNamePrefix("async-");
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}
@Async("asyncExecutor")
public CompletableFuture<Void> sendConfirmationEmail(Order order) {
emailService.sendConfirmation(order);
return CompletableFuture.completedFuture(null);
}
耐久性のためのメッセージキュー
CompletableFuture.runAsync()は耐久性がありません。メールが送信される前にサーバーがクラッシュすると、メールは失われます。
// Use a message queue for critical async tasks:
@PostMapping("/orders")
@Transactional
public OrderResponse placeOrder(@RequestBody OrderRequest request) {
Order order = orderService.create(request);
// Publish message within the same transaction (Transactional Outbox pattern)
outboxRepo.save(OutboxMessage.builder()
.eventType("ORDER_CONFIRMATION_EMAIL")
.payload(objectMapper.writeValueAsString(new EmailRequest(order)))
.build());
return OrderResponse.from(order);
}
// Consumer: runs separately, retries automatically on failure
@KafkaListener(topics = "order.email.requests")
public void processEmailRequest(EmailRequest request) {
emailService.sendConfirmation(request);
// If it fails: Kafka retries with backoff
// Message is not lost even if the server crashes
}
並列ファンアウト:複数サービスからの集約
// Sequential: slow
@GetMapping("/dashboard/{merchantId}")
public DashboardData getDashboard(@PathVariable Long merchantId) {
MerchantStats stats = statsService.get(merchantId); // 50ms
List<Order> recentOrders = orderService.getRecent(merchantId); // 80ms
RevenueChart chart = chartService.get(merchantId); // 60ms
// Total: 190ms sequential
return new DashboardData(stats, recentOrders, chart);
}
// Parallel: much faster
@GetMapping("/dashboard/{merchantId}")
public DashboardData getDashboard(@PathVariable Long merchantId) {
CompletableFuture<MerchantStats> statsFuture =
CompletableFuture.supplyAsync(() -> statsService.get(merchantId), asyncExecutor);
CompletableFuture<List<Order>> ordersFuture =
CompletableFuture.supplyAsync(() -> orderService.getRecent(merchantId), asyncExecutor);
CompletableFuture<RevenueChart> chartFuture =
CompletableFuture.supplyAsync(() -> chartService.get(merchantId), asyncExecutor);
CompletableFuture.allOf(statsFuture, ordersFuture, chartFuture).join();
// Total: max(50, 80, 60) = 80ms parallel vs 190ms sequential
return new DashboardData(
statsFuture.join(),
ordersFuture.join(),
chartFuture.join()
);
}
ハングを防ぐためのタイムアウト:
try {
CompletableFuture.allOf(statsFuture, ordersFuture, chartFuture)
.get(2, TimeUnit.SECONDS); // Overall timeout
} catch (TimeoutException e) {
// Cancel pending futures
statsFuture.cancel(true);
ordersFuture.cancel(true);
chartFuture.cancel(true);
throw new ServiceUnavailableException("Dashboard data timeout");
}
第10部:HTTPレベルの最適化
Keep-Aliveとコネクション再利用
Without Keep-Alive:
Client -> [TCP handshake] -> Request -> Response -> [TCP close]
3ms overhead 3ms overhead
With Keep-Alive (HTTP/1.1 default):
Client -> [TCP handshake] -> Request 1 -> Response 1
-> Request 2 -> Response 2 (no handshake!)
-> Request N -> Response N
3ms overhead once
Spring Boot(Tomcat)はデフォルトでKeep-Aliveを有効にしています。クライアント(RestTemplate、HttpClient)もサポートしていることを確認してください。
// RestTemplate with connection pooling:
@Bean
public RestTemplate restTemplate() {
HttpComponentsClientHttpRequestFactory factory =
new HttpComponentsClientHttpRequestFactory();
factory.setHttpClient(
HttpClients.custom()
.setConnectionManager(PoolingHttpClientConnectionManager.create(
RegistryBuilder.<ConnectionSocketFactory>create()
.register("http", PlainConnectionSocketFactory.getSocketFactory())
.register("https", SSLConnectionSocketFactory.getSystemSocketFactory())
.build()
))
.setConnectionReuseStrategy(DefaultClientConnectionReuseStrategy.INSTANCE)
.build()
);
return new RestTemplate(factory);
}
// Or WebClient (Spring WebFlux) -- handles connection pooling better:
@Bean
public WebClient webClient() {
HttpClient httpClient = HttpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 3000)
.responseTimeout(Duration.ofSeconds(5))
.doOnConnected(conn -> conn
.addHandlerLast(new ReadTimeoutHandler(5))
.addHandlerLast(new WriteTimeoutHandler(5)));
return WebClient.builder()
.clientConnector(new ReactorClientHttpConnector(httpClient))
.build();
}
HTTP/2:多重化とヘッダー圧縮
HTTP/1.1:各リクエストには独自のコネクションが必要です(またはサポートが限られているパイプライン)。ブラウザはドメインあたり最大6つのコネクションを開きます。
HTTP/2:1つのコネクションで複数のリクエストを処理し、ヘッドオブラインブロッキングがありません。
# Spring Boot -- Enable HTTP/2 with the embedded server
server:
http2:
enabled: true
ssl:
enabled: true # HTTP/2 requires TLS in most browsers
key-store: classpath:keystore.p12
key-store-password: ${SSL_PASSWORD}
key-store-type: PKCS12
HTTP/1.1 (6 parallel connections):
Conn1: GET /api/orders -> 80ms
Conn2: GET /api/users -> 60ms
Conn3: GET /api/products -> 90ms
... (3 more connections)
HTTP/2 (1 connection, multiplexed):
Stream1: GET /api/orders -|
Stream2: GET /api/users -+-> All on the same connection
Stream3: GET /api/products -|
+ Header compression (HPACK): repeated headers (Authorization, Content-Type) compressed
影響: サーバー間通信(通常すでにコネクションプーリングがある)では小さいです。ブラウザ対サーバーのトラフィック(多くの並列リクエスト、ヘッダー圧縮、HOLブロッキングなし)では大きいです。
ETagと条件付きリクエスト:不要なデータ転送の回避
@GetMapping("/products/{id}")
public ResponseEntity<ProductDto> getProduct(
@PathVariable Long id,
@RequestHeader(value = "If-None-Match", required = false) String ifNoneMatch
) {
Product product = productService.findById(id);
String etag = "\"" + product.getVersion() + "\""; // or an MD5 hash
if (etag.equals(ifNoneMatch)) {
return ResponseEntity.status(HttpStatus.NOT_MODIFIED).build();
// 304: no data transferred, client uses its cached version
// Saves: bandwidth + serialization cost + DB fetch (if version is cached)
}
return ResponseEntity.ok()
.eTag(etag)
.cacheControl(CacheControl.maxAge(60, TimeUnit.SECONDS))
.body(mapper.toDto(product));
}
Cache-Controlヘッダー:ブラウザとCDNのキャッシュ
@GetMapping("/static/product-catalog")
public ResponseEntity<List<ProductDto>> getProductCatalog() {
List<ProductDto> catalog = catalogService.getAll();
return ResponseEntity.ok()
.cacheControl(CacheControl
.maxAge(1, TimeUnit.HOURS) // Browser cache for 1 hour
.staleWhileRevalidate(5, TimeUnit.MINUTES) // Serve stale for 5 minutes while revalidating
.staleIfError(1, TimeUnit.DAYS) // Serve stale for 1 day if origin is down
)
.body(catalog);
}
@GetMapping("/user/{id}/profile")
public ResponseEntity<UserProfile> getProfile(@PathVariable Long id) {
UserProfile profile = userService.getProfile(id);
return ResponseEntity.ok()
.cacheControl(CacheControl.noStore()) // Sensitive data -- do not cache
.body(profile);
}
第11部:外部サービスの最適化
タイムアウト:最初の防衛線
タイムアウトがなければ、1つの遅いダウンストリームサービスがすべてのスレッドを無期限に保持する可能性があります。
// RestTemplate with timeouts:
@Bean
public RestTemplate restTemplate() {
HttpComponentsClientHttpRequestFactory factory =
new HttpComponentsClientHttpRequestFactory();
factory.setConnectTimeout(3000); // 3s to establish connection
factory.setReadTimeout(5000); // 5s to read the response
// If payment API takes more than 5s: timeout, fail fast
return new RestTemplate(factory);
}
// WebClient (preferred):
@Bean
public WebClient paymentClient() {
return WebClient.builder()
.baseUrl(paymentServiceUrl)
.clientConnector(new ReactorClientHttpConnector(
HttpClient.create()
.option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 3000)
.responseTimeout(Duration.ofSeconds(5))
))
.build();
}
タイムアウト戦略:
- 接続タイムアウトは読み取りタイムアウトより短くする:コネクション確立は通常速い
- 読み取りタイムアウト = ダウンストリームのP99レイテンシ + バッファ
- チェーン全体のタイムアウトはエンドポイントのSLAより短くする
サーキットブレーカー:遅い失敗ではなく素早い失敗
サーキットブレーカーはカスケード障害を防ぎます。ダウンストリームサービスが継続的に失敗している場合、絶望的なリクエストにリソースを浪費する代わりに呼び出しを停止します。
CLOSED state (normal):
Calls pass through -- track failure rate
If failure rate exceeds threshold -- OPEN state:
All calls fail immediately (no network call) -- saves resources
Wait for cooldown period
After cooldown -- HALF-OPEN state:
Allow limited calls to test if the service has recovered
If success: back to CLOSED
If fail: back to OPEN
// Resilience4j Circuit Breaker:
@Bean
public CircuitBreaker paymentCircuitBreaker(CircuitBreakerRegistry registry) {
CircuitBreakerConfig config = CircuitBreakerConfig.custom()
.slidingWindowType(CircuitBreakerConfig.SlidingWindowType.COUNT_BASED)
.slidingWindowSize(10) // Last 10 calls
.failureRateThreshold(50) // Open if >= 50% fail
.waitDurationInOpenState(Duration.ofSeconds(30)) // 30s cooldown
.permittedNumberOfCallsInHalfOpenState(3)
.slowCallRateThreshold(80) // Treat slow calls as failures too
.slowCallDurationThreshold(Duration.ofSeconds(2))
.build();
return registry.circuitBreaker("payment-service", config);
}
@Service
public class PaymentService {
@Autowired private CircuitBreaker paymentCircuitBreaker;
public PaymentResult charge(PaymentRequest request) {
return paymentCircuitBreaker.executeSupplier(
() -> paymentApiClient.charge(request)
);
}
}
フォールバック戦略:
@CircuitBreaker(name = "recommendation-service", fallbackMethod = "getDefaultRecommendations")
public List<ProductDto> getRecommendations(Long userId) {
return recommendationClient.getForUser(userId);
}
// Fallback: degrade gracefully -- do not fail the entire page
public List<ProductDto> getDefaultRecommendations(Long userId, Exception e) {
log.warn("Recommendation service unavailable, serving popular products. Error: {}", e.getMessage());
return popularProductsCache.getTopProducts(10); // Serve popular products instead
}
リトライストーム:リトライが問題になるとき
Payment service returns 503
1000 clients retry immediately
1000 requests hit the payment service at once
Payment service is overloaded by retries
503 continues
Clients retry again
Infinite loop (Retry Storm)
// Retry with exponential backoff and jitter:
@Bean
public Retry paymentRetry() {
RetryConfig config = RetryConfig.custom()
.maxAttempts(3)
.waitDuration(Duration.ofMillis(500))
.intervalFunction(IntervalFunction.ofExponentialRandomBackoff(
500, // Initial interval ms
2.0, // Multiplier
0.5, // Randomization factor (jitter)
10000 // Max interval ms
))
// Only retry specific errors:
.retryOnException(e -> e instanceof ConnectTimeoutException
|| e instanceof ServiceUnavailableException)
// Do not retry client errors:
.ignoreExceptions(BadRequestException.class, UnauthorizedException.class)
.build();
return Retry.of("payment-retry", config);
}
Retry timeline with jitter:
Request 1: 503
Retry 1: wait 400-600ms (500ms base +/- 50% jitter)
Retry 2: wait 800-1200ms
Retry 3: wait 1600-2400ms
Fail (max attempts reached)
Retries spread out, not simultaneous
バルクヘッド:ダウンストリームサービス間の分離
// Without a bulkhead:
// Payment service slows down -> consumes all threads -> Inventory and Shipping cannot be served either
// With bulkhead: dedicated thread pool per downstream service
@Bean
public ThreadPoolBulkhead paymentBulkhead() {
ThreadPoolBulkheadConfig config = ThreadPoolBulkheadConfig.custom()
.maxThreadPoolSize(5) // Only 5 threads for payment calls
.coreThreadPoolSize(3)
.queueCapacity(10) // Queue up to 10 requests
.keepAliveDuration(Duration.ofSeconds(30))
.build();
return ThreadPoolBulkhead.of("payment", config);
}
第12部:JVMレベルの最適化
ガベージコレクション:ワークロードに適したGCの選択
G1GC(Java 11+のデフォルト): レイテンシとスループットのバランスが取れています。ほとんどのアプリケーションに適したデフォルトです。
ZGC(Java 15+でプロダクション対応): サブミリ秒の停止時間。レイテンシに敏感なアプリケーションに最適です。
Shenandoah: ZGCに似ており、並行で低停止時間です。
Workload Recommended GC
----------------------------------------------
General OLTP API G1GC (default)
Low-latency API ZGC or Shenandoah
High-throughput batch ParallelGC
Large heap (> 32GB) ZGC
# JVM flags for a production API server:
# G1GC (default):
-XX:+UseG1GC
-Xms2g -Xmx4g # Heap: set min = max to avoid resize
-XX:MaxGCPauseMillis=200 # Target max pause (G1 optimizes toward this)
-XX:G1HeapRegionSize=8m
-XX:+G1UseAdaptiveIHOP
# ZGC for low latency:
-XX:+UseZGC
-Xms4g -Xmx4g
-XX:+ZGenerational # ZGC generational mode (Java 21+)
オブジェクト割り当て:見えないコスト
// High-allocation code: creates many short-lived objects
@GetMapping("/orders")
public List<OrderDto> getOrders() {
return orderRepo.findAll().stream()
.map(order -> {
// Creates a new StringBuilder per iteration
String formattedDate = new SimpleDateFormat("yyyy-MM-dd")
.format(order.getCreatedAt()); // SimpleDateFormat is NOT thread-safe!
return new OrderDto(
order.getId(),
order.getStatus(),
formattedDate,
// String concatenation creates StringBuilder + String objects
"Order-" + order.getId() + "-" + order.getMerchantId()
);
})
.collect(Collectors.toList());
}
// Better:
private static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd");
// DateTimeFormatter is thread-safe, created once
@GetMapping("/orders")
public List<OrderDto> getOrders() {
return orderRepo.findAll().stream()
.map(order -> new OrderDto(
order.getId(),
order.getStatus(),
FORMATTER.format(order.getCreatedAt()), // reuse the formatter
String.format("Order-%d-%d", order.getId(), order.getMerchantId())
))
.toList(); // Java 16+: does not create a mutable List
}
String割り当て:最も一般的なGCの犯人
// String concatenation in a loop creates many String objects:
public String buildQuery(List<Long> ids) {
String query = "SELECT * FROM orders WHERE id IN (";
for (Long id : ids) {
query += id + ","; // Each += creates a new StringBuilder!
}
return query + ")";
}
// Better:
public String buildQuery(List<Long> ids) {
return "SELECT * FROM orders WHERE id IN (" +
ids.stream()
.map(Object::toString)
.collect(Collectors.joining(",")) +
")";
}
// Or use a parameterized query (preferred for SQL):
// "SELECT * FROM orders WHERE id IN (:ids)"
// With Spring Data: findAllById(ids)
エスケープ解析:自動JVM最適化
JVMは、短命なオブジェクトをヒープの代わりにスタックに割り当てることができます(GC不要)。これは、JVMがオブジェクトがメソッドから「エスケープ」しないことを証明できる場合に発生します。
// Object can be stack-allocated (does not escape):
public int sumOrderItems(Order order) {
Iterator<OrderItem> iter = order.getItems().iterator(); // iterator is local
int sum = 0;
while (iter.hasNext()) {
sum += iter.next().getQuantity();
}
return sum; // iterator does not escape the method
}
// Object escapes (must be heap-allocated):
public List<OrderItem> getExpensiveItems(Order order) {
List<OrderItem> result = new ArrayList<>();
for (OrderItem item : order.getItems()) {
if (item.getPrice().compareTo(THRESHOLD) > 0) {
result.add(item); // result escapes the method
}
}
return result; // must be heap-allocated
}
これが、JVMのパフォーマンスが直感と一致しない場合がある理由です。オプティマイザーはあなたに見えない多くのことを行います。手動で最適化する前にプロファイリングしてください。
第13部:オブザーバビリティとパフォーマンスデバッグ
オブザーバビリティの3つの柱
メトリクス: 集約された数値:スループット、レイテンシパーセンタイル、エラーレート。アラートとトレンド分析に最適です。
トレース: 複数のサービスにわたるエンドツーエンドのリクエストフロー。分散システムのボトルネックを見つけるのに最適です。
ログ: 詳細なイベントレコード。特定のインシデントのデバッグに最適です。
Micrometer + Prometheus + Grafanaスタック
// Spring Boot exposes metrics automatically at /actuator/prometheus
// Adding custom metrics:
@RestController
public class OrderController {
private final Counter orderCounter;
private final Timer orderLatencyTimer;
private final DistributionSummary payloadSizeDistribution;
public OrderController(MeterRegistry registry) {
this.orderCounter = Counter.builder("api.orders.created")
.tag("environment", "production")
.description("Total orders created")
.register(registry);
this.orderLatencyTimer = Timer.builder("api.orders.latency")
.publishPercentiles(0.5, 0.95, 0.99) // Publish P50, P95, P99
.publishPercentileHistogram(true)
.register(registry);
this.payloadSizeDistribution = DistributionSummary.builder("api.response.size")
.baseUnit("bytes")
.register(registry);
}
@PostMapping("/orders")
public ResponseEntity<OrderResponse> createOrder(@RequestBody OrderRequest request) {
return orderLatencyTimer.recordCallable(() -> {
orderCounter.increment();
OrderResponse response = orderService.create(request);
payloadSizeDistribution.record(objectMapper.writeValueAsBytes(response).length);
return ResponseEntity.ok(response);
});
}
}
GrafanaダッシュボードクエリPromQL:
# API latency P99 (PromQL)
histogram_quantile(0.99, rate(api_orders_latency_seconds_bucket[5m]))
# Error rate
rate(http_server_requests_seconds_count{status=~"5.."}[5m])
/ rate(http_server_requests_seconds_count[5m])
# Throughput
rate(api_orders_created_total[5m])
# HikariCP saturation
hikaricp_connections_active / hikaricp_connections_max
OpenTelemetry分散トレーシング
// application.yml -- Spring Boot 3 auto-instruments with Micrometer Tracing
management:
tracing:
sampling:
probability: 0.1 # Sample 10% of requests (100% is too expensive)
spring:
application:
name: order-service
# Send traces to Jaeger or Zipkin
management:
otlp:
tracing:
endpoint: http://jaeger:4317
// Custom span for important operations:
@Service
public class OrderService {
@Autowired private Tracer tracer;
public Order processOrder(OrderRequest request) {
Span span = tracer.nextSpan().name("order.process")
.tag("merchant.id", request.getMerchantId().toString())
.start();
try (Tracer.SpanInScope ws = tracer.withSpan(span)) {
Order order = createOrder(request); // auto-traced if using an instrumented DB
Span paymentSpan = tracer.nextSpan().name("payment.charge").start();
try (Tracer.SpanInScope ps = tracer.withSpan(paymentSpan)) {
paymentService.charge(order);
} finally {
paymentSpan.end();
}
return order;
} finally {
span.end();
}
}
}
Java Flight Recorder:本番環境プロファイリング
JFRは1-2%未満のオーバーヘッドを持つ組み込みプロファイラーで、常時オンの本番記録に安全です。
# Start a JFR recording (can attach to a running JVM):
jcmd <PID> JFR.start duration=60s filename=/tmp/recording.jfr settings=profile
# Analyze with JDK Mission Control:
jmc /tmp/recording.jfr
# Automated with the JFR API in code:
@Component
public class PerformanceRecorder {
@Scheduled(fixedDelay = 3600000) // Every hour
public void captureProfile() throws Exception {
Path file = Path.of("/tmp/profile-" + System.currentTimeMillis() + ".jfr");
Recording recording = new Recording();
recording.enable("jdk.CPULoad").withPeriod(Duration.ofSeconds(1));
recording.enable("jdk.GarbageCollection");
recording.enable("jdk.SocketRead").withThreshold(Duration.ofMillis(10));
recording.start();
Thread.sleep(60000); // Record for 1 minute
recording.dump(file);
recording.stop();
s3Client.upload(file); // Upload for analysis
}
}
スレッドダンプ解析
# Capture a thread dump when the API is slow:
jstack <PID> > thread-dump.txt
# Or with jcmd:
jcmd <PID> Thread.print > thread-dump.txt
# Analysis: find BLOCKED threads
grep -A 5 "BLOCKED" thread-dump.txt
# Find threads waiting on a lock:
grep -B 2 "waiting to lock" thread-dump.txt
スレッドダンプの危険なパターン:
Many threads in WAITING state -> All waiting for the same condition (pool exhaustion)
BLOCKED "waiting to lock <0x...>" -> Lock contention
TIMED_WAITING "parking" -> May be OK (async queue) or stuck (idle)
RUNNABLE with "socketRead" -> Thread waiting on network I/O (blocking)
Async Profiler:CPUホットスポット検出
# Async Profiler: accurate CPU profiling without JVM safepoint bias
./profiler.sh -d 30 -f /tmp/flamegraph.html -e cpu,alloc <PID>
# Flame graph: wider bars mean more CPU time
# Look for:
# - Wide bars in serialization code -> optimize DTOs
# - Wide bars in reflection -> add AfterburnerModule
# - Wide bars in GC -> reduce allocation
# - Wide bars in JDBC -> N+1 queries, missing indexes
第14部:本番環境のパフォーマンスインシデント
インシデント1:APIレイテンシが80msから4秒にスパイク
症状: 午後2時、/api/ordersのP99レイテンシが80msから4,000msに急上昇しました。P50はまだ約90msでした。エラーレートはゼロに近く、CPUとメモリは正常でした。
調査:
-- Check for long-running queries
SELECT pid, now() - query_start AS duration, state, query
FROM pg_stat_activity
WHERE state != 'idle' AND now() - query_start > INTERVAL '1 second'
ORDER BY duration DESC;
-- Result: 1 query running for 3.5 seconds:
-- SELECT * FROM orders WHERE merchant_id = 123 ORDER BY created_at DESC
EXPLAIN ANALYZE SELECT * FROM orders
WHERE merchant_id = 123
ORDER BY created_at DESC
LIMIT 20;
-- Seq Scan on orders (cost=0.00..850000.00 rows=10000000)
-- Filter: merchant_id = 123
-- MISSING INDEX!
根本原因: マーチャント123はマーケティングキャンペーン後に10倍のトラフィック増加を経験しました。テーブルが小さかったため、以前はクエリは問題ありませんでした。今では1000万行あり、シーケンシャルスキャンに3-4秒かかります。
P50がまだOKだった理由: ほとんどのマーチャントは注文が少なく、クエリは高速です。P99は最大のマーチャントを反映しています。
修正:
CREATE INDEX CONCURRENTLY idx_orders_merchant_created
ON orders (merchant_id, created_at DESC);
-- CONCURRENTLY: does not block production traffic
-- Query time: 3.5s -> 3ms
予防策:
# Slow query logging is mandatory:
# postgresql.conf
log_min_duration_statement = 500 # Log queries taking more than 500ms
auto_explain.log_min_duration = 1000
インシデント2:フラッシュセール中のコネクションプール枯渇
症状: 午後3時のフラッシュセール開始後90秒以内に、すべてのリクエストがHikariPool: Connection is not available, request timed out after 5000msで失敗しました。データベースCPUは30%で、DBは忙しくありませんでした。
調査:
// Metrics during the incident:
// hikaricp_connections_active = 10 (MAX)
// hikaricp_connections_pending = 847
// api.orders.latency.p99 = 5000ms (timeout)
// Searching for why connections are held so long:
// Thread dump shows all 10 threads stuck at:
// at com.example.NotificationService.sendPushNotification(NotificationService.java:45)
// at com.example.OrderService.placeOrder(OrderService.java:78)
// Push notification call (Google FCM) is blocking the transaction!
根本原因: placeOrder()は@Transactionalメソッド内でFCMを呼び出していました。FCMはフラッシュセール中に2-10秒かかります。10スレッド掛ける10秒で、数分でプールが枯渇します。
修正:
// Before:
@Transactional
public OrderResponse placeOrder(OrderRequest request) {
Order order = createOrder(request);
notificationService.sendPushNotification(order); // holds connection for 2-10 seconds!
return OrderResponse.from(order);
}
// After:
@Transactional
public OrderResponse placeOrder(OrderRequest request) {
Order order = createOrder(request);
outboxRepo.save(OutboxMessage.forNotification(order)); // under 1ms
return OrderResponse.from(order);
// Transaction commits here, connection is released
}
// Outbox poller sends the notification asynchronously
インシデント3:Redisの障害がデータベースをクラッシュさせる
症状: 午後11時にRedisクラスターが障害を起こしました。10秒以内にデータベースCPUが20%から100%にスパイクしました。データベースはコネクションを落とし始め、プラットフォーム全体が20分間ダウンしました。
根本原因: キャッシュアバランシェ。100%のリクエストがRedisにヒットし、すべてミス(Redisがダウン)、すべてデータベースにヒットしました。データベースは瞬時に通常の10倍のトラフィックを受け取りました。データベースのサーキットブレーカーもレート制限もありませんでした。
調査タイムライン:
11:00:00 Redis master failure
11:00:01 Application detects Redis connection errors
11:00:02 All cache reads fail -> all requests hit DB
11:00:05 DB connection pool exhausted
11:00:10 DB CPU at 100%, queries timing out
11:00:15 Application cascade failing
11:00:30 On-call engineer paged
修正:
// 1. Graceful degradation when Redis fails:
public ProductDto getProduct(Long id) {
try {
ProductDto cached = redisTemplate.opsForValue().get("product:" + id);
if (cached != null) return cached;
} catch (RedisConnectionFailureException e) {
// Redis is down: log and continue to DB (do not rethrow!)
log.warn("Redis unavailable, falling back to DB for product {}", id);
redisDownMeter.increment();
}
return repo.findById(id).map(mapper::toDto).orElseThrow();
}
// 2. Local cache as a buffer when Redis is down:
@Cacheable(cacheManager = "localCacheManager", value = "products-local", key = "#id")
public ProductDto getProduct(Long id) {
// Caffeine local cache reduces DB hits when Redis is down
}
// 3. Circuit breaker for DB queries when Redis is down:
@CircuitBreaker(name = "database", fallbackMethod = "getDatabaseFallback")
public ProductDto getProductFromDb(Long id) {
return repo.findById(id).map(mapper::toDto).orElseThrow();
}
// If the DB is also overwhelmed: circuit breaker opens -> fail fast instead of cascading
インシデント4:リトライストームがプラットフォームをダウンさせる
症状: 決済サービスのデプロイが失敗し、一部のインスタンスが500を返しました。30秒以内にすべてのサービスが失敗し始め、プラットフォームは45分間到達不能になりました。
根本原因:
Payment service instances: 3
1 instance has a bad deploy -> returns 500
Order Service: calls Payment -> 500 -> retries 3 times (100ms wait each)
Each order request generates 4 payment requests (1 + 3 retries)
Traffic to payment increases by 4x
Bad payment instance becomes more overloaded -> 2 remaining instances slow down too
Retries happen for both remaining instances
Traffic increases to 16x (4 retries * 4 retries)
All payment instances down
Order service retry storm
All services cascade fail
修正:
// 1. Exponential backoff with jitter (reduces thundering herd)
// 2. Low max retry count (2-3, not 5-10)
// 3. Circuit breaker: stop retrying when failure rate is high
// 4. Total request timeout: even with retries, the total time must be capped
RetryConfig config = RetryConfig.custom()
.maxAttempts(2) // Only 2 retries
.waitDuration(Duration.ofMillis(200))
.intervalFunction(IntervalFunction.ofExponentialRandomBackoff(200, 2.0, 0.5, 2000))
.build();
// 5. Server-side rate limiting to protect the payment service
// 6. Separate retry budget: do not retry if there are already too many failures
インシデント5:シリアライゼーションが50MBのレスポンスを生成する
症状: あるAPIエンドポイントが非常にゆっくりとレスポンスを返す(30秒以上)、時々OOMを引き起こしていました。エンドポイントが呼び出されるたびにメモリが急増していました。
調査:
// Endpoint:
@GetMapping("/merchants/{id}/full-report")
public MerchantReport getFullReport(@PathVariable Long id) {
return merchantService.getFullReport(id);
}
// Service:
public MerchantReport getFullReport(Long id) {
Merchant merchant = merchantRepo.findById(id).orElseThrow();
// Hibernate EAGER loads everything:
// merchant.getOrders() -> 50,000 orders
// each order.getItems() -> 10 items
// each item.getProduct() -> full product with binary data
// Total: 50,000 * 10 * product (~100KB) = 50GB in the worst case
return new MerchantReport(merchant);
}
根本原因: オブジェクトグラフの爆発。EEAGERリレーションシップを持つエンティティと、グラフ全体を走査するシリアライザーの組み合わせ。
修正:
// 1. Never serialize entities directly -- always use DTOs
public record MerchantReportDto(
Long merchantId,
String merchantName,
long orderCount, // just a count, not a full list
BigDecimal totalRevenue,
List<OrderSummaryDto> recentOrders // only the 20 most recent orders
) {}
// 2. Database aggregation instead of loading all data
@Query("""
SELECT new com.example.MerchantReportDto(
m.id, m.name,
COUNT(o.id),
COALESCE(SUM(o.total), 0)
)
FROM Merchant m LEFT JOIN m.orders o
WHERE m.id = :id
GROUP BY m.id, m.name
""")
MerchantReportDto findReportById(@Param("id") Long id);
// 3. Streaming for large responses
@GetMapping(value = "/merchants/{id}/orders/export",
produces = MediaType.APPLICATION_NDJSON_VALUE)
public ResponseEntity<StreamingResponseBody> exportOrders(@PathVariable Long id) {
// newline-delimited JSON streaming
}
第15部:シニアエンジニアのパフォーマンスチェックリスト
ローンチ前:API設計レビュー
[] ページネーション:10K行以上のデータにはOFFSETではなくkeyset/cursorを使用する
[] レスポンスサイズ:スパースフィールドセットまたはプロジェクションが利用可能か?
無制限のデータを返せるエンドポイントは存在してはならない
[] フィルタリング:すべてのフィルターパラメーターに対応するインデックスがあるか?
[] ソーティング:インデックスのある列にのみソートを許可する
[] バルク操作:多くのアイテムが必要なユースケースのためのバッチエンドポイントが存在するか?
[] べき等性:すべてのPOST/PUTはべき等性キーをサポートしているか?
[] レート制限:悪用されやすいエンドポイントにはレート制限があるか?
[] すべてのエンドポイントでCache-Controlヘッダーが正しく設定されているか?
[] 機密エンドポイントはno-storeを使用しているか?
ローンチ前:データベースレビュー
[] 本番データ量でのEXPLAIN ANALYZEをすべてのクエリで実行する
[] 100K行以上のテーブルにSeq Scanがない
[] N+1クエリ:インテグレーションテストでクエリカウンターを使って検証済み
[] すべての外部キーにインデックスがある
[] 本番コードにSELECT *がない
[] インスタンス数に対してコネクションプールが正しくサイジングされている
[] ステートメントタイムアウトが設定されている(ハングしたクエリを防ぐ)
[] 読み取り専用トランザクションは@Transactional(readOnly=true)を使用している
[] バッチ操作は定期的にパーシステンスコンテキストをクリアする
ローンチ前:負荷テスト
[] 本番に近いデータ量での負荷テスト(100行ではない)
[] P50、P95、P99のテスト(平均だけではない)
[] 予想ピークトラフィックの1x、2x、5xでテスト
[] 持続的な負荷のテスト(スパイクだけではない)
[] グレースフルデグラデーションのテスト:Redisをシャットダウン、外部サービスをシャットダウン
[] 負荷下でのコネクションプール動作を検証済み
[] 負荷下でのGC動作を検証済み(200msを超えるGC停止なし)
[] 遅いまたは失敗するダウンストリームサービスでのリトライ動作をテスト済み
監視チェックリスト
[] レイテンシ:エンドポイントごとのP50、P95、P99アラート
[] エラーレート:0.1%を超えたらアラート
[] スループット:20%以上低下したらアラート(問題の兆候)
[] HikariCP:保留中のコネクション、タイムアウト数
[] JVM:ヒープ使用量、GC停止時間と頻度
[] 外部サービス:P99レイテンシ、エラーレート、サーキットブレーカーの状態
[] データベース:スロークエリ(500ms以上)、コネクション数、デッドロック
[] キャッシュ:ヒット率、エビクション率、メモリ使用量
[] スレッドプール:アクティブスレッド数、キューサイズ
インシデント対応:パフォーマンストリアージ
レイテンシスパイク:
1. エラーレートを確認 -- 相関があるか?
2. データベースのスロークエリを確認(pg_stat_activity)
3. コネクションプールメトリクスを確認(pending > 0か?)
4. 外部サービスのレイテンシを確認(トレース)
5. GCアクティビティを確認(JVMメトリクス)
6. スレッドプールを確認 -- ブロックされたスレッドがあるか?
7. 最近のデプロイを確認 -- 誰が何をデプロイしたか?
DBの場合:
-> スロークエリをEXPLAIN ANALYZE
-> 欠落インデックスを確認
-> ロック競合を確認(pg_locks)
外部サービスの場合:
-> サーキットブレーカーの状態を確認
-> リトライ動作を確認 -- リトライストームか?
JVMの場合:
-> スレッドダンプ -> BLOCKEDスレッドを見つける
-> GCログ -> メジャーGCの頻度が高すぎるか?
-> Async profiler -> CPUホットスポット
結論
パフォーマンスエンジニアリングは、すべてにキャッシュを追加したり、スレッド数を増やしたりすることではありません。システムを読み解く能力です。レイテンシがどこから来るのか、なぜP99がP50からこれほど乖離するのか、なぜデータベースが一定のスケールを超えるとボトルネックになるのか、そしてなぜリトライストームがプラットフォーム全体をダウンさせることができるのかを理解することです。
ほとんどのシステムで一貫して正しい3つのことがあります。
1. 最適化する前にプロファイリングする。 ボトルネックを推測することはたいてい間違っています。JFR、Async Profiler、分散トレーシングは時間が実際にどこに行くかを教えてくれます。測定していないものを最適化してはいけません。
2. データベースが通常ボトルネックです。まずそこを修正する。 欠落インデックス、N+1クエリ、データのオーバーフェッチングが新しいAPIのパフォーマンス問題の80%を占めます。EXPLAIN ANALYZEはあなたが持つ最も重要なツールです。
3. ハッピーパスだけでなく、障害に対して設計する。 タイムアウト、サーキットブレーカー、バックオフ付きリトライ、キャッシュがダウンしたときのグレースフルデグラデーション。これらは通常時のパフォーマンスには影響しませんが、P99とシステムがストレス下での動作を決定します。本番環境でのあなたのP99は、インシデント時のP50です。
シニアエンジニアはすべての最適化テクニックを暗記しているわけではありません。リクエストライフサイクルのメンタルモデルを持ち、正しい質問の仕方を知り、ボトルネックを見つけるためにデータを読む方法を知っています。それがこの記事が伝えようとしていることです。