在实际的生产环境里,消息中间件选什么用,往往不是看哪个名气大,而是看你的业务场景到底需要什么。电商订单流和日志采集这两类场景,虽然都是消息驱动,但对中间件的要求截然不同。订单流要的是"一条都不能丢",日志采集要的是"海量吞吐且别拖垮系统"。今天要聊的就是 Pulsar 怎么同时应对这两种需求,以及在真实项目里到底该怎么选型、怎么落地。

Pulsar 是一个由 Apache 基金会孵化的分布式消息系统,最早出自 StreamNative,后来捐赠给了 Apache。它和 Kafka 一样能发消息,但内部架构完全不一样——Pulsar 把存储和计算分开了,计算层是 Broker,存储层是 BookKeeper。这种设计让它在弹性扩缩容上比 Kafka 灵活得多。

下面我们就从订单流和日志采集两个场景出发,看看怎么在同一个 Pulsar 集群上跑起来。

一、订单流场景:消息可靠性的命门所在

1.1 订单流到底要什么

电商订单流的核心诉求其实就两个字:可靠。用户下单那一刻,消息必须送达下游——库存扣减、支付回调、物流预约、积分发放……任何一个环节丢了消息,都可能引发客诉甚至资金损失。

所以订单流场景的设计原则是:宁可慢一点,也不能丢一条。

1.2 Topic 命名与分区策略

在 Pulsar 里,Topic 是消息流动的基本单位。订单流场景推荐用分区 Topic(Partitioned Topic),把不同状态的消息分到不同分区里,既保证吞吐,也方便按业务维度做消费。

下面是一个 Topic 命名规范示例:

partition://ecommerce/order-created/10
partition://ecommerce/order-paid/10
partition://ecommerce/order-shipped/10
partition://ecommerce/order-completed/10
partition://ecommerce/order-cancelled/10

这里 ecommerce 是 namespace,order-created 等是 topic 名,10 是分区数。分区数要根据单机处理能力来定,一般建议是消费者数量的整数倍。

1.3 生产者代码示例

技术栈:Java 17 + Apache Pulsar Java Client 3.1 + Maven

/**
 * 电商订单消息生产者
 * 技术栈:Java 17 + Apache Pulsar Client 3.1
 * 职责:将订单事件发送到 Pulsar 的订单 Topic
 */
package com.demo.pulsar.producer;

import org.apache.pulsar.client.api.*;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.logging.Level;
import java.util.logging.Logger;

public class OrderProducer {

    private static final Logger LOG = Logger.getLogger(OrderProducer.class.getName());

    // Pulsar 服务地址
    private static final String PULSAR_SERVICE_URL = "pulsar://localhost:6650";

    // 订单 Topic 前缀
    private static final String ORDER_TOPIC_PREFIX = "partition://ecommerce/order";

    private PulsarClient client;
    private Producer<String> producer;

    /**
     * 初始化生产者
     */
    public void init() throws PulsarClientException {
        // 创建 Pulsar 客户端,设置发送超时 10 秒
        client = PulsarClient.builder()
                .serviceUrl(PULSAR_SERVICE_URL)
                .sendTimeout(10, TimeUnit.SECONDS)
                .operationTimeout(15, TimeUnit.SECONDS)
                .build();

        // 创建生产者,使用 Key 进行消息路由,确保同一订单走同一分区
        producer = client.newProducer(StandardCharsets.UTF_8)
                .topic(ORDER_TOPIC_PREFIX + "-created")
                .producerName("order-producer-" + UUID.randomUUID().toString().substring(0, 8))
                .hashingScheme(HashingScheme.xxHash) // 使用 xxHash 做消息键哈希
                .messageRoutingMode(MessageRoutingMode.KeyHash) // 按 Key 哈希路由到同一分区
                .blockIfQueueFull(true) // 队列满时阻塞,绝不丢弃消息
                .maxPendingMessages(50000)
                .maxPendingMessagesAcrossPartitions(100000)
                .compressionType(CompressionType.LZ4) // LZ4 压缩减少网络带宽
                .build();

        LOG.info("订单生产者初始化完成");
    }

    /**
     * 发送订单创建消息(异步方式,兼顾可靠性与性能)
     *
     * @param orderJson 订单 JSON 字符串
     * @param orderId   订单 ID,用作消息 Key
     */
    public void sendOrderCreated(String orderJson, String orderId) {
        // 构建消息:设置 Key 保证同订单同分区,设置 Properties 方便过滤
        producer.newMessage()
                .key(orderId) // 订单 ID 作为 Key,Pulsar 会用 xxHash 计算目标分区
                .value(orderJson)
                .property("event_type", "ORDER_CREATED")
                .property("order_id", orderId)
                .property("timestamp", String.valueOf(System.currentTimeMillis()))
                .sendAsync()
                .whenComplete((messageId, throwable) -> {
                    if (throwable != null) {
                        // 发送失败记录错误日志,需要重试或写入死信队列
                        LOG.log(Level.SEVERE,
                                String.format("订单消息发送失败,orderId=%s, error=%s",
                                        orderId, throwable.getMessage()));
                    } else {
                        LOG.info(String.format("订单消息发送成功,orderId=%s, msgId=%s",
                                orderId, messageId));
                    }
                });
    }

    /**
     * 发送订单支付消息(同步方式,关键节点要求确认)
     * 支付是资金操作,必须同步确认落盘后才继续
     */
    public void sendOrderPaid(String orderJson, String orderId) throws Exception {
        producer.newMessage()
                .key(orderId)
                .value(orderJson)
                .property("event_type", "ORDER_PAID")
                .send(); // 同步发送,等待服务端确认后再返回
    }

    /**
     * 关闭生产者资源
     */
    public void close() {
        try {
            if (producer != null) {
                producer.flush(); // 先刷未发送的消息
                producer.close();
            }
            if (client != null) {
                client.close();
            }
            LOG.info("订单生产者已关闭");
        } catch (PulsarClientException e) {
            LOG.log(Level.SEVERE, "关闭生产者时出错", e);
        }
    }

    /**
     * 测试入口
     */
    public static void main(String[] args) throws Exception {
        OrderProducer producer = new OrderProducer();
        producer.init();

        // 模拟发送 10 条订单消息
        String sampleOrder = "{\"orderId\":\"ORD-20240101-001\","
                + "\"userId\":\"user-1001\","
                + "\"amount\":299.00,"
                + "\"items\":[{\"skuId\":\"SKU-001\",\"quantity\":2}],"
                + "\"status\":\"CREATED\"}";

        for (int i = 0; i < 10; i++) {
            String orderId = "ORD-20240101-" + String.format("%03d", i + 1);
            producer.sendOrderCreated(sampleOrder, orderId);
            Thread.sleep(100); // 模拟生产速率
        }

        // 等待异步消息发送完成
        Thread.sleep(5000);
        producer.close();
    }
}

上面这个生产者有几个关键设计点值得注意。MessageRoutingMode.KeyHash 确保同一个订单的所有消息走同一个分区,这样消费端可以按订单维度做顺序处理。blockIfQueueFull(true) 保证在队列满的时候阻塞而不是丢弃消息,这是订单场景的核心保障。压缩选择 LZ4 是因为它在压缩率和解压速度之间取得了很好的平衡。

1.4 消费者代码示例

订单消费者需要做到:消费确认、失败重试、死信队列兜底。

/**
 * 电商订单消息消费者
 * 技术栈:Java 17 + Apache Pulsar Client 3.1
 * 职责:消费订单创建消息,执行后续业务逻辑
 */
package com.demo.pulsar.consumer;

import org.apache.pulsar.client.api.*;
import com.google.gson.Gson;
import com.google.gson.annotations.SerializedName;

import java.util.concurrent.TimeUnit;
import java.util.logging.Level;
import java.util.logging.Logger;

public class OrderConsumer {

    private static final Logger LOG = Logger.getLogger(OrderConsumer.class.getName());

    private static final String PULSAR_SERVICE_URL = "pulsar://localhost:6650";
    private static final String ORDER_TOPIC = "partition://ecommerce/order-created";
    private static final String SUBSCRIPTION_NAME = "order-processor-sub";
    private static final String DEAD_LETTER_TOPIC = "ecommerce/order-dead-letter";

    private PulsarClient client;
    private Consumer<String> consumer;
    private Gson gson = new Gson();

    /**
     * 订单事件实体
     */
    public static class OrderEvent {
        @SerializedName("orderId")
        public String orderId;
        @SerializedName("userId")
        public String userId;
        @SerializedName("amount")
        public double amount;
        @SerializedName("status")
        public String status;
        @SerializedName("timestamp")
        public long timestamp;

        @Override
        public String toString() {
            return String.format("OrderEvent{orderId='%s', userId='%s', amount=%.2f, status='%s'}",
                    orderId, userId, amount, status);
        }
    }

    /**
     * 初始化消费者
     */
    public void init() throws PulsarClientException {
        client = PulsarClient.builder()
                .serviceUrl(PULSAR_SERVICE_URL)
                .operationTimeout(30, TimeUnit.SECONDS)
                .build();

        consumer = client.newConsumer(StandardCharsets.UTF_8)
                .topic(ORDER_TOPIC)
                .subscriptionName(SUBSCRIPTION_NAME)
                .subscriptionType(SubscriptionType.Failover) // Failover:同一分组只有一个实例消费
                .subscriptionInitialPosition(SubscriptionInitialPosition.Earliest) // 从最早的消息开始
                .receiverQueueSize(1000) // 本地接收队列大小
                .deadLetterPolicy(DeadLetterPolicy.builder()
                        .maxRedeliverCount(5) // 最多重试 5 次
                        .deadLetterTopic(DEAD_LETTER_TOPIC) // 重试失败后进入死信队列
                        .build())
                .subscribe();

        LOG.info("订单消费者初始化完成,订阅模式=Failover");
    }

    /**
     * 开始消费消息
     */
    public void startConsuming() {
        LOG.info("开始消费订单消息...");

        while (true) {
            try {
                // 使用 MessageListener 异步监听,避免轮询
                MessageListener<String> listener = (consumerInst, message) -> {
                    processOrder(message);
                    try {
                        consumerInst.acknowledge(message); // 处理成功后确认
                    } catch (PulsarClientException e) {
                        LOG.log(Level.SEVERE, "消息确认失败", e);
                    }
                };
                consumer.listen(listener);
                Thread.sleep(3600000); // 保持线程存活
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                break;
            } catch (PulsarClientException e) {
                LOG.log(Level.SEVERE, "消费异常,3秒后重试", e);
                try { Thread.sleep(3000); } catch (InterruptedException ex) {
                    Thread.currentThread().interrupt(); break;
                }
            }
        }
    }

    /**
     * 处理订单消息的核心业务逻辑
     */
    private void processOrder(Message<String> message) {
        try {
            String payload = message.getValue();
            OrderEvent event = gson.fromJson(payload, OrderEvent.class);
            LOG.info("收到订单消息: " + event);

            // 1. 校验订单数据完整性
            if (event.orderId == null || event.orderId.isEmpty()) {
                throw new IllegalArgumentException("订单ID不能为空");
            }

            // 2. 幂等性检查:避免重复处理
            if (isDuplicate(event.orderId)) {
                LOG.warning("订单已处理过,跳过: " + event.orderId);
                return;
            }

            // 3. 执行业务逻辑(库存扣减、积分发放等)
            deductInventory(event);
            sendNotification(event);
            updateOrderStatus(event, "PROCESSING");

            LOG.info("订单处理完成: " + event.orderId);

        } catch (IllegalArgumentException e) {
            // 数据校验失败,不重试
            LOG.log(Level.SEVERE, "订单数据校验失败", e);
        } catch (Exception e) {
            // 业务处理失败,不确认消息,Pulsar 会自动重发
            LOG.log(Level.SEVERE, "订单处理失败,等待重试", e);
            throw new RuntimeException("订单处理失败", e);
        }
    }

    /**
     * 检查订单是否重复处理(实际项目中查 Redis 或数据库)
     */
    private boolean isDuplicate(String orderId) {
        return false; // 示例中返回 false
    }

    private void deductInventory(OrderEvent event) {
        LOG.info("扣减库存: " + event.orderId);
    }

    private void sendNotification(OrderEvent event) {
        LOG.info("发送订单通知: " + event.userId);
    }

    private void updateOrderStatus(OrderEvent event, String newStatus) {
        LOG.info("更新订单状态: " + event.orderId + " -> " + newStatus);
    }

    /**
     * 关闭消费者资源
     */
    public void close() {
        try {
            if (consumer != null) consumer.close();
            if (client != null) client.close();
            LOG.info("订单消费者已关闭");
        } catch (PulsarClientException e) {
            LOG.log(Level.SEVERE, "关闭消费者时出错", e);
        }
    }
}

关于死信队列,Pulsar 提供了内置的 Dead Letter Queue 支持。上面示例里设置了 maxRedeliverCount(5),意思是同一条消息最多重试 5 次,如果 5 次都失败就自动把消息投到死信 Topic 里。这样的好处是:消费者不会因为一条毒消息无限重试而阻塞整个队列。生产环境建议单独做一个死信消费者,专门处理死信队列里的消息,人工介入或触发告警。

二、日志采集场景:海量数据下的吞吐与成本平衡

2.1 日志采集的特殊需求

日志采集和订单流最大的区别在于:日志是"允许少量丢失"的,但吞吐量极高。一次系统重启可能产生几十 GB 的日志,如果每条都同步确认,系统性能会被严重拖慢。

日志场景的核心矛盾是:既要处理海量数据,又不能因为日志太多把业务系统搞挂。

2.2 日志 Topic 设计策略

日志 Topic 的设计建议按照"系统 + 模块"来划分,同时使用批量发送和批量消费来最大化吞吐。

persistent://logging/java-app/system-log     # 系统级日志(INFO/WARN/ERROR)
persistent://logging/java-app/access-log     # 访问日志(API请求)
persistent://logging/java-app/audit-log      # 审计日志(用户操作)

分区数要开大一些,日志场景通常按机器数量来定。比如你有 50 台应用服务器,那分区数设成 50 或者 100 比较合适。

2.3 日志生产者代码示例

日志生产者的核心策略是异步批量发送,减少网络往返次数。

/**
 * 应用日志采集生产者
 * 技术栈:Java 17 + Apache Pulsar Client 3.1
 * 职责:将应用日志异步批量发送到 Pulsar
 */
package com.demo.pulsar.logging;

import org.apache.pulsar.client.api.*;
import com.google.gson.Gson;
import com.google.gson.GsonBuilder;

import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.TimeUnit;
import java.util.logging.Level;
import java.util.logging.Logger;

public class LogProducer {

    private static final Logger LOG = Logger.getLogger(LogProducer.class.getName());

    private static final String PULSAR_SERVICE_URL = "pulsar://localhost:6650";
    private static final String SYSTEM_LOG_TOPIC = "persistent://logging/java-app/system-log";
    private static final String ACCESS_LOG_TOPIC = "persistent://logging/java-app/access-log";

    private PulsarClient client;
    private Producer<String> systemLogProducer;
    private Producer<String> accessLogProducer;
    private Gson gson;

    /**
     * 初始化日志生产者
     */
    public void init() throws PulsarClientException {
        gson = new GsonBuilder().setPrettyPrinting(false).create();

        client = PulsarClient.builder()
                .serviceUrl(PULSAR_SERVICE_URL)
                .operationTimeout(60, TimeUnit.SECONDS)
                .ioThreads(4) // 增加 IO 线程数以提升并发能力
                .build();

        // 系统日志生产者:批量发送优化
        systemLogProducer = client.newProducer(StandardCharsets.UTF_8)
                .topic(SYSTEM_LOG_TOPIC)
                .producerName("system-log-producer")
                .batchingMaxPublishDelay(10, TimeUnit.MILLISECONDS) // 每 10ms 批量发送一次
                .batchingMaxMessages(1000) // 或攒够 1000 条再发,取先到条件
                .batchingMaxBytes(5 * 1024 * 1024) // 或攒够 5MB 再发
                .batchingMaxBucketCount(10) // 批量桶数,控制并发发送
                .compressionType(CompressionType.LZ4)
                .blockIfQueueFull(true)
                .maxPendingMessages(100000) // 日志场景队列可以设大
                .maxPendingMessagesAcrossPartitions(200000)
                .enableBatching(true) // 开启批量
                .sendTimeout(30, TimeUnit.SECONDS)
                .build();

        // 访问日志生产者
        accessLogProducer = client.newProducer(StandardCharsets.UTF_8)
                .topic(ACCESS_LOG_TOPIC)
                .producerName("access-log-producer")
                .batchingMaxPublishDelay(20, TimeUnit.MILLISECONDS) // 访问日志可以攒更久
                .batchingMaxMessages(2000)
                .compressionType(CompressionType.LZ4)
                .blockIfQueueFull(true)
                .maxPendingMessages(100000)
                .build();

        LOG.info("日志生产者初始化完成,系统日志批量延迟=10ms");
    }

    /**
     * 发送系统日志
     *
     * @param level   日志级别(INFO/WARN/ERROR)
     * @param logger  日志来源类名
     * @param message 日志消息
     */
    public void sendSystemLog(String level, String logger, String message) {
        LogEntry entry = new LogEntry();
        entry.timestamp = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME);
        entry.level = level;
        entry.logger = logger;
        entry.message = message;
        entry.thread = Thread.currentThread().getName();
        entry.appName = "java-web-app";
        entry.env = "production";
        entry.instanceId = getInstanceId();

        systemLogProducer.newMessage()
                .key(entry.instanceId + "-" + entry.timestamp)
                .value(gson.toJson(entry))
                .property("log_level", level)
                .property("app_name", entry.appName)
                .sendAsync()
                .whenComplete((messageId, throwable) -> {
                    if (throwable != null) {
                        LOG.warning("日志发送失败: " + throwable.getMessage());
                    }
                });
    }

    /**
     * 发送访问日志
     */
    public void sendAccessLog(String requestId, String method, String uri,
                              int status, long duration) {
        AccessLogEntry entry = new AccessLogEntry();
        entry.timestamp = LocalDateTime.now().format(DateTimeFormatter.ISO_LOCAL_DATE_TIME);
        entry.requestId = requestId;
        entry.method = method;
        entry.uri = uri;
        entry.status = status;
        entry.duration = duration;
        entry.appName = "java-web-app";
        entry.env = "production";
        entry.instanceId = getInstanceId();

        // 按状态码路由:正常请求和错误请求走不同分区,方便后续统计
        String routingKey = (status >= 400) ? "error-" + status : "success-" + status;

        accessLogProducer.newMessage()
                .key(routingKey + "-" + requestId)
                .value(gson.toJson(entry))
                .property("http_status", String.valueOf(status))
                .property("duration", String.valueOf(duration))
                .sendAsync();
    }

    /**
     * 日志条目实体
     */
    public static class LogEntry {
        public String timestamp;
        public String level;
        public String logger;
        public String message;
        public String thread;
        public String appName;
        public String env;
        public String instanceId;
    }

    /**
     * 访问日志条目实体
     */
    public static class AccessLogEntry {
        public String timestamp;
        public String requestId;
        public String method;
        public String uri;
        public int status;
        public long duration;
        public String appName;
        public String env;
        public String instanceId;
    }

    /**
     * 获取实例 ID
     */
    private static String getInstanceId() {
        String id = System.getenv("INSTANCE_ID");
        return (id != null) ? id : "unknown-instance";
    }

    /**
     * 优雅关闭
     */
    public void close() {
        try {
            systemLogProducer.flush();
            accessLogProducer.flush();
            systemLogProducer.close();
            accessLogProducer.close();
            client.close();
            LOG.info("日志生产者已关闭");
        } catch (PulsarClientException e) {
            LOG.log(Level.SEVERE, "关闭日志生产者时出错", e);
        }
    }

    /**
     * 使用示例:模拟批量发送日志
     */
    public static void main(String[] args) throws Exception {
        LogProducer logProducer = new LogProducer();
        logProducer.init();

        // 模拟发送 1000 条系统日志
        for (int i = 0; i < 1000; i++) {
            String level = (i % 10 == 0) ? "ERROR" :
                           (i % 5 == 0) ? "WARN" : "INFO";
            logProducer.sendSystemLog(level, "com.demo.OrderService",
                    "处理订单#" + i + ",耗时" + (long)(Math.random() * 100) + "ms");
        }

        // 模拟发送访问日志
        for (int i = 0; i < 500; i++) {
            int status = (i % 10 == 0) ? 500 :
                         (i % 5 == 0) ? 404 : 200;
            logProducer.sendAccessLog(
                    "REQ-" + i,
                    (i % 2 == 0) ? "GET" : "POST",
                    "/api/orders/" + i,
                    status,
                    (long)(Math.random() * 500)
            );
        }

        Thread.sleep(10000); // 等待批量消息发送完成
        logProducer.close();
    }
}

日志生产者这里有个关键的优化点:批量发送。batchingMaxPublishDelay(10, TimeUnit.MILLISECONDS) 的意思是,每攒 10 毫秒的消息就发一次批次。如果 10 毫秒内攒够了 1000 条或 5MB,就提前发。这样在高峰期可以把网络请求数降低几个数量级——原来是 1000 条消息发 1000 次请求,现在是发 1 次请求带 1000 条消息。

2.4 日志消费者代码示例

日志消费者通常用于:日志归档到存储、日志分析、告警触发。

/**
 * 日志消息消费者(用于归档与分析)
 * 技术栈:Java 17 + Apache Pulsar Client 3.1
 * 职责:消费日志消息,写入存储或触发告警
 */
package com.demo.pulsar.logging;

import org.apache.pulsar.client.api.*;
import com.google.gson.Gson;

import java.util.concurrent.*;
import java.util.logging.Level;
import java.util.logging.Logger;

public class LogConsumer {

    private static final Logger LOG = Logger.getLogger(LogConsumer.class.getName());

    private static final String PULSAR_SERVICE_URL = "pulsar://localhost:6650";
    private static final String SYSTEM_LOG_TOPIC = "persistent://logging/java-app/system-log";
    private static final String SUBSCRIPTION_NAME = "log-archive-sub";

    private PulsarClient client;
    private Consumer<String> consumer;
    private Gson gson = new Gson();

    // 日志归档缓冲区
    private final BlockingQueue<String> archiveBuffer = new LinkedBlockingQueue<>(10000);
    // 异步归档线程池
    private final ExecutorService archiveExecutor = Executors.newFixedThreadPool(3);

    /**
     * 初始化日志消费者
     */
    public void init() throws PulsarClientException {
        client = PulsarClient.builder()
                .serviceUrl(PULSAR_SERVICE_URL)
                .build();

        consumer = client.newConsumer(StandardCharsets.UTF_8)
                .topic(SYSTEM_LOG_TOPIC)
                .subscriptionName(SUBSCRIPTION_NAME)
                .subscriptionType(SubscriptionType.Failover)
                .receiverQueueSize(5000) // 日志量大,接收队列设大
                .ackTimeout(10, TimeUnit.SECONDS)
                .negativeAckRedeliveryDelay(30, TimeUnit.SECONDS)
                .subscribe();

        LOG.info("日志消费者初始化完成");
    }

    /**
     * 开始消费日志消息
     */
    public void startConsuming() {
        LOG.info("开始消费日志消息...");
        consumer.listen((consumerInst, message) -> {
            try {
                String payload = message.getValue();
                LogProducer.LogEntry logEntry = gson.fromJson(payload, LogProducer.LogEntry.class);

                // 按日志级别处理
                switch (logEntry.level) {
                    case "ERROR":
                        handleErrorMessage(logEntry);
                        break;
                    case "WARN":
                        handleWarningMessage(logEntry);
                        break;
                    default:
                        break;
                }

                archiveBuffer.offer(gson.toJson(logEntry));
                consumerInst.acknowledge(message);

            } catch (Exception e) {
                LOG.log(Level.SEVERE, "处理日志消息失败", e);
            }
        });
    }

    /**
     * 处理错误日志:触发告警
     */
    private void handleErrorMessage(LogProducer.LogEntry entry) {
        String alertMessage = String.format(
                "[生产告警] 应用=%s, 级别=%s, 来源=%s, 消息=%s, 时间=%s",
                entry.appName, entry.level, entry.logger, entry.message, entry.timestamp
        );
        // 异步发送告警,不阻塞消费线程
        archiveExecutor.submit(() -> sendAlert(alertMessage));
    }

    private void handleWarningMessage(LogProducer.LogEntry entry) {
        LOG.warning("WARN 日志: " + entry.message);
    }

    /**
     * 发送告警通知
     */
    private void sendAlert(String message) {
        System.out.println("告警通知: " + message);
    }

    /**
     * 启动日志归档后台线程
     */
    public void startArchiveThread() {
        archiveExecutor.submit(() -> {
            LOG.info("日志归档线程已启动");
            StringBuilder batchBuffer = new StringBuilder();
            int count = 0;

            while (true) {
                try {
                    String entry = archiveBuffer.poll(5, TimeUnit.SECONDS);
                    if (entry != null) {
                        batchBuffer.append(entry).append("\n");
                        count++;
                    }

                    // 缓冲区超过 100 条或等待超时则刷入存储
                    if (count >= 100 || (entry == null && batchBuffer.length() > 0)) {
                        System.out.println("归档 " + count + " 条日志");
                        batchBuffer.setLength(0);
                        count = 0;
                    }
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                    break;
                }
            }
        });
    }

    /**
     * 优雅关闭
     */
    public void close() {
        archiveExecutor.shutdown();
        try { archiveExecutor.awaitTermination(30, TimeUnit.SECONDS); }
        catch (InterruptedException e) { archiveExecutor.shutdownNow(); }
        try {
            consumer.close();
            client.close();
        } catch (PulsarClientException e) {
            LOG.log(Level.SEVERE, "关闭日志消费者时出错", e);
        }
        LOG.info("日志消费者已关闭");
    }

    public static void main(String[] args) throws Exception {
        LogConsumer logConsumer = new LogConsumer();
        logConsumer.init();
        logConsumer.startConsuming();
        logConsumer.startArchiveThread();
        Thread.currentThread().join();
    }
}

日志消费者这里的设计思路是:用 MessageListener 做异步消费,避免单线程瓶颈。每条消息消费后先做分级处理——ERROR 日志立即触发告警,WARN 日志做统计后延迟告警,INFO 日志直接归档。归档部分用了独立的线程池和缓冲区,这样消费线程和归档线程互不干扰,日志量大时不会因为归档慢而拖慢消费。

三、技术优缺点分析

3.1 Pulsar 在订单流场景的优势

Pulsar 的分层架构(计算与存储分离)在订单流场景下有几个实实在在的好处。首先是弹性扩缩容方便——流量高峰时加 Broker 节点就能提升消费能力,不需要像 Kafka 那样重平衡分区。其次是消息不丢——Pulsar 的 ACK 机制是消息写入 BookKeeper 后才确认,比 Kafka 的 Leader 确认更可靠。第三是 Topic 数量可以非常大,Pulsar 单集群可以轻松支撑百万级 Topic,这在订单流场景里很实用,每个业务线甚至每个租户都可以有独立的 Topic。

3.2 Pulsar 在日志采集场景的优势

日志采集场景主要看重吞吐和成本。Pulsar 的批量发送能力、多层压缩(LZ4、ZSTD、SNAPPY)以及分层存储(热数据在 SSD、冷数据自动下沉到对象存储)让它在日志场景下非常经济。特别是分层存储功能——如果不用 Pulsar,日志冷数据要么自己搭存储,要么全放在 SSD 上成本很高。Pulsar 可以自动把 24 小时前的日志下沉到 S3,查询时再从对象存储拉取,成本能降好几倍。

3.3 Pulsar 的劣势与局限

Pulsar 不是万能的。它的劣势也很明显:

第一,架构复杂度高。Pulsar 由 ZooKeeper + BookKeeper + Broker 三个组件组成,部署运维的复杂度比 Kafka 高不少。如果你团队没有足够的运维能力,可能觉得 Kafka 更省心。

第二,小消息场景的延迟略高。因为 Pulsar 的元数据存储在 ZooKeeper 里,当 Topic 数量非常大时(比如几万个),ZooKeeper 可能成为瓶颈。不过对于电商订单流和日志采集这两种场景来说,Topic 数量通常在几千级别,不会碰到这个问题。

第三,Java Client 的批量发送在低延迟场景下表现一般。如果你需要微秒级延迟的消息传递,Pulsar 可能不是最佳选择。但对于订单流和日志采集来说,毫秒级延迟完全够用。

3.4 与 Kafka 的简单对比

  • Kafka 的优势:生态成熟、社区活跃、运维资料多、KRaft 模式简化架构
  • Pulsar 的优势:计算存储分离、弹性伸缩、多租户天然支持、分层存储、低延迟

对于同时需要订单流和日志采集的场景,Pulsar 的一个大优势是可以用同一个集群同时跑两种业务。Kafka 的话,通常建议把日志和交易消息分到不同的集群,因为日志的写模式是"大量小写入",交易消息是"少量大写入",混在一起会影响彼此性能。

四、应用场景分析

4.1 电商订单流场景的落地要点

在电商订单流里,Pulsar 的链式消费非常自然:

用户下单 → 订单服务产生消息 → Pulsar → 库存服务消费扣库存 → Pulsar → 支付服务消费发起支付 → Pulsar → 物流服务消费预约发货 → Pulsar → 通知服务消费发送短信/邮件

每一跳都用独立的 Topic,消费者之间通过 Pulsar 解耦。库存服务挂了,消息不会丢,它会停在 Pulsar 里等库存服务恢复后再消费。

关键配置汇总:订阅模式选 Failover,消息路由用 KeyHash,ACK 模式用 Individual,开启死信队列。

4.2 日志采集场景的落地要点

典型架构是:应用服务 → 日志SDK/Agent → Pulsar → 日志消费者 → Elasticsearch/S3/ClickHouse。

应用服务不需要直接依赖 Pulsar SDK,而是通过一个日志 Agent 把日志文件读取出来,批量发送到 Pulsar。关键配置:订阅模式选 Shared 让多个消费者分担消费,消息路由用 RoundRobin(日志不需要顺序),ACK 模式用 Cumulative 减少 ACK 次数,开启批量发送,压缩用 LZ4。

五、注意事项

5.1 Topic 管理

建议按照业务线划分 namespace,比如 ecommerce、logging、payment 各自独立。Topic 命名要有统一规范。不要在代码里硬编码 Topic 名,而是通过配置文件注入,这样可以灵活切换环境。

5.2 消息可靠性保障

消息不丢靠三个层面配合:生产端开启 blockIfQueueFull(true) 并区分同步异步发送,服务端配置合理的 replicationClusters 和 BookKeeper 的 ensembleSize/writeQuorum,消费端处理完消息后再 ACK 并用独立字段做幂等判断。

5.3 性能调优实战建议

批量发送:日志场景一定开,订单场景可选。接收队列:日志场景设 5000-10000。压缩:LZ4 是压缩率和速度的平衡点,ZSTD 压缩率更好但 CPU 开销更大。分区数:消费者数量乘以 2 到 4 比较合理。

5.4 运维监控要点

建议监控这些指标:pulsar_producer_messages_published_total(发送量)、pulsar_consumer_messages_consumed_total(消费量)、pulsar_backlog_size_bytes(积压大小)、pulsar_pending_messages(待确认消息数)。积压是订单流最关键的监控指标,如果积压持续增长说明消费跟不上生产。

5.5 多租户隔离

在一个集群上同时跑订单流和日志采集时,建议用不同的 tenant 做资源隔离。tenant 之间配额独立,一个租户的流量暴涨不会影响另一个租户。

# 创建两个租户的示例命令
pulsar-admin tenants create ecommerce
pulsar-admin tenants create logging

# 创建 namespace 并设置配额
pulsar-admin namespaces create ecommerce/order
pulsar-admin namespaces create logging/app-logs

# 为订单 namespace 设置消息保留策略:保留 7 天
pulsar-admin namespaces set-retention -n ecommerce/order -s 1TB -t 7d

# 为日志 namespace 设置保留策略:保留 24 小时
pulsar-admin namespaces set-retention -n logging/app-logs -s 500GB -t 24h

# 开启日志 namespace 的分层存储(冷数据自动下沉到 S3)
pulsar-admin namespaces set-tiered-storage -n logging/app-logs -p s3://log-archive-bucket

六、文章总结

Pulsar 在电商订单流和日志采集这两个场景下都能很好地胜任,但需要注意的核心点不同。订单流关注消息可靠性和顺序,日志采集关注吞吐和成本。同一个 Pulsar 集群可以承载这两种业务,只要配置得当、Topic 规划合理。

选 Pulsar 还是 Kafka,取决于你的团队能力和业务需求。如果你需要多租户隔离、分层存储、弹性扩缩容这些能力,Pulsar 是很好的选择。如果团队更熟悉 Kafka 生态且业务规模不需要太复杂的多租户能力,Kafka 也完全够用。

实际落地的建议:先在测试环境用 Docker Compose 部署一个最小集群跑通流程,验证消息可靠性和吞吐量后,再逐步迁移生产环境。最后一点很重要:不管选什么中间件,消息可靠性最终靠的是代码逻辑。幂等设计、死信队列处理、告警监控、灰度发布,这些软功夫比中间件本身的配置更关键。