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不处理