Memahami SQS Visibility Timeout: Mengapa Pesan Diproses Dua Kali?
Kamu baru saja menemukan bahwa order yang sama diproses dua kali di sistem e-commerce — dua email konfirmasi terkirim, stok berkurang dua kali lipat. Setelah investigasi, akar masalahnya bukan di aplikasi, tapi di konfigurasi SQS Visibility Timeout yang terlalu pendek dibanding waktu aktual pemrosesan pesan.
TL;DR: SQS Visibility Timeout
| Aspek | Detail |
|---|---|
| Apa itu | Durasi pesan 'disembunyikan' dari consumer lain setelah di-receive |
| Default | 30 detik |
| Range valid | 0 detik hingga 12 jam |
| Penyebab double processing | Timeout habis sebelum consumer selesai memproses dan menghapus pesan |
| Solusi utama | Set timeout lebih besar dari waktu pemrosesan maksimum, atau gunakan ChangeMessageVisibility secara berkala |
| Risiko timeout terlalu panjang | Pesan gagal tidak segera tersedia kembali — delay recovery meningkat |
Bagaimana SQS Visibility Timeout Bekerja
SQS bukan message broker yang melacak 'siapa sedang memproses apa' secara stateful. Model dasarnya adalah at-least-once delivery — SQS menjamin pesan akan terkirim minimal satu kali, tapi tidak menjamin tepat satu kali. Mekanisme utama yang mencegah double processing adalah visibility timeout, bukan lock eksklusif.
Ketika consumer memanggil ReceiveMessage, SQS mengembalikan pesan tersebut dan langsung memulai countdown visibility timeout. Selama countdown berjalan, pesan tidak akan muncul di response ReceiveMessage consumer lain. Setelah consumer selesai memproses, ia harus memanggil DeleteMessage untuk menghapus pesan secara permanen. Jika DeleteMessage tidak dipanggil sebelum timeout habis, pesan kembali visible dan consumer lain bisa mengambilnya.
Bayangkan visibility timeout seperti 'tanda reserved' di meja restoran. Selama tanda itu terpasang, pelayan lain tidak akan mengarahkan tamu ke meja itu. Tapi kalau tanda diangkat sebelum tamu pergi — karena timer habis — meja itu dianggap kosong lagi.
- Consumer A memanggil ReceiveMessage — SQS mengembalikan pesan M1 dan memulai visibility timeout countdown.
- Pesan M1 tidak visible untuk consumer lain selama timeout berlangsung.
- Skenario sukses: Consumer A selesai memproses dan memanggil
DeleteMessagesebelum timeout habis. Pesan terhapus permanen. - Skenario gagal: Consumer A masih memproses saat timeout habis. Pesan M1 kembali visible — Consumer B mengambilnya dan terjadi double processing.
- Skenario perpanjangan: Consumer A memanggil
ChangeMessageVisibilityuntuk memperpanjang timeout sebelum habis.
Mengapa Visibility Timeout Menyebabkan Pesan Diproses Dua Kali
Ini adalah kesalahan konfigurasi yang paling sering terjadi di production: developer mengukur waktu pemrosesan rata-rata di environment development, lalu menetapkan visibility timeout berdasarkan angka itu. Di production, ada kondisi yang tidak muncul di dev — koneksi database lambat, downstream API timeout, GC pause di JVM, atau sekadar volume data yang lebih besar.
Misalnya, visibility timeout di-set 30 detik karena rata-rata pemrosesan 10-15 detik. Tapi saat terjadi spike traffic dan database connection pool penuh, satu request bisa memakan waktu 45 detik. Timeout habis di detik ke-30, pesan kembali visible, consumer lain mengambilnya — dan sekarang dua consumer memproses order yang sama secara bersamaan.
- t=0: Consumer A menerima pesan, visibility timeout 30 detik dimulai.
- t=30: Timeout habis. Consumer A masih memproses karena DB lambat. Pesan kembali visible.
- t=31: Consumer B mengambil pesan yang sama. Double processing dimulai.
- t=45: Consumer A selesai, memanggil
DeleteMessage. Tapi Consumer B sudah terlanjur memproses. - t=50: Consumer B juga selesai dan memanggil
DeleteMessage. SQS menerima kedua delete — tidak ada error, tapi damage sudah terjadi.
Diagnosis: Verifikasi Konfigurasi Visibility Timeout
Langkah 1: Cek Visibility Timeout Saat Ini di Queue
Sebelum mengubah apapun, pastikan kamu tahu nilai yang sedang aktif. Visibility timeout bisa di-set di level queue (default untuk semua pesan) dan bisa di-override per pesan saat ReceiveMessage dipanggil dengan parameter VisibilityTimeout. Cek keduanya — banyak engineer hanya mengecek queue attribute tapi lupa bahwa kode consumer-nya override nilai tersebut.
# Cek visibility timeout di level queue
aws sqs get-queue-attributes \
--queue-url https://sqs.us-east-1.amazonaws.com/123456789012/my-order-queue \
--attribute-names VisibilityTimeout
Output yang perlu diperhatikan:
{
"Attributes": {
"VisibilityTimeout": "30"
}
}
Lalu bandingkan dengan waktu pemrosesan aktual di CloudWatch atau application log. Jika ada gap, itulah sumber masalahnya.
Langkah 2: Monitor ApproximateNumberOfMessagesNotVisible
Metrik CloudWatch ApproximateNumberOfMessagesNotVisible menunjukkan jumlah pesan yang sedang 'in-flight' — sudah di-receive tapi belum dihapus. Jika angka ini terus naik dan tidak turun, itu sinyal bahwa consumer tidak menyelesaikan pemrosesan dalam visibility timeout window. Ini bukan bukti langsung double processing, tapi indikator kuat bahwa timeout terlalu pendek.
aws cloudwatch get-metric-statistics \
--namespace AWS/SQS \
--metric-name ApproximateNumberOfMessagesNotVisible \
--dimensions Name=QueueName,Value=my-order-queue \
--start-time 2024-01-15T00:00:00Z \
--end-time 2024-01-15T01:00:00Z \
--period 300 \
--statistics Maximum
Langkah 3: Ukur Waktu Pemrosesan Aktual
Jangan andalkan estimasi. Tambahkan logging di consumer untuk mencatat waktu antara receive dan delete. Kamu butuh nilai P99, bukan rata-rata — karena visibility timeout harus mengakomodasi kasus terburuk, bukan kasus normal.
# Contoh pseudocode di consumer (Python)
import time
import boto3
sqs = boto3.client('sqs', region_name='us-east-1')
queue_url = 'https://sqs.us-east-1.amazonaws.com/123456789012/my-order-queue'
response = sqs.receive_message(
QueueUrl=queue_url,
MaxNumberOfMessages=1,
WaitTimeSeconds=20
)
if 'Messages' in response:
message = response['Messages'][0]
receipt_handle = message['ReceiptHandle']
start_time = time.time()
try:
# Proses pesan di sini
process_order(message['Body'])
elapsed = time.time() - start_time
print(f"Processing time: {elapsed:.2f}s")
sqs.delete_message(
QueueUrl=queue_url,
ReceiptHandle=receipt_handle
)
except Exception as e:
elapsed = time.time() - start_time
print(f"Failed after: {elapsed:.2f}s, error: {e}")
# Jangan delete — biarkan timeout habis atau gunakan ChangeMessageVisibility
Solusi: Memperbaiki Konfigurasi Visibility Timeout
Solusi 1: Set Visibility Timeout yang Tepat di Level Queue
Aturan praktisnya: set visibility timeout minimal 6 kali waktu pemrosesan P99. Faktor 6x terdengar berlebihan, tapi ini mengakomodasi retry logic, cold start, dan kondisi degraded. Untuk queue dengan pemrosesan yang bisa memakan waktu hingga 2 menit di P99, set timeout ke 12 menit.
aws sqs set-queue-attributes \
--queue-url https://sqs.us-east-1.amazonaws.com/123456789012/my-order-queue \
--attributes VisibilityTimeout=720
Perubahan ini berlaku untuk semua pesan yang di-receive setelah perubahan diterapkan. Pesan yang sudah in-flight menggunakan visibility timeout yang berlaku saat mereka di-receive.
Solusi 2: Perpanjang Visibility Timeout Secara Dinamis dengan ChangeMessageVisibility
Untuk pemrosesan dengan durasi yang tidak bisa diprediksi — misalnya memanggil external API dengan SLA yang bervariasi — pendekatan yang lebih robust adalah memperpanjang visibility timeout secara berkala selama pemrosesan berlangsung. Consumer memanggil ChangeMessageVisibility sebelum timeout habis untuk 'mereset' countdown.
🔽 Klik untuk melihat contoh implementasi heartbeat dengan ChangeMessageVisibility
import time
import threading
import boto3
sqs = boto3.client('sqs', region_name='us-east-1')
queue_url = 'https://sqs.us-east-1.amazonaws.com/123456789012/my-order-queue'
def extend_visibility(receipt_handle, stop_event, extension_seconds=60, interval=45):
"""Perpanjang visibility timeout setiap 'interval' detik."""
while not stop_event.is_set():
time.sleep(interval)
if not stop_event.is_set():
try:
sqs.change_message_visibility(
QueueUrl=queue_url,
ReceiptHandle=receipt_handle,
VisibilityTimeout=extension_seconds
)
print(f"Visibility extended by {extension_seconds}s")
except sqs.exceptions.MessageNotInflight:
# Pesan sudah dihapus atau timeout sudah habis
break
response = sqs.receive_message(
QueueUrl=queue_url,
MaxNumberOfMessages=1,
WaitTimeSeconds=20,
VisibilityTimeout=60 # Initial timeout 60 detik
)
if 'Messages' in response:
message = response['Messages'][0]
receipt_handle = message['ReceiptHandle']
stop_event = threading.Event()
heartbeat = threading.Thread(
target=extend_visibility,
args=(receipt_handle, stop_event)
)
heartbeat.daemon = True
heartbeat.start()
try:
process_order(message['Body'])
sqs.delete_message(
QueueUrl=queue_url,
ReceiptHandle=receipt_handle
)
finally:
stop_event.set()
heartbeat.join()
Satu hal yang sering terlewat: ChangeMessageVisibility tidak bisa dipanggil setelah visibility timeout habis. Jika timeout sudah habis dan pesan sudah kembali visible (atau sudah di-receive consumer lain), panggilan ini akan mengembalikan error MessageNotInflight. Artinya heartbeat interval harus lebih pendek dari timeout yang sedang berjalan — bukan sama atau lebih panjang.
Solusi 3: Desain Consumer yang Idempoten
Visibility timeout adalah mekanisme pencegahan, bukan jaminan. SQS secara eksplisit mendokumentasikan at-least-once delivery — dalam kondisi tertentu (misalnya hardware failure di sisi SQS), pesan bisa terkirim lebih dari sekali meskipun visibility timeout dikonfigurasi dengan benar. Satu-satunya cara untuk benar-benar aman dari double processing adalah membuat consumer idempoten.
Implementasi paling praktis: simpan MessageId SQS di database dengan unique constraint sebelum memproses. Jika insert gagal karena duplicate, skip pemrosesan. Ini membutuhkan satu round-trip database tambahan per pesan, tapi mengeliminasi risiko side effect ganda.
# Contoh idempotency check menggunakan DynamoDB
import boto3
from botocore.exceptions import ClientError
dynamodb = boto3.client('dynamodb', region_name='us-east-1')
def is_already_processed(message_id):
"""Return True jika pesan sudah pernah diproses."""
try:
dynamodb.put_item(
TableName='processed-messages',
Item={
'MessageId': {'S': message_id},
'ProcessedAt': {'S': str(time.time())}
},
ConditionExpression='attribute_not_exists(MessageId)'
)
return False # Insert berhasil, pesan belum pernah diproses
except ClientError as e:
if e.response['Error']['Code'] == 'ConditionalCheckFailedException':
return True # Pesan sudah pernah diproses
raise
Kasus Nyata: Misdiagnosis yang Sering Terjadi
Tim menerima laporan bahwa beberapa transaksi diproses dua kali, tapi hanya saat traffic tinggi. Investigasi awal mengarah ke bug di application code — mungkin ada loop yang tidak sengaja memanggil fungsi pemrosesan dua kali. Semua unit test hijau, tidak ada loop yang mencurigakan.
Setelah menambahkan logging timestamp yang lebih detail, ditemukan bahwa dua pemrosesan terjadi dari instance berbeda, bukan dari instance yang sama. Log menunjukkan:
[instance-A] 14:32:01 - Received message msg-abc123, starting processing
[instance-B] 14:32:31 - Received message msg-abc123, starting processing
[instance-A] 14:32:45 - Completed processing msg-abc123, deleting
[instance-B] 14:32:58 - Completed processing msg-abc123, deleting
Instance A menerima pesan pukul 14:32:01. Visibility timeout 30 detik habis pukul 14:32:31 — tepat saat traffic spike membuat pemrosesan lebih lambat dari biasanya. Instance B mengambil pesan yang sama 1 detik setelah timeout habis.
Asumsi awalnya salah: masalah bukan di kode, tapi di gap antara visibility timeout (30 detik) dan waktu pemrosesan aktual saat load tinggi (44 detik). Solusinya: naikkan visibility timeout ke 5 menit dan tambahkan idempotency check sebagai safety net.
IAM: Permission yang Dibutuhkan Consumer
Consumer SQS membutuhkan permission spesifik untuk operasi yang dibahas di atas. Berikut policy dengan least privilege untuk skenario ini:
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "SQSConsumerPermissions",
"Effect": "Allow",
"Action": [
"sqs:ReceiveMessage",
"sqs:DeleteMessage",
"sqs:ChangeMessageVisibility",
"sqs:GetQueueAttributes"
],
"Resource": "arn:aws:sqs:us-east-1:123456789012:my-order-queue"
}
]
}
Jangan tambahkan sqs:SendMessage ke consumer role kecuali memang dibutuhkan. Pisahkan producer role dan consumer role.
Memahami SQS Visibility Timeout: Dead Letter Queue sebagai Safety Net
Visibility timeout yang terlalu panjang punya trade-off: jika consumer crash setelah menerima pesan, pesan tidak akan tersedia kembali sampai timeout habis. Untuk pemrosesan yang memakan waktu lama, ini bisa berarti delay recovery yang signifikan.
Kombinasikan visibility timeout yang tepat dengan Dead Letter Queue (DLQ). Set maxReceiveCount di redrive policy — jika pesan di-receive lebih dari N kali tanpa berhasil dihapus, SQS otomatis memindahkannya ke DLQ. Ini memisahkan 'pesan yang butuh waktu lama' dari 'pesan yang memang bermasalah'.
# Set redrive policy untuk memindahkan pesan ke DLQ setelah 3 kali receive
aws sqs set-queue-attributes \
--queue-url https://sqs.us-east-1.amazonaws.com/123456789012/my-order-queue \
--attributes '{
"RedrivePolicy": "{\"deadLetterTargetArn\":\"arn:aws:sqs:us-east-1:123456789012:my-order-dlq\",\"maxReceiveCount\":\"3\"}",
"VisibilityTimeout": "720"
}'
Wrap-Up dan Langkah Selanjutnya
Double processing di SQS hampir selalu berakar dari satu dari dua hal: visibility timeout lebih pendek dari waktu pemrosesan aktual, atau consumer tidak mengimplementasikan idempotency. Keduanya perlu ditangani — visibility timeout yang tepat mengurangi frekuensi double delivery, idempotency mengeliminasi konsekuensinya.
Langkah konkret yang bisa dilakukan sekarang:
- Ukur P99 processing time dari log production, bukan dari estimasi.
- Set visibility timeout ke nilai yang lebih besar dari P99, dengan buffer yang cukup.
- Implementasikan
ChangeMessageVisibilityheartbeat untuk pemrosesan dengan durasi tidak pasti. - Tambahkan idempotency check di consumer sebagai safety net.
- Konfigurasi DLQ dengan
maxReceiveCountyang sesuai.
Referensi resmi: AWS SQS Visibility Timeout Documentation.
Glossary
| Istilah | Definisi |
|---|---|
| Visibility Timeout | Durasi pesan tidak terlihat oleh consumer lain setelah di-receive. Default 30 detik, maksimum 12 jam. |
| At-Least-Once Delivery | Jaminan SQS bahwa pesan akan terkirim minimal satu kali, tapi tidak menjamin tepat satu kali. |
| ReceiptHandle | Token unik yang diterima consumer saat mengambil pesan. Digunakan untuk DeleteMessage dan ChangeMessageVisibility. Berbeda dari MessageId. |
| Dead Letter Queue (DLQ) | Queue terpisah tempat SQS memindahkan pesan yang gagal diproses setelah mencapai batas maxReceiveCount. |
| Idempotency | Properti operasi yang menghasilkan hasil sama meskipun dieksekusi lebih dari satu kali. Kunci untuk menangani at-least-once delivery dengan aman. |
Komentar
Posting Komentar