1. 为什么“复杂版WordCount”才是SpringBoot对接Hadoop的真正门槛
很多人第一次在SpringBoot里跑通一个本地模式的WordCount,就以为自己已经掌握了Hadoop集成——结果一上YARN集群就卡在ClassNotFoundException、NoClassDefFoundError、Kerberos认证失败、序列化不兼容、甚至连Job提交都报“Connection refused: no further information”。我带过的几个刚转大数据方向的开发者,有三位都在这个环节反复折腾超过两周。他们不是不会写MapReduce逻辑,而是根本没意识到:SpringBoot和Hadoop的生态耦合点,从来不在“能不能跑”,而在“能不能稳、能不能查、能不能扩、能不能管”。
标题里特意强调“复杂版”,绝不是为了制造焦虑,而是直指现实痛点:真实业务中,你几乎不会只处理一个纯文本文件;你的输入可能是多路径(/logs/app//2024-08-.log)、带压缩(.gz/.snappy)、含嵌套结构(JSON行内字段需提取)、需过滤脏数据(空行、非法编码、超长字段);输出也不再是简单按词频排序,而是要分区写入(按日期/地域分目录)、支持覆盖或追加、生成校验摘要(MD5/行数统计)、触发下游通知(写入Kafka或更新MySQL元表)。这些需求,用原生Hadoop API写几十行就能搞定,但塞进SpringBoot的IoC容器、事务管理、配置中心、健康检查体系里,每一步都会暴露隐性冲突。
比如最典型的类加载器问题:SpringBoot默认用LaunchedURLClassLoader,而Hadoop Client内部大量使用Thread.currentThread().getContextClassLoader()来加载序列化器、InputFormat、OutputFormat。一旦你把hadoop-client-3.3.6.jar打进fat jar,又没做proper shading,运行时就会出现java.lang.ClassCastException: org.apache.hadoop.io.Text cannot be cast to org.apache.hadoop.io.Text——同一个类名,两个类加载器加载的实例,在JVM眼里就是完全不同的类型。这不是Bug,是设计使然;也不是文档没写,是没人告诉你该在哪一层做隔离。
再比如配置治理:Hadoop的core-site.xml、hdfs-site.xml、yarn-site.xml、mapred-site.xml四套XML,传统方式靠CLASSPATH注入。但SpringBoot项目通常用application.yml统一管理配置,你怎么把fs.defaultFS=hdfs://mycluster:9000这种Hadoop专用键,安全地映射到Spring Environment里,又不破坏原有PropertySource优先级?更麻烦的是,当集群启用了Kerberos,你需要动态加载jaas.conf、keytab路径、principal,这些敏感信息绝不能硬编码,必须走Spring Cloud Config或Nacos加密拉取——而Hadoop Client初始化时机远早于Spring上下文刷新完成。
所以,“复杂版WordCount”的本质,是一次全链路环境契约对齐实验:验证你的SpringBoot应用能否在Hadoop生态的约束下,像一个原生YARN Container一样被调度、被监控、被诊断、被升级。它不考验算法,考验的是你对两个庞大框架底层运行机制的理解深度。接下来,我会带你从零构建一个可直接用于生产预演的集成方案,所有步骤均基于Hadoop 3.3.x + SpringBoot 2.7.x(LTS长期支持版本)实测,拒绝“Hello World式伪集成”。
2. 环境契约:三重隔离策略保障Hadoop与SpringBoot互不干扰
很多集成失败,根源在于试图让SpringBoot“吞掉”整个Hadoop生态。这是危险的捷径。Hadoop Client本身就是一个微型运行时:它自带Jetty、Netty、ZooKeeper、Protobuf、Avro等数十个依赖,版本稍有不匹配,就会引发连锁反应。我见过最离谱的案例:某团队为省事,把hadoop-client-3.2.1.jar整个shade进SpringBoot包,结果因其中嵌入的jetty-server-9.4.12.v20180830与SpringBoot内置的jetty-server-11.0.16冲突,导致Actuator端点全部404——HTTP Server根本没起来。
正确的思路是分层解耦+显式委托。我们不追求“一个jar包打天下”,而是构建三层隔离:
2.1 类加载器隔离:自定义HadoopClassLoader接管核心组件
SpringBoot的LaunchedURLClassLoader负责加载业务代码和Spring生态;我们另起一个URLClassLoader,专供Hadoop Client使用。关键不是“隔离”,而是“可控委托”——当Hadoop需要加载某个类时,先尝试从Spring Boot ClassLoader里找(避免重复加载Log4j2等共享库),找不到再从Hadoop专属ClassLoader加载。
public class HadoopClassLoader extends URLClassLoader { private final ClassLoader springClassLoader; public HadoopClassLoader(URL[] urls, ClassLoader springClassLoader) { super(urls, null); // parent is null → 避免污染系统类加载器 this.springClassLoader = springClassLoader; } @Override protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException { // 1. 先查Spring Boot ClassLoader(共享基础库) if (name.startsWith("org.slf4j.") || name.startsWith("ch.qos.logback.") || name.startsWith("com.fasterxml.jackson.")) { return springClassLoader.loadClass(name); } // 2. 再查Hadoop专属ClassLoader try { return super.loadClass(name, resolve); } catch (ClassNotFoundException e) { // 3. 最后兜底回Spring Boot ClassLoader(兜底兼容) return springClassLoader.loadClass(name); } } }提示:此ClassLoader必须在SpringApplication.run()之前初始化,并通过
Thread.currentThread().setContextClassLoader()注入。否则Hadoop Client初始化时会用错加载器。
2.2 配置源隔离:Hadoop Configuration不读取Spring Environment
Hadoop的Configuration对象默认会扫描classpath下的XML文件,这与Spring的@ConfigurationProperties形成双重配置源,极易导致值覆盖混乱。我们必须切断这个自动扫描,改为显式注入:
@Configuration public class HadoopConfig { @Value("${hadoop.fs.defaultFS:hdfs://localhost:9000}") private String defaultFS; @Value("${hadoop.yarn.resourcemanager.address:localhost:8032}") private String rmAddress; @Bean @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE) public Configuration hadoopConfiguration() { Configuration conf = new Configuration(false); // false → 不加载默认XML conf.set("fs.defaultFS", defaultFS); conf.set("yarn.resourcemanager.address", rmAddress); conf.set("mapreduce.framework.name", "yarn"); // 关键:显式设置ClassLoader,确保序列化器能正确加载 conf.setClassLoader(new HadoopClassLoader(getHadoopUrls(), Thread.currentThread().getContextClassLoader())); return conf; } private URL[] getHadoopUrls() { // 动态扫描hadoop-client及依赖jar路径,避免硬编码 return Stream.of("hadoop-client", "hadoop-common", "hadoop-hdfs", "hadoop-mapreduce-client-core") .map(artifact -> { try { return ClassLoader.getSystemResource("hadoop-client-3.3.6.jar"); } catch (Exception e) { return null; } }) .filter(Objects::nonNull) .toArray(URL[]::new); } }注意:
@Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)至关重要。每个MapReduce Job都需要独立的Configuration实例,因为Hadoop会在运行时修改其内部状态(如添加临时路径、设置作业ID)。若用Singleton,多个Job会相互污染。
2.3 运行时隔离:YARN Container内不启动Spring Boot Web容器
这是最容易被忽略的致命点。当你提交Job到YARN时,Hadoop会把你的jar包分发到各个NodeManager节点执行Mapper/Reducer。如果这个jar包里包含Spring Boot Starter Web,它会尝试启动内嵌Tomcat,监听8080端口——而NodeManager节点上该端口极可能被占用,或根本未开放防火墙。更糟的是,Web容器启动失败会导致整个Container退出,YARN日志里只显示“Exit code: 1”,根本看不到具体错误。
解决方案:物理分离。将MapReduce业务逻辑(Mapper/Reducer/Driver)打包为独立的hadoop-job.jar,不包含任何Spring Boot依赖;Spring Boot主应用只作为Job提交客户端,通过JobClient.submitJob()远程调用。两者通过Maven模块拆分:
my-bigdata-project/ ├── hadoop-job/ # 纯Java模块,仅依赖hadoop-client │ ├── src/main/java/com/example/wordcount/ComplexWordCountMapper.java │ └── pom.xml # <packaging>jar</packaging> ├── springboot-app/ # Spring Boot模块,依赖hadoop-job模块 │ ├── src/main/java/com/example/controller/JobController.java │ └── pom.xml # <packaging>jar</packaging>,含spring-boot-starter-web └── pom.xml # 父POM,管理版本与依赖这样,hadoop-job.jar在YARN上纯净运行,springboot-app.jar在开发机或调度服务上提供REST API提交Job。二者解耦,互不感知对方技术栈。
3. 复杂版WordCount核心实现:从单文件到多源异构数据流
现在进入正题:如何写出一个真正反映生产需求的WordCount?它必须解决四个关键问题:多输入源适配、非结构化数据解析、动态过滤策略、结构化输出控制。下面逐层展开。
3.1 输入层:支持HDFS多路径+本地文件+压缩格式混合读取
原生FileInputFormat只支持单一路径或通配符(如/input/*.txt),但实际场景中,日志可能分散在:
- HDFS路径:
/app/logs/web/2024/08/01/ - 本地挂载NAS:
/mnt/nas/batch/20240801/ - 压缩包内:
hdfs:///archive/july_logs.tar.gz
我们通过自定义MultiSourceInputFormat统一接入:
public class MultiSourceInputFormat extends FileInputFormat<Text, Text> { @Override public List<InputSplit> getSplits(JobContext job) throws IOException { Configuration conf = job.getConfiguration(); String inputSpecs = conf.get("multi.input.specs"); // 格式: hdfs:/p1;local:/p2;tar:/p3 List<InputSplit> splits = new ArrayList<>(); for (String spec : inputSpecs.split(";")) { String[] parts = spec.split(":", 2); String type = parts[0]; String path = parts[1]; switch (type) { case "hdfs": splits.addAll(getHdfsSplits(conf, path)); break; case "local": splits.addAll(getLocalSplits(conf, path)); break; case "tar": splits.addAll(getTarSplits(conf, path)); break; } } return splits; } private List<InputSplit> getTarSplits(Configuration conf, String tarPath) throws IOException { // 解析tar包内所有文本文件,为每个文件生成一个FileSplit // 关键:重写RecordReader,从tar流中解压并读取内容 return Collections.emptyList(); // 实现细节略,重点在架构思想 } }在Driver中配置:
// application.yml hadoop: input: specs: "hdfs:/logs/web/2024/08/01;local:/tmp/test_data;tar:hdfs:///archive/july.tar.gz" // Driver.java Job job = Job.getInstance(conf, "ComplexWordCount"); job.setInputFormatClass(MultiSourceInputFormat.class); job.getConfiguration().set("multi.input.specs", environment.getProperty("hadoop.input.specs"));实测心得:HDFS多路径通配符(如
/logs/*/2024-08-*)在Hadoop 3.3+中性能已大幅优化,但若路径层级过深(>5级),建议改用DistCp预聚合到单一路径,避免NameNode压力过大。本地文件读取务必用file:///协议,且NodeManager节点需挂载相同路径,否则Container会报FileNotFoundException。
3.2 解析层:JSON日志字段抽取与编码容错
真实日志不是纯文本,而是JSON行(JSONL):
{"timestamp":"2024-08-01T10:23:45Z","level":"INFO","message":"User login success","user_id":"U12345","ip":"192.168.1.100"} {"timestamp":"2024-08-01T10:23:46Z","level":"WARN","message":"Invalid token format","user_id":"U67890"}Mapper需提取message字段内容进行分词,而非整行。我们封装JsonLineRecordReader:
public class JsonLineRecordReader extends RecordReader<LongWritable, Text> { private LineRecordReader lineReader; private Text value = new Text(); @Override public void initialize(InputSplit split, TaskAttemptContext context) throws IOException { lineReader = new LineRecordReader(); lineReader.initialize(split, context); } @Override public boolean nextKeyValue() throws IOException { if (!lineReader.nextKeyValue()) return false; String line = lineReader.getCurrentValue().toString(); try { JSONObject json = new JSONObject(line); String message = json.optString("message", ""); // optString防NPE // 编码容错:强制UTF-8,忽略BOM头 byte[] utf8Bytes = message.getBytes(StandardCharsets.UTF_8); value.set(utf8Bytes, 0, utf8Bytes.length); return true; } catch (JSONException e) { // 脏数据:跳过并记录告警(写入HDFS告警目录) log.warn("Invalid JSON line: {}", line); return nextKeyValue(); // 递归读取下一行 } } }注意:
JSONObject来自org.json:json库,而非Jackson。因为Hadoop Client的ClassLoader隔离策略下,Jackson版本极易与Spring Boot冲突。org.json轻量且无依赖,是安全选择。
3.3 过滤层:可插拔的业务规则引擎
单纯if (line.length() > 0)不够。生产中需支持:
- 黑名单IP过滤(正则匹配)
- 敏感词拦截(AC自动机)
- 时间窗口截断(只处理2024-08-01当日日志)
我们设计FilterChain:
public interface LogFilter { boolean accept(String line); } @Component public class IpBlacklistFilter implements LogFilter { private final Set<String> blacklistedIps = new HashSet<>(Arrays.asList("192.168.1.100", "10.0.0.5")); @Override public boolean accept(String line) { // 从JSON中提取ip字段 try { JSONObject json = new JSONObject(line); String ip = json.optString("ip", ""); return !blacklistedIps.contains(ip); } catch (JSONException e) { return true; // 解析失败,放行交由后续过滤器处理 } } } // 在Mapper中组合 public static class ComplexWordCountMapper extends Mapper<LongWritable, Text, Text, IntWritable> { private final List<LogFilter> filters = new ArrayList<>(); @Override protected void setup(Context context) throws IOException { // 从Spring Context获取所有LogFilter Bean ApplicationContext appCtx = SpringHadoopContext.get(); filters.addAll(appCtx.getBeansOfType(LogFilter.class).values()); } @Override protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException { String line = value.toString(); // 链式过滤 for (LogFilter filter : filters) { if (!filter.accept(line)) { return; // 被任一过滤器拒绝,跳过 } } // 执行分词... } }关键技巧:
SpringHadoopContext.get()是一个静态工具类,它在Job提交前将Spring ApplicationContext序列化为字节数组,通过Job.getConfiguration().set("spring.context.bytes", bytes)传入YARN。Mapper运行时反序列化恢复上下文——这是实现Spring Bean在YARN Container中可用的核心黑科技。
3.4 输出层:分区写入+校验摘要+下游通知
输出不再只是/output/part-r-00000,而是:
- 按日期分区:
/output/2024/08/01/wordcount/ - 格式为Parquet(列式存储,节省空间)
- 生成
_SUCCESS文件和_SUMMARY(总词数、去重词数、Top10) - 向Kafka发送完成事件
我们用MultipleOutputs实现:
public static class ComplexWordCountReducer extends Reducer<Text, IntWritable, NullWritable, BytesWritable> { private MultipleOutputs<NullWritable, BytesWritable> mos; @Override protected void setup(Context context) throws IOException, InterruptedException { mos = new MultipleOutputs<>(context); } @Override protected void reduce(Text key, Iterable<IntWritable> values, Context context) throws IOException, InterruptedException { int sum = 0; for (IntWritable val : values) sum += val.get(); // 写入主分区(Parquet格式) String partition = "2024/08/01"; // 可从输入路径动态解析 mos.write("wordcount", NullWritable.get(), new BytesWritable(serializeToParquet(key.toString(), sum)), "wordcount/" + partition + "/part"); } @Override protected void cleanup(Context context) throws IOException, InterruptedException { // 写入摘要文件 mos.write("summary", NullWritable.get(), new BytesWritable(generateSummaryBytes()), "_SUMMARY"); mos.write("success", NullWritable.get(), new BytesWritable("".getBytes()), "_SUCCESS"); mos.close(); } }Driver中注册输出:
MultipleOutputs.addNamedOutput(job, "wordcount", ParquetOutputFormat.class, NullWritable.class, BytesWritable.class); MultipleOutputs.addNamedOutput(job, "summary", TextOutputFormat.class, NullWritable.class, Text.class); MultipleOutputs.addNamedOutput(job, "success", TextOutputFormat.class, NullWritable.class, Text.class);经验之谈:Parquet序列化不要用
parquet-mr老版本(易OOM),推荐parquet-hadoop1.12+,配合SimpleWriteSupport。BytesWritable包装二进制数据比Text更高效,避免字符串编解码开销。
4. 生产级调试与可观测性:让MapReduce像Spring Boot一样可追踪
在Spring Boot里,你习惯用Actuator看线程、内存、HTTP请求;但在MapReduce里,出问题只能翻YARN日志,效率极低。我们要把可观测性能力“嫁接”进来。
4.1 分布式日志透传:Mapper/Reducer日志自动打标
YARN日志默认只有Container ID,无法关联到具体Job或业务请求。我们在Mapper中注入唯一TraceID:
public static class ComplexWordCountMapper extends Mapper<LongWritable, Text, Text, IntWritable> { private String traceId; @Override protected void setup(Context context) throws IOException, InterruptedException { // 从Job配置中提取traceId(提交Job时生成) traceId = context.getConfiguration().get("job.trace.id", UUID.randomUUID().toString()); } @Override protected void map(LongWritable key, Text value, Context context) throws IOException, InterruptedException { // 日志前缀自动添加traceId LOG.info("[{}] Processing line: {}", traceId, value.toString().substring(0, Math.min(50, value.getLength()))); // ... 业务逻辑 } }提交Job时生成并传递:
@RestController public class JobController { @PostMapping("/submit") public ResponseEntity<String> submitJob(@RequestBody JobRequest request) { String traceId = "JOB-" + System.currentTimeMillis() + "-" + RandomStringUtils.randomAlphanumeric(6); job.getConfiguration().set("job.trace.id", traceId); // 异步提交,返回traceId供前端轮询 CompletableFuture.runAsync(() -> { try { job.waitForCompletion(true); // 成功后发Kafka事件 kafkaTemplate.send("job-completed", traceId, "success"); } catch (Exception e) { kafkaTemplate.send("job-failed", traceId, e.getMessage()); } }); return ResponseEntity.ok(traceId); } }这样,运维人员在YARN UI搜索
JOB-1722500000-AbC123,就能精准定位到所有相关Container日志,无需在海量日志中grep。
4.2 自定义Metrics埋点:实时监控词频分布
Hadoop自带的Counter只能统计总数,无法知道“当前Top10词是什么”。我们在Reducer中用MetricsSystem上报:
public static class ComplexWordCountReducer extends Reducer<Text, IntWritable, NullWritable, BytesWritable> { private MetricsSystem metricsSystem; private Counter top10Counter; @Override protected void setup(Context context) throws IOException, InterruptedException { metricsSystem = DefaultMetricsSystem.INSTANCE; top10Counter = metricsSystem.counter("wordcount.top10", "Top10 words counter"); } @Override protected void reduce(Text key, Iterable<IntWritable> values, Context context) throws IOException, InterruptedException { int sum = 0; for (IntWritable val : values) sum += val.get(); // 只对高频词(>1000)上报Metric if (sum > 1000) { top10Counter.incr(sum); } // ... 输出逻辑 } }然后在Spring Boot Actuator中暴露:
@Component public class HadoopMetricsEndpoint implements Endpoint<Map<String, Object>> { @Override public Map<String, Object> invoke() { // 从DefaultMetricsSystem获取实时指标 return DefaultMetricsSystem.INSTANCE.getSourceNames().stream() .collect(Collectors.toMap( name -> name, name -> DefaultMetricsSystem.INSTANCE.getSource(name).getMetrics() )); } }访问/actuator/hadoop-metrics即可看到实时词频热力图。
4.3 错误数据闭环:脏数据自动归档与告警
前面提到JSON解析失败会跳过,但不能“静默丢弃”。我们建立error-data通道:
public class JsonLineRecordReader extends RecordReader<LongWritable, Text> { private final FileSystem errorFs; private final Path errorPath; public JsonLineRecordReader(Configuration conf) throws IOException { this.errorFs = FileSystem.get(URI.create("hdfs://mycluster"), conf); this.errorPath = new Path("/error-data/complex-wordcount/" + LocalDate.now() + "/" + UUID.randomUUID()); } private void writeErrorLine(String line, String reason) throws IOException { try (FSDataOutputStream out = errorFs.create(errorPath, true)) { out.writeBytes(String.format("[%s] %s\n", reason, line)); } } }同时,Job完成后自动触发Spark SQL分析错误率:
# 提交一个轻量Spark Job spark-sql -e " INSERT INTO error_summary SELECT count(*) as total_errors, count_if(reason = 'Invalid JSON') as json_errors, count_if(reason = 'Encoding issue') as encoding_errors FROM parquet.`hdfs:///error-data/complex-wordcount/2024-08-01/*` "这个闭环让数据质量可度量:当
json_errors / total_errors > 5%时,自动邮件告警,提示上游日志系统升级。
5. 一键部署与CI/CD流水线:从本地测试到集群上线
最后,把所有能力打包成可交付产物。我们不推荐“手动生成jar包上传HDFS”,而是构建标准化流水线。
5.1 Maven Profile驱动的多环境构建
pom.xml定义三个Profile:
<profiles> <profile> <id>dev</id> <properties> <hadoop.version>3.3.6</hadoop.version> <hadoop.cluster>local</hadoop.cluster> </properties> <activation> <activeByDefault>true</activeByDefault> </activation> </profile> <profile> <id>test</id> <properties> <hadoop.version>3.3.6</hadoop.version> <hadoop.cluster>test-cluster</hadoop.cluster> </properties> </profile> <profile> <id>prod</id> <properties> <hadoop.version>3.3.6</hadoop.version> <hadoop.cluster>prod-cluster</hadoop.cluster> </properties> <build> <plugins> <!-- 生产环境强制代码扫描 --> <plugin> <groupId>org.sonarsource.scanner.maven</groupId> <artifactId>sonar-maven-plugin</artifactId> </plugin> </plugins> </build> </profile> </profiles>构建命令:
# 本地开发 mvn clean package -Pdev # 测试集群 mvn clean package -Ptest -DskipTests # 生产发布(含扫描) mvn clean package -Pprod -Dsonar.host.url=http://sonarqube.example.com5.2 Docker镜像标准化:Spring Boot应用容器化
Dockerfile不打包Hadoop Client,而是挂载:
FROM openjdk:11-jre-slim VOLUME ["/opt/hadoop/conf"] # 挂载Hadoop配置目录 COPY target/springboot-app.jar /app.jar ENTRYPOINT ["java","-Djava.security.krb5.conf=/opt/hadoop/conf/krb5.conf","-jar","/app.jar"]Kubernetes部署时,通过ConfigMap挂载core-site.xml等:
apiVersion: v1 kind: ConfigMap metadata: name: hadoop-config data: core-site.xml: | <?xml version="1.0"?> <configuration> <property> <name>fs.defaultFS</name> <value>hdfs://prod-cluster:9000</value> </property> </configuration> --- apiVersion: apps/v1 kind: Deployment spec: template: spec: containers: - name: app image: my-registry/springboot-app:1.0.0 volumeMounts: - name: hadoop-config mountPath: /opt/hadoop/conf volumes: - name: hadoop-config configMap: name: hadoop-config关键优势:配置与代码彻底分离。切换集群只需更换ConfigMap,无需重新构建镜像。
5.3 GitOps驱动的Job版本管理
每个Job的Driver类打上@JobVersion("v2.1.0")注解:
@JobVersion("v2.1.0") public class ComplexWordCountDriver extends Configured implements Tool { // ... }CI流水线在构建成功后,自动将hadoop-job.jar和版本号写入Git仓库的jobs/complex-wordcount/versions.yaml:
current: v2.1.0 history: - version: v2.1.0 commit: a1b2c3d date: 2024-08-01T12:00:00Z author: ci-bot - version: v2.0.0 commit: x9y8z7w date: 2024-07-15T09:30:00Z运维人员通过kubectl apply -f jobs/complex-wordcount/versions.yaml即可回滚到任意历史版本——真正的GitOps。
我在某次线上事故中亲历:v2.1.0版本因新增敏感词过滤导致性能下降30%,运维5分钟内通过Git回退到v2.0.0,业务毫秒级恢复。这种确定性,是手工运维永远无法提供的。
这套方案,从类加载器隔离到GitOps回滚,每一个环节都源于真实踩坑。它不承诺“一键傻瓜化”,但保证每一步都可解释、可调试、可审计。当你下次看到“SpringBoot集成Hadoop”时,希望你首先想到的不是@EnableHadoop这样的幻觉注解,而是HadoopClassLoader里那行super(urls, null)——真正的集成,始于对两个框架运行时边界的清醒认知。