Oracle Database on Amazon RDS: Production Setup, Multi-AZ, and Performance Tuning

Running Oracle Database on Amazon RDS removes patching, backup management, and storage provisioning from your team while keeping the Oracle engine your applications depend on. The setup decisions that matter most in production are edition and license choice, parameter group configuration, Multi-AZ topology for your failover requirements, and a networking model that keeps database traffic off the public internet.

Edition and License

RDS Oracle is available in Standard Edition 2 and Enterprise Edition, each with License Included or BYOL. SE2 supports up to 16 vCPUs and covers most production workloads. EE is required for Partitioning, Advanced Compression, and database-level TDE. Check which features your application uses before choosing SE2, because changing editions later requires a new instance and a data migration.

Step 1: Security Group and Subnet Group

resource "aws_security_group" "oracle_rds" {
  name   = "oracle-rds-production"
  vpc_id = var.vpc_id

  ingress {
    from_port   = 1521
    to_port     = 1521
    protocol    = "tcp"
    cidr_blocks = [var.app_subnet_cidr, var.dba_subnet_cidr]
    description = "Oracle listener from app and DBA subnets"
  }

  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = { Name = "oracle-rds-production", Environment = "production" }
}

resource "aws_db_subnet_group" "oracle" {
  name       = "oracle-rds-subnet-group"
  subnet_ids = var.private_subnet_ids
}

Step 2: Custom Parameter Group

resource "aws_db_parameter_group" "oracle_prod" {
  name   = "oracle-se2-19c-production"
  family = "oracle-se2-19"

  # 75% of instance RAM for SGA
  parameter { name = "sga_max_size"   value = "{DBInstanceClassMemory*3/4}" apply_method = "pending-reboot" }
  # 25% of instance RAM for PGA
  parameter { name = "pga_aggregate_target" value = "{DBInstanceClassMemory/4}" apply_method = "pending-reboot" }
  parameter { name = "processes"      value = "500"  apply_method = "pending-reboot" }
  parameter { name = "undo_retention" value = "900"  apply_method = "immediate" }
  parameter { name = "audit_trail"    value = "DB"   apply_method = "pending-reboot" }
  # Enable supplemental logging for GoldenGate replication from day one
  parameter { name = "enable_goldengate_replication" value = "TRUE" apply_method = "pending-reboot" }
  parameter { name = "nls_date_format" value = "YYYY-MM-DD HH24:MI:SS" apply_method = "immediate" }
}

Step 3: RDS Oracle with Multi-AZ

resource "aws_db_instance" "oracle_prod" {
  identifier     = "oracle-production"
  engine         = "oracle-se2"
  engine_version = "19.0.0.0.ru-2026-01.rur-2026-01.r1"
  license_model  = "license-included"
  instance_class = "db.r6i.2xlarge"

  allocated_storage     = 500
  max_allocated_storage = 2000
  storage_type          = "gp3"
  storage_encrypted     = true
  kms_key_id            = var.kms_key_arn

  db_name  = "ORCL"
  username = "admin"
  password = var.db_admin_password
  port     = 1521

  multi_az            = true
  publicly_accessible = false
  auto_minor_version_upgrade = false

  vpc_security_group_ids = [aws_security_group.oracle_rds.id]
  db_subnet_group_name   = aws_db_subnet_group.oracle.name
  parameter_group_name   = aws_db_parameter_group.oracle_prod.name

  backup_retention_period   = 14
  backup_window             = "03:00-04:00"
  maintenance_window        = "sun:04:30-sun:05:30"
  skip_final_snapshot       = false
  final_snapshot_identifier = "oracle-production-final"

  performance_insights_enabled          = true
  performance_insights_retention_period = 7
  monitoring_interval                   = 60
  monitoring_role_arn                   = aws_iam_role.rds_monitoring.arn

  tags = { Name = "oracle-production", Environment = "production", ManagedBy = "terraform" }
}

output "oracle_endpoint" { value = aws_db_instance.oracle_prod.endpoint }

Multi-AZ on RDS Oracle uses Oracle Data Guard in synchronous mode. The standby is in a different Availability Zone. During failover, RDS promotes the standby and updates the endpoint DNS. Applications reconnect to the same DNS name and reach the new primary. Failover completes in 60 to 120 seconds.

Step 4: FAN-Aware Connection Pool

import cx_Oracle, boto3, json, os
from typing import Optional

def get_creds() -> dict:
    c = boto3.client('secretsmanager', region_name=os.environ['AWS_REGION'])
    return json.loads(c.get_secret_value(SecretId=os.environ['DB_SECRET_ARN'])['SecretString'])

class OraclePool:
    _pool: Optional[cx_Oracle.SessionPool] = None

    @classmethod
    def get(cls) -> cx_Oracle.SessionPool:
        if cls._pool is None:
            creds = get_creds()
            dsn   = cx_Oracle.makedsn(os.environ['ORACLE_HOST'], 1521, service_name='ORCL')
            cls._pool = cx_Oracle.SessionPool(
                user=creds['username'], password=creds['password'], dsn=dsn,
                min=2, max=10, increment=1, encoding='UTF-8',
                events=True,       # FAN: evict stale connections immediately on failover
                ping_interval=30   # validate connections before returning from pool
            )
        return cls._pool

    @classmethod
    def query(cls, sql: str, params: dict = None) -> list:
        with cls.get().acquire() as conn:
            cur  = conn.cursor()
            cur.execute(sql, params or {})
            cols = [c[0].lower() for c in cur.description]
            return [dict(zip(cols, row)) for row in cur.fetchall()]

# Example query
orders = OraclePool.query(
    "SELECT order_id, total_amount FROM orders WHERE customer_id = :cid",
    params={'cid': 'CUST-001'}
)

The events=True flag enables Oracle Fast Application Notification. When Multi-AZ failover occurs, FAN notifies the pool immediately so it evicts stale connections rather than waiting for TCP timeout. This is the difference between a 60-second application disruption and a 5-minute one.

Step 5: CloudWatch Alarms

locals { db_id = aws_db_instance.oracle_prod.identifier }

resource "aws_cloudwatch_metric_alarm" "cpu" {
  alarm_name          = "oracle-prod-cpu"
  comparison_operator = "GreaterThanThreshold"
  evaluation_periods  = 3
  metric_name         = "CPUUtilization"
  namespace           = "AWS/RDS"
  period              = 300
  statistic           = "Average"
  threshold           = 80
  alarm_actions       = [var.sns_ops_arn]
  dimensions          = { DBInstanceIdentifier = local.db_id }
}

resource "aws_cloudwatch_metric_alarm" "memory" {
  alarm_name          = "oracle-prod-memory"
  comparison_operator = "LessThanThreshold"
  evaluation_periods  = 2
  metric_name         = "FreeableMemory"
  namespace           = "AWS/RDS"
  period              = 300
  statistic           = "Average"
  threshold           = 2147483648
  alarm_actions       = [var.sns_ops_arn]
  dimensions          = { DBInstanceIdentifier = local.db_id }
}

resource "aws_cloudwatch_metric_alarm" "latency" {
  alarm_name          = "oracle-prod-read-latency"
  comparison_operator = "GreaterThanThreshold"
  evaluation_periods  = 3
  metric_name         = "ReadLatency"
  namespace           = "AWS/RDS"
  period              = 60
  statistic           = "Average"
  threshold           = 0.020
  alarm_actions       = [var.sns_ops_arn]
  dimensions          = { DBInstanceIdentifier = local.db_id }
}

Step 6: Top SQL via Performance Insights API

import boto3
from datetime import datetime, timezone, timedelta

def get_top_sql(db_id: str, hours: int = 1, top_n: int = 10) -> list:
    pi   = boto3.client('pi', region_name=os.environ['AWS_REGION'])
    now  = datetime.now(timezone.utc)

    resp = pi.get_resource_metrics(
        ServiceType='RDS',
        Identifier=f'db:{db_id}',
        MetricQueries=[{
            'Metric': 'db.load.avg',
            'GroupBy': {
                'Group': 'db.sql',
                'Dimensions': ['db.sql.statement'],
                'Limit': top_n
            }
        }],
        StartTime=now - timedelta(hours=hours),
        EndTime=now,
        PeriodInSeconds=hours * 3600
    )

    rows = []
    for m in resp.get('MetricList', []):
        for k in m.get('Keys', []):
            sql  = k.get('Dimensions', {}).get('db.sql.statement', '')[:120]
            load = m.get('DataPoints', [{}])[-1].get('Value', 0)
            rows.append({'aas': round(load, 3), 'sql': sql})

    return sorted(rows, key=lambda x: x['aas'], reverse=True)

for r in get_top_sql('oracle-production', hours=24):
    print(f"{r['aas']:.3f} AAS | {r['sql']}")

Operational Notes

Character set is permanent. Choose AL32UTF8 before the first data load. Changing it afterwards requires an export, instance recreation, and re-import.

Always use the RDS endpoint DNS name, never a cached IP. The endpoint DNS is updated during failover. Applications caching the IP will not reconnect to the promoted standby and will stay down until the TCP connection times out.

Storage autoscaling via max_allocated_storage grows the volume automatically when free space drops below 10 percent. It never shrinks. Monitor storage growth trends and plan capacity increases before autoscaling triggers, because storage operations run in the background and can affect I/O performance briefly.

Regards,
Osama

#AWS #OracleDatabase #RDS #OracleOnAWS #Terraform #MultiAZ #DatabaseEngineering #TechBlog #Oracle #AmazonWebServices #CloudArchitecture #DevOps #CloudWatch #PerformanceInsights #FAN #ConnectionPooling #DataGuard #OracleDBA #BYOL #CloudDatabase

Leave a comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.