storm实践

一 环境与部署

1. 环境变量

  由于是分布式环境,对于java单机的 -Djvm参数不适用,在storm中处理环境变量是通过org.apache.storm.Config,在提交任务创建Topology时设置Config,在Spout和Bolt中通过open方法中的config参数读取

 

2. 部署

  storm集群重启时会有短暂时间的不可用,解决这种方式可以通过部署两个功能完全相同的集群,在提交任务的时候传入集群名,就好像同样的服务部署在多个机器上,但是这种方式对于按照字段分组的场景可能产生并发问题

 

以下为启动代码示例:

public class TestTopology {

    private static final Logger logger = LoggerFactory.getLogger(TestTopology.class);

    protected static String topologyIndex = ""; // 区分A,B集群

    protected String testTopology = "test-topology";

    public static void main(String args[]) {
        topologyIndex = args[0];
        if (args.length > 1) {
            System.setProperty("env", args[1]); // 接收环境变量
            System.out.println("init env is " + args[1]);
        }
        if (StringUtils.isEmpty(topologyIndex)) {
            logger.error("topology index error");
            System.exit(0);
        }
        new TestTopology().build();
    }

    public void build() {
        AdStormEnvironment.init(System.getProperties(), false);
        TopologyBuilder builder = new TopologyBuilder();


        Config config = new Config();
        config.put("env", System.getProperty("env")); // 设置环境变量
        try {
            StormSubmitter.submitTopology(testTopology + "-" + topologyIndex, config, builder.createTopology());
        } catch (Exception e) {
            SlfLogUtil.error(logger, e, "部署集群任务失败|{0}|{1}", System.getenv(), StormEnvironment.getName(testTopology));
        }
    }
}

 

二 日志兼容问题

  storm日志可以说是最头疼的问题,这也和java中日志体系有关,log4j log4j2 slf4j commons-logging jdk-log logback 等等

  java日志体系分成两类,一类是实现,一类是接口定义。实现就是具体的log,接口定义主要是为了整合其他日志框架

1. 日志类实现

  jdk-logging, jdk自带log类,基本没人用

  log4j, log4j的实现,常见版本1.2.17

  logback,log4j作者的有一个实现,性能更好

  log4j2,性能更好的日志实现,storm使用的就是log4j2,版本为2.8。只不过log4j2引入有两个包,一个是log4j2-api,一个是log4j2-core,所以其他日志框架实现了log4j2-api都可以整合到log4j2的项目中

  commons-logging,由于log4j先于jdk-logging,commons-logging主要做的事就是优先使用log4j,没有的话使用jdk-logging,再不行的话使用自己的实现

2. 日志类接口

  slf4j,一种日志标准实现的定义,逐渐形成新的行业标准

  slf4j有很多子包,所有xxx-slf4j-impl的包都是slf4j的具体实现,例如log4j-slf4j-impl, log4j2-slf4j-impl等

  实现原理就是调用具体的日志实现的方法,比如log4j-slf4j-impl中的Logger会调用log4j的Logger

  xxx-over-slf4j的包都是把日志实现转移到slf4j上,例如log4j-over-slf4j

  实现原理是比如log4j包中有个叫Logger的类,在log4j-over-slf4j包中也有个同名的Logger类,但是方法实现是调用slf4j的api,java中同名类加载的顺序是按照classpath哪个在前面就先加载哪一个,如果是先加载了log4j也是无法生效的

  所以xxx-over-slf4j和xxx-slf4j-impl不能同时引入,不然会造成死循环

3. storm中引入了哪些包

  slf4j-api log4j-api-2.8 log4j-slf4j-impl-2.8 log4j-core-2.8 这几个主要是slf4j和log4j2的组合

  log4j-over-slf4j-1.6.6 log4j的日志转到slf4j

log4j-over-slf4j-1.6.6 中的坑,主要是这个adapter对于log4j-1.2.17的实现有问题,比如https://jira.qos.ch/browse/SLF4J-368 这个问题,在使用DailyRollingAppender时会有问题,还有一些奇奇怪怪的问题,总的来说还是要把log4j迁移到slf4j比较保险

  storm应用中会引入storm-core这个包,这个包也会依赖log4j2,当版本与storm安装目录下log4j2的2.8版本不一致的时候可能会产生错误,maven打包时最好execlude掉

 

三. 日志配置问题

  由于storm不能指定log4j2配置文件的位置,所以集群中只能使用一份配置,对于测试和线上环境的区分就比较麻烦,以下给出解决方案如下:

  修改config.yaml

  worker.childopts: "-Xloggc:/data1/logs/storm/%TOPOLOGY-ID%/%ID%/gc.log" 其中%TOPOLOGY-ID%代表拓扑名,%ID%代表worker端口号,这样可以让GC LOG打印到与worker log同级

  修改worker.xml

<configuration monitorInterval="60" shutdownHook="disable">
    <properties>
        <property name="pattern">[%p] %d{yyyy-MM-dd HH:mm:ss,SSS} [%c]-[%M line:%L] %m%n</property>
        <property name="baseDir">/data1/logs/storm</property>
    </properties>
    <appenders>
        <RollingFile name="ROOT_LOG"
                     fileName="${baseDir}/${sys:storm.id}/${sys:worker.port}/root.log"
                     filePattern="${baseDir}/${sys:storm.id}/${sys:worker.port}/root.log.%d{yyyy-MM-dd}.gz">
            <PatternLayout>
                <pattern>${pattern}</pattern>
            </PatternLayout>
            <Policies>
                <TimeBasedTriggeringPolicy modulate="true" interval="1"/>
            </Policies>
            <DefaultRolloverStrategy max="9"/>
        </RollingFile>
        <RollingFile name="worker"
                     fileName="${baseDir}/${sys:storm.id}/${sys:worker.port}/worker.log"
                     filePattern="${baseDir}/${sys:storm.id}/${sys:worker.port}/worker.log.%d{yyyy-MM-dd}.gz">
            <PatternLayout>
                <pattern>${pattern}</pattern>
            </PatternLayout>
            <Policies>
                <TimeBasedTriggeringPolicy modulate="true" interval="1"/>
            </Policies>
            <DefaultRolloverStrategy max="9"/>
        </RollingFile>
        <RollingFile name="ERROR_LOG"
                     fileName="${baseDir}/${sys:storm.id}/${sys:worker.port}/stdout.log"
                     filePattern="${baseDir}/${sys:storm.id}/${sys:worker.port}/stdout.log.%d{yyyy-MM-dd}.gz">
            <PatternLayout>
                <pattern>${pattern}</pattern>
            </PatternLayout>
            <Filters>
                <ThresholdFilter level="error" onMatch="ACCEPT" onMismatch="DENY" />
            </Filters>
            <Policies>
                <TimeBasedTriggeringPolicy modulate="true" interval="1"/>
            </Policies>
            <DefaultRolloverStrategy max="4"/>
        </RollingFile>
    </appenders>
    <loggers>
        <root level="info">
            <appender-ref ref="ROOT_LOG"/>
            <appender-ref ref="ERROR_LOG"/>
        </root>
        <Logger name="STDERR" level="INFO" additivity="true">
        </Logger>
        <Logger name="STDOUT" level="INFO" additivity="true">
        </Logger>
        <Logger name="oo.xx" level="info">
            <appender-ref ref="worker"/>
        </Logger>
    </loggers>
</configuration>

  主要修改:

  a. 自己应用的日志打印到worker.log

    b. storm和一些其他方面的日志打印到root.log

      c. 所有error级别的log打印到stdout.log,用来监控应用错误

  d. [%c]-[%M line:%L] 这三个设置可以方便知道日志打印的 类,方法,行

  

  创建自己自定义的Logger类

public class SlfLogUtil {
    
    private static final String FQCN = SlfLogUtil.class.getName();

    public static void info(Logger logger, String message) {
        if (logger instanceof LocationAwareLogger) {
            ((LocationAwareLogger) logger).log(null, FQCN, LocationAwareLogger.INFO_INT, message, null, null);
        } else {
            logger.info(message);
        }
    }

    public static void debug(Logger logger, String message, Object... params) {
        if (Environment.isTest()) {
            info(logger, format(message, params), false);
        }
    }
}

  主要修改:

  a. 代理info debug error 方法,尤其是debug,自己可以根据环境变量来判断,这里Environment.isTest()方法可以自己实现

  b. 为了可以打印方法调用位置需要使用slf4j的LocationAwareLogger,不然日志中会变成SlfLogUtil方法调用的位置

 

四. 日志收集问题

  Storm由于是分布式的,所以日志会分布在各各机器上,而且每次重启会新生成一次目录,非常不利于对应用日志的观察,提出如下几种解决办法

  可以使用日志收集客户端,直接采集文件,但是需要监控文件和文件夹的新增,比较麻烦

  还有一种办法是固定日志文件的名字,比如使用LOG4J2中的Routing功能,通过ThreadContext固定日志的位置,但是ThreadContext对于多线程的时候需要传递ThreadContext中的值,而且一个拓扑在不同worker中肯定无法输出到同一个文件,还是只能在固定路径生成stdout.$worker.log 这样的log

  第三种办法后来尝试直接使用KafkaAppender,通过网络转走日志,最后也是使用这种办法,如果使用Log4j2的KafkaAppender在配置文件中配置,测试环境和线上环境又不能在同一个topic,所以自己实现了KafkaAppender,具体实现如下

  

@Plugin(name = "StormAppender", category = "Core", elementType = "appender", printObject = true)
public class StormAppender extends AbstractAppender {
    
    String topic;

    Producer<String, String> producer = null;

    String hostName;

    /* 构造函数 */
    public StormAppender(String name, Filter filter, Layout<? extends Serializable> layout, boolean ignoreExceptions, String topic) {
        super(name, filter, layout, ignoreExceptions);
        this.topic = topic;
        producer = new KafkaProducer<String, String>(getKafkaConfig());
        try {
            hostName = InetAddress.getLocalHost().getHostName();
        } catch (Exception e) {
            hostName = "unknown";
        }
    }

    private Properties getKafkaConfig() {
        Properties props = new Properties();
        props.put("bootstrap.servers", "localhost:9092");
        props.put("acks", "1");
        props.put("retries", 2);
        props.put("batch.size", 512);
        props.put("linger.ms", 1);
        props.put("max.block.ms", 1000);
        props.put("buffer.memory", 32 * 1024 * 1024);
        props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
        props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
        props.put("compression.type", "gzip");
        return props;
    }


    @Override
    public void append(LogEvent event) {
        if (event.getLoggerName().startsWith("org.apache.kafka")) {
            LOGGER.warn("Recursive logging from [{}] for appender [{}].", event.getLoggerName(), getName());
        } else {
            try {
                final Layout<? extends Serializable> layout = getLayout();
                byte[] data;
                if (layout != null) {
                    if (layout instanceof SerializedLayout) {
                        final byte[] header = layout.getHeader();
                        final byte[] body = layout.toByteArray(event);
                        data = new byte[header.length + body.length];
                        System.arraycopy(header, 0, data, 0, header.length);
                        System.arraycopy(body, 0, data, header.length, body.length);
                    } else {
                        data = layout.toByteArray(event);
                    }
                } else {
                    data = StringEncoder.toBytes(event.getMessage().getFormattedMessage(), StandardCharsets.UTF_8);
                }
                String line = hostName + "|" + StormEnvironment.getTopology() + "|" + new String(data);
                producer.send(new ProducerRecord<String, String>(topic, line));
            } catch (final Exception e) {
                LOGGER.error("Unable to write to Kafka [{}] for appender [{}].", topic, getName(), e);
                throw new AppenderLoggingException("Unable to write to Kafka in appender: " + e.getMessage(), e);
            }
        }
    }

    @PluginFactory
    public static StormAppender createAppender(@PluginAttribute("name") String name,
                                               @PluginAttribute("topic") String topic,
                                                @PluginElement("Filter") final Filter filter,
                                                @PluginElement("Layout") Layout<? extends Serializable> layout,
                                                @PluginAttribute("ignoreExceptions") boolean ignoreExceptions) {
        if (name == null) {
            LOGGER.error("no name defined in conf.");
            return null;
        }
        if (layout == null) {
            layout = PatternLayout.createDefaultLayout();
        }
        return new StormAppender(name, filter, layout, ignoreExceptions, topic);
    }

    @Override
    public void stop() {

    }
}

 

  主要修改:

  a. 向kafka投递日志内容的时候会增加拓扑名,方便消费的时候做区分,根据LogEvent还可以做更多的定制

      b. 日志防止循环打印,如果是org.apache.kafka的日志不打印,还有Appender不要使用工程中的其他类,比如A类的m方法中记录日志会调用自定义的Appender,在自定义的Appender中又会调用A类的m方法造成死循环

 

  有了Appender,下一步是在启动拓扑的时候,将appender加入到rootlogger中,可以使用代码的方式动态更新rootlogger的appender,这样也可以根据环境来投递到不同的topic种,代码如下

final LoggerContext ctx = (LoggerContext) LogManager.getContext(false);
final Configuration config = ctx.getConfiguration();
Layout layout = PatternLayout.newBuilder().withPattern("[%p] %d{yyyy-MM-dd HH:mm:ss,SSS} [%c]-[%M line:%L] %m%n")
        .withConfiguration(config).build();
for (Appender appender : config.getAppenders().values()) {
    if ("stormAppender".equals(appender.getName())) {
        System.out.println("storm appender has inited");
        return;
    }
}
System.out.println("init storm log to kafka topic " + StormEnvironment.getName(KafkaTopics.STORM_LOG));
final Appender stormAppender = StormAppender.createAppender("stormAppender", StormEnvironment.getName(KafkaTopics.STORM_LOG), null, layout, true);
stormAppender.start();
config.addAppender(stormAppender);
AppenderRef stormAppenderRef = AppenderRef.createAppenderRef("stormAppender", Level.INFO, null);
final Appender appender = AsyncAppender.newBuilder().setIgnoreExceptions(true).setConfiguration(config)
        .setName("asyncStormAppender").setIncludeLocation(true).setBlocking(false).setAppenderRefs(new AppenderRef[] {stormAppenderRef}).build();
appender.start();
config.getRootLogger().addAppender(appender, Level.INFO, null);

  主要修改:

  a. 这里自定义的Appender用AsyncAppender包了一层,一定要设置setIncludeLocation(true),不然无法打印日志调用的位置

 

五.  Storm不只是WordCount

1. streamid的概念

  strorm资料比较多的是讲word count的例子,http://zqhxuyuan.github.io/2016/06/30/Hello-Storm/ 这篇文章讲了streamid的概念,让流可以分叉,可以合并

  一个bolt可以同时定义多种输入,一个bolt或者spout在emit数据的时候也可以到任意的spout或者bolt

  mergeBolt接收两种输入

builder.setBolt("merge", merge, mergeThread)
                .fieldsGrouping("recoverSpout", "merge-recover", new Fields("ideaId"))
                .fieldsGrouping("log", new Fields("ideaId"));

  定义两种输出流,可以在运行时emit的时候指定streamid输出到哪个bolt

@Override
    public void declareOutputFields(OutputFieldsDeclarer outputFieldsDeclarer) {
        outputFieldsDeclarer.declare(new Fields("encrypt", "topic", "messageId"));
        outputFieldsDeclarer.declareStream("merge-recover", new Fields("param", "campaignId", "ideaId", "messageId", "topic"));
    }

 

2. ack

  开启ack机制后需要注意如下几点:

  2.1 Spout需要自己存储未确认的消息,一般存储在一个LRUMapV2<String, Object> pending这样的定长队列中,防止爆内存

  2.2 Spout ack时删除pending数据

  2.3 Spout fail时一般选择重新投递,如果失败次数过多只记录日志,防止死消息在拓扑中无限循环

  2.4 messageId 最好由topology上游业务生成,更安全

  2.5 因为有重投存在,所以所有需要写数据的地方需要保持幂等性,用messageId + 操作做key,redis集群来存储,比如一个IncBolt -> SaveBolt,在SaveBolt失败,重投的时候IncBolt不需要在做具体的Inc操作

  ack机制不作为业务处理失败时的恢复机制:

  每个bolt在finally中进行ack,如果消息处理有异常放入到外部队列,手动进行重投,而不是依靠storm进行自动重投。让storm的ack机制保证的仅仅是当某个bolt挂掉时候的自动重投

 

3. 慎用静态变量

  首先topology中的bolt和spout都是worker中的线程,可以使用jstack查看,如果一个boltA和boltB在同一个jvm中同时操作static变量可能会造成问题,所以最好不要使用单例,尤其是有状态的类,一定要在open的时候创建一个实例

  如果是资源类的,比如数据库连接,可以是各种bolt spout共享一个,将其设置为静态,但是注意初始化的时候需要synchronize

 

4. bolt异常退出问题

  bolt的execute(Tuple input)方法,一定要加try catch exception finally,如果为execute的异常抛出到storm框架,会导致worker异常退出,整个拓扑stop,建议是异常自己catch并处理

 

5. 长时间空消息导致Spout emptyEmit

  在测试环境发现Spout长时间的没有消息emit会出现一只调用emptyEmit,但是也不是一定出现,为了安全做了两点

  5.1 Spout从kafka消费的时候增加超时时间,当超时以后nextTuple立刻return 

  5.2 长时间没有emit消失,Spout emit一条空消息,bolt收到空消息只ack不处理

 

posted @ 2018-01-26 19:14  23lalala  阅读(612)  评论(0编辑  收藏  举报