0
  • 聊天消息
  • 系统消息
  • 评论与回复
登录后你可以
  • 下载海量资料
  • 学习在线课程
  • 观看技术视频
  • 写文章/发帖/加入社区
会员中心
创作中心

完善资料让更多小伙伴认识你,还能领取20积分哦,立即完善>

3天内不再提示

这么多技术框架,为什么选debezium?

jf_ro2CN3Fa 来源:稀土掘金 2023-08-30 16:40 次阅读

在一些小型项目当中,没有引入消息中间件,也不想引入,但有一些业务逻辑想要解耦异步,那怎么办呢?

我们的web项目,单独内网部署,由于大数据背景,公司消息中间件统一使用的kafka,在一些小项目上kafka就显得很笨重。 引入rocketmq或rabittmq也没必要。 事件或多线程也不适合。

具体一点的,之前对接的一个系统,一张记录表有10+以上的类型状态,新的需求是,针对每种状态做出对应的不同的操作。 之前写入这张记录表的时候,方式也是五花八门,有的是单条记录写入,有的是批量写入,有的调用了统一的service,有的呢直接调用了DAO层mapper直接写入。

所以想找到一个统一入口进行切入处理,就不行了。

这个时候就算引入消息队列,也需要在不同的业务方法里进行写入消息的操作。业务方也不太愿意配合改。

可以使用触发器,但它是属于上个时代的产物,槽点太多。(这里并不是完全不主张使用触发器,技术永远是为业务服务的,只要评估觉得可行,就可以使用)那么这个时候,CDC技术就可以粉墨登场了。

CDC(change data capture)数据更改捕获。常见的数据更改捕获都是通过数据库比如mysql的binlog来达到目的。

我们可以监控mysql binlog日志,当写入一条数据的时候,接收到数据变更日志,做出相应的操作。

这样的好处是,只需导入依赖,不额外引入组件,同时无需改动之前的代码。 两边完全解耦,互不干扰。

常见的CDC框架,比如,canal (非Camel)

canal [kə'næl],译意为水道/管道/沟渠,主要用途是基于 MySQL 数据库增量日志解析,提供增量数据订阅和消费 早期阿里巴巴因为杭州和美国双机房部署,存在跨机房同步的业务需求,实现方式主要是基于业务 trigger 获取增量变更。 从 2010 年开始,业务逐步尝试数据库日志解析获取增量变更进行同步,由此衍生出了大量的数据库增量订阅和消费业务。

它是基于日志增量订阅和消费的业务,包括

数据库镜像 数据库实时备份 索引构建和实时维护(拆分异构索引、倒排索引等) 业务 cache 刷新 带业务逻辑的增量数据处理

c88b4a38-3e3c-11ee-ac96-dac502259ad0.jpg

它的原理

canal 模拟 MySQL slave 的交互协议,伪装自己为 MySQL slave ,向 MySQL master 发送dump 协议

MySQL master 收到 dump 请求,开始推送 binary log 给 slave (即 canal );关注工众号:码猿技术专栏,回复关键词:1111 获取阿里内部Java性能调优手册!

canal 解析 binary log 对象(原始为 byte 流)

再比如,debezium(音同 dbzm 滴BZ姆)很多人可能不太了解. 包括databus,maxwell,flink cdc(大数据领域)等等,它们同属CDC捕获数据更改(change data capture)类的技术。

c8b79c1e-3e3c-11ee-ac96-dac502259ad0.jpg

为什么是debezium

这么多技术框架,为什么选debezium?

看起来很多。但一一排除下来就debezium和canal。

sqoop,kettle,datax之类的工具,属于前大数据时代的产物,地位类似于web领域的structs2。而且,它们基于查询而非binlog日志,其实不属于CDC。首先排除。

flink cdc是大数据领域的框架,一般web项目的数据量属于大材小用了。

同时databus,maxwell相对比较冷门,用得比较少。

最后不用canal的原因有以下几点。

canal需要安装,这违背了“如非必要,勿增实体”的原则。

canal只能对MYSQL进行CDC监控。有很大的局限性。

大数据领域非常流行的flink cdc(阿里团队主导)底层使用的也是debezium,而非同是阿里出品的canal。

debezium可借助kafka组件,将变动的数据发到kafka topic,后续的读取操作只需读取kafka,可有效减少数据库的读取压力。可保证一次语义,至少一次语义。 同时,也可基于内嵌部署模式,无需我们手动部署kafka集群,可满足”如非必要,勿增实体“的原则。

c8eacf4e-3e3c-11ee-ac96-dac502259ad0.jpg

Debezium是一个捕获数据更改(CDC)平台,并且利用Kafka和Kafka Connect实现了自己的持久性、可靠性和容错性。 每一个部署在Kafka Connect分布式的、可扩展的、容错性的服务中的connector监控一个上游数据库服务器,捕获所有的数据库更改, 然后记录到一个或者多个Kafka topic(通常一个数据库表对应一个kafka topic)。

Kafka确保所有这些数据更改事件都能够多副本并且总体上有序(Kafka只能保证一个topic的单个分区内有序),这样, 更多的客户端可以独立消费同样的数据更改事件而对上游数据库系统造成的影响降到很小(如果N个应用都直接去监控数据库更改,对数据库的压力为N, 而用debezium汇报数据库更改事件到kafka,所有的应用都去消费kafka中的消息,可以把对数据库的压力降到1)。

另外,客户端可以随时停止消费,然后重启, 从上次停止消费的地方接着消费。每个客户端可以自行决定他们是否需要exactly-once或者at-least-once消息交付语义保证, 并且所有的数据库或者表的更改事件是按照上游数据库发生的顺序被交付的。

c90670aa-3e3c-11ee-ac96-dac502259ad0.jpg

对于不需要或者不想要这种容错级别、性能、可扩展性、可靠性的应用,他们可以使用内嵌的Debezium connector引擎来直接在应用内部运行connector。 这种应用仍需要消费数据库更改事件,但更希望connector直接传递给它,而不是持久化到Kafka里。

简介

Debezium是一个开源项目,为捕获数据更改(change data capture,CDC)提供了一个低延迟的流式处理平台。你可以安装并且配置Debezium去监控你的数据库,然后你的应用就可以消费对数据库的每一个行级别(row-level)的更改。只有已提交的更改才是可见的,所以你的应用不用担心事务(transaction)或者更改被回滚(roll back)。Debezium为所有的数据库更改事件提供了一个统一的模型,所以你的应用不用担心每一种数据库管理系统的错综复杂性。另外,由于Debezium用持久化的、有副本备份的日志来记录数据库数据变化的历史,因此,你的应用可以随时停止再重启,而不会错过它停止运行时发生的事件,保证了所有的事件都能被正确地、完全地处理掉。

监控数据库,并且在数据变动的时候获得通知一直是很复杂的事情。关系型数据库的触发器可以做到,但是只对特定的数据库有效,而且通常只能更新数据库内的状态(无法和外部的进程通信)。一些数据库提供了监控数据变动的API或者框架,但是没有一个标准,每种数据库的实现方式都是不同的,并且需要大量特定的知识和理解特定的代码才能运用。确保以相同的顺序查看和处理所有更改,同时最小化影响数据库仍然非常具有挑战性。

Debezium提供了模块为你做这些复杂的工作。一些模块是通用的,并且能够适用多种数据库管理系统,但在功能和性能方面仍有一些限制。另一些模块是为特定的数据库管理系统定制的,所以他们通常可以更多地利用数据库系统本身的特性来提供更多功能。

github官网上罗列的一些典型应用场景

缓存失效(Cache invalidation) 经典问题 Redis与MySQL双写一致性如何保证?Debezium利用kafka单分区的有序性(忽略mysql binlog本身可能的延迟和乱序),可完全解决此问题。 在缓存中缓存的条目(entry)在源头被更改或者被删除的时候立即让缓存中的条目失效。 如果缓存在一个独立的进程中运行(例如Redis,Memcache,Infinispan或者其他的),那么简单的缓存失效逻辑可以放在独立的进程或服务中, 从而简化主应用的逻辑。在一些场景中,缓存失效逻辑可以更复杂一点,让它利用更改事件中的更新数据去更新缓存中受影响的条目。

简化单体应用(Simplifying monolithic applications) 许多应用更新数据库,然后在数据库中的更改被提交后,做一些额外的工作:更新搜索索引,更新缓存,发送通知,运行业务逻辑,等等。 这种情况通常称为双写(dual-writes),因为应用没有在一个事务内写多个系统。这样不仅应用逻辑复杂难以维护, 而且双写容易丢失数据或者在一些系统更新成功而另一些系统没有更新成功的时候造成不同系统之间的状态不一致。使用捕获更改数据技术(change data capture,CDC), 在源数据库的数据更改提交后,这些额外的工作可以被放在独立的线程或者进程(服务)中完成。这种实现方式的容错性更好,不会丢失事件,容易扩展,并且更容易支持升级。

共享数据库(Sharing databases) 当多个应用共用同一个数据库的时候,一个应用提交的更改通常要被另一个应用感知到。一种实现方式是使用消息总线, 尽管非事务性(non-transactional)的消息总线总会受上面提到的双写(dual-writes)影响。但是,另一种实现方式,即Debezium,变得很直接:每个应用可以直接监控数据库的更改,并且响应更改。

数据集成(Data integration) 数据通常被存储在多个地方,尤其是当数据被用于不同的目的的时候,会有不同的形式。保持多系统的同步是很有挑战性的, 但是可以通过使用Debezium加上简单的事件处理逻辑来实现简单的ETL类型的解决方案。

命令查询职责分离(CQRS) 在命令查询职责分离 Command Query Responsibility Separation (CQRS) 架构模式中,更新数据使用了一种数据模型, 读数据使用了一种或者多种数据模型。由于数据更改被记录在更新侧(update-side),这些更改将被处理以更新各种读展示。 所以CQRS应用通常更复杂,尤其是他们需要保证可靠性和全序(totally-ordered)处理。Debezium和CDC可以使这种方式更可行: 写操作被正常记录,但是Debezium捕获数据更改,并且持久化到全序流里,然后供那些需要异步更新只读视图的服务消费。 写侧(write-side)表可以表示面向领域的实体(domain-oriented entities),或者当CQRS和 Event Sourcing 结合的时候,写侧表仅仅用做追加操作命令事件的日志。

springboot 整合 Debezium

依赖

1.7.0.Final
8.0.26


mysql
mysql-connector-java
${mysql.connector.version}
runtime


io.debezium
debezium-api
${debezium.version}


io.debezium
debezium-embedded
${debezium.version}


io.debezium
debezium-connector-mysql
${debezium.version}


mysql
mysql-connector-java



注意debezium版本为1.7.0.Final,对应mysql驱动为8.0.26,低于这个版本会报兼容错误。

配置

相应的配置

debezium.datasource.hostname=localhost
debezium.datasource.port=3306
debezium.datasource.user=root
debezium.datasource.password=123456
debezium.datasource.tableWhitelist=test.test
debezium.datasource.storageFile=E:/debezium/test/offsets/offset.dat
debezium.datasource.historyFile=E:/debezium/test/history/custom-file-db-history.dat
debezium.datasource.flushInterval=10000
debezium.datasource.serverId=1
debezium.datasource.serverName=name-1

然后进行配置初始化。

主要的配置项:

connector.class

监控的数据库类型,这里选mysql。

offset.storage

选择FileOffsetBackingStore时,意思把读取进度存到本地文件,因为我们不用kafka,当使用kafka时,选KafkaOffsetBackingStore 。

offset.storage.file.filename

存放读取进度的本地文件地址。

offset.flush.interval.ms

读取进度刷新保存频率,默认1分钟。如果不依赖kafka的话,应该就没有exactly once只读取一次语义,应该是至少读取一次。意味着可能重复读取。如果web容器挂了,最新的读取进度没有刷新到文件里,下次重启时,就会重复读取binlog。

table.whitelist

监控的表名白名单,建议设置此值,只监控这些表的binlog。

database.whitelist

监控的数据库白名单,如果选此值,会忽略table.whitelist,然后监控此db下所有表的binlog。

/**
*@className:MysqlConfig
*@author:nyp
*@description:TODO
*@date:2023/8/713:53
*@version:1.0
*/
@Configuration
@ConfigurationProperties(prefix="debezium.datasource")
@Data
publicclassMysqlBinlogConfig{

privateStringhostname;
privateStringport;
privateStringuser;
privateStringpassword;
privateStringtableWhitelist;
privateStringstorageFile;
privateStringhistoryFile;
privateLongflushInterval;
privateStringserverId;
privateStringserverName;

@Bean
publicio.debezium.config.ConfigurationMysqlBinlogConfig()throwsException{
checkFile();
io.debezium.config.Configurationconfiguration=io.debezium.config.Configuration.create()
.with("name","mysql_connector")
.with("connector.class",MySqlConnector.class)
//.with("offset.storage",KafkaOffsetBackingStore.class)
.with("offset.storage",FileOffsetBackingStore.class)
.with("offset.storage.file.filename",storageFile)
.with("offset.flush.interval.ms",flushInterval)
.with("database.history",FileDatabaseHistory.class.getName())
.with("database.history.file.filename",historyFile)
.with("snapshot.mode","Schema_only")
.with("database.server.id",serverId)
.with("database.server.name",serverName)
.with("database.hostname",hostname)
//.with("database.dbname",dbname)
.with("database.port",port)
.with("database.user",user)
.with("database.password",password)
//.with("database.whitelist","test")
.with("table.whitelist",tableWhitelist)
.build();
returnconfiguration;

}

privatevoidcheckFile()throwsIOException{
Stringdir=storageFile.substring(0,storageFile.lastIndexOf("/"));
FiledirFile=newFile(dir);
if(!dirFile.exists()){
dirFile.mkdirs();
}
Filefile=newFile(storageFile);
if(!file.exists()){
file.createNewFile();
}
}
}

snapshot.mode 快照模式,指定连接器启动时运行快照的条件。可能的设置有:

initial 只有在没有为逻辑服务器名记录偏移量时,连接器才运行快照。

When_needed 当连接器认为有必要时,它会在启动时运行快照。也就是说,当没有可用的偏移量时,或者当先前记录的偏移量指定了服务器中不可用的binlog位置或GTID时。

Never 连接器从不使用快照。在第一次使用逻辑服务器名启动时,连接器从binlog的开头读取。谨慎配置此行为。只有当binlog保证包含数据库的整个历史记录时,它才有效。

Schema_only 连接器运行模式而不是数据的快照。当您不需要主题包含数据的一致快照,而只需要主题包含自连接器启动以来的更改时,此设置非常有用。

Schema_only_recovery 这是已经捕获更改的连接器的恢复设置。当您重新启动连接器时,此设置允许恢复损坏或丢失的数据库历史主题。您可以定期将其设置为“清理”意外增长的数据库历史主题。数据库历史主题需要无限保留。

database.server.id

伪装成slave的Debezium服务的id,自定义,有多个Debezium服务不能重复,如果重复的话会报以下异常。

io.debezium.DebeziumException:Aslavewiththesameserver_uuid/server_idasthisslavehasconnectedtothemaster;thefirstevent'binlog.000013'at46647257,thelasteventreadfrom'./binlog.000013'at125,thelastbytereadfrom'./binlog.000013'at46647257.Errorcode:1236;SQLSTATE:HY000.
atio.debezium.connector.mysql.MySqlStreamingChangeEventSource.wrap(MySqlStreamingChangeEventSource.java:1167)
atio.debezium.connector.mysql.MySqlStreamingChangeEventSource$ReaderThreadLifecycleListener.onCommunicationFailure(MySqlStreamingChangeEventSource.java:1212)
atcom.github.shyiko.mysql.binlog.BinaryLogClient.listenForEventPackets(BinaryLogClient.java:980)
atcom.github.shyiko.mysql.binlog.BinaryLogClient.connect(BinaryLogClient.java:599)
atcom.github.shyiko.mysql.binlog.BinaryLogClient$7.run(BinaryLogClient.java:857)
atjava.lang.Thread.run(Thread.java:750)
Causedby:com.github.shyiko.mysql.binlog.network.ServerException:Aslavewiththesameserver_uuid/server_idasthisslavehasconnectedtothemaster;thefirstevent'binlog.000013'at46647257,thelasteventreadfrom'./binlog.000013'at125,thelastbytereadfrom'./binlog.000013'at46647257.
atcom.github.shyiko.mysql.binlog.BinaryLogClient.listenForEventPackets(BinaryLogClient.java:944)
...3commonframesomitted

监听

配置监听服务

/**
*@projectName:test
*@package:com.test.config
*@className:MysqlBinlogListener
*@author:nyp
*@description:TODO
*@date:2023/8/713:56
*@version:1.0
*/
@Component
@Slf4j
publicclassMysqlBinlogListener{

@Resource
privateExecutortaskExecutor;

privatefinalList>>engineList=newArrayList<>();

privateMysqlBinlogListener(@Qualifier("mysqlConnector")Configurationconfiguration){
this.engineList.add(DebeziumEngine.create(Json.class)
.using(configuration.asProperties())
.notifying(record->receiveChangeEvent(record.value()))
.build());
}

privatevoidreceiveChangeEvent(Stringvalue){
if(Objects.nonNull(value)){
Mappayload=getPayload(value);
Stringop=JSON.parseObject(JSON.toJSONString(payload.get("op")),String.class);
if(!(StringUtils.isBlank(op)||Envelope.Operation.READ.equals(op))){
ChangeDatachangeData=getChangeData(payload);
log.info("changeData="+changeData);
}
}
}

@PostConstruct
privatevoidstart(){
for(DebeziumEngine>engine:engineList){
taskExecutor.execute(engine);
}
}

@PreDestroy
privatevoidstop(){
for(DebeziumEngine>engine:engineList){
if(engine!=null){
try{
engine.close();
}catch(IOExceptione){
log.error("",e);
}
}
}
}


publicstaticMapgetPayload(Stringvalue){
Mapmap=JSON.parseObject(value,Map.class);
Mappayload=JSON.parseObject(JSON.toJSONString(map.get("payload")),Map.class);
returnpayload;
}

publicstaticChangeDatagetChangeData(Mappayload){
Mapsource=JSON.parseObject(JSON.toJSONString(payload.get("source")),Map.class);
returnChangeData.builder()
.op(payload.get("op").toString())
.table(source.get("table").toString())
.after(JSON.parseObject(JSON.toJSONString(payload.get("after")),Map.class))
.source(JSON.parseObject(JSON.toJSONString(payload.get("source")),Map.class))
.before(JSON.parseObject(JSON.toJSONString(payload.get("before")),Map.class))
.build();
}

@Data
@Builder
publicstaticclassChangeData{
/**
*更改前数据
*/
privateMapafter;
privateMapsource;
/**
*更改后数据
*/
privateMapbefore;
/**
*更改的表名
*/
privateStringtable;
/**
*操作类型,枚举Envelope.Operation
*/
privateStringop;
}

}

将监听到的binlog日志封装为ChangeData对象,包括表名,更改前后的数据,

以及操作类型

READ("r"),
CREATE("c"),
UPDATE("u"),
DELETE("d"),
TRUNCATE("t");

测试

update操作输出

MysqlListener.ChangeData(after={
name=SuzukiMio2,
id=1
},source={
file=binlog.000013,
connector=mysql,
pos=42587833,
name=test-1,
row=0,
server_id=1,
version=1.7.0.Final,
ts_ms=1691458956000,
snapshot=false,
db=test
table=test
},before={
name=SuzukiMio,
id=1
},table=test,op=u)
data={
name=SuzukiMio2,
id=1
}

新增操作输出

MysqlListener.ChangeData(after={
name=王五,
id=0
},source={
file=binlog.000013,
connector=mysql,
pos=42588175,
name=test-1,
row=0,
server_id=1,
version=1.7.0.Final,
ts_ms=1691459066000,
snapshot=false,
db=test,
table=test
},before=null,table=test,op=c)

删除操作输出

MysqlListener.ChangeData(after=null,source={
file=binlog.000013,
connector=mysql,
pos=42588959,
name=test-1,
row=0,
server_id=1,
version=1.7.0.Final,
ts_ms=1691459104000,
snapshot=false,
db=test
table=test
},before={
name=王五,
id=0
},table=test,op=d)

我们之前配置的保存读取进度的文件storageFile,类似于kafka的偏移量,记录的内容如下:

c92ad81e-3e3c-11ee-ac96-dac502259ad0.jpg

停止服务,对数据库进行操作,再次重启,会根据进度重新读取。

小结

本文介绍了debezium,更多的时候,我们一谈到CDC,第一想到的是大量数据同步的工具。 但其实也可以利用其数据变更捕获的特性,来达到一部份消息队列的作用。 但其毕竟不能完全替代消息队列。大家理性看待与选择。

本文的重点在介绍一种思路,具体的某项技术反而不那么重要。






审核编辑:刘清

声明:本文内容及配图由入驻作者撰写或者入驻合作网站授权转载。文章观点仅代表作者本人,不代表电子发烧友网立场。文章及其配图仅供工程师学习之用,如有内容侵权或者其他违规问题,请联系本站处理。 举报投诉
  • JAVA语言
    +关注

    关注

    0

    文章

    138

    浏览量

    20070
  • MYSQL数据库
    +关注

    关注

    0

    文章

    95

    浏览量

    9378
  • CDC技术
    +关注

    关注

    0

    文章

    9

    浏览量

    6850

原文标题:不想引入MQ?试试debezium

文章出处:【微信号:芋道源码,微信公众号:芋道源码】欢迎添加关注!文章转载请注明出处。

收藏 人收藏

    评论

    相关推荐

    看了这么多论坛 还是这个论坛好啊...

    其他的论坛没有这么多人,没有这么快的更新.... 你们觉得呢?
    发表于 04-17 10:52

    这里的那些是程序,要弄成word 文档的,新手没做过这么多程序

    这里的那些是程序,要弄成word 文档的,新手没做过这么多程序
    发表于 04-15 23:29

    为什么roll一上电就飘了这么多

    放在水平位置上校准之后pitch还是挺准的,roll一上电就飘了这么多,为什么??
    发表于 07-04 04:35

    为什么OLED初始化的时候要这么多命令?

    void OLED_Init(void)这个函数里面要写的命令好多啊,不知道为什么初始化的时候要这么多命令啊??求解具体在数据手册哪几页
    发表于 09-18 23:58

    QFP PZP封装的thermal pad上有49个过孔,一定要这么多吗?

    第一次使用TMS320F28377S,有两个问题请教:1、QFP PZP封装的thermal pad上有49个过孔,一定要这么多吗?可不可以删掉一些?2、如果不能删掉,能不能把这些过孔改成盲孔,只通到内电层,不要通孔?谢谢了,先。
    发表于 07-23 10:21

    什么是VBA?为什么这么多软件支持VBA?

    什么是VBA?什么是VBS?二者有什么不同?为什么这么多软件支持VBA?
    发表于 07-02 06:35

    怎么记住这么多代码格式?

    我记得刚开始接触编程的时候,觉得太难了。也很好奇,写代码的那些人也太厉害了吧?全是英文的,他们的英文水平一定很好吧?他们是怎么记住这么多代码格式的?而且错了一个标点符号,整个程序都会有影响。一个程序
    发表于 07-15 08:56

    电流密度和电荷密度两个的表达式怎么差这么多

    电流密度是什么?电荷密度是什么?电流密度和电荷密度两个的表达式怎么差这么多
    发表于 09-28 09:36

    为什么要搞这么多架构

    问题:为什么要搞这么多架构?webrtc虽然是一项主要使用p2p的实时通讯技术,本应该是无中心化节点的,但是在一些大型多人通讯场景,如果都使用端对端直连,端上会遇到很带宽和性能的问题,所以就有了下图
    发表于 10-29 06:05

    STM32系统为什么要有时钟?为什么有这么多个时钟源

    STM32系统为什么要有时钟?为什么有这么多个时钟源?STM32系统时钟的框架是由哪些部分组成的?
    发表于 11-22 07:00

    为什么有这么多编程语言呢

    关注+星标公众号,不错过精彩内容编排|strongerHuang微信公众号 |嵌入式专栏有很多初学者都会问:我到底是该学C语言,还是学C++,或者JAVA呢?为什么有这么多编程语言呢...
    发表于 01-12 06:34

    安卓8.0最新消息:安卓8.0初体验,竟然这么流畅还有这么多黑科技功能

    安卓8.0初体验,竟然这么流畅还有这么多黑科技功能
    发表于 04-13 09:00 3620次阅读

    小垫圈,里面还有这么多名堂……

    小垫圈,里面还有这么多名堂……
    的头像 发表于 07-02 11:40 2634次阅读

    AC-DC电源适配器还有这么多门道?看完才知道

    AC-DC电源适配器还有这么多门道?看完才知道
    的头像 发表于 07-02 11:40 6091次阅读

    硬件电路设计有这么多坑,如何少走弯路?看大牛怎么说

    硬件电路设计有这么多坑,如何少走弯路?看大牛怎么说
    的头像 发表于 11-27 17:34 602次阅读