Performance Tuning and Caching

Level: Advanced Module: Architecture 24 min read Lesson 30 of 47

Overview

  • What you’ll learn:
    • How iDempiere’s CacheMgt system and CCache class work, and how to use caching effectively in your custom code
    • How to optimize SQL queries, design indexes for custom tables, and tune PostgreSQL configuration for iDempiere workloads
    • How to tune JVM settings, configure connection pools, monitor performance, and diagnose common bottlenecks
  • Prerequisites: Lesson 2 — iDempiere Architecture Overview, Lesson 15 — Building Your First Plugin
  • Estimated reading time: 24 minutes

Introduction

A well-configured iDempiere installation can serve hundreds of concurrent users processing thousands of transactions daily. However, performance does not happen by accident — it requires deliberate attention to caching, query optimization, JVM configuration, and database tuning. Performance problems in ERP systems are particularly costly because they affect every user and every business process, from order entry to financial reporting.

This lesson covers performance optimization at every layer of the iDempiere stack: the application-level cache, the Java Virtual Machine, the database engine, and the connection pool. You will learn both the theory behind each optimization and the practical configuration changes to implement it.

The CacheMgt System

iDempiere’s Cache Manager (CacheMgt) is a centralized system that coordinates all application-level caches. It tracks every cache instance, provides global statistics, and supports targeted or full cache resets. Understanding CacheMgt is essential because iDempiere relies heavily on caching to avoid repeated database queries for configuration data, reference lists, and frequently accessed records.

How CacheMgt Works

When iDempiere starts, various subsystems register their caches with CacheMgt. Each cache is identified by a table name (e.g., AD_Column, C_BPartner) and stores key-value pairs in memory. When data changes — through the UI, API, or background processes — the cache manager can selectively invalidate affected caches.

// CacheMgt provides these key operations:
CacheMgt cacheMgt = CacheMgt.get();

// Get total number of cached objects across all caches
int totalCached = cacheMgt.getElementCount();

// Reset all caches (expensive — use sparingly)
cacheMgt.reset();

// Reset caches for a specific table
cacheMgt.reset(MProduct.Table_Name);

// Reset a specific record in caches
cacheMgt.reset(MProduct.Table_Name, productId);

Cache Statistics

Monitor cache effectiveness through the iDempiere System Admin menu. Navigate to System Admin > Cache Management to view all registered caches, their sizes, and hit/miss statistics. This information helps you identify which caches are effective and which might need tuning.

The CCache Class

CCache is iDempiere’s primary cache implementation — a hash map that automatically registers itself with CacheMgt and supports time-based expiration. You will use CCache extensively when building plugins that need to cache data for performance.

Basic Usage

import org.compiere.util.CCache;

public class ProductCategoryCache {

    // Cache declaration: tableName, cacheSize (initial capacity), expireMinutes
    private static CCache<Integer, MProductCategory> s_cache =
        new CCache<>(MProductCategory.Table_Name, 50, 60);

    /**
     * Get a product category, using cache to avoid repeated DB queries.
     */
    public static MProductCategory get(int M_Product_Category_ID) {
        // Check cache first
        MProductCategory category = s_cache.get(M_Product_Category_ID);
        if (category != null)
            return category;

        // Cache miss — load from database
        category = new MProductCategory(Env.getCtx(), M_Product_Category_ID, null);

        // Store in cache for future requests
        if (category.get_ID() > 0)
            s_cache.put(M_Product_Category_ID, category);

        return category;
    }
}

CCache Constructor Parameters

The CCache constructor accepts several parameters that control its behavior:

// Basic: table name and initial capacity
CCache<Integer, MProduct> cache1 = new CCache<>("M_Product", 100);

// With expiration: entries expire after 120 minutes
CCache<Integer, MProduct> cache2 = new CCache<>("M_Product", 100, 120);

// With distributed cache support (for clustered deployments)
CCache<Integer, MProduct> cache3 = new CCache<>("M_Product", "M_Product",
    100, 120, false, 500);
  • Table name (first parameter): Used by CacheMgt to identify which caches to reset when data changes in a specific table. Use the actual table name so cache invalidation works correctly.
  • Initial capacity: Pre-allocates space for the expected number of entries. Set this to your expected cache size to avoid rehashing.
  • Expire minutes: Entries older than this are automatically evicted. Set to 0 for no expiration. Use shorter expiration for frequently changing data and longer expiration (or no expiration) for stable reference data.
  • Max size: Limits the maximum number of entries. When exceeded, the least recently used entries are evicted.

Cache Patterns for Common Scenarios

Immutable Reference Data

For data that rarely changes (countries, currencies, UOMs), use long or no expiration:

// Cache with no expiration — data only refreshes on explicit reset
private static CCache<Integer, MCountry> s_countries =
    new CCache<>(MCountry.Table_Name, 250, 0);

Frequently Changing Data

For volatile data (pricing, inventory levels), use short expiration or avoid caching entirely:

// Cache with 5-minute expiration for pricing data
private static CCache<String, BigDecimal> s_priceCache =
    new CCache<>("M_ProductPrice", 500, 5);

Composite Cache Keys

When the cache key involves multiple fields, create a composite key:

// Cache keyed by org + product combination
private static CCache<String, BigDecimal> s_qtyCache =
    new CCache<>("M_StorageOnHand", 1000, 10);

public static BigDecimal getQtyOnHand(int orgId, int productId) {
    String key = orgId + "_" + productId;
    BigDecimal qty = s_qtyCache.get(key);
    if (qty != null) return qty;
    // ... load from database and cache
}

Cache Invalidation

Cache invalidation is one of the hardest problems in software engineering. iDempiere provides several mechanisms:

Automatic Invalidation

When a record is saved through iDempiere’s PO (Persistent Object) framework, the framework automatically calls CacheMgt.reset(tableName, recordId). This means caches registered with the correct table name are automatically invalidated when data changes through the UI or API.

Manual Invalidation

If data changes outside the PO framework (direct SQL updates, external processes), you must manually invalidate affected caches:

// After a direct SQL update to product prices
DB.executeUpdate("UPDATE M_ProductPrice SET PriceStd = 99.99 WHERE ...", null);
CacheMgt.get().reset("M_ProductPrice");  // Invalidate price caches

Cache Reset via UI

Administrators can reset all caches from the System Admin > Cache Reset window. This is useful after bulk data imports or direct database modifications. In a multi-node deployment, cache reset messages are broadcast to all nodes.

Query Optimization

Even with effective caching, many operations require database queries. Optimizing these queries has a direct impact on user experience and system throughput.

Using EXPLAIN to Analyze Queries

PostgreSQL’s EXPLAIN ANALYZE command reveals exactly how the database executes a query and where time is spent:

-- Analyze a slow query
EXPLAIN ANALYZE
SELECT bp.C_BPartner_ID, bp.Name, bp.Value, loc.City
FROM C_BPartner bp
JOIN C_BPartner_Location bpl ON bp.C_BPartner_ID = bpl.C_BPartner_ID
JOIN C_Location loc ON bpl.C_Location_ID = loc.C_Location_ID
WHERE bp.IsCustomer = 'Y'
  AND bp.IsActive = 'Y'
  AND bp.AD_Client_ID = 11
ORDER BY bp.Name;

Key things to look for in the EXPLAIN output:

  • Seq Scan on large tables — indicates a missing index.
  • Nested Loop with high row counts — may indicate a need for a different join strategy or index.
  • Sort operations on large result sets — consider adding an index that matches the ORDER BY clause.
  • Actual time vs. estimated time — large discrepancies indicate stale statistics; run ANALYZE on the affected tables.

Index Strategies for Custom Tables

When creating custom tables for your plugins, design indexes thoughtfully:

-- Index for frequently filtered columns
CREATE INDEX idx_custom_table_status
ON custom_table (DocStatus)
WHERE IsActive = 'Y';

-- Composite index for common query patterns
CREATE INDEX idx_custom_table_partner_date
ON custom_table (C_BPartner_ID, DateOrdered DESC);

-- Partial index for active records only (reduces index size)
CREATE INDEX idx_custom_table_active
ON custom_table (Value)
WHERE IsActive = 'Y';

-- Index for foreign keys (critical for JOIN performance)
CREATE INDEX idx_custom_line_header
ON custom_line_table (custom_header_id);

Index design guidelines for iDempiere:

  • Always index foreign key columns — iDempiere windows frequently join parent-child tables.
  • Index columns used in WHERE clauses of common queries and report parameters.
  • Use partial indexes with WHERE IsActive = 'Y' since most queries filter on active records.
  • Avoid over-indexing — each index slows down INSERT and UPDATE operations.
  • For columns with low cardinality (e.g., IsSOTrx with only Y/N values), indexes are only helpful when combined with other columns or as partial indexes.

Query Best Practices in Plugin Code

// GOOD: Use Query class with parameters (prevents SQL injection, enables plan caching)
List<MOrder> orders = new Query(ctx, MOrder.Table_Name,
    "C_BPartner_ID=? AND DocStatus IN (?,?) AND DateOrdered>=?", trxName)
    .setParameters(bpartnerId, "CO", "CL", startDate)
    .setApplyAccessFilter(true)
    .setOrderBy("DateOrdered DESC")
    .list();

// BAD: String concatenation (SQL injection risk, no plan caching)
String sql = "SELECT * FROM C_Order WHERE C_BPartner_ID=" + bpartnerId;

// GOOD: Retrieve only needed columns for large result sets
int count = new Query(ctx, MOrder.Table_Name,
    "C_BPartner_ID=? AND DocStatus='CO'", null)
    .setParameters(bpartnerId)
    .count();

// GOOD: Use first() instead of list() when you expect a single result
MBPartner bp = new Query(ctx, MBPartner.Table_Name, "Value=?", null)
    .setParameters(customerCode)
    .first();

Lazy Loading Patterns

Avoid loading data until it is actually needed. This is particularly important for records with large text fields or many related child records:

// Lazy loading pattern
public class OrderProcessor {

    private MBPartner partner;  // loaded on demand

    private MBPartner getPartner(MOrder order) {
        if (partner == null || partner.getC_BPartner_ID() != order.getC_BPartner_ID()) {
            partner = MBPartner.get(Env.getCtx(), order.getC_BPartner_ID());
        }
        return partner;
    }

    public void processOrders(List<MOrder> orders) {
        for (MOrder order : orders) {
            // Partner is only loaded when needed and reused across orders
            // for the same business partner
            MBPartner bp = getPartner(order);
            // ... process order
        }
    }
}

JVM Tuning for iDempiere

iDempiere runs on the Java Virtual Machine, and JVM configuration significantly affects performance. The key settings are in the idempiereEnv.properties file or the startup script.

Heap Size Configuration

# Set in idempiere-server.sh or idempiere.ini
# Minimum heap size — set equal to max to avoid resize pauses
-Xms2g
# Maximum heap size — typically 50-75% of available RAM
-Xmx4g

Sizing guidelines:

  • Small deployment (1-10 users): 1-2 GB heap
  • Medium deployment (10-50 users): 2-4 GB heap
  • Large deployment (50-200 users): 4-8 GB heap
  • Enterprise deployment (200+ users): 8-16 GB heap

Set -Xms equal to -Xmx to prevent the JVM from wasting time growing and shrinking the heap.

Garbage Collection Settings

Modern JVMs (Java 17+) default to the G1 garbage collector, which is generally well-suited for iDempiere. Fine-tune it for your workload:

# Use G1 garbage collector (default in Java 17)
-XX:+UseG1GC
# Set maximum GC pause target (milliseconds)
-XX:MaxGCPauseMillis=200
# Set region size for large heaps
-XX:G1HeapRegionSize=16m
# Enable GC logging for analysis
-Xlog:gc*:file=/opt/idempiere/log/gc.log:time,uptime,level,tags:filecount=5,filesize=20m

For iDempiere workloads characterized by many short-lived objects (request processing) and some long-lived objects (caches), G1’s default behavior is usually optimal. If you observe long GC pauses, consider ZGC (-XX:+UseZGC) which provides sub-millisecond pause times but uses more CPU.

Other JVM Settings

# Metaspace for class metadata (OSGi loads many classes)
-XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize=512m

# Thread stack size (reduce if you have many concurrent users)
-Xss512k

# String deduplication (reduces memory for repeated strings)
-XX:+UseStringDeduplication

PostgreSQL Tuning

The database is often the primary bottleneck in ERP systems. PostgreSQL’s default configuration is extremely conservative and must be tuned for production iDempiere workloads.

Key Configuration Parameters

Edit postgresql.conf with these recommended settings:

# Memory settings
shared_buffers = '2GB'          # 25% of total RAM (e.g., 2GB for 8GB server)
effective_cache_size = '6GB'    # 75% of total RAM (tells planner about OS cache)
work_mem = '64MB'               # Per-sort operation memory (increase for complex reports)
maintenance_work_mem = '512MB'  # For VACUUM, CREATE INDEX, etc.

# Write-ahead log
wal_buffers = '64MB'            # WAL buffer size
checkpoint_completion_target = 0.9  # Spread checkpoint writes
max_wal_size = '2GB'            # Maximum WAL size before checkpoint

# Query planner
random_page_cost = 1.1          # Set to 1.1 for SSD storage (default 4.0 is for HDD)
effective_io_concurrency = 200  # For SSD storage (default 1 is for HDD)

# Parallelism
max_parallel_workers_per_gather = 4
max_parallel_workers = 8
max_worker_processes = 8

# Connection settings
max_connections = 200           # Match your connection pool max + admin overhead

Tuning Guidelines by Server Size

Parameter 4GB RAM 8GB RAM 16GB RAM 32GB RAM
shared_buffers 1GB 2GB 4GB 8GB
effective_cache_size 3GB 6GB 12GB 24GB
work_mem 32MB 64MB 128MB 256MB
maintenance_work_mem 256MB 512MB 1GB 2GB

Autovacuum Configuration

PostgreSQL’s autovacuum process reclaims dead row space and updates statistics. For iDempiere’s write-heavy tables (C_Order, C_Invoice, AD_PInstance), tune autovacuum to run more aggressively:

# Run autovacuum more frequently on busy tables
autovacuum_vacuum_scale_factor = 0.05     # Default 0.2 — trigger at 5% dead rows
autovacuum_analyze_scale_factor = 0.02    # Default 0.1 — update stats at 2% changes
autovacuum_vacuum_cost_delay = '2ms'      # Default 2ms — acceptable for SSDs

Connection Pool Configuration

iDempiere uses a database connection pool to reuse database connections rather than creating a new connection for each request. Proper pool sizing is critical.

# In idempiereEnv.properties or connection pool configuration
# Minimum idle connections — keep some connections ready
db.connection.pool.min=10
# Maximum connections — limit to prevent database overload
db.connection.pool.max=50
# Connection validation — test connections before use
db.connection.pool.testOnBorrow=true
# Evict idle connections after 30 minutes
db.connection.pool.minEvictableIdleTimeMillis=1800000

The maximum pool size should be less than PostgreSQL’s max_connections setting, leaving room for administrative connections and other applications. A good rule of thumb: set the pool maximum to the expected number of concurrent active users plus 20% overhead.

Monitoring and Profiling

Identifying Slow Queries

Enable PostgreSQL’s slow query log to identify performance bottlenecks:

# In postgresql.conf
log_min_duration_statement = 1000   # Log queries taking more than 1 second
log_statement = 'none'              # Don't log all statements (too verbose)
log_line_prefix = '%t [%p] %u@%d '  # Include timestamp, PID, user, database

JVM Monitoring

Use JMX (Java Management Extensions) to monitor iDempiere’s JVM in real-time:

# Enable JMX in startup script
-Dcom.sun.management.jmxremote
-Dcom.sun.management.jmxremote.port=9090
-Dcom.sun.management.jmxremote.ssl=false
-Dcom.sun.management.jmxremote.authenticate=true

Connect to the JMX port using tools like VisualVM, JConsole, or Grafana with a JMX exporter. Monitor these key metrics:

  • Heap usage: Should stay below 80% of max heap. Consistently higher indicates a memory leak or insufficient heap.
  • GC frequency and pause times: Frequent long pauses indicate heap sizing or GC algorithm issues.
  • Thread count: Excessively high thread counts suggest thread leaks or too many concurrent sessions.
  • CPU usage: Sustained high CPU may indicate inefficient queries or insufficient caching.

PostgreSQL Monitoring Queries

-- Active queries and their duration
SELECT pid, now() - pg_stat_activity.query_start AS duration,
       query, state
FROM pg_stat_activity
WHERE state != 'idle'
ORDER BY duration DESC;

-- Table access statistics (identifies hot tables)
SELECT relname, seq_scan, seq_tup_read, idx_scan, idx_tup_fetch
FROM pg_stat_user_tables
ORDER BY seq_tup_read DESC
LIMIT 20;

-- Index usage statistics (identifies unused indexes)
SELECT indexrelname, idx_scan, idx_tup_read
FROM pg_stat_user_indexes
ORDER BY idx_scan ASC
LIMIT 20;

-- Cache hit ratio (should be above 99%)
SELECT
  sum(heap_blks_hit) / (sum(heap_blks_hit) + sum(heap_blks_read)) AS cache_hit_ratio
FROM pg_statio_user_tables;

Common Performance Bottlenecks

Here are the most frequent performance issues encountered in iDempiere deployments and their solutions:

Problem: Slow Window Loading

Cause: Windows with many fields and lookups generate numerous queries to populate dropdown lists and reference data.

Solution: Ensure Application Dictionary caches are warmed up. Review the window’s tabs and remove unnecessary fields. Add indexes on lookup columns.

Problem: Report Timeouts

Cause: Complex reports with large date ranges query millions of rows without proper indexing.

Solution: Add indexes that match the report’s WHERE clause parameters. Use materialized views for complex aggregations. Increase work_mem for sort-heavy reports.

Problem: Document Processing Slowdowns

Cause: Completing orders or invoices with many lines triggers cascading model validators and callouts.

Solution: Profile model validators to identify slow ones. Use batch processing for bulk operations. Ensure validators use cached data rather than querying the database for each line.

Problem: High Memory Usage

Cause: Large caches, memory leaks in plugins, or sessions not being properly cleaned up.

Solution: Review CCache sizes and expiration settings. Use heap dump analysis (jmap, Eclipse MAT) to identify memory hogs. Implement session timeout policies.

Summary

Performance tuning is a systematic discipline, not a one-time task. You learned how to leverage iDempiere’s CCache system for application-level caching, optimize database queries with proper indexing and EXPLAIN analysis, tune the JVM for your workload, configure PostgreSQL for production use, and monitor the entire stack to identify and resolve bottlenecks. Apply these techniques incrementally, measuring the impact of each change, and your iDempiere deployment will serve your users reliably at scale. In the next lesson, we shift focus to the user interface and explore advanced UI customization techniques using ZK components and dashboard gadgets.

繁體中文翻譯

概覽

  • 您將學到:
    • iDempiere 的 CacheMgt 系統和 CCache 類別如何運作,以及如何在自訂程式碼中有效使用快取
    • 如何最佳化 SQL 查詢、為自訂表格設計索引,以及針對 iDempiere 工作負載調校 PostgreSQL 設定
    • 如何調校 JVM 設定、設定連線池、監控效能,以及診斷常見瓶頸
  • 先備知識: 第 2 課 — iDempiere 架構概覽、第 15 課 — 建構您的第一個外掛
  • 預估閱讀時間: 24 分鐘

簡介

一個設定良好的 iDempiere 安裝可以服務數百位同時使用者,每天處理數千筆交易。然而,效能不會自然發生 — 它需要對快取、查詢最佳化、JVM 設定和資料庫調校的刻意關注。ERP 系統中的效能問題尤其代價高昂,因為它們影響每位使用者和每個業務流程,從訂單輸入到財務報告。

本課涵蓋 iDempiere 堆疊每一層的效能最佳化:應用程式層級的快取、Java 虛擬機、資料庫引擎和連線池。您將學習每項最佳化背後的理論和實作的實際設定變更。

CacheMgt 系統

iDempiere 的快取管理員(CacheMgt)是一個集中式系統,協調所有應用程式層級的快取。它追蹤每個快取實例、提供全域統計資料,並支援針對性或全面的快取重設。理解 CacheMgt 至關重要,因為 iDempiere 嚴重依賴快取來避免對設定資料、參照清單和常用記錄的重複資料庫查詢。

CacheMgt 的運作方式

當 iDempiere 啟動時,各個子系統向 CacheMgt 註冊其快取。每個快取由表格名稱(例如 AD_ColumnC_BPartner)識別,並在記憶體中儲存鍵值對。當資料變更時 — 透過 UI、API 或背景處理 — 快取管理員可以選擇性地使受影響的快取失效。

// CacheMgt 提供以下關鍵操作:
CacheMgt cacheMgt = CacheMgt.get();

// 取得所有快取中的快取物件總數
int totalCached = cacheMgt.getElementCount();

// 重設所有快取(代價高昂 — 謹慎使用)
cacheMgt.reset();

// 重設特定表格的快取
cacheMgt.reset(MProduct.Table_Name);

// 重設快取中的特定記錄
cacheMgt.reset(MProduct.Table_Name, productId);

快取統計

透過 iDempiere 系統管理員選單監控快取效率。導覽至系統管理 > 快取管理以檢視所有已註冊的快取、其大小和命中/未命中統計。此資訊可幫助您識別哪些快取有效,哪些可能需要調整。

CCache 類別

CCache 是 iDempiere 的主要快取實作 — 一個會自動向 CacheMgt 註冊並支援基於時間過期的雜湊映射。在建構需要快取資料以提升效能的外掛時,您將廣泛使用 CCache

基本用法

import org.compiere.util.CCache;

public class ProductCategoryCache {

    // 快取宣告:tableName, cacheSize(初始容量), expireMinutes
    private static CCache<Integer, MProductCategory> s_cache =
        new CCache<>(MProductCategory.Table_Name, 50, 60);

    /**
     * 取得產品類別,使用快取以避免重複的資料庫查詢。
     */
    public static MProductCategory get(int M_Product_Category_ID) {
        // 先檢查快取
        MProductCategory category = s_cache.get(M_Product_Category_ID);
        if (category != null)
            return category;

        // 快取未命中 — 從資料庫載入
        category = new MProductCategory(Env.getCtx(), M_Product_Category_ID, null);

        // 儲存到快取以供未來請求使用
        if (category.get_ID() > 0)
            s_cache.put(M_Product_Category_ID, category);

        return category;
    }
}

CCache 建構函式參數

CCache 建構函式接受幾個控制其行為的參數:

// 基本:表格名稱和初始容量
CCache<Integer, MProduct> cache1 = new CCache<>("M_Product", 100);

// 帶過期時間:條目在 120 分鐘後過期
CCache<Integer, MProduct> cache2 = new CCache<>("M_Product", 100, 120);

// 帶分散式快取支援(用於叢集部署)
CCache<Integer, MProduct> cache3 = new CCache<>("M_Product", "M_Product",
    100, 120, false, 500);
  • 表格名稱(第一個參數): 由 CacheMgt 用於識別在特定表格中資料變更時要重設哪些快取。使用實際的表格名稱,以便快取失效正確運作。
  • 初始容量: 為預期的條目數量預先分配空間。將此設定為您預期的快取大小,以避免重新雜湊。
  • 過期分鐘數: 超過此時間的條目會自動被驅逐。設定為 0 表示不過期。對於經常變更的資料使用較短的過期時間,對於穩定的參照資料使用較長的過期時間(或不過期)。
  • 最大大小: 限制條目的最大數量。超過時,最近最少使用的條目會被驅逐。

常見場景的快取模式

不可變的參照資料

對於很少變更的資料(國家、貨幣、計量單位),使用長期或不過期:

// 不過期的快取 — 資料僅在明確重設時重新整理
private static CCache<Integer, MCountry> s_countries =
    new CCache<>(MCountry.Table_Name, 250, 0);

經常變更的資料

對於易變資料(定價、庫存水準),使用短過期時間或完全避免快取:

// 定價資料使用 5 分鐘過期的快取
private static CCache<String, BigDecimal> s_priceCache =
    new CCache<>("M_ProductPrice", 500, 5);

複合快取鍵

當快取鍵涉及多個欄位時,建立複合鍵:

// 以組織 + 產品組合為鍵的快取
private static CCache<String, BigDecimal> s_qtyCache =
    new CCache<>("M_StorageOnHand", 1000, 10);

public static BigDecimal getQtyOnHand(int orgId, int productId) {
    String key = orgId + "_" + productId;
    BigDecimal qty = s_qtyCache.get(key);
    if (qty != null) return qty;
    // ... 從資料庫載入並快取
}

快取失效

快取失效是軟體工程中最困難的問題之一。iDempiere 提供了幾種機制:

自動失效

當記錄透過 iDempiere 的 PO(持久化物件)框架儲存時,框架會自動呼叫 CacheMgt.reset(tableName, recordId)。這意味著使用正確表格名稱註冊的快取在資料透過 UI 或 API 變更時會自動失效。

手動失效

如果資料在 PO 框架之外變更(直接 SQL 更新、外部處理),您必須手動使受影響的快取失效:

// 在對產品價格的直接 SQL 更新之後
DB.executeUpdate("UPDATE M_ProductPrice SET PriceStd = 99.99 WHERE ...", null);
CacheMgt.get().reset("M_ProductPrice");  // 使價格快取失效

透過 UI 重設快取

管理員可以從系統管理 > 快取重設視窗重設所有快取。這在批量資料匯入或直接資料庫修改後很有用。在多節點部署中,快取重設訊息會廣播到所有節點。

查詢最佳化

即使有效的快取,許多操作仍需要資料庫查詢。最佳化這些查詢對使用者體驗和系統吞吐量有直接影響。

使用 EXPLAIN 分析查詢

PostgreSQL 的 EXPLAIN ANALYZE 指令揭示資料庫如何執行查詢以及時間花在哪裡:

-- 分析慢查詢
EXPLAIN ANALYZE
SELECT bp.C_BPartner_ID, bp.Name, bp.Value, loc.City
FROM C_BPartner bp
JOIN C_BPartner_Location bpl ON bp.C_BPartner_ID = bpl.C_BPartner_ID
JOIN C_Location loc ON bpl.C_Location_ID = loc.C_Location_ID
WHERE bp.IsCustomer = 'Y'
  AND bp.IsActive = 'Y'
  AND bp.AD_Client_ID = 11
ORDER BY bp.Name;

在 EXPLAIN 輸出中要注意的關鍵事項:

  • Seq Scan(順序掃描)在大型表格上 — 表示缺少索引。
  • Nested Loop(巢狀迴圈)具有高行數 — 可能表示需要不同的聯結策略或索引。
  • 大型結果集上的 Sort(排序)操作 — 考慮加入與 ORDER BY 子句匹配的索引。
  • 實際時間與估計時間 — 大的差異表示統計資料過時;在受影響的表格上執行 ANALYZE

自訂表格的索引策略

為外掛建立自訂表格時,仔細設計索引:

-- 經常篩選欄位的索引
CREATE INDEX idx_custom_table_status
ON custom_table (DocStatus)
WHERE IsActive = 'Y';

-- 常見查詢模式的複合索引
CREATE INDEX idx_custom_table_partner_date
ON custom_table (C_BPartner_ID, DateOrdered DESC);

-- 僅限有效記錄的部分索引(減少索引大小)
CREATE INDEX idx_custom_table_active
ON custom_table (Value)
WHERE IsActive = 'Y';

-- 外鍵索引(對 JOIN 效能至關重要)
CREATE INDEX idx_custom_line_header
ON custom_line_table (custom_header_id);

iDempiere 的索引設計準則:

  • 始終索引外鍵欄位 — iDempiere 視窗經常聯結父子表格。
  • 索引在常見查詢和報表參數的 WHERE 子句中使用的欄位。
  • 使用 WHERE IsActive = 'Y' 的部分索引,因為大多數查詢都會篩選有效記錄。
  • 避免過度索引 — 每個索引都會減慢 INSERT 和 UPDATE 操作。
  • 對於低基數欄位(例如只有 Y/N 值的 IsSOTrx),索引僅在與其他欄位組合或作為部分索引時才有用。

外掛程式碼中的查詢最佳實務

// 良好:使用 Query 類別和參數(防止 SQL 注入,啟用計畫快取)
List<MOrder> orders = new Query(ctx, MOrder.Table_Name,
    "C_BPartner_ID=? AND DocStatus IN (?,?) AND DateOrdered>=?", trxName)
    .setParameters(bpartnerId, "CO", "CL", startDate)
    .setApplyAccessFilter(true)
    .setOrderBy("DateOrdered DESC")
    .list();

// 不好:字串串接(SQL 注入風險,無計畫快取)
String sql = "SELECT * FROM C_Order WHERE C_BPartner_ID=" + bpartnerId;

// 良好:大型結果集僅擷取需要的欄位
int count = new Query(ctx, MOrder.Table_Name,
    "C_BPartner_ID=? AND DocStatus='CO'", null)
    .setParameters(bpartnerId)
    .count();

// 良好:預期單一結果時使用 first() 而非 list()
MBPartner bp = new Query(ctx, MBPartner.Table_Name, "Value=?", null)
    .setParameters(customerCode)
    .first();

延遲載入模式

避免在實際需要之前載入資料。這對於具有大型文字欄位或許多相關子記錄的記錄特別重要:

// 延遲載入模式
public class OrderProcessor {

    private MBPartner partner;  // 按需載入

    private MBPartner getPartner(MOrder order) {
        if (partner == null || partner.getC_BPartner_ID() != order.getC_BPartner_ID()) {
            partner = MBPartner.get(Env.getCtx(), order.getC_BPartner_ID());
        }
        return partner;
    }

    public void processOrders(List<MOrder> orders) {
        for (MOrder order : orders) {
            // 夥伴僅在需要時載入,並在相同業務夥伴的
            // 訂單間重複使用
            MBPartner bp = getPartner(order);
            // ... 處理訂單
        }
    }
}

iDempiere 的 JVM 調校

iDempiere 在 Java 虛擬機上執行,JVM 設定對效能有顯著影響。關鍵設定位於 idempiereEnv.properties 檔案或啟動腳本中。

堆積大小設定

# 在 idempiere-server.sh 或 idempiere.ini 中設定
# 最小堆積大小 — 設定為與最大值相同以避免調整大小暫停
-Xms2g
# 最大堆積大小 — 通常為可用 RAM 的 50-75%
-Xmx4g

大小準則:

  • 小型部署(1-10 位使用者): 1-2 GB 堆積
  • 中型部署(10-50 位使用者): 2-4 GB 堆積
  • 大型部署(50-200 位使用者): 4-8 GB 堆積
  • 企業級部署(200+ 位使用者): 8-16 GB 堆積

-Xms 設定為與 -Xmx 相同,以防止 JVM 浪費時間擴展和縮減堆積。

垃圾回收設定

現代 JVM(Java 17+)預設使用 G1 垃圾回收器,通常適合 iDempiere。針對您的工作負載進行微調:

# 使用 G1 垃圾回收器(Java 17 預設)
-XX:+UseG1GC
# 設定最大 GC 暫停目標(毫秒)
-XX:MaxGCPauseMillis=200
# 設定大型堆積的區域大小
-XX:G1HeapRegionSize=16m
# 啟用 GC 日誌以供分析
-Xlog:gc*:file=/opt/idempiere/log/gc.log:time,uptime,level,tags:filecount=5,filesize=20m

對於 iDempiere 工作負載,其特點是許多短期物件(請求處理)和一些長期物件(快取),G1 的預設行為通常是最佳的。如果觀察到長時間的 GC 暫停,考慮使用 ZGC(-XX:+UseZGC),它提供亞毫秒級的暫停時間,但使用更多 CPU。

其他 JVM 設定

# 類別中繼資料的 Metaspace(OSGi 載入許多類別)
-XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize=512m

# 執行緒堆疊大小(如果有許多同時使用者則減少)
-Xss512k

# 字串去重(減少重複字串的記憶體使用)
-XX:+UseStringDeduplication

PostgreSQL 調校

資料庫通常是 ERP 系統的主要瓶頸。PostgreSQL 的預設設定極為保守,必須針對正式的 iDempiere 工作負載進行調校。

關鍵設定參數

使用以下建議設定編輯 postgresql.conf

# 記憶體設定
shared_buffers = '2GB'          # 總 RAM 的 25%(例如 8GB 伺服器使用 2GB)
effective_cache_size = '6GB'    # 總 RAM 的 75%(告訴規劃器有關 OS 快取)
work_mem = '64MB'               # 每次排序操作的記憶體(複雜報表時增加)
maintenance_work_mem = '512MB'  # 用於 VACUUM、CREATE INDEX 等

# 預寫日誌
wal_buffers = '64MB'            # WAL 緩衝區大小
checkpoint_completion_target = 0.9  # 分散檢查點寫入
max_wal_size = '2GB'            # 檢查點前的最大 WAL 大小

# 查詢規劃器
random_page_cost = 1.1          # SSD 儲存設定為 1.1(預設 4.0 用於 HDD)
effective_io_concurrency = 200  # SSD 儲存(預設 1 用於 HDD)

# 平行處理
max_parallel_workers_per_gather = 4
max_parallel_workers = 8
max_worker_processes = 8

# 連線設定
max_connections = 200           # 匹配您的連線池最大值 + 管理開銷

依伺服器大小的調校準則

參數 4GB RAM 8GB RAM 16GB RAM 32GB RAM
shared_buffers 1GB 2GB 4GB 8GB
effective_cache_size 3GB 6GB 12GB 24GB
work_mem 32MB 64MB 128MB 256MB
maintenance_work_mem 256MB 512MB 1GB 2GB

Autovacuum 設定

PostgreSQL 的 autovacuum 處理程序回收死行空間並更新統計資料。對於 iDempiere 的寫入密集型表格(C_Order、C_Invoice、AD_PInstance),調校 autovacuum 以更積極地執行:

# 在忙碌的表格上更頻繁地執行 autovacuum
autovacuum_vacuum_scale_factor = 0.05     # 預設 0.2 — 在 5% 死行時觸發
autovacuum_analyze_scale_factor = 0.02    # 預設 0.1 — 在 2% 變更時更新統計
autovacuum_vacuum_cost_delay = '2ms'      # 預設 2ms — SSD 可接受

連線池設定

iDempiere 使用資料庫連線池來重複使用資料庫連線,而不是為每個請求建立新連線。正確的池大小至關重要。

# 在 idempiereEnv.properties 或連線池設定中
# 最小閒置連線 — 保持一些連線就緒
db.connection.pool.min=10
# 最大連線 — 限制以防止資料庫過載
db.connection.pool.max=50
# 連線驗證 — 使用前測試連線
db.connection.pool.testOnBorrow=true
# 30 分鐘後驅逐閒置連線
db.connection.pool.minEvictableIdleTimeMillis=1800000

最大池大小應小於 PostgreSQL 的 max_connections 設定,為管理連線和其他應用程式留出空間。一個好的經驗法則:將池最大值設定為預期的同時活躍使用者數加 20% 的額外開銷。

監控與分析

識別慢查詢

啟用 PostgreSQL 的慢查詢日誌以識別效能瓶頸:

# 在 postgresql.conf 中
log_min_duration_statement = 1000   # 記錄超過 1 秒的查詢
log_statement = 'none'              # 不記錄所有語句(太冗長)
log_line_prefix = '%t [%p] %u@%d '  # 包含時間戳、PID、使用者、資料庫

JVM 監控

使用 JMX(Java Management Extensions)即時監控 iDempiere 的 JVM:

# 在啟動腳本中啟用 JMX
-Dcom.sun.management.jmxremote
-Dcom.sun.management.jmxremote.port=9090
-Dcom.sun.management.jmxremote.ssl=false
-Dcom.sun.management.jmxremote.authenticate=true

使用 VisualVM、JConsole 或帶有 JMX 匯出器的 Grafana 等工具連接到 JMX 連接埠。監控以下關鍵指標:

  • 堆積使用量: 應保持在最大堆積的 80% 以下。持續更高表示記憶體洩漏或堆積不足。
  • GC 頻率和暫停時間: 頻繁的長暫停表示堆積大小或 GC 演算法問題。
  • 執行緒計數: 過高的執行緒計數暗示執行緒洩漏或太多同時工作階段。
  • CPU 使用率: 持續的高 CPU 可能表示低效的查詢或快取不足。

PostgreSQL 監控查詢

-- 活動查詢及其持續時間
SELECT pid, now() - pg_stat_activity.query_start AS duration,
       query, state
FROM pg_stat_activity
WHERE state != 'idle'
ORDER BY duration DESC;

-- 表格存取統計(識別熱門表格)
SELECT relname, seq_scan, seq_tup_read, idx_scan, idx_tup_fetch
FROM pg_stat_user_tables
ORDER BY seq_tup_read DESC
LIMIT 20;

-- 索引使用統計(識別未使用的索引)
SELECT indexrelname, idx_scan, idx_tup_read
FROM pg_stat_user_indexes
ORDER BY idx_scan ASC
LIMIT 20;

-- 快取命中率(應高於 99%)
SELECT
  sum(heap_blks_hit) / (sum(heap_blks_hit) + sum(heap_blks_read)) AS cache_hit_ratio
FROM pg_statio_user_tables;

常見效能瓶頸

以下是 iDempiere 部署中最常見的效能問題及其解決方案:

問題:視窗載入緩慢

原因: 具有許多欄位和查詢的視窗會產生大量查詢來填充下拉清單和參照資料。

解決方案: 確保應用程式字典快取已預熱。檢查視窗的頁籤並移除不必要的欄位。在查詢欄位上加入索引。

問題:報表逾時

原因: 具有大日期範圍的複雜報表在沒有適當索引的情況下查詢數百萬行。

解決方案: 加入與報表 WHERE 子句參數匹配的索引。對複雜聚合使用具體化視觀表。對排序密集的報表增加 work_mem

問題:文件處理變慢

原因: 完成具有許多明細的訂單或發票會觸發串聯的模型驗證器和呼叫返回。

解決方案: 分析模型驗證器以識別慢的驗證器。對批量操作使用批次處理。確保驗證器使用快取資料而不是為每一行查詢資料庫。

問題:高記憶體使用量

原因: 大型快取、外掛中的記憶體洩漏,或工作階段未被正確清理。

解決方案: 檢查 CCache 大小和過期設定。使用堆積傾印分析(jmap、Eclipse MAT)來識別記憶體大戶。實施工作階段逾時政策。

總結

效能調校是一門系統性的學科,而非一次性的任務。您學習了如何利用 iDempiere 的 CCache 系統進行應用程式層級的快取、使用適當的索引和 EXPLAIN 分析最佳化資料庫查詢、針對工作負載調校 JVM、設定 PostgreSQL 用於正式環境,以及監控整個堆疊以識別和解決瓶頸。逐步應用這些技術,測量每項變更的影響,您的 iDempiere 部署將能夠可靠地大規模服務您的使用者。在下一課中,我們將焦點轉移到使用者介面,探索使用 ZK 元件和儀表板小工具的進階 UI 自訂技術。

日本語翻訳

概要

  • 学習内容:
    • iDempiere の CacheMgt システムと CCache クラスの仕組み、およびカスタムコードでキャッシングを効果的に使用する方法
    • SQL クエリの最適化、カスタムテーブルのインデックス設計、iDempiere ワークロード向けの PostgreSQL 設定のチューニング方法
    • JVM 設定のチューニング、コネクションプールの設定、パフォーマンスの監視、一般的なボトルネックの診断方法
  • 前提条件: レッスン 2 — iDempiere アーキテクチャ概要、レッスン 15 — 最初のプラグインの構築
  • 推定読了時間: 24 分

はじめに

適切に設定された iDempiere インストールは、数百の同時ユーザーにサービスを提供し、毎日数千のトランザクションを処理できます。しかし、パフォーマンスは偶然には実現しません。キャッシング、クエリ最適化、JVM 設定、データベースチューニングへの意図的な注意が必要です。ERP システムのパフォーマンス問題は、受注入力から財務報告まで、すべてのユーザーとすべてのビジネスプロセスに影響するため、特にコストが高くなります。

このレッスンでは、iDempiere スタックのすべてのレイヤーでのパフォーマンス最適化を取り上げます:アプリケーションレベルのキャッシュ、Java 仮想マシン、データベースエンジン、コネクションプール。各最適化の背後にある理論と、それを実装するための実用的な設定変更の両方を学びます。

CacheMgt システム

iDempiere のキャッシュマネージャー(CacheMgt)は、すべてのアプリケーションレベルのキャッシュを調整する集中型システムです。すべてのキャッシュインスタンスを追跡し、グローバル統計を提供し、対象を絞った、または完全なキャッシュリセットをサポートします。iDempiere は設定データ、参照リスト、頻繁にアクセスされるレコードの繰り返しのデータベースクエリを回避するためにキャッシングに大きく依存しているため、CacheMgt の理解は不可欠です。

CacheMgt の仕組み

iDempiere が起動すると、さまざまなサブシステムが CacheMgt にキャッシュを登録します。各キャッシュはテーブル名(例:AD_ColumnC_BPartner)で識別され、キーと値のペアをメモリに格納します。データが変更されると — UI、API、またはバックグラウンドプロセスを通じて — キャッシュマネージャーは影響を受けるキャッシュを選択的に無効化できます。

// CacheMgt は以下の主要な操作を提供します:
CacheMgt cacheMgt = CacheMgt.get();

// すべてのキャッシュにわたるキャッシュオブジェクトの合計数を取得
int totalCached = cacheMgt.getElementCount();

// すべてのキャッシュをリセット(コストが高い — 慎重に使用)
cacheMgt.reset();

// 特定のテーブルのキャッシュをリセット
cacheMgt.reset(MProduct.Table_Name);

// キャッシュ内の特定のレコードをリセット
cacheMgt.reset(MProduct.Table_Name, productId);

キャッシュ統計

iDempiere のシステム管理メニューからキャッシュの効率を監視します。システム管理 > キャッシュ管理に移動して、登録されたすべてのキャッシュ、そのサイズ、ヒット/ミス統計を表示します。この情報は、どのキャッシュが効果的で、どのキャッシュにチューニングが必要かを特定するのに役立ちます。

CCache クラス

CCache は iDempiere の主要なキャッシュ実装です。CacheMgt に自動的に登録され、時間ベースの期限切れをサポートするハッシュマップです。パフォーマンスのためにデータをキャッシュする必要があるプラグインを構築する際に、CCache を広範に使用します。

基本的な使い方

import org.compiere.util.CCache;

public class ProductCategoryCache {

    // キャッシュ宣言:tableName, cacheSize(初期容量), expireMinutes
    private static CCache<Integer, MProductCategory> s_cache =
        new CCache<>(MProductCategory.Table_Name, 50, 60);

    /**
     * 製品カテゴリを取得し、キャッシュを使用して繰り返しの DB クエリを回避します。
     */
    public static MProductCategory get(int M_Product_Category_ID) {
        // まずキャッシュを確認
        MProductCategory category = s_cache.get(M_Product_Category_ID);
        if (category != null)
            return category;

        // キャッシュミス — データベースからロード
        category = new MProductCategory(Env.getCtx(), M_Product_Category_ID, null);

        // 将来のリクエストのためにキャッシュに格納
        if (category.get_ID() > 0)
            s_cache.put(M_Product_Category_ID, category);

        return category;
    }
}

CCache コンストラクタのパラメータ

CCache コンストラクタは、動作を制御するいくつかのパラメータを受け付けます:

// 基本:テーブル名と初期容量
CCache<Integer, MProduct> cache1 = new CCache<>("M_Product", 100);

// 期限切れ付き:エントリは 120 分後に期限切れ
CCache<Integer, MProduct> cache2 = new CCache<>("M_Product", 100, 120);

// 分散キャッシュサポート付き(クラスタデプロイメント用)
CCache<Integer, MProduct> cache3 = new CCache<>("M_Product", "M_Product",
    100, 120, false, 500);
  • テーブル名(最初のパラメータ): CacheMgt が特定のテーブルのデータが変更されたときにリセットするキャッシュを識別するために使用します。キャッシュの無効化が正しく機能するように、実際のテーブル名を使用してください。
  • 初期容量: 予想されるエントリ数のスペースを事前に確保します。再ハッシュを避けるため、予想されるキャッシュサイズに設定してください。
  • 期限切れ分数: これより古いエントリは自動的に退避されます。期限切れなしの場合は 0 に設定します。頻繁に変更されるデータには短い期限切れを、安定した参照データには長い期限切れ(または期限切れなし)を使用します。
  • 最大サイズ: エントリの最大数を制限します。超過すると、最も最近使用されていないエントリが退避されます。

一般的なシナリオのキャッシュパターン

不変の参照データ

ほとんど変更されないデータ(国、通貨、単位)には、長い期限切れまたは期限切れなしを使用します:

// 期限切れなしのキャッシュ — 明示的なリセット時のみデータが更新される
private static CCache<Integer, MCountry> s_countries =
    new CCache<>(MCountry.Table_Name, 250, 0);

頻繁に変更されるデータ

変動の激しいデータ(価格設定、在庫レベル)には、短い期限切れを使用するか、キャッシングを完全に避けます:

// 価格データ用の 5 分間期限切れキャッシュ
private static CCache<String, BigDecimal> s_priceCache =
    new CCache<>("M_ProductPrice", 500, 5);

複合キャッシュキー

キャッシュキーが複数のフィールドを含む場合、複合キーを作成します:

// 組織 + 製品の組み合わせをキーとするキャッシュ
private static CCache<String, BigDecimal> s_qtyCache =
    new CCache<>("M_StorageOnHand", 1000, 10);

public static BigDecimal getQtyOnHand(int orgId, int productId) {
    String key = orgId + "_" + productId;
    BigDecimal qty = s_qtyCache.get(key);
    if (qty != null) return qty;
    // ... データベースからロードしてキャッシュ
}

キャッシュの無効化

キャッシュの無効化はソフトウェアエンジニアリングで最も難しい問題の 1 つです。iDempiere はいくつかのメカニズムを提供します:

自動無効化

レコードが iDempiere の PO(永続オブジェクト)フレームワークを通じて保存されると、フレームワークは自動的に CacheMgt.reset(tableName, recordId) を呼び出します。これは、正しいテーブル名で登録されたキャッシュが、UI や API を通じてデータが変更されたときに自動的に無効化されることを意味します。

手動無効化

PO フレームワークの外部でデータが変更された場合(直接 SQL 更新、外部プロセス)、影響を受けるキャッシュを手動で無効化する必要があります:

// 製品価格への直接 SQL 更新後
DB.executeUpdate("UPDATE M_ProductPrice SET PriceStd = 99.99 WHERE ...", null);
CacheMgt.get().reset("M_ProductPrice");  // 価格キャッシュを無効化

UI によるキャッシュリセット

管理者はシステム管理 > キャッシュリセットウィンドウからすべてのキャッシュをリセットできます。これは一括データインポートや直接的なデータベース変更の後に便利です。マルチノードデプロイメントでは、キャッシュリセットメッセージはすべてのノードにブロードキャストされます。

クエリの最適化

効果的なキャッシングがあっても、多くの操作にはデータベースクエリが必要です。これらのクエリを最適化することは、ユーザーエクスペリエンスとシステムスループットに直接影響します。

EXPLAIN を使用したクエリ分析

PostgreSQL の EXPLAIN ANALYZE コマンドは、データベースがクエリをどのように実行し、どこに時間がかかっているかを正確に示します:

-- 遅いクエリを分析
EXPLAIN ANALYZE
SELECT bp.C_BPartner_ID, bp.Name, bp.Value, loc.City
FROM C_BPartner bp
JOIN C_BPartner_Location bpl ON bp.C_BPartner_ID = bpl.C_BPartner_ID
JOIN C_Location loc ON bpl.C_Location_ID = loc.C_Location_ID
WHERE bp.IsCustomer = 'Y'
  AND bp.IsActive = 'Y'
  AND bp.AD_Client_ID = 11
ORDER BY bp.Name;

EXPLAIN 出力で注目すべき重要なポイント:

  • 大きなテーブルでの Seq Scan(シーケンシャルスキャン)— インデックスが不足していることを示します。
  • 行数が多い Nested Loop(ネストループ)— 異なる結合戦略やインデックスが必要な場合があります。
  • 大きな結果セットでの Sort(ソート)操作 — ORDER BY 句に一致するインデックスの追加を検討してください。
  • 実際の時間と推定時間の大きな乖離 — 統計が古いことを示します。影響を受けるテーブルで ANALYZE を実行してください。

カスタムテーブルのインデックス戦略

プラグインのカスタムテーブルを作成する際は、インデックスを慎重に設計してください:

-- 頻繁にフィルタリングされるカラムのインデックス
CREATE INDEX idx_custom_table_status
ON custom_table (DocStatus)
WHERE IsActive = 'Y';

-- 一般的なクエリパターンの複合インデックス
CREATE INDEX idx_custom_table_partner_date
ON custom_table (C_BPartner_ID, DateOrdered DESC);

-- アクティブなレコードのみの部分インデックス(インデックスサイズを削減)
CREATE INDEX idx_custom_table_active
ON custom_table (Value)
WHERE IsActive = 'Y';

-- 外部キーのインデックス(JOIN パフォーマンスに不可欠)
CREATE INDEX idx_custom_line_header
ON custom_line_table (custom_header_id);

iDempiere のインデックス設計ガイドライン:

  • 外部キーカラムには常にインデックスを作成してください — iDempiere のウィンドウは頻繁に親子テーブルを結合します。
  • 一般的なクエリやレポートパラメータの WHERE 句で使用されるカラムにインデックスを作成してください。
  • ほとんどのクエリがアクティブなレコードでフィルタリングするため、WHERE IsActive = 'Y' の部分インデックスを使用してください。
  • 過度のインデックス作成は避けてください — 各インデックスは INSERT と UPDATE 操作を遅くします。
  • 低カーディナリティのカラム(例:Y/N 値のみの IsSOTrx)の場合、インデックスは他のカラムと組み合わせた場合、または部分インデックスとしてのみ有用です。

プラグインコードでのクエリのベストプラクティス

// 良い:パラメータ付きの Query クラスを使用(SQL インジェクションを防止、プランキャッシュを有効化)
List<MOrder> orders = new Query(ctx, MOrder.Table_Name,
    "C_BPartner_ID=? AND DocStatus IN (?,?) AND DateOrdered>=?", trxName)
    .setParameters(bpartnerId, "CO", "CL", startDate)
    .setApplyAccessFilter(true)
    .setOrderBy("DateOrdered DESC")
    .list();

// 悪い:文字列連結(SQL インジェクションのリスク、プランキャッシュなし)
String sql = "SELECT * FROM C_Order WHERE C_BPartner_ID=" + bpartnerId;

// 良い:大きな結果セットでは必要なカラムのみを取得
int count = new Query(ctx, MOrder.Table_Name,
    "C_BPartner_ID=? AND DocStatus='CO'", null)
    .setParameters(bpartnerId)
    .count();

// 良い:単一の結果を期待する場合は list() ではなく first() を使用
MBPartner bp = new Query(ctx, MBPartner.Table_Name, "Value=?", null)
    .setParameters(customerCode)
    .first();

遅延ロードパターン

実際に必要になるまでデータのロードを避けてください。これは、大きなテキストフィールドや多くの関連子レコードを持つレコードで特に重要です:

// 遅延ロードパターン
public class OrderProcessor {

    private MBPartner partner;  // 必要に応じてロード

    private MBPartner getPartner(MOrder order) {
        if (partner == null || partner.getC_BPartner_ID() != order.getC_BPartner_ID()) {
            partner = MBPartner.get(Env.getCtx(), order.getC_BPartner_ID());
        }
        return partner;
    }

    public void processOrders(List<MOrder> orders) {
        for (MOrder order : orders) {
            // パートナーは必要な時にのみロードされ、同じビジネスパートナーの
            // 受注間で再利用されます
            MBPartner bp = getPartner(order);
            // ... 受注を処理
        }
    }
}

iDempiere の JVM チューニング

iDempiere は Java 仮想マシン上で動作し、JVM の設定はパフォーマンスに大きく影響します。主要な設定は idempiereEnv.properties ファイルまたは起動スクリプトにあります。

ヒープサイズの設定

# idempiere-server.sh または idempiere.ini で設定
# 最小ヒープサイズ — リサイズの一時停止を避けるために最大と同じに設定
-Xms2g
# 最大ヒープサイズ — 通常、利用可能な RAM の 50-75%
-Xmx4g

サイジングガイドライン:

  • 小規模デプロイメント(1-10 ユーザー): 1-2 GB ヒープ
  • 中規模デプロイメント(10-50 ユーザー): 2-4 GB ヒープ
  • 大規模デプロイメント(50-200 ユーザー): 4-8 GB ヒープ
  • エンタープライズデプロイメント(200+ ユーザー): 8-16 GB ヒープ

JVM がヒープの拡大と縮小に時間を浪費するのを防ぐため、-Xms-Xmx と同じ値に設定してください。

ガベージコレクションの設定

最新の JVM(Java 17+)はデフォルトで G1 ガベージコレクターを使用し、これは一般的に iDempiere に適しています。ワークロードに合わせて微調整します:

# G1 ガベージコレクターを使用(Java 17 のデフォルト)
-XX:+UseG1GC
# 最大 GC 一時停止目標を設定(ミリ秒)
-XX:MaxGCPauseMillis=200
# 大きなヒープのリージョンサイズを設定
-XX:G1HeapRegionSize=16m
# 分析用の GC ロギングを有効化
-Xlog:gc*:file=/opt/idempiere/log/gc.log:time,uptime,level,tags:filecount=5,filesize=20m

多くの短命オブジェクト(リクエスト処理)といくつかの長命オブジェクト(キャッシュ)を特徴とする iDempiere のワークロードでは、G1 のデフォルト動作が通常最適です。長い GC の一時停止が観察される場合は、サブミリ秒の一時停止時間を提供する ZGC(-XX:+UseZGC)を検討してください。ただし、CPU 使用量は増加します。

その他の JVM 設定

# クラスメタデータ用の Metaspace(OSGi は多くのクラスをロード)
-XX:MetaspaceSize=256m
-XX:MaxMetaspaceSize=512m

# スレッドスタックサイズ(同時ユーザーが多い場合は削減)
-Xss512k

# 文字列重複排除(繰り返し文字列のメモリを削減)
-XX:+UseStringDeduplication

PostgreSQL チューニング

データベースは ERP システムの主要なボトルネックになることがよくあります。PostgreSQL のデフォルト設定は非常に保守的で、本番の iDempiere ワークロード向けにチューニングする必要があります。

主要な設定パラメータ

以下の推奨設定で postgresql.conf を編集します:

# メモリ設定
shared_buffers = '2GB'          # 合計 RAM の 25%(例:8GB サーバーで 2GB)
effective_cache_size = '6GB'    # 合計 RAM の 75%(OS キャッシュについてプランナーに通知)
work_mem = '64MB'               # ソート操作ごとのメモリ(複雑なレポートでは増加)
maintenance_work_mem = '512MB'  # VACUUM、CREATE INDEX などに使用

# 先行書き込みログ
wal_buffers = '64MB'            # WAL バッファサイズ
checkpoint_completion_target = 0.9  # チェックポイント書き込みを分散
max_wal_size = '2GB'            # チェックポイント前の最大 WAL サイズ

# クエリプランナー
random_page_cost = 1.1          # SSD ストレージの場合 1.1 に設定(デフォルト 4.0 は HDD 用)
effective_io_concurrency = 200  # SSD ストレージ用(デフォルト 1 は HDD 用)

# 並列処理
max_parallel_workers_per_gather = 4
max_parallel_workers = 8
max_worker_processes = 8

# 接続設定
max_connections = 200           # コネクションプールの最大値 + 管理オーバーヘッドに一致

サーバーサイズ別のチューニングガイドライン

パラメータ 4GB RAM 8GB RAM 16GB RAM 32GB RAM
shared_buffers 1GB 2GB 4GB 8GB
effective_cache_size 3GB 6GB 12GB 24GB
work_mem 32MB 64MB 128MB 256MB
maintenance_work_mem 256MB 512MB 1GB 2GB

Autovacuum の設定

PostgreSQL の autovacuum プロセスは、デッドロウスペースを回収し、統計を更新します。iDempiere の書き込み負荷の高いテーブル(C_Order、C_Invoice、AD_PInstance)では、autovacuum をより積極的に実行するようにチューニングします:

# ビジーなテーブルで autovacuum をより頻繁に実行
autovacuum_vacuum_scale_factor = 0.05     # デフォルト 0.2 — 5% のデッドロウでトリガー
autovacuum_analyze_scale_factor = 0.02    # デフォルト 0.1 — 2% の変更で統計を更新
autovacuum_vacuum_cost_delay = '2ms'      # デフォルト 2ms — SSD では許容可能

コネクションプールの設定

iDempiere はデータベースコネクションプールを使用して、リクエストごとに新しい接続を作成するのではなく、データベース接続を再利用します。適切なプールサイジングは重要です。

# idempiereEnv.properties またはコネクションプール設定で
# 最小アイドル接続 — いくつかの接続を準備状態に保つ
db.connection.pool.min=10
# 最大接続 — データベースの過負荷を防ぐために制限
db.connection.pool.max=50
# 接続の検証 — 使用前に接続をテスト
db.connection.pool.testOnBorrow=true
# 30 分後にアイドル接続を退避
db.connection.pool.minEvictableIdleTimeMillis=1800000

最大プールサイズは PostgreSQL の max_connections 設定よりも小さくする必要があり、管理接続や他のアプリケーションのための余裕を残します。良い経験則:プールの最大値を、予想される同時アクティブユーザー数プラス 20% のオーバーヘッドに設定してください。

監視とプロファイリング

遅いクエリの特定

パフォーマンスのボトルネックを特定するために PostgreSQL のスロークエリログを有効にします:

# postgresql.conf で
log_min_duration_statement = 1000   # 1 秒以上かかるクエリをログに記録
log_statement = 'none'              # すべてのステートメントをログに記録しない(冗長すぎる)
log_line_prefix = '%t [%p] %u@%d '  # タイムスタンプ、PID、ユーザー、データベースを含む

JVM 監視

JMX(Java Management Extensions)を使用して iDempiere の JVM をリアルタイムで監視します:

# 起動スクリプトで JMX を有効化
-Dcom.sun.management.jmxremote
-Dcom.sun.management.jmxremote.port=9090
-Dcom.sun.management.jmxremote.ssl=false
-Dcom.sun.management.jmxremote.authenticate=true

VisualVM、JConsole、または JMX エクスポーター付きの Grafana などのツールで JMX ポートに接続します。以下の主要な指標を監視してください:

  • ヒープ使用量: 最大ヒープの 80% 以下に維持する必要があります。常にそれ以上の場合は、メモリリークまたはヒープ不足を示します。
  • GC の頻度と一時停止時間: 頻繁な長い一時停止は、ヒープサイジングまたは GC アルゴリズムの問題を示します。
  • スレッド数: 過度に高いスレッド数は、スレッドリークまたは同時セッションが多すぎることを示唆します。
  • CPU 使用率: 持続的に高い CPU は、非効率なクエリまたはキャッシュ不足を示す可能性があります。

PostgreSQL 監視クエリ

-- アクティブなクエリとその実行時間
SELECT pid, now() - pg_stat_activity.query_start AS duration,
       query, state
FROM pg_stat_activity
WHERE state != 'idle'
ORDER BY duration DESC;

-- テーブルアクセス統計(ホットテーブルの特定)
SELECT relname, seq_scan, seq_tup_read, idx_scan, idx_tup_fetch
FROM pg_stat_user_tables
ORDER BY seq_tup_read DESC
LIMIT 20;

-- インデックス使用統計(未使用インデックスの特定)
SELECT indexrelname, idx_scan, idx_tup_read
FROM pg_stat_user_indexes
ORDER BY idx_scan ASC
LIMIT 20;

-- キャッシュヒット率(99% 以上であるべき)
SELECT
  sum(heap_blks_hit) / (sum(heap_blks_hit) + sum(heap_blks_read)) AS cache_hit_ratio
FROM pg_statio_user_tables;

一般的なパフォーマンスのボトルネック

以下は iDempiere デプロイメントで最も頻繁に発生するパフォーマンスの問題とその解決策です:

問題:ウィンドウの読み込みが遅い

原因: 多くのフィールドとルックアップを持つウィンドウが、ドロップダウンリストや参照データを取得するために多数のクエリを生成します。

解決策: アプリケーションディクショナリのキャッシュがウォームアップされていることを確認します。ウィンドウのタブを確認し、不要なフィールドを削除します。ルックアップカラムにインデックスを追加します。

問題:レポートのタイムアウト

原因: 大きな日付範囲を持つ複雑なレポートが、適切なインデックスなしに数百万行をクエリします。

解決策: レポートの WHERE 句パラメータに一致するインデックスを追加します。複雑な集計にはマテリアライズドビューを使用します。ソート負荷の高いレポートには work_mem を増やします。

問題:伝票処理の遅延

原因: 多くの明細行を持つ受注や請求書の完了が、連鎖するモデルバリデーターとコールアウトをトリガーします。

解決策: モデルバリデーターをプロファイリングして遅いものを特定します。一括操作にはバッチ処理を使用します。バリデーターが各行ごとにデータベースをクエリするのではなく、キャッシュされたデータを使用するようにします。

問題:高いメモリ使用量

原因: 大きなキャッシュ、プラグインのメモリリーク、またはセッションが適切にクリーンアップされていない。

解決策: CCache のサイズと期限切れの設定を確認します。ヒープダンプ分析(jmap、Eclipse MAT)を使用してメモリの大量消費を特定します。セッションタイムアウトポリシーを実装します。

まとめ

パフォーマンスチューニングは一度きりの作業ではなく、体系的な規律です。iDempiere の CCache システムを活用したアプリケーションレベルのキャッシング、適切なインデックスと EXPLAIN 分析によるデータベースクエリの最適化、ワークロードに合わせた JVM のチューニング、本番環境向けの PostgreSQL の設定、そしてボトルネックを特定して解決するためのスタック全体の監視について学びました。これらのテクニックを段階的に適用し、各変更の影響を測定することで、iDempiere のデプロイメントは大規模にユーザーに確実にサービスを提供できるようになります。次のレッスンでは、ZK コンポーネントとダッシュボードガジェットを使用した高度な UI カスタマイズ技術を探ります。

You Missed