去年帮人看一个毕设项目,场景挺典型:环境装好了,Hadoop 伪分布式能起来,Hive 建了三四张表,Spark 提交过一个统计任务,Django 页面上用 ECharts 画出了几条农作物产量趋势曲线。整套 Hadoop + Spark + Hive + Django 的组合看起来该有的都有。然后答辩老师问了一句:“你这个数据量,用 MySQL 加 Python 画图是不是也能做?”现场安静了几秒。
这个问题不是刁难,它恰好戳中了这类选题最容易出问题的地方:技术栈堆得很满,但每一层为什么存在、它替换掉了什么、数据从哪来到哪去,讲不清楚。 一套离线分析链路真正值钱的地方,不是你装了几个组件,而是你能不能把一个原始表格,经过存储、建模、计算、聚合,最后变成一个可以被追问、被复用、被解释的指标。
我更愿意把这类项目看成一个“数据链路练习”,而不是“框架拼装作业”。下面拆开讲,从每一层的职责,到数据口径,到代码落地,到答辩会被追问的地方。
1. 先别急着堆组件,搞清楚每一层为什么存在
很多同学做这类项目,顺序是反的:先决定要用 Hadoop、Spark、Hive、Django,再去找一个题目套上去。结果就是每一层都只跑了最浅的样例,讲不出设计理由。
正常顺序应该是倒过来:有一个“农作物产量趋势”的分析需求,问它需要什么能力,再决定哪一层用什么。需求大概是这样几个:多年的产量数据要能存下来;要按地区、作物、年份做多维聚合;要能算出单产、同比增速这类派生指标;最后要能在网页上按需筛选、看趋势。
这四个需求拆开看,对应的是存储、建模、计算、展示四件事。把这四件事和组件对齐,你就有了讲清楚的基础。
1.1 HDFS 和 YARN 提供的是“能存下、能跑起来”的地基
HDFS 解决的是“文件放在多台机器上也像一块盘”的问题。它把一个文件切成块,分散存到不同节点,并保留副本。对农作物产量这类数据来说,原始数据通常是一堆按年份、按地区拆分的 CSV 或 Excel 导出文件,直接丢到本地磁盘也能存,但一旦你想演示“数据量增长之后怎么办”,HDFS 就是那个可以自然接上的位置。
YARN 解决的是“谁来分配 CPU 和内存”的问题。Spark 提交任务时需要资源,YARN 负责调度。在单机伪分布式环境下,这一层其实感受不深,但它在架构图上必须有位置,因为它解释了“计算和存储是分开的”这件事。
这里要提醒一句:不要为了显得复杂而强行多说 Hadoop。 如果你的数据只有几十万行,MapReduce 那一层基本可以略过,Hive 和 Spark 完全可以在本地模式下把活干完。诚实地说“Hadoop 在这里主要承担存储和资源调度的角色,计算由 Spark 完成”,比含糊地说“用了大数据技术”要好得多。
1.2 Hive 的价值不是存数据,而是把文件变成可查询的表
Hive 最容易被误解。它不是数据库,它本身不存数据,数据还在 HDFS 上。Hive 提供的是元数据 + SQL 解析:它记住“这个目录下的文件,第一列叫地区编码,第二列叫作物名称”,然后把你写的 SQL 翻译成分布式任务。
这个设计带来的直接好处是:原始文件可以是纯文本 CSV,不需要先导入某个数据库;需要查询时,用 SQL 描述你要什么,而不用写程序去解析每一行。对分析类项目来说,这一步的意义很大——你可以像操作一张普通表一样,对几年的产量数据做 GROUP BY。
代价也要知道:Hive 的查询延迟高,不适合做交互式的秒级响应。所以它在这套链路里应该承担的是离线加工的角色,把宽表、汇总表提前算好,而不是让网页直接去查 Hive。
1.3 Spark 解决的是“反复读取、多轮计算”的效率问题
如果只是跑一次聚合,Hive 自带的引擎也能做。Spark 的价值在于:它把中间结果尽量放在内存里,多个计算步骤之间不用反复落盘。做产量分析时,通常不是一步算完——要先清洗、再去重、再算单产、再算同比、最后按维度汇总,这一串步骤用 Spark SQL 串起来就很自然。
另一个现实原因是生态:Spark 可以直接读 Hive 表,也可以直接写 MySQL,一条链路两头的对接都很顺。这对毕设项目来说能省掉大量胶水代码。
1.4 Django 只负责最后一公里
Django 在这套架构里的位置非常明确:读已经算好的结果,返回给页面。 它不应该去连 Hive,不应该在请求里跑 Spark 任务,也不应该在 Python 里对几十万行明细做循环。
原因很直接:Web 请求是有超时的,用户点一下筛选,等三十秒才出图,体验就崩了。离线算好、在线只查,是这类分析系统最基本的分工。
把这一层想清楚,后面所有代码的边界就都清楚了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 农作物产量这个题目,真正难的是数据口径不是技术栈
技术栈是可以照抄的,数据口径不能。答辩时被问倒的,十有八九不是“Spark 怎么用”,而是“你这个单产怎么算的”“为什么这两个省的数据对不上”“你的数据是哪来的”。
先说清楚一件事:公开的农业统计数据通常是汇总值,不是明细记录。 你拿到的是“某省某年小麦播种面积 X 千公顷、产量 Y 万吨”,而不是每块地的记录。这意味着你的“大数据分析”,本质上是小样本、多维度的分析——维度组合多,但每个组合只有一条记录。这一点必须心里有数,后面讲答辩时会用到。
2.1 数据来源要经得起追问
常见的数据来源有几种,各有各的坑:
- 公开统计年鉴、统计公报:权威性最好,但需要人工整理,字段口径可能逐年调整,年份不连续很常见。
- 公开数据竞赛平台的数据集:结构规整,拿来就能用,但要注意许可证和使用范围,别把“公开下载”理解成“可以随便宣称是真实生产数据”。
- 自己采集:涉及网站规则、频率控制、数据版权,属于容易被追问且容易出问题的方向,不建议作为主要来源。
- 仿真生成:在字段结构合理、量级合理的前提下,用来补齐数据量是可以的,但必须在文档和答辩中明确说明是模拟数据。
注意:任何情况下都不要把模拟数据说成真实统计结果。老师追问一句“这个数你从哪查的”,答不上来,整个项目的可信度就没了。
务实的做法是:真实数据打底,缺失的年份或地区用明确标注的模拟数据补齐,并在 README 里写清楚哪部分来自哪里、哪部分是补的。这个说明本身就是一个加分项,因为它体现了数据治理意识。
2.2 维度建模:把“产量”拆成可分析的字段
拿到数据之后,第一步不是写 SQL,是设计表结构。农作物产量分析天然适合星型模型:
- 维度:地区(省/市/县)、作物(小麦、玉米、水稻等)、时间(年、季/生长季)
- 度量:播种面积、产量
一个够用的明细表大概长这样:
| 字段 | 类型 | 说明 |
|---|---|---|
| region_code | STRING | 地区编码,用于关联行政区划 |
| region_name | STRING | 地区名称,展示用 |
| crop_name | STRING | 作物名称,需统一口径 |
| stat_year | INT | 统计年份 |
| season | STRING | 生长季,没有则填“全年” |
| plant_area | DOUBLE | 播种面积,单位千公顷 |
| yield_amount | DOUBLE | 产量,单位万吨 |
这里有两个容易埋雷的地方。一是单位:不同来源的面积单位可能是“千公顷”“万亩”“公顷”,产量可能是“万吨”“吨”“公斤”,混在一张表里不做换算,算出来的单产能差好几个数量级。二是作物名称:有的数据写“玉米”,有的写“玉米(籽粒)”,直接 GROUP BY 会分裂成两个作物。清洗阶段必须统一。
2.3 指标设计:总量、单产、增速、结构占比
指标设计的水平,直接决定图表有没有信息量。只画“产量逐年变化”,基本等于把表格搬到图上。建议至少准备四类:
| 指标 | 计算方式 | 回答什么问题 |
|---|---|---|
| 总产量 | SUM(yield_amount) | 这个地区/作物一年产出多少 |
| 播种面积 | SUM(plant_area) | 投入了多少土地 |
| 单产 | 产量 / 面积(需统一单位) | 效率是升还是降 |
| 同比增速 | (本年 - 上年) / 上年 | 变化幅度有多大 |
| 结构占比 | 某作物产量 / 总产量 | 种植结构怎么变 |
单产这个指标特别值得单独讲,因为它是“总量分析”和“效率分析”的分水岭。总产量增长可能是因为扩种,也可能是因为单产提高,这两件事的政策含义完全不同。你的图表能把这层区分出来,深度立刻不一样。
3. 从原始数据到 Hive 表:离线链路的落地顺序
这一节讲具体的落地顺序。很多人卡住不是因为不会写 SQL,而是因为顺序乱了:先写查询,发现表里字段不对;改表,发现原始文件编码不对;修编码,发现分区没建。
建议的顺序是:定目录 → 建 ODS 外部表 → 灌数据 → 建 DWD 清洗表 → 建 ADS 汇总表。一层一层验证,每层都能单独跑通再往下走。
3.1 目录规划与 ODS 层建表
HDFS 上先按层分目录,这个习惯值得养成:
bash复制hdfs dfs -mkdir -p /warehouse/ods/crop_yield
hdfs dfs -mkdir -p /warehouse/dwd/crop_yield
hdfs dfs -mkdir -p /warehouse/ads/crop_yield
hdfs dfs -put ./data/crop_yield_2023.csv /warehouse/ods/crop_yield/
ODS 层建议用外部表 + 分区。外部表的好处是删表不删数据,改表结构不至于把原始文件搞丢;分区的好处是按批次管理,重跑某一天不影响其他批次。
sql复制CREATE DATABASE IF NOT EXISTS agri_dw;
CREATE EXTERNAL TABLE IF NOT EXISTS agri_dw.ods_crop_yield (
region_code STRING COMMENT '地区编码',
region_name STRING COMMENT '地区名称',
crop_name STRING COMMENT '作物名称',
stat_year INT COMMENT '统计年份',
season STRING COMMENT '生长季',
plant_area DOUBLE COMMENT '播种面积(千公顷)',
yield_amount DOUBLE COMMENT '产量(万吨)'
)
COMMENT '农作物产量原始层'
PARTITIONED BY (dt STRING COMMENT '入库批次日期')
ROW FORMAT DELIMITED FIELDS TERMINATED BY ','
STORED AS TEXTFILE
LOCATION '/warehouse/ods/crop_yield';
建完表别忘了加分区,否则查询会是空的:
sql复制ALTER TABLE agri_dw.ods_crop_yield
ADD IF NOT EXISTS PARTITION (dt='2024-06-01')
LOCATION '/warehouse/ods/crop_yield/2024-06-01';
这一步是新手最容易漏的。表建好了、文件也传了,SELECT COUNT(*) 出来是 0,八成就是分区没加,或者分区目录和文件实际位置对不上。
3.2 DWD 清洗:把脏数据挡在计算之前
清洗层的作用是让下游所有计算都基于一份“可信的、口径统一的”数据。农作物产量数据常见的脏点有:地区名带空格、作物名不规范、面积为 0 或负数、年份明显异常。
sql复制INSERT OVERWRITE TABLE agri_dw.dwd_crop_yield PARTITION (dt='2024-06-01')
SELECT
TRIM(region_code) AS region_code,
TRIM(region_name) AS region_name,
CASE
WHEN crop_name LIKE '玉米%' THEN '玉米'
WHEN crop_name LIKE '小麦%' THEN '小麦'
WHEN crop_name LIKE '稻谷%' THEN '稻谷'
ELSE TRIM(crop_name)
END AS crop_name,
stat_year,
NVL(TRIM(season), '全年') AS season,
plant_area,
yield_amount
FROM agri_dw.ods_crop_yield
WHERE dt = '2024-06-01'
AND stat_year BETWEEN 1990 AND 2100
AND plant_area > 0
AND yield_amount >= 0;
这里有几个设计取舍值得说明。为什么用 INSERT OVERWRITE 而不是 INSERT INTO:重跑同一批次时不会产生重复数据,这对调试阶段非常重要——你会反复重跑十几次。为什么用 CASE WHEN 统一作物名而不是在查询时处理:因为下游可能有五六个地方都要按作物分组,每处都写一遍归一化逻辑,迟早不一致。
3.3 DWS/ADS 层:把指标算一次就够
汇总层的关键原则是:能在离线算的,绝不留到线上算。 网页上要展示的每一种图表,背后都应该有一张已经算好的结果表对应。
sql复制INSERT OVERWRITE TABLE agri_dw.ads_crop_yield_stat PARTITION (dt='2024-06-01')
SELECT
region_name,
crop_name,
stat_year,
SUM(yield_amount) AS total_yield,
SUM(plant_area) AS total_area,
ROUND(SUM(yield_amount) * 10000
/ NULLIF(SUM(plant_area) * 1000, 0), 2) AS unit_yield
FROM agri_dw.dwd_crop_yield
WHERE dt = '2024-06-01'
GROUP BY region_name, crop_name, stat_year;
单位换算说明一下,免得算错:产量单位是万吨,乘以 10000 得吨;面积单位是千公顷,乘以 1000 得公顷;两者相除得到“吨/公顷”,这是一个直观的单产口径。NULLIF 是为了防止面积为 0 时除零报错。
同比增速可以在这张表基础上再算一层,也可以在 Spark 里用窗口函数做,后面会提到。
4. Spark 这一环该写什么,才不像演示代码
我见过太多毕设项目的 Spark 部分,就是把官方示例改个名字跑一遍,跟主业务没有任何关系。判断标准很简单:把这段 Spark 代码删掉,项目功能是不是完全不受影响? 如果是,那它就是摆设。
正确的做法是让 Spark 承担链路上真正需要它的一环。在这套链路里,它最适合做的是“读 Hive 明细表 → 算复杂指标 → 写出到 MySQL”。
4.1 用 Spark SQL 读 Hive 表,而不是重新读文件
既然 Hive 已经建好了表和元数据,Spark 就应该复用它,而不是绕过 Hive 再去解析 CSV。这样清洗逻辑只有一份,不会出现两套口径。
python复制from pyspark.sql import SparkSession
spark = (SparkSession.builder
.appName("crop_yield_ads")
.enableHiveSupport()
.config("spark.sql.warehouse.dir", "/warehouse")
.getOrCreate())
spark.sql("USE agri_dw")
df = spark.sql("""
SELECT
region_name,
crop_name,
stat_year,
SUM(yield_amount) AS total_yield,
SUM(plant_area) AS total_area
FROM dwd_crop_yield
WHERE dt = '2024-06-01'
GROUP BY region_name, crop_name, stat_year
""")
df.createOrReplaceTempView("base")
enableHiveSupport() 这个调用是关键,没有它 Spark 就找不到 Hive 的元数据,会报“表不存在”。这是最常见的报错之一,通常不是表没建,而是这个开关没开,或者 hive-site.xml 没有放进 Spark 的配置目录。
4.2 结果怎么落到 MySQL 供 Django 查询
Spark 直接通过 JDBC 写 MySQL,省掉了导出文件再导入的中间步骤。
python复制result = spark.sql("""
SELECT
region_name,
crop_name,
stat_year,
total_yield,
total_area,
ROUND(total_yield * 10000 / NULLIF(total_area * 1000, 0), 2) AS unit_yield
FROM base
""")
(result.write
.format("jdbc")
.option("url", "jdbc:mysql://127.0.0.1:3306/agri_bi?useUnicode=true&characterEncoding=utf8")
.option("dbtable", "ads_crop_yield_stat")
.option("user", "app_user")
.option("password", "******")
.option("truncate", "true")
.mode("overwrite")
.save())
几个必须注意的点:characterEncoding=utf8 不加,中文地区名可能变问号;.option("truncate", "true") 配合 overwrite 才能保证重跑不重复;MySQL 那边的表要提前建好,或者至少字段类型对得上,region_name 得是 varchar 不能是 int。
还有个细节:Spark 的 JDBC 写入是按分区的,并发写同一张表可能产生锁等待。数据量小的时候感觉不到,数据量一大就容易超时。稳妥的做法是先写临时表再换名,或者直接把 numPartitions 调小。
4.3 参数和资源:本地模式与集群模式的区别
毕设项目大部分时间是单机跑的,这时用 local[*] 就够了:
bash复制spark-submit \
--master local[*] \
--conf spark.sql.shuffle.partitions=8 \
--conf spark.driver.memory=2g \
jobs/crop_yield_ads.py
spark.sql.shuffle.partitions 默认是 200,这个默认值是为大数据集群准备的。你在单机上处理几万行数据,跑出 200 个分区,光调度开销就比计算本身还大。改成 8 到 16,任务耗时会明显下降。
要上集群演示的话,把 --master 换成 yarn,再加 --executor-memory 和 --num-executors。但这里要诚实:如果你的数据量只有几 MB,YARN 模式跑出来的时间反而更长,因为申请容器本身就有开销。别为了演示而演示,用 local[*] 跑通、用 YARN 跑一次证明链路通,就够了。
5. Django 是展示层,不要让它承担计算
后端这一层的设计原则只有一条:请求进来,查表,返回 JSON,结束。 所有循环、聚合、排序,都应该在离线阶段完成。
这条原则听着简单,但很多项目会不自觉地违反——比如在视图里对查询结果再做一次 Python 循环求平均,或者在模板里做复杂判断。数据量一上来,页面就卡。
5.1 数据模型只存结果,不存明细
Django 的 model 直接映射 MySQL 里的结果表,字段和 ads_crop_yield_stat 一一对应:
python复制class CropYieldStat(models.Model):
region_name = models.CharField(max_length=64)
crop_name = models.CharField(max_length=32)
stat_year = models.IntegerField()
total_yield = models.FloatField()
total_area = models.FloatField()
unit_yield = models.FloatField()
class Meta:
db_table = "ads_crop_yield_stat"
unique_together = ("region_name", "crop_name", "stat_year")
indexes = [
models.Index(fields=["crop_name", "stat_year"]),
]
unique_together 保证重跑不会产生重复行,索引保证按作物和年份筛选时不用全表扫。这两个约束在数据量小的时候看不出效果,但它们是“工程化”和“能跑就行”的分界线,写上去成本很低。
5.2 接口设计:一次返回一个图表需要的数据
接口按图表拆分,而不是做一个万能接口。这样前端逻辑简单,也方便单独调试。
python复制from django.http import JsonResponse
from .models import CropYieldStat
def trend_api(request):
crop = request.GET.get("crop", "小麦")
region = request.GET.get("region")
qs = CropYieldStat.objects.filter(crop_name=crop)
if region:
qs = qs.filter(region_name=region)
qs = qs.order_by("stat_year")
return JsonResponse({
"years": [row.stat_year for row in qs],
"yield": [row.total_yield for row in qs],
"unit_yield": [row.unit_yield for row in qs],
})
返回结构刻意做成“平行数组”,因为 ECharts 的 xAxis.data 和 series.data 就是这个形状,前端不用再转一次。
5.3 前端图表:让趋势“可读”而不是“可看”
图表设计上有个常见误区:把所有维度都塞进一张图。正确的做法是一张图只回答一个问题。
- 趋势问题 → 折线图(年份为 X 轴,产量为 Y 轴)
- 对比问题 → 柱状图(地区为 X 轴,同一年份不同地区对比)
- 结构问题 → 饼图或堆叠柱(各作物占比)
- 效率问题 → 双轴图(产量柱 + 单产折线)
双轴图这个组合特别值得用:柱状表示总产量,折线表示单产,一眼就能看出“产量涨了是因为扩种还是因为单产提高”。这个洞察比单纯画一条曲线有价值得多。
注意:双轴图的左右轴范围要手动设置,别用默认值。默认自动缩放容易让两条线的交叉点产生误导性的视觉暗示。
6. 跑不通的时候,按这条链路排查
这套架构出错的地方很分散,但排查顺序是固定的:从下游往上游查,或者从上游往下游查,但一定要单向。 我最常用的方法是先确认最后一环有没有数据,再一层层往回流。
6.1 五层排查顺序
| 层级 | 检查内容 | 典型现象 |
|---|---|---|
| 页面 | 接口有没有返回、字段名是否匹配 | 图空白、控制台报 undefined |
| 接口 | Django 视图能否查出数据 | 返回空数组或 500 |
| MySQL | 结果表有没有行、编码是否正确 | SELECT COUNT(*) 为 0、中文变问号 |
| Spark | 任务是否成功、写入是否报错 | 日志里出现 JDBC 异常 |
| Hive | 分区是否存在、明细表是否有数据 | COUNT(*) 为 0、分区未注册 |
具体展开几个高频问题。
页面有图但全是 0:先看接口返回,大概率是 MySQL 表里有行但字段值全是 0,再往上是 Spark 计算时源数据为空,再往上是 Hive 分区没加。这条链一路查下去,一般五分钟能定位。
中文显示成问号:这是编码问题,出现在三个可能的位置——JDBC URL 少了 characterEncoding=utf8、MySQL 建表时字符集不是 utf8mb4、前端页面没声明 charset。三个地方都要检查,只改一个往往不够。
Spark 报“Table or view not found”:九成是 enableHiveSupport() 没加,或者 hive-site.xml 没放到 $SPARK_HOME/conf 下。也可能是在 Spark 里没执行 USE agri_dw,表名要写全库名。
任务跑得特别慢:先看 spark.sql.shuffle.partitions 是不是还在 200,再看数据有没有倾斜(某个地区数据量特别大),最后看是不是在做全表扫描没走分区。
6.2 环境版本这块要留个心眼
Hadoop、Hive、Spark 三者的版本兼容关系比较敏感,尤其是 Spark 读 Hive 元数据的时候。如果原始环境不是你自己搭的,落地前一定要确认三件事:Hive 的元数据服务是否启动、Spark 编译时用的 Hadoop 版本和集群是否一致、MySQL 里存放 Hive 元数据的库是否可访问。
保守做法是:先把 local[*] 模式跑通,证明业务逻辑没问题,再去折腾集群模式。反过来的话,出了问题你分不清是代码错还是环境错,排查成本会翻倍。
注意:不要一上来就把并发和分区数拉满。先用一个批次、一个地区的数据跑通全链路,确认每个环节的输出都符合预期,再扩大范围。
7. 答辩前该准备的不是演示脚本,而是这几个回答
回到开头那个问题。老师问“为什么不用 MySQL”,本质上不是质疑技术选型,而是在检验你是否理解自己做的事。这个问题没有标准答案,但有诚实的答案。
7.1 四类最可能被追问的问题
第一类,技术选型。 “为什么用 Spark 不用 MapReduce”“Hive 和 MySQL 有什么区别”“Django 为什么不做实时查询”。回答的落脚点应该是职责分工,而不是性能对比——在这个数据量级下谈性能优势是站不住的。
第二类,数据口径。 “单产怎么算的”“单位是什么”“两个省的播种面积统计口径一致吗”。这类问题最能区分“真做过”和“抄了一遍”。把你的单位换算写进文档,把作物名归一化的规则讲出来,就有说服力。
第三类,数据真实性。 “这些数据哪来的”“为什么这个省缺了三年”。诚实回答:哪些来自公开统计资料,哪些是模拟补齐的,为什么这样处理。
第四类,扩展性。 “如果数据量涨一百倍,你这套还能跑吗”“如果要加实时更新怎么做”。前者答分区和并行度,后者答“当前是离线链路,实时需要换成流式处理,这是另一套设计”,承认边界比硬答要好。
7.2 怎么诚实地讲清楚“数据量不大为什么用大数据”
这是这类选题绕不过去的坎。我的建议是主动讲,而不是等被问。
可以这样组织:这个项目的真实数据规模确实不大,用单机数据库也能完成。选择这套技术栈的目的是验证一条可扩展的离线分析链路——数据从文件进入分布式存储,经过分层建模和清洗,用计算引擎产出指标,最后由 Web 层消费。当数据规模增长时,这条链路上的每一层都不需要重新设计,只需要调整资源和并行度。
这个回答的好处是:它把“用了大数据”从“炫技”变成了“架构演示”,逻辑自洽,也不夸大。
7.3 把项目讲成一个可延展的方案
最后一个建议:别把项目讲成“我做完了这些功能”,讲成“我搭了一个可以往上加东西的底座”。
可以延展的方向很多:加入气象数据做相关性分析、把年份维度扩展到季度做季节性分析、把单产和地区做聚类、把离线链路换成定时调度。这些不需要全部实现,但你要能说出“如果继续做,我会从这里入手,因为……”
这套 Hadoop + Spark + Hive + Django 的组合,真正的价值从来不是组件数量。它逼着你把一个原始表格,完整地走一遍存储、建模、清洗、聚合、消费的流程,并且每一层都要说得清为什么在这里。这个过程走通了,换个题目、换个数据集,你照样能重新搭一遍。
所以如果现在要开始动手,我建议第一步不是装环境,而是先把那三张表的结构和那五个指标的计算口径写在一张纸上。纸上的东西想清楚了,代码只是把它们翻译一遍。纸上没想清楚,装再多组件也只是在原地打转。
