从淘宝亿级订单汇总到Hadoop日志分析Reducer如何驱动大规模数据处理
一、先说说淘宝亿级订单这件事
想象一下,双11那一晚,天猫和淘宝的服务器上每秒涌入多少笔订单?
不是几千,不是几万,是每秒数十万笔,全天累计订单量轻松突破数亿级。这些订单分布在成百上千个服务器节点上,数据量达到PB级别。如果没有一套高效的大数据处理系统,光是把这些订单数据从各个服务日志中汇总出来,就可能让公司的数据平台直接瘫痪。
那淘宝是怎么做到的呢?
核心答案就是——Hadoop MapReduce框架,尤其是其中的Reducer组件。
二、为什么需要Reducer?先搞清楚问题本质
假设我们要做一个简单的需求:
统计过去一个月,每个用户的订单总金额。
在单机环境下,这很简单,遍历一遍数据就能搞定。但在淘宝这个场景里,数据分布在几百台服务器上,订单日志散落在各个日志文件里。
如果让一台机器去处理所有数据:
- 需要把几百TB的数据通过网络传输到一台机器上
- 那台机器内存装不下,磁盘IO打满
- 处理时间可能是几天甚至几周
这就是单机处理大规模数据的瓶颈。
Hadoop的解决方案是:把计算送到数据那里去,而不是把数据送到计算那里去。这就是MapReduce的核心思想。
三、MapReduce的工作流程——Reducer是怎么驱动的
3.1 整体流程概览
MapReduce的工作分为两个主要阶段:
输入数据 → Map阶段 → Shuffle阶段 → Reduce阶段 → 输出结果
- Map阶段:负责把原始数据打散成键值对(key-value pairs)
- Shuffle阶段:负责把相同key的数据重新分组,发送到同一个Reducer
- Reduce阶段:负责把分组后的数据进行聚合计算,输出最终结果
Reducer就是整个流程中真正干”汇总”这件事的核心组件。
3.2 用淘宝订单场景具体说明
输入数据格式
假设我们有原始的订单日志数据,格式大概是这样:
订单ID, 用户ID, 订单金额, 订单时间, 商品信息
1001, user_001, 299.00, 2024-11-11 00:00:01
1002, user_001, 599.50, 2024-11-11 00:00:02
1003, user_002, 128.00, 2024-11-11 00:00:03
1004, user_003, 899.00, 2024-11-11 00:00:04
1005, user_001, 450.00, 2024-11-11 00:00:05
1006, user_002, 333.00, 2024-11-11 00:00:06
...
Map阶段处理
每个Map任务处理一部分数据,输出中间结果:
(user_001, 299.00)
(user_001, 599.50)
(user_002, 128.00)
(user_003, 899.00)
(user_001, 450.00)
(user_002, 333.00)
...
Shuffle阶段——关键的分发过程
Shuffle阶段会把相同key的数据发送到同一个Reducer:
Reducer_1 收到: user_001的所有订单 → [(299.00), (599.50), (450.00)]
Reducer_2 收到: user_002的所有订单 → [(128.00), (333.00)]
Reducer_3 收到: user_003的所有订单 → [(899.00)]
...
Reduce阶段——真正汇总
每个Reducer对自己收到的数据进行聚合:
Reducer_1: user_001 的总金额 = 299.00 + 599.50 + 450.00 = 1348.50
Reducer_2: user_002 的总金额 = 128.00 + 333.00 = 461.00
Reducer_3: user_003 的总金额 = 899.00
...
3.3 Reducer的完整Java代码实现
下面是一个完整的MapReduce程序,模拟淘宝订单汇总的场景:
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.DoubleWritable;
import org.apache.hadoop.io.LongWritable;
import org.apache.hadoop.io.Text;
import org.apache.hadoop.mapreduce.Job;
import org.apache.hadoop.mapreduce.Mapper;
import org.apache.hadoop.mapreduce.Reducer;
import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;
import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;
import java.io.IOException;
/**
* 淘宝订单汇总MapReduce程序
*
* 功能:统计每个用户的订单总金额
* 输入:订单日志文件
* 输出:每个用户ID对应的总订单金额
*/
public class OrderSummaryMapReduce {
// ==================== Map阶段 ====================
public static class OrderMapper extends Mapper<LongWritable, Text, Text, DoubleWritable> {
private Text userId = new Text();
private DoubleWritable orderAmount = new DoubleWritable();
@Override
protected void map(LongWritable key, Text value, Context context)
throws IOException, InterruptedException {
// 解析一行订单日志
String line = value.toString();
// 假设格式:订单ID, 用户ID, 订单金额, 时间, 商品
String[] fields = line.split(",");
if (fields.length >= 3) {
// 提取用户ID
String uid = fields[1].trim();
// 提取订单金额
double amount = Double.parseDouble(fields[2].trim());
userId.set(uid);
orderAmount.set(amount);
// 输出:(用户ID, 订单金额)
context.write(userId, orderAmount);
}
}
}
// ==================== Reduce阶段(核心!)====================
public static class OrderReducer extends Reducer<Text, DoubleWritable, Text, DoubleWritable> {
private DoubleWritable totalAmount = new DoubleWritable();
@Override
protected void reduce(Text key, Iterable<DoubleWritable> values, Context context)
throws IOException, InterruptedException {
double sum = 0.0;
int orderCount = 0;
// 遍历同一用户的所有订单金额
for (DoubleWritable value : values) {
sum += value.get();
orderCount++;
}
// 输出:(用户ID, 总订单金额)
totalAmount.set(sum);
context.write(key, totalAmount);
}
}
// ==================== 主程序入口 ====================
public static void main(String[] args) throws Exception {
// 1. 配置Job
Configuration conf = new Configuration();
Job job = Job.getInstance(conf, "淘宝订单汇总分析");
// 2. 设置Jar包
job.setJarByClass(OrderSummaryMapReduce.class);
// 3. 设置Mapper和Reducer
job.setMapperClass(OrderMapper.class);
job.setReducerClass(OrderReducer.class);
// 4. 设置输出类型
job.setMapOutputKeyClass(Text.class);
job.setMapOutputValueClass(DoubleWritable.class);
job.setOutputKeyClass(Text.class);
job.setOutputValueClass(DoubleWritable.class);
// 5. 设置输入输出路径
FileInputFormat.addInputPath(job, new Path(args[0]));
FileOutputFormat.setOutputPath(job, new Path(args[1]));
// 6. 提交任务并等待完成
System.exit(job.waitForCompletion(true) ? 0 : 1);
}
}
四、深入理解Reducer的工作原理
4.1 Reducer的生命周期
一个Reducer实例在运行过程中,会经历以下几个阶段:
1. 初始化(setup)
└── 读取配置文件,分配内存,准备缓冲区
2. Map阶段输出拉取(Fetch)
└── 从各个Mapper节点通过网络拉取中间结果
3. 排序与分组(Sort & Spill)
└── 对拉取的数据按key排序,相同key的数据归为一组
4. Reduce处理(reduce)
└── 对每一组数据调用reduce()方法进行处理
5. 输出(write)
└── 将处理结果写入HDFS
6. 清理(cleanup)
└── 释放资源,关闭连接
4.2 Shuffle过程中Reducer的关键角色
在MapReduce的Shuffle阶段,Reducer扮演了”数据汇聚中心”的角色:
4.2.1 Partition阶段(Map端)
// Hadoop默认使用哈希分区
public int getPartition(Text key, DoubleWritable value, int numPartitions) {
return (key.hashCode() & Integer.MAX_VALUE) % numPartitions;
}
这个机制决定了哪个Mapper的输出会发送到哪个Reducer。在淘宝场景中,可以自定义分区策略,比如按用户ID的哈希值分配到不同的Reducer,确保同一个用户的订单数据集中处理。
4.2.2 Sort阶段(Reduce端)
Reducer收到数据后,并不是直接处理,而是先进行排序。这个排序过程非常关键:
输入的中间数据(未排序):
(user_003, 899.00)
(user_001, 299.00)
(user_002, 128.00)
(user_001, 599.50)
(user_002, 333.00)
(user_001, 450.00)
排序后:
(user_001, 299.00)
(user_001, 599.50)
(user_001, 450.00)
(user_002, 128.00)
(user_002, 333.00)
(user_003, 899.00)
排序后,相同key的数据会连续排列,Reducer就可以按组调用reduce()方法。
4.2.3 Reduce阶段(实际计算)
// reduce()方法被调用的时机:每遇到一个新的key就调用一次
// 输入:key + 该key对应的所有value列表
protected void reduce(Text key, Iterable<DoubleWritable> values, Context context)
throws IOException, InterruptedException {
double sum = 0.0;
for (DoubleWritable value : values) {
sum += value.get(); // 累加
}
context.write(key, new DoubleWritable(sum));
}
五、Reducer驱动大规模数据处理的几个关键技术点
5.1 内存管理——Reducer如何装下海量数据
Reducer的内存是有限的,但Map输出可能非常大。Hadoop通过以下机制解决:
┌─────────────────────────────────────────┐
│ Reducer内存布局 │
├─────────────────────────────────────────┤
│ 环形缓冲区 (Buffer) 80-90%内存 │
│ ├── 正在接收Map输出 │
│ ├── 当缓冲区达到阈值 → 触发spill到磁盘 │
│ └── spill文件合并后,再读入内存处理 │
├─────────────────────────────────────────┤
│ spill文件(磁盘) │
│ ├── 多个spill文件按key排序 │
│ └── 合并后得到有序的reduce输入 │
└─────────────────────────────────────────┘
spill阈值配置:
<property>
<name>mapreduce.reduce.memory.mb</name>
<value>2048</value> <!-- Reducer可用内存 -->
</property>
<property>
<name>mapreduce.reduce.shuffle.memory.limit.percent</name>
<value>0.8</value> <!-- 缓冲区占总内存比例 -->
</property>
5.2 并行化——多个Reducer如何协作
假设我们有10亿条订单记录,分布在1000个Mapper上,我们可以配置100个Reducer:
Mapper_1 → Reducer_1 (处理 user_*_001 的订单)
Mapper_2 → Reducer_1 (处理 user_*_001 的订单)
...
Mapper_50 → Reducer_2 (处理 user_*_002 的订单)
...
Mapper_1000 → Reducer_100
每个Reducer独立处理一部分数据,互不干扰,这就是分布式并行计算的威力。
5.3 数据倾斜问题的处理
在淘宝这样的场景下,可能会出现数据倾斜:某些热门用户的订单量远大于普通用户。
场景:用户"user_001"是超级VIP,订单量是普通用户的100倍
普通Reducer: 收到 1000条订单 → 处理很快
Reducer_001: 收到 100000条订单 → 处理很慢,成为瓶颈
解决方案:自定义Partitioner + Combiner
/**
* 自定义分区器,避免数据倾斜
*/
public class CustomPartitioner extends Partitioner<Text, DoubleWritable> {
@Override
public int getPartition(Text key, DoubleWritable value, int numPartitions) {
// 使用用户ID的hash值进行分区
// 同时考虑负载均衡,避免某些Reducer过载
int hash = key.hashCode() & Integer.MAX_VALUE;
return hash % numPartitions;
}
}
/**
* Combiner(本地聚合器)
* 在Map端预先做部分汇总,减少网络传输量
*/
public static class OrderCombiner extends Reducer<Text, DoubleWritable, Text, DoubleWritable> {
private DoubleWritable result = new DoubleWritable();
@Override
protected void reduce(Text key, Iterable<DoubleWritable> values, Context context)
throws IOException, InterruptedException {
double sum = 0.0;
for (DoubleWritable value : values) {
sum += value.get();
}
result.set(sum);
context.write(key, result);
}
}
六、从淘宝订单到Hadoop日志分析——Reducer的实际应用
6.1 日志分析场景
除了订单汇总,Reducer还广泛应用于日志分析:
6.1.1 统计每个URL的访问次数
public class LogAnalysisMapReduce {
public static class LogMapper extends Mapper<LongWritable, Text, Text, IntWritable> {
private static final IntWritable ONE = new IntWritable(1);
private Text url = new Text();
@Override
protected void map(LongWritable key, Text value, Context context)
throws IOException, InterruptedException {
String line = value.toString();
// 假设日志格式:IP - - [时间] "GET /path HTTP/1.1" 200 1234
String[] parts = line.split("\"");
if (parts.length > 1) {
String request = parts[1];
String[] requestParts = request.split("\\s+");
if (requestParts.length >= 2) {
url.set(requestParts[1]); // 提取URL路径
context.write(url, ONE);
}
}
}
}
public static class LogReducer extends Reducer<Text, IntWritable, Text, IntWritable> {
private IntWritable count = new IntWritable();
@Override
protected void reduce(Text key, Iterable<IntWritable> values, Context context)
throws IOException, InterruptedException {
int sum = 0;
for (IntWritable val : values) {
sum += val.get();
}
count.set(sum);
context.write(key, count);
}
}
}
6.1.2 统计每个用户的PV和UV
/**
* 统计每个用户的PV(页面浏览量)和UV(独立访客数)
*/
public class PVUVAnalysis {
public static class PVUVMapper extends Mapper<LongWritable, Text, Text, Text> {
private Text userId = new Text();
private Text metric = new Text();
@Override
protected void map(LongWritable key, Text value, Context context)
throws IOException, InterruptedException {
String line = value.toString();
String[] fields = line.split("\\t");
String uid = fields[0]; // 用户ID
String page = fields[1]; // 页面路径
// 输出两条记录:一条标记为PV,一条标记为UV
userId.set(uid);
metric.set("PV:" + page);
context.write(userId, metric);
metric.set("UV");
context.write(userId, metric);
}
}
public static class PVUVReducer extends Reducer<Text, Text, Text, Text> {
private Text result = new Text();
@Override
protected void reduce(Text key, Iterable<Text> values, Context context)
throws IOException, InterruptedException {
int pvCount = 0;
Set<String> uvPages = new HashSet<>();
for (Text val : values) {
String v = val.toString();
if (v.startsWith("PV:")) {
pvCount++;
String page = v.substring(3);
uvPages.add(page);
} else if (v.equals("UV")) {
// UV逻辑在这里处理
}
}
result.set("PV=" + pvCount + ", UV=" + uvPages.size());
context.write(key, result);
}
}
}
七、Reducer的性能优化策略
7.1 合理配置Reducer数量
Reducer数量不是越多越好,也不是越少越好:
推荐配置:
- Reducer数量 ≈ 0.95 * 节点数 * 每个节点最大的Reducer容器数
- 或者根据数据量估算:数据量 ÷ 单个Reducer处理的最佳数据量
示例:
- 1000个数据块,每个块128MB,总数据量约128GB
- 每个Reducer处理1GB数据较为合适
- 推荐Reducer数量:128个左右
<property>
<name>mapreduce.job.reduces</name>
<value>128</value>
</property>
7.2 使用Combiner减少网络传输
Combiner是Map端的”本地Reducer”,可以预先聚合数据:
没有Combiner:
Mapper_1 → 输出100万条(key, value) → 通过网络 → Reducer
Mapper_2 → 输出100万条(key, value) → 通过网络 → Reducer
...
总计:1000个Mapper × 100万条 = 10亿条数据通过网络
有Combiner:
Mapper_1 + Combiner → 预先聚合 → 输出1万条 → 通过网络 → Reducer
Mapper_2 + Combiner → 预先聚合 → 输出1万条 → 通过网络 → Reducer
...
总计:1000个Mapper × 1万条 = 1000万条数据通过网络
网络传输量减少100倍!
7.3 压缩中间数据
<property>
<name>mapreduce.job.output.compression.enabled</name>
<value>true</value>
</property>
<property>
<name>mapreduce.job.output.compress</name>
<value>true</value>
</property>
<property>
<name>mapreduce.map.output.compress</name>
<value>true</value>
</property>
<property>
<name>mapreduce.map.output.compress.codec</name>
<value>org.apache.hadoop.io.compress.SnappyCodec</value>
</property>
使用Snappy压缩可以显著减少网络传输量,同时压缩和解压的速度比gzip快很多。
八、从理论到实践:一个完整的淘宝订单分析案例
8.1 业务需求
假设我们要分析淘宝的以下数据:
- 每个用户的订单总金额和订单数量
- 每个品类的总销售额
- 每日订单趋势
- 每个城市的订单分布
8.2 完整MapReduce程序
import org.apache.hadoop.conf.Configuration;
import org.apache.hadoop.fs.Path;
import org.apache.hadoop.io.*;
import org.apache.hadoop.mapreduce.Job;
import org.apache.hadoop.mapreduce.Mapper;
import org.apache.hadoop.mapreduce.Reducer;
import org.apache.hadoop.mapreduce.lib.input.FileInputFormat;
import org.apache.hadoop.mapreduce.lib.output.FileOutputFormat;
import java.io.IOException;
import java.text.ParseException;
import java.text.SimpleDateFormat;
import java.util.Date;
/**
* 淘宝订单综合分析MapReduce程序
*
* 分析维度:
* 1. 用户订单汇总(金额、数量)
* 2. 品类销售额统计
* 3. 每日订单趋势
* 4. 城市订单分布
*/
public class TaobaoOrderAnalysis {
// ==================== Mapper:解析订单数据 ====================
public static class OrderMapper extends Mapper<LongWritable, Text, Text, OrderData> {
private Text outKey = new Text();
private OrderData outValue = new OrderData();
@Override
protected void map(LongWritable key, Text value, Context context)
throws IOException, InterruptedException {
String line = value.toString();
if (line == null || line.isEmpty()) return;
// 解析订单日志
// 格式:订单ID, 用户ID, 金额, 时间, 品类, 城市, 商品ID
String[] fields = line.split(",");
if (fields.length < 7) return;
String orderId = fields[0].trim();
String userId = fields[1].trim();
double amount = Double.parseDouble(fields[2].trim());
String orderTime = fields[3].trim();
String category = fields[4].trim();
String city = fields[5].trim();
String productId = fields[6].trim();
// 输出1:用户订单汇总
// key = userId, value = (amount, 1)
outKey.set("USER:" + userId);
outValue.setAmount(amount);
outValue.setOrderCount(1);
context.write(outKey, outValue);
// 输出2:品类销售额
// key = category, value = amount
outKey.set("CATEGORY:" + category);
outValue.setAmount(amount);
outValue.setOrderCount(0);
context.write(outKey, outValue);
// 输出3:每日订单趋势
// key = date, value = 1
outKey.set("DATE:" + orderTime.substring(0, 10));
outValue.setAmount(0);
outValue.setOrderCount(1);
context.write(outKey, outValue);
// 输出4:城市订单分布
// key = city, value = 1
outKey.set("CITY:" + city);
outValue.setAmount(0);
outValue.setOrderCount(1);
context.write(outKey, outValue);
}
}
// ==================== Reducer:聚合分析结果 ====================
public static class OrderAggregator extends Reducer<Text, OrderData, Text, Text> {
private Text result = new Text();
@Override
protected void reduce(Text key, Iterable<OrderData> values, Context context)
throws IOException, InterruptedException {
double totalAmount = 0.0;
int totalOrderCount = 0;
for (OrderData val : values) {
totalAmount += val.getAmount();
totalOrderCount += val.getOrderCount();
}
// 格式化输出结果
String keyType = key.toString().split(":")[0];
String keyValue = key.toString().substring(keyType.length() + 1);
if ("USER".equals(keyType)) {
result.set(String.format("用户: %s, 订单数: %d, 总金额: %.2f元",
keyValue, totalOrderCount, totalAmount));
} else if ("CATEGORY".equals(keyType)) {
result.set(String.format("品类: %s, 总销售额: %.2f元", keyValue, totalAmount));
} else if ("DATE".equals(keyType)) {
result.set(String.format("日期: %s, 订单数: %d", keyValue, totalOrderCount));
} else if ("CITY".equals(keyType)) {
result.set(String.format("城市: %s, 订单数: %d", keyValue, totalOrderCount));
}
context.write(key, result);
}
}
// ==================== 辅助数据类 ====================
public static class OrderData implements Writable {
private double amount = 0.0;
private int orderCount = 0;
public double getAmount() { return amount; }
public void setAmount(double amount) { this.amount = amount; }
public int getOrderCount() { return orderCount; }
public void setOrderCount(int orderCount) { this.orderCount = orderCount; }
@Override
public void write(java.io.DataOutput out) throws IOException {
out.writeDouble(amount);
out.writeInt(orderCount);
}
@Override
public void readFields(java.io.DataInput in) throws IOException {
amount = in.readDouble();
orderCount = in.readInt();
}
}
// ==================== 主程序 ====================
public static void main(String[] args) throws Exception {
if (args.length != 2) {
System.err.println("用法: TaobaoOrderAnalysis <输入路径> <输出路径>");
System.exit(-1);
}
Configuration conf = new Configuration();
// 配置优化参数
conf.set("mapreduce.map.memory.mb", "4096");
conf.set("mapreduce.reduce.memory.mb", "8192");
conf.set("mapreduce.job.reduces", "100");
conf.setBoolean("mapreduce.map.output.compress", true);
conf.set("mapreduce.map.output.compress.codec",
"org.apache.hadoop.io.compress.SnappyCodec");
Job job = Job.getInstance(conf, "淘宝订单综合分析");
job.setJarByClass(TaobaoOrderAnalysis.class);
job.setMapperClass(OrderMapper.class);
job.setReducerClass(OrderAggregator.class);
job.setMapOutputKeyClass(Text.class);
job.setMapOutputValueClass(OrderData.class);
job.setOutputKeyClass(Text.class);
job.setOutputValueClass(Text.class);
FileInputFormat.addInputPath(job, new Path(args[0]));
FileOutputFormat.setOutputPath(job, new Path(args[1]));
System.exit(job.waitForCompletion(true) ? 0 : 1);
}
}
8.3 运行结果示例
# 用户订单汇总(部分)
USER:user_001 用户: user_001, 订单数: 156, 总金额: 45890.50元
USER:user_002 用户: user_002, 订单数: 89, 总金额: 23456.00元
USER:user_003 用户: user_003, 订单数: 234, 总金额: 67890.00元
# 品类销售额(部分)
CATEGORY:电子产品 品类: 电子产品, 总销售额: 12345678.00元
CATEGORY:服装鞋帽 品类: 服装鞋帽, 总销售额: 8765432.00元
CATEGORY:食品饮料 品类: 食品饮料, 总销售额: 3456789.00元
# 每日订单趋势
DATE:2024-11-11 日期: 2024-11-11, 订单数: 8923456
DATE:2024-11-10 日期: 2024-11-10, 订单数: 5678901
# 城市订单分布
CITY:北京 城市: 北京, 订单数: 1234567
CITY:上海 城市: 上海, 订单数: 1123456
CITY:广州 城市: 广州, 订单数: 987654
九、Reducer在Hadoop生态中的演进
9.1 从MapReduce到Spark
虽然MapReduce是Hadoop的基石,但它的缺点也很明显:
- 磁盘IO开销大:每个阶段都要写磁盘
- 不适合迭代计算:每次迭代都要重新从磁盘读取
- 编程模型较复杂:需要写大量的样板代码
因此,淘宝等大厂后来逐渐迁移到了Spark等更先进的计算框架。Spark的Reducer思想被Transformations和Actions继承:
Spark RDD的reduceByKey操作,本质上就是Reducer思想的延续:
orders.reduceByKey((a, b) => a + b)
// 等价于MapReduce中的Reducer逻辑
9.2 但Reducer的思想永不过时
即使技术上已经从MapReduce迁移到Spark、Flink等框架,Reducer的核心思想——数据本地化、分区聚合、分布式并行——仍然是现代大数据处理的基础。
MapReduce Reducer → Spark reduceByKey → Flink KeyBy + process
↓ ↓ ↓
分区聚合思想 分区聚合思想 分区聚合思想
分布式并行 分布式并行 分布式并行
数据本地化 数据本地化 数据本地化
十、总结:Reducer如何驱动大规模数据处理
回顾整个淘宝亿级订单的处理过程,Reducer的核心价值体现在以下几个方面:
| 方面 | 说明 |
|---|---|
| 数据本地化 | 计算送到数据所在节点,避免海量数据传输 |
| 分区并行 | 多个Reducer并行工作,线性提升处理能力 |
| 自动容错 | Mapper失败自动重执行,Reducer失败自动恢复 |
| 编程简化 | 开发者只需关注Map和Reduce逻辑,框架处理底层细节 |
| 可扩展性 | 增加节点即可线性提升处理能力 |
一句话总结:Reducer通过将海量数据按key分区、在多个节点上并行聚合,使得淘宝每天亿级订单的汇总分析成为可能,这就是分布式计算的魅力所在。
希望这篇内容能帮助你理解从淘宝亿级订单到Hadoop Reducer的完整技术链路。如果你对某个具体环节还有疑问,欢迎继续交流!
