ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

HBase二级索引实战指南:从查询痛点到底层原理与最佳实践

HBase二级索引实战指南:从查询痛点到底层原理与最佳实践 做大数据开发的兄弟应该都有这种经历一张订单表几十亿行按订单号点查毫秒级返回结果产品过来说“帮我按手机号拉一下这个用户最近三个月的订单”你一听心里咯噔一下——这玩意儿在HBase里就是全表扫描跑个MapReduce都得几分钟更别说直接Scan了。说白了这就是HBase最大的“查询痛点”它的设计目标就是单主键RowKey的随机读写和范围扫描想按非RowKey列做条件查询原生机制根本不给你走索引。于是HBase二级索引就成了绕不开的话题。这篇文章不跟你整虚的直接把这几年我在生产环境里处理二级索引的方案、设计细节、踩坑记录和实操代码一次性说透。不管是刚接触HBase的新手还是在准备HBase面试题、做技术选型的朋友这篇文章都可以当一份实战参考。1. 先搞清楚HBase的查询痛点到底在哪1.1 单主键查询的“天花板效应”HBase的查询模型非常“轴”给你一个RowKeyGet一次RPC直接定位到RegionServer速度飞快给你一个RowKey范围Scan限定StartRow和StopRow效率也还不错。可一旦查询条件里没有RowKey问题就来了——HBase只能全表Scan把每一行数据拉出来再用Filter在服务端过滤。我用一个真实场景说明白订单表10亿行RowKey是order_id。你执行“select * from orders where user_idu12345”HBase的物理执行计划是所有Region并行扫全量HFile然后逐行比对user_id。10亿行数据扫一遍哪怕有几十台RegionServer撑着响应时间也是分钟级起步。更恐怖的是这个操作每次查询都要完整跑一遍客户等不了BI报表也等不了。这不只是慢的问题还直接影响集群稳定性。大范围的Scan会占满RegionServer的CPU和IO把正常点查的延迟也拖上去。我见过不止一次就因为线上有人跑了一个不带RowKey的Scan整个集群的P99延迟从10ms直接飙到500ms那叫一个惨烈。1.2 为什么HBase不能像MySQL那样随便加索引很多人下意识会问MySQL里加个二级索引不就行了吗HBase为什么不行这里要说透一个底层差异。MySQL是B树存储引擎二级索引的叶子节点存储主键查询的时候先在二级索引树里找到主键再回聚簇索引查数据这是数据库内核帮你干的事。但HBase底层是LSM-Tree一张表的数据按RowKey字典序分布在多个Region里每个Region再有MemStore和HFile。HBase所谓的内建索引本质就是RowKey这一个维度它的索引和数据是绑在一起的。想在HBase里实现对任意列的快速查询并没有“在表上加个索引字段”这种原生能力。你能做的事情只有两类要么在RowKey设计上做文章把查询维度拼进RowKey要么在业务代码里自己维护一张索引表靠外部组件或者自研逻辑实现“先查索引再查主表”。这就是HBase二级索引方案产生的根本原因。另外还有一个现实约束HBase单行事务只对同一行RowKey有效跨表、跨行的写入根本没有内建事务支持。这就让“维护索引表”这件事变得没那么简单——你写主表成功了索引表写入失败了数据立刻不一致。后面我会详细说补偿机制但先记住这个底层约束很多方案设计的逻辑都从这里起源。1.3 我见过的一些“伪方案”和血泪教训在没有想清楚架构之前很多团队会先用一些看似省事的办法顶着我这里列几个我真实见过的第一全量Scan加Filter硬查。数据量几百万行的时候这个方案还能接受因为全表扫一遍也就几百毫秒。但数据量一旦过亿这就是灾难。我有一次排查线上问题发现一个“查用户订单”的接口走的是全表Scan单次查询耗时长直接把RegionServer的CPU打满连累了其他业务。第二把所有业务维度拼进RowKey。这种设计确实能让部分维度查询变快比如把user_id放进RowKey前缀按用户查订单就很高效。但副作用很明显其他维度的查询依然全表扫描RowKey设计一旦变了写入热点问题马上出现某个用户如果订单量特别大他的所有数据会全部堆积在同一个Region上造成严重的Region热点。第三业务层自己写辅助表然后读时合并。逻辑上听着没毛病写订单的同时写一张user_order_index表查询时先查索引表拿到订单号再回主表拿数据。但这里有个要命的点——两个表之间的数据一致性靠什么保证没有事务没有补偿一旦出现一次写入失败或者程序重启丢失半条消息索引表和主表就对不上了。到时候查出来的数据比慢更可怕因为是错的。血的教训就一句话方案一定要在动手之前想清楚别等数据规模上去了再返工返工的成本是几何级增长的。2. 二级索引主流实现方案全景图2.1 四类常见方案对比做技术选型之前先看全景。我把生产环境里真正有人用的HBase二级索引方案整理成一张对比表每个方案都从原理、一致性、复杂度、适用场景几个维度拆开看方案实现原理数据一致性复杂度适用场景自建同步索引表业务双写主表和索引表读时先查索引表再回主表强一致需补偿机制配合常规下最终一致中业务代码可控、查询模式固定、不想引入重组件自建异步索引表主表写入成功后经消息队列或CDC异步写索引表最终一致延迟秒级中高写QPS高、能容忍索引短暂延迟Apache Phoenix二级索引Phoenix在SQL层管理索引表对业务透明依赖Phoenix实现索引写入与主表在同一事务流程中中团队用SQL开发、希望屏蔽HBase API华为Lemon独立索引组件监听主表WAL/写路径异步构建索引最终一致高大集群、对索引功能要求全面、有人维护专有组件Elasticsearch外置索引通过CDC将HBase数据同步到ES查询直接走ES最终一致延迟秒级高复杂查询、全文检索、聚合分析场景2.2 选型判断标准问自己四个问题方案没有绝对的好坏只有合不合适。我在做技术选型时一般会问团队四个问题第一你愿不愿意接受HBase原生API开发如果团队都是SQL思维业务开发也都是写惯了MySQL的那自建索引表意味着所有查询逻辑都要自己实现这个学习成本和维护成本都不小。这时候Phoenix是更平滑的选择SQL透明开发体验接近关系型数据库。第二线上写入QPS有多高能不能接受写放大凡是索引本质都是写放大。主表写入一份数据索引表也要写入一份。你建了三个索引写入放大就是四倍。如果写入QPS本身就很高同步双写可能直接压垮集群这时候优先考虑异步方案把索引构建放到链路下游去削峰填谷。第三数据一致性要求有多高有些业务场景索引数据晚几秒出现是可以接受的比如运营后台查报表、查用户列表但有些场景不行比如下单后立刻要回显订单状态如果索引还没构建完用户一刷新看到订单不见了这个体验就很糟糕。一致性要求高的话自建同步索引加补偿机制是更稳的路线。第四你愿意为这个需求多维护一个组件吗引入Elasticsearch或者Lemon意味着你的运维体系里多了一个需要监控、调优、处理故障的组件。如果团队运维能力有限用自建索引表反而是更务实的选择。2.3 自建索引的核心思路映射表我个人的经验是如果需求就是“按某些固定字段快速定位主表数据”自建索引表是最灵活、也最可控的方案。它的核心思路很简单把一条条件查询转换成两步点查。索引表本质是一张映射表RowKey是你要查的索引值比如user_id拼接时间维度value里存的是主表的RowKey比如order_id。查询的时候第一步Scan这张索引表拿到一批主表RowKey第二步根据这批RowKey去主表批量Get。整个过程相当于把“全表扫描过滤”转换成了“小索引表范围扫描加随机点查”性能完全不是一个量级。但注意我说“最简单”不等于“最简单就能做好”。索引表本身的RowKey设计、预分区策略、双写一致性、补偿机制每一步都有细节接下来我用一整章把这几个核心环节拆开讲。3. 通用二级索引设计中的五大核心细节3.1 索引表RowKey怎么设计才不踩坑索引表也是一张HBase表所以它同样要面对RowKey设计问题。这里我有三个原则踩过坑之后总结出来的原则一要让索引表查询走RangeScan而不是全表Scan。既然索引表的目的是“先小范围定位”那RowKey必须把查询条件放在前缀位置。比如按user_id查订单索引表RowKey就设计成user_id 分隔符 create_time反转 分隔符 order_id。这样查某个用户的订单时StartRow是user_id|StopRow是user_id|~一次扫描就能拿到该用户按时间倒序排列的订单RowKey列表。原则二前缀要加盐避免热点。如果直接用user_id做前缀用户量大但分布又不均匀某些热门用户的订单数据会把索引表的某个Region写得过热。解决方案是在user_id前面加哈希前缀比如hash(user_id) % 64作为分区键再把完整user_id跟在后面。这样同一个用户的数据还是连续存放的但不同用户会被分散到不同的Region。原则三索引表RowKey里一定冗余一个时间戳。这个细节我后面讲对账的时候还会提但这里先说结论索引表RowKey里带一个写入时间排查数据不一致问题时能直接对比时间线省掉一大半定位问题的功夫。3.2 数据一致性同步双写、异步双写与补偿机制HBase跨表没有事务这是所有二级索引方案都绕不开的坎。我实际用的有三层机制组合同步双写是基础。业务在写主表的同时同一个请求里把索引表也写了。注意这里不能理解为“两个Put就是一个事务”而是要在代码层面做好顺序控制。我的习惯是先写索引表再写主表。原因是如果主表写成功了索引表写失败你还有办法通过主表反推索引反过来如果索引表写成功了主表写失败那索引表里就会留一条孤儿数据排查起来更麻烦。当然这只是一种取舍你还可以用事务型API把两个Put放到同一个批量请求里但批量的原子性也不是绝对的所以补偿机制必须有。补偿机制是必须的。最简单的做法是准备一张“索引写入日志表”双写时如果索引表写入抛异常就把这条索引写入记录落盘后台有一个补偿线程定期重放这些失败的索引写入。这个方案听起来很土但生产环境里最实用。我也试过用消息队列把索引更新异步化写主表成功后就发一条MQ消息消费者拿到消息再写索引表。这个方案的好处是写路径解耦主表写入不用等索引表写完成坏处是要处理消息丢失、消息乱序、重复消费幂等等问题必须做好消息的去重和顺序保障。定时对账是最后的兜底。不管同步还是异步双写总会因为各种边界情况出现数据不一致。我线上有一个每天凌晨跑的一致性校验任务全量扫描主表按索引规则生成应有的索引RowKey再去索引表比对是否存在、内容是否一致。发现差异就自动触发重建任务。这个对账任务虽然跑起来要花一两个小时但它是保证数据正确的最后一道防线不能省。3.3 覆盖索引让查询连主表都不用回自建索引表的一个进阶玩法是引入“覆盖索引”思想。原始设计里索引表的value只存主表RowKey查询拿到RowKey后必须回主表Get一次才能拿到业务字段。但如果查询要返回的字段是固定的几个比如状态、时间、金额那你完全可以在写索引表的时候把这些字段一并冗余进索引表。举个例子按状态查最近订单列表业务方只需要展示订单号、状态、创建时间。那索引表的列族里就放status、create_time、amount这几个字段Scan索引表之后直接返回完全不用回主表。这个优化效果非常明显——查询只走一次小索引表的Scan没有回表的批量GetRT能再降一个量级。当然覆盖索引的代价就是索引表占用存储空间变大而且如果冗余字段需要更新你得跟着更新索引表。所以它只适合那些字段频繁读取但不常更新的场景。3.4 查询改造与分页从“全表扫描”到“两层RPC”有了索引表之后原本的查询逻辑要从“一次全表Scan”改成“两层RPC”这中间的性能调优我详细说下。第一步Scan索引表。因为索引表RowKey设计时把查询条件放在前缀所以这里就是一次小范围的RangeScanRegionServer返回的row数量一般也就几十条、几百条毫秒级返回。第二步用Scan出来的RowKey列表批量Get主表。这一步要注意几个细节批量Get的条数不要一次性拉太多我一般控制在50条以内一批多了容易触发RegionServer的RPC超时同时可以用线程池做并发批量Get五批并发和串行五批延迟差距非常明显。分页逻辑也顺势而解——索引表的Scan支持StartRow你把上一页最后一条索引RowKey拿出来作为下一页的StartRow起点就行。这种方式比游标翻页稳得多数据量大也不怕。3.5 多列组合条件联合索引还是组合RowKey很多时候查询条件不止一个维度比如“查状态为PAID且创建时间在一个月内的订单”。这种情况有两种做法第一种是组合RowKey把多个维度拼进索引RowKey比如status 反转时间 order_id。这样查询时按状态做前缀Scan再在Scan的Filter里过滤时间范围。前缀匹配的性能还是不错的适合查询条件顺序固定的场景。第二种是多张索引表。如果查询条件组合非常多变比如有时候按用户查、有时候按状态查、有时候按时间查那你很难靠一种RowKey组合覆盖所有场景。这时候要么建多张索引表分别应对各查询场景要么提示业务侧把查询条件收窄。我的经验是索引表数量控制在2到3张以内再多的话写放大和存储成本会变得非常难看。还要提一种思路宽表冗余。比如日志类的数据按天建一张表RowKey是业务ID_时间戳再把同一条数据的不同维度拆到不同的表里。相比维护索引宽表冗余在查询侧更简单直接缺点是写入侧要同步写多张表。哪种方案更好取决于你的业务查询模式是“少数固定条件”还是“条件多变”。4. 实操从零搭一个可用的二级索引4.1 场景设定与表结构规划纸上谈兵没用我直接用一个真实业务场景带大家把代码跑一遍。假设有一个订单表ordersRowKey是order_id列族info下有user_id、status、create_time、amount四个列。现有两个核心查询需求一是按用户ID查TA的订单列表且按时间倒序二是按订单状态查最近一天内的订单列表。针对两个需求我设计两张索引表idx_user_orderRowKey为hash(user_id) _ user_id _ 反转时间戳 _ order_idvalue冗余status和amountidx_status_orderRowKey为hash(status) _ status _ 反转时间戳 _ order_id为什么要反转时间戳因为HBase的字典序是从小到大时间戳反转过之后大时间在前、小时间在后。这样Scan索引表时拿到的第一行就是最新的订单天然支持倒序返回。4.2 写路径实现Java程序双写索引表这段代码是核心中的核心。用HBase原生Java API实现同步双写public void writeOrderWithIndex(Order order) { try (Connection conn ConnectionFactory.createConnection(conf)) { Table ordersTable conn.getTable(TableName.valueOf(orders)); Table idxUserTable conn.getTable(TableName.valueOf(idx_user_order)); // 1. 构建主表Put Put ordersPut new Put(Bytes.toBytes(order.getOrderId())); ordersPut.addColumn(Bytes.toBytes(info), Bytes.toBytes(user_id), Bytes.toBytes(order.getUserId())); ordersPut.addColumn(Bytes.toBytes(info), Bytes.toBytes(status), Bytes.toBytes(order.getStatus())); ordersPut.addColumn(Bytes.toBytes(info), Bytes.toBytes(create_time), Bytes.toBytes(order.getCreateTime())); ordersPut.addColumn(Bytes.toBytes(info), Bytes.toBytes(amount), Bytes.toBytes(order.getAmount())); // 2. 构建索引表Put String idxRowKey buildUserIndexRowKey(order); Put idxPut new Put(Bytes.toBytes(idxRowKey)); idxPut.addColumn(Bytes.toBytes(idx), Bytes.toBytes(order_id), Bytes.toBytes(order.getOrderId())); idxPut.addColumn(Bytes.toBytes(idx), Bytes.toBytes(status), Bytes.toBytes(order.getStatus())); idxPut.addColumn(Bytes.toBytes(idx), Bytes.toBytes(amount), Bytes.toBytes(order.getAmount())); // 3. 顺序写先索引表再主表 idxUserTable.put(idxPut); ordersTable.put(ordersPut); } catch (Exception e) { // 4. 写入失败必须记录补偿日志由后台线程重试 saveCompensateLog(order, idx_user_order); } }注意这里我用的是“先索引表、再主表”的顺序。前面我说过原因索引表失败可以重放主表失败的话索引表里会有孤儿数据宁可让主表成为最终真相源。如果你不想在业务代码里手动双写也可以用HBase的WAL回放机制来做增量数据的索引同步比如部署一个监听WAL的组件解析日志文件里的写操作自动生成索引表的Put。这个方案对业务代码零侵入但对组件开发能力要求高普通团队不建议上来就玩这个。4.3 读路径实现先索引再回表读路径代码也很清晰分两步public ListOrder getOrdersByUser(String userId, int pageSize, String lastRowKey) { ListOrder result new ArrayList(); try (Connection conn ConnectionFactory.createConnection(conf)) { Table idxTable conn.getTable(TableName.valueOf(idx_user_order)); // 1. Scan索引表获取主表RowKey列表 Scan scan new Scan(); String prefix hash(userId) _ userId _; scan.withStartRow(Bytes.toBytes(prefix)); scan.withStopRow(Bytes.toBytes(prefix ~)); // 分页lastRowKey不为空时以它为StartRow if (StringUtils.isNotBlank(lastRowKey)) { scan.withStartRow(Bytes.toBytes(lastRowKey)); } scan.setLimit(pageSize); ResultScanner scanner idxTable.getScanner(scan); ListString orderIds new ArrayList(); for (Result r : scanner) { String orderId Bytes.toString(r.getValue(Bytes.toBytes(idx), Bytes.toBytes(order_id))); orderIds.add(orderId); } scanner.close(); // 2. 批量Get主表 Table ordersTable conn.getTable(TableName.valueOf(orders)); // 分批每批50个 for (int i 0; i orderIds.size(); i 50) { ListGet gets new ArrayList(); for (int j i; j Math.min(i 50, orderIds.size()); j) { gets.add(new Get(Bytes.toBytes(orderIds.get(j)))); } Result[] results ordersTable.get(gets); for (Result res : results) { if (res.isEmpty()) continue; // 解析并加入结果集 result.add(parseOrder(res)); } } } return result; }这段代码就是一个标准的“两层RPC”模式。有几个性能细节我强调一下scan.setLimit(pageSize)一定要设置不然索引表Scan会把该用户所有历史订单全部扫出来内存吃不消批量Get每批50个是我线上压测出来的折中值太少了浪费RPC次数太多了单个RPC包太大容易超时。4.4 索引表的预分区与调优索引表建完之后如果直接使用默认的自动分区写入热点问题会在数据量上来之后立刻暴露。我在建索引表时会先做预分区比如针对idx_user_order按hash(user_id) % 128分成128个Regioncreate idx_user_order, {NAME idx}, {NUMREGIONS 128, SPLITALGO HexStringSplit}这里用HexStringSplit是约定俗成的做法原因也很简单HBase的RowKey是字节数组HexStringSplit会按十六进制字符串均匀切分0~255的区间配合哈希前缀能很好地打散写入压力。还有一个调优细节索引表本质上不需要太高的实时可见性因为它的职责就是服务查询晚个几秒写入问题不大。所以索引表的MemStore可以调大一点让更多数据在内存里攒批落盘减少小HFile的数量。同时可以考虑把索引表的DURABILITY设为SKIP_WAL牺牲一点写可靠性换写入性能——前提是你有补偿机制兜底。4.5 存量数据回填已有数据如何补索引索引上了线之后线上还有一堆存量数据没有索引这个问题逃不掉。回填的思路只有一条全量扫描主表生成索引RowKey批量写入索引表。如果存量数据量不大直接写个Java程序串行跑就行。但如果几个T的数据就必须用分布式手段了。我用Spark做过回填核心逻辑就是val ordersDF spark.read.format(org.apache.hadoop.hbase.spark) .option(hbase.table, orders) .option(hbase.columns.mapping, rowkey string, info:user_id string, ...) .load() ordersDF.foreachPartition { iter // 每条记录生成索引RowKey // 用HBase BulkLoad写入索引表 }回填过程还有两个我踩过的坑要提醒坑一回填和在线写入并发时会丢数据。回填程序在全量Scan主表的同时线上可能还有新的订单在写入。如果这两个动作没有协调好新写入的订单可能既没被回填线程扫到又已经被业务代码双写过了两边一叠加反而出幺蛾子。我的做法是回填期间关掉索引表双写只保留主表写入回填完成后再恢复双写或者给主表数据加一个batch_id标记回填结束后做一次增量对账。坑二回填代码写了索引但没有校验。回填结束后必须做数据校验否则你根本不知道索引表少没少数据。校验方式就是拿主表Scan出来的RowKey列表和索引表的RowKey列表做差集比对有差异就重跑对应批次。5. 实际项目中的坑与排查记录5.1 索引数据不一致了怎么排查这是二级索引上线后最常遇到的问题。现象通常是某些用户的订单在列表里查不到但点查订单详情能查到说明主表数据没问题问题出在索引表。我的排查步骤是这样的第一步确认差异范围。分别用索引Scan和主表Scan统计该用户的订单数量看是少了几条还是全部没有。全部没有说明索引写入链路整个断了比如消息队列积压、消费者死掉少了几条说明是零星写入失败大概率是补偿机制没兜住。第二步对比时间线。这时候就用到我在索引RowKey里冗余的时间戳了。把主表该用户最近订单的写入时间和索引表里最后一条记录的时间对比能快速判断是从哪个时间点开始出现索引缺失的。第三步翻补偿日志。补偿日志表里记录了每一条索引写入失败的操作看看失败原因是什么是超时还是写入被拒然后针对修复。第四步跑对账任务重建。就没有然后了直接触发一致性校验任务把差异数据补上。5.2 查询还是慢到底哪一步拖后腿有些时候明明索引表建好了查询还是慢。这时候要会拆解“两层RPC”里到底哪一层慢。如果慢在索引表Scan这一层大概率是索引表的预分区没做够或者RowKey设计有问题造成热点Region。我排查时会看HBase的Region监控页面如果某个Region的读请求量比其他Region高出一个数量级那就是热点需要重新设计索引RowKey的哈希前缀。如果慢在回表Get这一层大概率是批量Get的批次太大或者并发不够。我遇到过一次线上事故开发同事图省事把一个用户的上千个订单RowKey一次性丢给Table.get()结果单个RPC包太大直接超时。后来改成每批50个并发拉取延迟从3秒降到了200毫秒。还有一种情况就是索引表Scan和主表Get都不慢但整条接口RT还是高——那就要往HBase集群本身看了比如RegionServer的JVM GC时间异常、磁盘IO占用过高、热数据没进BlockCache。别一上来就怪索引方案先把你自己的瓶颈定位清楚。5.3 别把二级索引变成“写放大灾难”二级索引本质上是用写放大换读性能但写放大是有代价的。一张订单表如果同步维护了3张索引表主表每次写入总共要写4张表。4倍的写入量意味着4倍的RegionServer CPU消耗、4倍的磁盘IO、4倍的HFile compaction压力。我在真实项目里见过一个反面案例团队给一张日志表建了6个索引结果集群的写入性能从每秒8万条掉到不足2万条。后来砍掉3个冷门索引写入性能才恢复到6万左右。所以我在设计阶段就会给业务方立几条规矩索引必须有明确的高频查询场景支撑能复用组合索引的不要建多张单列索引能用覆盖索引减少回表的就不要纯做映射。如果你整个业务的写入QPS极高但查询需求又复杂不要硬撑着做同步多索引表。异步索引方案、外置ES在这类场景下反而更合适——写入链路保持简单索引用异步构建用延迟换吞吐。5.4 常见问题速查表最后整理一份问题速查表也算是对我自己这几年踩坑记录的一个沉淀症状可能原因解决办法索引查到RowKey但主表Get返回空主表和索引表数据不一致比对主表与索引表差异触发补偿重放索引表写入后查不到记录Scan未提交、HFile未合并、SKIP_WAL丢数据确认索引表可见性设置检查是否用了SKIP_WAL且无补偿索引表出现Region热点哈希前缀设计不均匀重设计RowKey加盐逻辑增加预分区数回表批量Get超时单批次条数过大控制在50条/批改用并发批量Get索引表HFile数量暴增MemStore频繁刷写调大MemStore阈值或者用bulkload批量刷对账任务耗时太长全量Scan主表开销太大改为增量对账抽样校验高峰期错峰执行这一张表对应的大多是生产环境中真正发生过的故障拿去对照排查基本能解决80%的问题。剩下的20%往往是多个问题叠加导致的需要你结合日志、监控和业务场景现场推演。我在多个项目里体会最深的一点是HBase二级索引不是一个“调包就能用的功能”它更像一个架构决策——选择自建、Phoenix还是外置搜索取决于你对一致性、复杂度、团队维护能力之间的平衡。如果团队没有专人长期维护这套索引体系优先考虑Phoenix这样的现成方案如果你决定自建那补偿机制、对账任务、索引表预分区这些细节一个都不能少代码反而是最简单的一步。最后再分享一个小技巧设计索引表RowKey时无论如何都要在RowKey或者列值里冗余一个写入时间戳字段。刚开始你可能觉得多余等出了数据不一致问题需要逐条对账的时候你会感谢当时多写的这一个小字段。
返回列表