做大数据实验的时候,HBase 这关几乎是绕不过去的。我印象里,像“大数据技术原理与应用实验2——熟悉常用的 Hbase 操作”这类题目,核心就是让你在真实环境里把 HBase 的壳跑通,把表建起来,把数据塞进去再查出来。听起来不难,但真正动手时,很多人会卡在环境启动、表结构设计、Shell 语法和 Java API 落地上,一卡就是一下午。
这篇内容就把我带人做这个实验时的完整思路理出来:动手前的设计怎么想,Shell 命令怎么敲,Java API 怎么封装,过滤器怎么用,以及常见坑怎么排查。无论你是第一次做这个实验,还是准备把 HBase 操作重新过一遍,照着这个顺序走,会比盲目敲命令省很多时间。
1. 动手前,先把 HBase 的操作思路拆清楚
1.1 从实验标题拆解出本次要练的核心动作
实验标题虽然是“熟悉常用的 Hbase 操作”,但落到实际场景里,通常包含几个固定动作:启动集群、通过 Shell 建表、设计列族、写入数据、按行键查询、按条件扫描、删除数据,再用 Java API 把同样的流程从代码层面跑一遍。
很多人在这一步就犯了一个方向性错误:急着去敲命令,结果建表之后不知道为什么要这样设计,扫描的时候也不知道该扫哪个行键范围。实际上,HBase 实验真正要练的不是“记住命令”,而是理解这张表在分布式存储里长什么样。比如建一张学生表,通常会设计两个列族:info放姓名、年龄、性别这类基础信息,score放课程成绩。这个设计和关系型数据库里的“学生表”有本质区别:关系型数据库是先定义所有列,再插入数据;而 HBase 是先定义列族,列族里面具体放哪些列,完全由写入时决定。
你甚至可以先把实验要求拆成一张清单:准备环境、Shell DDL、Shell DML、Java API CRUD、过滤器查询、实验数据收集。每完成一项,就在清单上打个勾。这样做完一整轮,你才知道自己是卡在环境、语法,还是 API 调用上。
1.2 环境准备:先让集群跑起来,再谈操作
HBase 本身不自带存储和协调能力,它是跑在 HDFS 之上的,依赖 ZooKeeper 做集群协调。所以启动顺序一般是这样:先启动 HDFS,再启动 ZooKeeper,最后启动 HBase。实验环境如果是老师已经配好的虚拟机,直接执行启动脚本就好;如果自己搭,最容易出问题的是漏掉某个进程。
我第一次带实验时,遇到过一位同学在 HDFS 没启动的情况下直接执行了start-hbase.sh,结果 HMaster 起来了又自动退出,日志里全是“连接 HDFS 失败”。怎么排查?很简单,先看进程。终端里执行jps,正常情况下至少要有NameNode、DataNode、QuorumPeerMain、HMaster、HRegionServer这几个进程。如果缺NameNode,说明 HDFS 没启动;如果HMaster反复消失,多半是 ZooKeeper 没起来或者 HDFS 还在安全模式。
另外说一个很实际的经验:实验环境里不要乱改 hosts 文件和 hostname,HBase 对机器名称解析特别敏感。之前有个同学把/etc/hostname改掉,结果 HMaster 找不到 RegionServer,界面上整个集群都是空白的。如果你的集群之前能跑,突然某天起不来,先检查这几个基础项。
1.3 用仓库类比看懂行键、列族和时间戳
HBase 的存储结构对初学者很不友好,因为大家脑子里的二维表格根深蒂固。我的办法是用一个仓库来类比:一张 HBase 表就是一个大仓库,行键是货架编号,每个货架上按区域分好了大类,这个区域就是列族。同一列族下可以有多个小格子,每个格子的标签就是列限定符,小格子里存储的内容就是值。每次往格子里放东西,仓库系统都会自动盖一个“入库时间”的章,这个时间就是时间戳。
比如往学生表里插一条数据,行键是001,列族是info,列限定符是name,值是张三。HBase 并不需要你先在表里定义好name这一列,只要你往info这个列族下写入name,这条数据就自动存在了。换一个学生002,同样的列族下就算没有name,也完全不影响他的年龄、成绩等其他数据。这是 HBase 区别于关系型数据库的核心特征:稀疏存储、动态列。
弄懂这个模型之后,再去记 Shell 语法会轻松很多,因为本质上所有命令都是在操作一个四层结构:表、行键、列族、列限定符,最多再加一个时间戳版本。
2. HBase Shell:最常用的 DDL 和 DML 实操
2.1 进入 Shell 后先花三十秒看清集群状态
启动 HBase 之后,在终端输入hbase shell,进入交互界面。很多人进去之后直接建表,其实应该先看集群状态。status命令能看到集群里 RegionServer 数量、每个 RegionServer 上加载的 Region 数量,以及运行状态是否正常。version可以看到 HBase 版本。whoami可以确认当前操作身份。
我通常的习惯是先执行list,把当前集群里的表全部列出来。如果list能快速返回,说明 ZooKeeper 连接、HBase Master、RegionServer 都是通的。如果list在半分钟到一分钟内没反应,那大概率是某个组件没起来,继续敲命令只会更卡。这时候不要硬等,而是去检查进程和日志。
还有一个实用小技巧:Shell 命令末尾的分号不是必须的,但每条命令的引号、逗号、括号一定要成对,尤其在输入中文字符串的时候,用英文半角引号。Shell 里的报错信息可能不会太友好,但它通常会告诉你“语法错误”还是“表不存在”,别一看红字就懵。
2.2 建表、改表、删表:把 DDL 流程走顺
建表是第一个重点,语法大概是这样的:
create 'student', 'info', 'score'这条命令创建了一张名为student的表,里面有两个列族:info和score。如果你希望某个列族支持保存多个版本,可以这样写:
create 'student', {NAME => 'info', VERSIONS => 3}, {NAME => 'score', VERSIONS => 1}VERSIONS => 3表示info列族下的数据最多保留最近 3 个版本。这个参数在做多版本实验时很关键,后面查询时要用get加VERSIONS参数才能读出来。
创建完表以后,用describe 'student'查看表结构。你会看到NAME、VERSIONS、TTL、COMPRESSION等一堆属性。实验阶段不需要全懂,但至少要知道NAME是列族名,VERSIONS是列族保存的数据版本数。
修改表同样用alter,比如把info列族的版本数改成 5:
alter 'student', {NAME => 'info', VERSIONS => 5}删除表需要注意顺序,必须先禁用,再删除。直接drop一张正在使用的表,HBase 会直接拒绝。正确流程是:
disable 'student' drop 'student'如果记不清表是否已禁用,可以执行is_enabled 'student'查看状态。很多实验报告里会让同学记录“删除表时为什么要先 disable”,这个问题的本质是:一张表正在接收读写请求,直接删掉会让已经写入的数据和服务状态变得不一致。禁用的过程其实是优雅地停止服务,等所有请求处理完毕后再清理元数据。
2.3 Put/Get/Scan/Delete:DML 最常用的四个动作
数据写入用put,这是 HBase Shell 里最基础也最常用的命令:
put 'student', '001', 'info:name', '张三' put 'student', '001', 'info:age', '20' put 'student', '001', 'score:math', '90'这段代码的意思是:向student表、行键为001的数据,写入info列族下的name列,值是张三。注意info:name中间的冒号是列族和列限定符之间的分隔符,不能写错。如果你写成info_name,HBase 会认为这是一个字符串,但严格来说列族名不对应,查询时就会查不到。
查询单条数据用get:
get 'student', '001' get 'student', '001', {COLUMN => 'info:name'} get 'student', '001', {COLUMN => 'info:age', VERSIONS => 3}第一条返回整行的所有列;第二条只返回info:name这一列;第三条返回info:age的多个历史版本。注意get默认只能读回一个版本,这也是很多同学明明写入时覆盖过数据,却看不到旧版本的原因。
全表扫描用scan:
scan 'student' scan 'student', {LIMIT => 5} scan 'student', {STARTROW => '001', ENDROW => '003'}第三行代码扫描行键从001到003的数据。这里有个关键细节:STARTROW是闭区间,ENDROW是开区间,也就是说003这个行键本身不会被包含。如果希望003也包含进来,通常会把ENDROW写成004。这个规则在实验里经常把人坑到,后面我会继续展开。
删除数据用delete和deleteall:
delete 'student', '001', 'info:age' deleteall 'student', '001'第一条只删除001这行里info:age这一列的数据,不影响同行的其他列;第二条直接删除001这一整行。用delete删除列时,其实删除的是该列最新版本的值,如果你设置了多个版本,旧版本仍然能通过get加VERSIONS查到,这一点也需要留意。
3. 用 Java API 完成实验中的增删改查
3.1 项目依赖和连接配置:别在起步阶段翻车
实验做到这一步,很多人开始紧张,因为要从 Shell 切换成 Java 代码。其实 Java API 和 Shell 命令的对应关系非常清晰:Shell 的create对应Admin.createTable,put对应Table.put,get对应Table.get,scan对应Table.getScanner,delete对应Table.delete。只要 Shell 命令理解到位,Java API 只是换了一种表达方式。
项目里需要引入 HBase 客户端依赖。我用 Maven 管理依赖时通常这样配:
<dependency> <groupId>org.apache.hbase</groupId> <artifactId>hbase-client</artifactId> <version>请与本机HBase版本保持一致</version> </dependency>版本号一定要和集群里的 HBase 版本匹配,否则可能出现 RPC 层协议不一致。如果你拿不准,就在集群上执行hbase version,然后去对应的 Maven 仓库找同版本。
连接配置最常用的方式是读取本地hbase-site.xml,或者在代码里直接设置。实验环境里,我们最需要关注的是 ZooKeeper 地址:
Configuration config = HBaseConfiguration.create(); config.set("hbase.zookeeper.quorum", "node1,node2,node3"); config.set("hbase.zookeeper.property.clientPort", "2181"); Connection connection = ConnectionFactory.createConnection(config);如果只有一个节点,hbase.zookeeper.quorum填那个节点的主机名或 IP 就行。注意别写成localhost,有些环境下 localhost 映射不对,会连不上 ZooKeeper。
3.2 动手封装工具类,把 Connection 管理好
HBase 的Connection是一个重量级对象,底层维护了跟 ZooKeeper、HBase Master、RegionServer 的连接池。一个 JVM 进程里最好只创建一次,别每调一个方法就 new 一个 Connection,不然实验快结束的时候,你会发现 ZooKeeper 连接数暴涨,整个集群都变得很卡。
我习惯写一个简单的工具类:
public class HBaseUtil { private static Connection connection = null; public static Connection getConnection() throws IOException { if (connection == null || connection.isClosed()) { Configuration config = HBaseConfiguration.create(); config.set("hbase.zookeeper.quorum", "node1,node2,node3"); config.set("hbase.zookeeper.property.clientPort", "2181"); connection = ConnectionFactory.createConnection(config); } return connection; } public static void close() throws IOException { if (connection != null && !connection.isClosed()) { connection.close(); } } }ConnectionFactory.createConnection这个方法会读取 classpath 里的配置,如果你本地没有 HBase 配置文件,就自己在代码里用config.set补上。这里我额外提一句:实验代码里不要把 ZooKeeper 地址写死成别人的虚拟机 IP,换环境就得改,最好用一个配置文件或者常量统一管理。
3.3 核心增删改查方法的骨架实现
先看建表。新版 HBase 的 API 和旧版差别不小,旧版常用HTableDescriptor,新版推荐用TableDescriptorBuilder:
public void createTable(String tableName, String... columnFamilies) throws IOException { Connection conn = HBaseUtil.getConnection(); TableName tn = TableName.valueOf(tableName); if (conn.getAdmin().tableExists(tn)) { System.out.println("表已存在"); return; } TableDescriptorBuilder builder = TableDescriptorBuilder.newBuilder(tn); for (String cf : columnFamilies) { ColumnFamilyDescriptor cfd = ColumnFamilyDescriptorBuilder.newBuilder(Bytes.toBytes(cf)).build(); builder.setColumnFamily(cfd); } conn.getAdmin().createTable(builder.build()); }插入数据put,对应的代码是这样的:
public void putData(String tableName, String rowKey, String cf, String col, String value) throws IOException { Connection conn = HBaseUtil.getConnection(); Table table = conn.getTable(TableName.valueOf(tableName)); Put put = new Put(Bytes.toBytes(rowKey)); put.addColumn(Bytes.toBytes(cf), Bytes.toBytes(col), Bytes.toBytes(value)); table.put(put); table.close(); }查询单条数据get:
public void getData(String tableName, String rowKey, String cf, String col) throws IOException { Connection conn = HBaseUtil.getConnection(); Table table = conn.getTable(TableName.valueOf(tableName)); Get get = new Get(Bytes.toBytes(rowKey)); if (cf != null && col != null) { get.addColumn(Bytes.toBytes(cf), Bytes.toBytes(col)); } Result result = table.get(get); Cell[] cells = result.rawCells(); for (Cell cell : cells) { System.out.println(Bytes.toString(CellUtil.cloneRow(cell))); System.out.println(Bytes.toString(CellUtil.cloneQualifier(cell))); System.out.println(Bytes.toString(CellUtil.cloneValue(cell))); } table.close(); }全表扫描scan:
public void scanTable(String tableName) throws IOException { Connection conn = HBaseUtil.getConnection(); Table table = conn.getTable(TableName.valueOf(tableName)); Scan scan = new Scan(); ResultScanner scanner = table.getScanner(scan); for (Result result : scanner) { System.out.println(Bytes.toString(result.getRow())); for (Cell cell : result.rawCells()) { System.out.println(Bytes.toString(CellUtil.cloneQualifier(cell)) + " = " + Bytes.toString(CellUtil.cloneValue(cell))); } } scanner.close(); table.close(); }这些方法写完之后,在main方法里依次调用,实验的数据写入和查询流程就串起来了。建议每一步都加System.out.println打印结果,不然代码跑完你也看不到数据,很难确认是否成功。
3.4 资源释放:实验里最容易忽略的坑
我在检查实验代码时,最常见的两个问题:Connection不关,ResultScanner不关。Shell 命令用完没有连接问题,但 Java API 不一样,你每创建一个Table实例,底层都会向 RegionServer 申请资源。如果你在循环里反复创建Table而不关闭,等到几百次迭代之后,RegionServer 上会积累大量线程和连接,最后把 ZooKeeper 会话拖垮。
推荐写法是用try-with-resources:
try (Table table = connection.getTable(TableName.valueOf("student"))) { Put put = new Put(Bytes.toBytes("001")); put.addColumn(Bytes.toBytes("info"), Bytes.toBytes("name"), Bytes.toBytes("张三")); table.put(put); }Table和ResultScanner都实现了Closeable,用 try-with-resources 能保证方法结束时自动释放。而Connection是重量级资源,应该在程序快结束的时候统一关闭,不要放在每次操作里关。
之前有个同学跑完整个实验流程后发现集群变得特别卡,jps一看,他那个 Java 进程还在,ZooKeeper 连接数飙到了两三百。原因就是他创建的Connection没有 close,程序虽然退出,但连接还挂在那里。遇到这种情况,最直接的排查方式是杀掉残留的 Java 进程,再重启 HBase 服务。
4. Scan 查询与过滤器组合,实验里最灵活的加分项
4.1 为什么 Scan 是最容易写错的查询
scan的语法比get复杂,比put更需要动脑,因为它是范围查询。很多人写scan 'student', {STARTROW => '001', ENDROW => '003'}时,理所当然地认为结果应该包含001、002、003,但实际结果只包含001和002。这是 HBase 行键扫描的默认规则:STARTROW包含,ENDROW不包含。理解这个规则,你才能准确描述“查某个区间的数据”。
还有一个容易踩的坑:如果一张表的行键设计成前面是字母后面是数字,比如a001、b002、c003,你用scan 'student', {STARTROW => 'a', ENDROW => 'z'}是能扫到 a 到 z 开头的所有行键。但如果行键是001、002、010这种字符串排序方式,字典序不是数字序,010会排在002前面。实验数据量小可能看不出问题,但你要知道这一点,避免在面试或者真实项目里莫名其妙查不到数据。
还有一个常见误区是过度使用scan加过滤器,导致全表扫描。实验阶段数据量才几十行,无所谓;但真实场景下,一张表可能有几千万行,你写一个没有起始行键的scan,就相当于把整张表从第一行扫到最后一行,效率非常差。所以设计表结构时,行键要尽量能支撑你的查询范围。
4.2 常用过滤器一览与选型
HBase 过滤器很多,实验里真正高频使用的其实就那几个。我把它们整理成一张表:
| 过滤器 | 作用 | 典型使用场景 |
|---|---|---|
RowFilter | 对行键做比较运算 | 按行键范围或条件取数据 |
PrefixFilter | 匹配行键前缀 | 查询同一前缀前缀的数据 |
SingleColumnValueFilter | 根据某列的值过滤 | 筛选满足指定列值的行 |
ColumnPrefixFilter | 匹配列限定符前缀 | 查某个列族下的部分列 |
PageFilter | 按页返回固定行数 | 分页查询 |
FilterList | 组合多个过滤器 | 多条件联合过滤 |
选型逻辑很简单:能通过行键范围解决的问题,就不要用过滤器;一定要用过滤器时,优先用RowFilter和PrefixFilter这种对行键生效的类型,因为它们能尽量配合 Region 定位减少扫描范围。SingleColumnValueFilter虽然好用,但会把符合条件的行返回,然后逐行判断列值,本质上还是全表扫描,数据量大了会慢。
4.3 三个可以直接抄的过滤场景
第一个场景:查询所有以00开头的学生行键。
Shell 写法:
scan 'student', {FILTER => "PrefixFilter('00')"}这个命令会返回行键前缀为00的所有数据,适合按班级、按批次这种行键前缀分组的场景。
第二个场景:查询score:math大于 90 的行。
Shell 写法:
scan 'student', {FILTER => "SingleColumnValueFilter('score', 'math', =, 'binary:95')"}这里要注意,比较条件里的'binary:95'表示用字节序比较,值是 95。如果你用substring做判断,比如substring:张,它做的是子串匹配。不同比较器的适用范围不同,binary适合数值和精确匹配,substring适合字符串模糊匹配。实验里经常有人照抄语法但忘记binary:或者substring:前缀,导致结果为空。
第三个场景:在 Java API 里组合过滤器,实现“扫码info:name为张三且行键以00开头的数据”:
Scan scan = new Scan(); FilterList filterList = new FilterList(FilterList.Operator.MUST_PASS_ALL); filterList.addFilter(new PrefixFilter(Bytes.toBytes("00"))); filterList.addFilter(new SingleColumnValueFilter( Bytes.toBytes("info"), Bytes.toBytes("name"), CompareOperator.EQUAL, Bytes.toBytes("张三") )); scan.setFilter(filterList);MUST_PASS_ALL表示所有条件都要满足,相当于AND关系;如果你希望“满足任一条件即可”,就用MUST_PASS_ONE,相当于OR关系。写完代码后,建议先在 Shell 里用同样的过滤条件验证一遍结果,再翻译成 Java 代码,这样能少踩很多语法坑。
还有一个非常有用的分页写法:scan加PageFilter。但PageFilter和LIMIT不完全一样,LIMIT是直接限制返回行数,PageFilter更适合在内存里控制每批读取的数据量。实验里如果想演示分页扫描,可以在Scan对象上设置setLimit(10),逻辑上更直观。
5. 常见问题与排查技巧实录
5.1 服务起不来或者 Master 自动退出
这个现象在实验环境里最常见。你执行start-hbase.sh之后,HMaster 进程出现了,但过几秒又没了。排查思路按这个顺序来:
先看jps,确认NameNode、DataNode、QuorumPeerMain是不是都在。HDFS 如果没启动,HBase 建表时无法在 HDFS 上创建数据目录,Master 自然会挂。再看 HDFS 是否处于安全模式,可以用:
hdfs dfsadmin -safemode get如果返回Safe mode is ON,就等待安全模式自动退出,或者主动执行:
hdfs dfsadmin -safemode leave安全模式下,HBase 无法往 HDFS 写入块文件,Master 会反复重试后退出。这个原因我至少遇到过五六次。最后再看 hostname 和 IP 映射,HBase 通过 ZooKeeper 管理节点,节点间互相识别靠 hosts 文件,一旦本机 hostname 解析不对,RegionServer 注册不上,Master 也会干着急。
5.2 Shell 卡住、没响应怎么办
Shell 命令卡住,最常见的是status、list、scan这几条。如果之前能正常操作,突然所有命令都卡,优先怀疑 RegionServer 挂了或 ZooKeeper 会话超时。先jps看进程,再打开日志目录找 RegionServer 日志。HBase 默认日志位置是你的安装目录下的logs文件夹,文件名类似hbase-xxx-regionserver-xxx.log。日志里如果出现频繁的Connection refused,说明 RegionServer 和 HMaster 之前网络不通。
另一个原因是 Zookeeper 节点没有服务,而客户端在 ZooKeeper 上等会话超时。Shell 默认不会一直等你,但有的时候hbase.rpc.timeout设得太长,表面看起来就是命令卡死了。实验环境里如果遇到卡住,最省事的方法是重启 HBase 服务,但过程中要注意先关闭 ZooKeeper 再关 HBase,顺序和启动时相反。
5.3 数据写进去却查不到,先按顺序排除
这个问题的出现频率非常高。明明put成功,为什么get出来是空的?我一般按下面几个方向排查:
第一,看命令行里的列族和列限定符是否一致。put 'student','001','info:name','张三',如果查询时错写成get 'student','001',{COLUMN=>'inf:name'},那肯定查不到。
第二,看表名是否弄混。很多同学建表用了stu,后面查询却写student,HBase 会直接提示表不存在。
第三,看是否指定了VERSIONS。如果你写入时覆盖了同一个值,get默认只返回最新版本,旧版本需要指定VERSIONS才能看到。比如:
put 'student', '001', 'info:name', '张三' put 'student', '001', 'info:name', '李四' get 'student', '001', {COLUMN => 'info:name', VERSIONS => 2}这样查出来的结果会包含李四和张三两个版本。
第四,看scan的STARTROW和ENDROW范围。如果ENDROW正好等于你要查的最后一个行键,会被排除。行键类型要一致,比如你的行键是数字1,Shell 里如果你写字符串'1'是没问题的,但如果你用Bytes.toBytes(1)在 Java 里写的是整型 1,字节内容就和'1'不一样,也会查不到。
5.4 版本不一致导致的隐蔽问题
这个坑比较隐蔽,因为你不会立刻看到报错,而是各种奇怪现象。比如本地 Java 代码连不上集群,或者能连上但是执行操作时抛ProtocolException。最常见的原因是客户端 HBase 版本和服务端版本差异过大,导致 RPC 协议不兼容。
解决方式不复杂:进你集群的安装目录,执行hbase version,看清楚版本号。然后去 Maven 仓库找到同一个版本的hbase-client依赖。实验环境里如果允许直接拷贝服务器上的hbase-clientJAR 包到本地项目,那更保险。
另外如果你用 Hadoop 相关客户端连接 HDFS,也可能遇到 Hadoop 版本不匹配。很多课程实验用了单独下载的 HBase 发行包,里面自带的客户端 JAR 就是匹配的,用这种 JAR 做一个 fat jar 跑 Java API,能少很多麻烦。
5.5 实验报告需要收集哪些信息
HBase 实验通常要交实验报告,里面要求贴一些关键结果。我建议在操作过程中顺手收集这几类内容:集群状态、表结构输出、数据插入结果、查询结果、删除后的结果、Java API 运行输出。
具体的命令可以是:
version status list describe 'student' scan 'student' get 'student', '001', {COLUMN => 'info:name'}把这些输出复制到报告里,比手写伪代码更直观。如果实验报告要求展示 Java API 效果,就把每个方法调用后的控制台输出截图。另外,HDFS 上 HBase 的数据目录也值得看一眼。表的数据被写到 HDFS 的/hbase/data/default/下面,一张表对应一个子目录,用hdfs dfs -ls /hbase/data/default/能看到表和 Region 的分布情况,这是证明“懂了 HBase 存储模型”的好素材。
我个人在带实验的时候,还会让学生额外记录每个 HBase 表的 Region 数量,用status 'detailed'能看到。因为 Region 是分布式存储的基本单元,这个信息能在报告里体现对系统设计的理解。
做实验这件事,最磨人的往往不是某个 API 写不出来,而是环境和服务状态的不稳定。你会发现,只要集群正常、表结构合理、命令语法正确,HBase 的操作链路是非常清晰的。Shell 和 Java API 并不是两套独立的东西,它们背后是一套相同的逻辑模型。把put、get、scan、delete这四件事搞明白,再掌握过滤器和版本的概念,HBase 实验的收获会比刷一遍命令大得多。
如果你在敲代码时实在查不到数据,我还有个土办法:先在 Shell 里手动插入一条数据并查询,确认基础功能正常,再去跑 Java API。这样可以把问题范围缩小一半,避免在代码和集群状态之间反复猜。熟悉了这套流程之后,后续再做性能测试、数据迁移、读写优化,你就有底气了。