לקוחות Kafka בסביבת ייצור אינם רק "שליחת הודעה" ו"קבלת הודעה". לעיתים קרובות, תצורה שגויה פוגעת בעקביות נתונים עסקיים. לאחרונה נתקלנו בפרויקט שבו auto-commit והיעדר idempotency גרמו לאובדן של עד 10% מההזמנות במהלך הפעלות מחדש של הצרכן. לאחר יישום ניהול אופסט ידני ו-producer אידמפוטנטי, ההפסדים צומצמו לאפס. העלות של הפסדים כאלה עבור חנות מקוונת ממוצעת יכולה להגיע ל-$18k–26k בחודש. אנו מתמחים בפיתוח producers ו-consumers אמינים עבור יישומי אינטרנט בעומס גבוה ב-Java ו-Python. מאמר זה מכסה בעיות מפתח, תצורות אופייניות ומקרים מעשיים—החל מהגדרת idempotency ועד עיבוד מקבילי עם שמירת סדר.
בעיות שאנו פותרים
אובדן הודעות במהלך תקלות של Broker או Consumer
ללא תצורה נכונה של acks ו-idempotency, אתה מסתכן באובדן נתונים עסקיים קריטיים. לדוגמה, עם acks=1, הודעה יכולה ללכת לאיבוד אם ה-leader נכשל לפני שכפול. אנו משתמשים ב-acks=all עם min.insync.replicas=2 ו-enable.idempotence=true. זה מבטל אובדן גם אם broker אחד נכשל. חיסכון בתשתית על ידי הימנעות מפתרונות יקרים: עד 40%.
הפרת סדר וכפילויות
צרכנים ללא ניהול אופסט עשויים לעבד את אותה הודעה פעמיים, ועיבוד מקבילי הורס את הסדר. הפתרון: commit ידני לאחר עיבוד וניתוב לפי מפתח בתוך תור מחיצה. בפרויקט אחד, זה הפחית שגיאות אימות ב-30%.
Rebalance ארוך ועיבוד חוזר
כאשר צרכן נכשל ומתרחש rebalance, הקבוצה עשויה לקרוא מחדש כמויות גדולות של נתונים. אנו מגדירים session.timeout.ms ו-max.poll.interval.ms כך שה-rebalance לא ייקח יותר מ-5 שניות, ואנו משתמשים ב-DLQ כדי למנוע חסימה על הודעות "רעות".
כיצד אנו מבטיחים אספקה
לפי תיעוד Apache Kafka, ערבויות אספקה נקבעות על ידי השילוב של acks, idempotency ו-transactions. בואו נסתכל על דפוסים מרכזיים.
Idempotent Producer עם acks=all
עבור עסקאות פיננסיות, אירועי הזמנות וכל זרם קריטי, אנו מגדירים enable.idempotence=true ו-acks=all. זה מבטל כפילויות גם עם ניסיונות חוזרים. ההשפעה על התפוקה היא מינורית (5-10%), אך האמינות משתפרת משמעותית.
// Java — идемпотентный продюсер
Properties props = new Properties();
props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "kafka-1:9092,kafka-2:9092,kafka-3:9092");
props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, KafkaAvroSerializer.class);
props.put("schema.registry.url", "http://schema-registry:8081");
// Надёжность
props.put(ProducerConfig.ACKS_CONFIG, "all");
props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true);
props.put(ProducerConfig.MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION, 5);
props.put(ProducerConfig.RETRIES_CONFIG, Integer.MAX_VALUE);
props.put(ProducerConfig.DELIVERY_TIMEOUT_MS_CONFIG, 120_000);
// Производительность
props.put(ProducerConfig.BATCH_SIZE_CONFIG, 65536); // 64KB батч
props.put(ProducerConfig.LINGER_MS_CONFIG, 5); // ждём до 5ms для наполнения батча
props.put(ProducerConfig.BUFFER_MEMORY_CONFIG, 67_108_864); // 64MB буфер
props.put(ProducerConfig.COMPRESSION_TYPE_CONFIG, "lz4");
KafkaProducer<String, OrderEvent> producer = new KafkaProducer<>(props);שליחה עם טיפול בשגיאות:
public CompletableFuture<RecordMetadata> sendOrderEvent(OrderEvent event) {
ProducerRecord<String, OrderEvent> record = new ProducerRecord<>(
"order-events", event.getOrderId(), event);
CompletableFuture<RecordMetadata> future = new CompletableFuture<>();
producer.send(record, (metadata, exception) -> {
if (exception != null) {
if (exception instanceof RetriableException) {
log.error("Retriable error, Kafka will retry: {}", exception.getMessage());
} else {
log.error("Fatal producer error for order {}: {}", event.getOrderId(), exception.getMessage());
future.completeExceptionally(exception);
}
} else {
log.debug("Sent to partition {} offset {}", metadata.partition(), metadata.offset());
future.complete(metadata);
}
});
return future;
} דוגמה לתצורת צרכן עם commit ידני
Properties consumerProps = new Properties();
consumerProps.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "kafka-1:9092,kafka-2:9092,kafka-3:9092");
consumerProps.put(ConsumerConfig.GROUP_ID_CONFIG, "order-processor");
consumerProps.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
consumerProps.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, KafkaAvroDeserializer.class);
consumerProps.put("schema.registry.url", "http://schema-registry:8081");
consumerProps.put("specific.avro.reader", true);
// Отключаем auto-commit
consumerProps.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false);
consumerProps.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
// Session timeout
consumerProps.put(ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG, 30_000);
consumerProps.put(ConsumerConfig.HEARTBEAT_INTERVAL_MS_CONFIG, 10_000);
consumerProps.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, 500);
consumerProps.put(ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG, 300_000);
KafkaConsumer<String, OrderEvent> consumer = new KafkaConsumer<>(consumerProps);
consumer.subscribe(List.of("order-events"), new ConsumerRebalanceListener() {
@Override
public void onPartitionsRevoked(Collection<TopicPartition> partitions) {
commitCurrentOffsets();
}
@Override
public void onPartitionsAssigned(Collection<TopicPartition> partitions) {
log.info("Assigned partitions: {}", partitions);
}
});
Map<TopicPartition, OffsetAndMetadata> pendingOffsets = new HashMap<>();
try {
while (!shutdown.get()) {
ConsumerRecords<String, OrderEvent> records = consumer.poll(Duration.ofMillis(100));
for (ConsumerRecord<String, OrderEvent> record : records) {
try {
processOrder(record.value());
pendingOffsets.put(
new TopicPartition(record.topic(), record.partition()),
new OffsetAndMetadata(record.offset() + 1)
);
} catch (NonRetriableException e) {
sendToDlq(record, e);
pendingOffsets.put(
new TopicPartition(record.topic(), record.partition()),
new OffsetAndMetadata(record.offset() + 1)
);
}
}
if (!pendingOffsets.isEmpty()) {
consumer.commitSync(pendingOffsets);
pendingOffsets.clear();
}
}
} finally {
consumer.close();
} למה ניהול אופסט ידני עדיף על Auto-Commit
auto-commit יוצר אשליה של פשטות אך מסתיר סיכון: האופסט נשמר לפני העיבוד. אם הצרכן קורס בין commit לעיבוד, ההודעה אובדת ללא אפשרות שחזור. אנו תמיד משביתים את // Java — идемпотентный продюсер Properties props = new Properties(); props.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, "kafka-1:9092,kafka-2:9092,kafka-3:9092"); props.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class); props.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, KafkaAvroSerializer.class); props.put("schema.registry.url", "http://schema-registry:8081"); // Надёжность props.put(ProducerConfig.ACKS_CONFIG, "all"); props.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true); props.put(ProducerConfig.MAX_IN_FLIGHT_REQUESTS_PER_CONNECTION, 5); props.put(ProducerConfig.RETRIES_CONFIG, Integer.MAX_VALUE); props.put(ProducerConfig.DELIVERY_TIMEOUT_MS_CONFIG, 120_000); // Производительность props.put(ProducerConfig.BATCH_SIZE_CONFIG, 65536); // 64KB батч props.put(ProducerConfig.LINGER_MS_CONFIG, 5); // ждём до 5ms для наполнения батча props.put(ProducerConfig.BUFFER_MEMORY_CONFIG, 67_108_864); // 64MB буфер props.put(ProducerConfig.COMPRESSION_TYPE_CONFIG, "lz4"); KafkaProducer<String, OrderEvent> producer = new KafkaProducer<>(props); ומבצעים commit ידני לאחר עיבוד מוצלח של הרשומה. זהו נוהג סטנדרטי עבור מערכות ייצור.
השוואת מצבי acks
| מצב | acks | אובדן נתונים בכשל Leader | ביצועים | המלצה |
|---|---|---|---|---|
| Fire-and-forget | 0 | אפשרי | מקסימלי | רק עבור לוגים לא קריטיים |
| אישור Leader | 1 | אפשרי (לא משוכפל) | גבוה | שירותים פנימיים עם ניסיונות חוזרים |
| כל ISR | all (עם min.insync.replicas) | בלתי אפשרי | נמוך ב-10-15% | נתונים קריטיים, הזמנות, תשלומים |
השוואת לקוחות Java ו-Python
| קריטריון | Java (kafka-clients) | Python (confluent-kafka) |
|---|---|---|
| ביצועים | גבוהים (JVM מקורי) | בינוניים (דרך ספריית C) |
| תמיכה ב-transactions | מלאה | מוגבלת |
| ניהול אופסט | ידני + אסינכרוני | בעיקר commit סינכרוני |
| צריכת זיכרון | גבוהה יותר (JVM heap) | נמוכה יותר |
| המלצה | קריטי, תפוקה גבוהה | אבות טיפוס מהירים, עומס בינוני |
כיצד לארגן עיבוד מקבילי מבלי לאבד סדר
שרשור poll אחד + מאגר עובדים הממופתח לפי מפתח מחיצה הוא הדפוס הקלאסי. אנו שומרים על סדר בתוך אירועי הזמנה בודדת תוך מקביליות בין הזמנות.
Map<Integer, BlockingQueue<ConsumerRecord<String, OrderEvent>>> partitionQueues = new HashMap<>();
ExecutorService workers = Executors.newFixedThreadPool(12);
for (ConsumerRecord<String, OrderEvent> record : records) {
int partitionIndex = record.partition() % NUM_WORKERS;
workerQueues.get(partitionIndex).offer(record);
} תהליך פיתוח מקצה לקצה
- אנליטיקה — הגדרת נושאים, מפתחות הודעות, פורמט (Avro/JSON/Protobuf), קבוצות צרכנים.
- יישום — כתיבת producers עם idempotency, consumers עם commit ידני, DLQ.
- בדיקות — בדיקות יחידה עם EmbeddedKafka, בדיקות אינטגרציה עם Testcontainers, בדיקות עומס.
- פריסה — הגדרת ניטור (פיגור צרכן, שגיאות) והתראות.
מה כלול בעבודה
- מאגר עם קוד (Java/Python) ותיעוד תצורה.
- תמונות Docker מוגדרות עבור staging/production.
- הוראות פריסה וחזרה לגרסה קודמת.
- הדרכה לצוות הלקוח (1-2 שעות).
- אחריות על קוד: 3 חודשים של תמיכה חינם.
לוח זמנים
מ-6 עד 10 ימי עבודה בהתאם למורכבות. בקש הערכת פרויקט — נגיב תוך יום אחד.
טעויות נפוצות (רשימת בדיקה)
- [ ]
public CompletableFuture<RecordMetadata> sendOrderEvent(OrderEvent event) { ProducerRecord<String, OrderEvent> record = new ProducerRecord<>( "order-events", event.getOrderId(), event ); CompletableFuture<RecordMetadata> future = new CompletableFuture<>(); producer.send(record, (metadata, exception) -> { if (exception != null) { if (exception instanceof RetriableException) { log.error("Retriable error, Kafka will retry: {}", exception.getMessage()); } else { log.error("Fatal producer error for order {}: {}", event.getOrderId(), exception.getMessage()); future.completeExceptionally(exception); } } else { log.debug("Sent to partition {} offset {}", metadata.partition(), metadata.offset()); future.complete(metadata); } }); return future; }לא מופעל — כפילויות בניסיונות חוזרים. - [ ] auto-commit בשימוש — אובדן הודעות בכשל צרכן.
- [ ]
Properties consumerProps = new Properties(); consumerProps.put(ConsumerConfig.BOOTSTRAP_SERVERS_CONFIG, "kafka-1:9092,kafka-2:9092,kafka-3:9092"); consumerProps.put(ConsumerConfig.GROUP_ID_CONFIG, "order-processor"); consumerProps.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class); consumerProps.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, KafkaAvroDeserializer.class); consumerProps.put("schema.registry.url", "http://schema-registry:8081"); consumerProps.put("specific.avro.reader", true); // Отключаем auto-commit consumerProps.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false); consumerProps.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest"); // Session timeout consumerProps.put(ConsumerConfig.SESSION_TIMEOUT_MS_CONFIG, 30_000); consumerProps.put(ConsumerConfig.HEARTBEAT_INTERVAL_MS_CONFIG, 10_000); consumerProps.put(ConsumerConfig.MAX_POLL_RECORDS_CONFIG, 500); consumerProps.put(ConsumerConfig.MAX_POLL_INTERVAL_MS_CONFIG, 300_000); KafkaConsumer<String, OrderEvent> consumer = new KafkaConsumer<>(consumerProps); consumer.subscribe(List.of("order-events"), new ConsumerRebalanceListener() { @Override public void onPartitionsRevoked(Collection<TopicPartition> partitions) { commitCurrentOffsets(); } @Override public void onPartitionsAssigned(Collection<TopicPartition> partitions) { log.info("Assigned partitions: {}", partitions); } }); Map<TopicPartition, OffsetAndMetadata> pendingOffsets = new HashMap<>(); try { while (!shutdown.get()) { ConsumerRecords<String, OrderEvent> records = consumer.poll(Duration.ofMillis(100)); for (ConsumerRecord<String, OrderEvent> record : records) { try { processOrder(record.value()); pendingOffsets.put( new TopicPartition(record.topic(), record.partition()), new OffsetAndMetadata(record.offset() + 1) ); } catch (NonRetriableException e) { sendToDlq(record, e); pendingOffsets.put( new TopicPartition(record.topic(), record.partition()), new OffsetAndMetadata(record.offset() + 1) ); } } if (!pendingOffsets.isEmpty()) { consumer.commitSync(pendingOffsets); pendingOffsets.clear(); } } } finally { consumer.close(); }גדול מדי — rebalance ארוך. - [ ] אין טיפול ב-
enable.auto.commit— הודעה אובדת ללא DLQ. - [ ] גרסאות serializer לא תואמות בין producer ל-consumer — שגיאות deserialization.
אם אתה מזהה את הפרויקט שלך בבעיות אלה, צור קשר. פיתחנו producers ו-consumers עבור 15+ פרויקטים המטפלים בעד 100,000 הודעות בשנייה.
אחריות: אנו קובעים מועדים בחוזה ומספקים 3 חודשים של תמיכה לאחר מסירה.







