上篇文章讲述了Canal的组成部分以及工作原理,包括使用场景,本文主要从CanalServer的部署,CanalClient客户端的订阅消费等方面实战,更好的记录工作中如何使用Canal的。
一:环境要求
机器 | 应用 | |
192.168.111.128 | mysql、zookeeper、kafka | |
192.168.111.130 | mysql、zookeeper、kafka、kafka-eagle | |
192.168.111.131 | mysql、zookeeper、kafka、zkUi |
在进行canal实战之前,需要依赖一些基础环境的安装,如 mysql安装、kafka集群安装、zookeeper集群安装、zk管理端zkUI的安装、kafka管理端kafka-eagle安装,本文这里不在描述,可以参考对应的链接进行安装
Mysql要求
a. 当前的canal开源版本支持5.7及以下的版本(阿里内部mysql 5.7.13, 5.6.10, mysql 5.5.18和5.1.40/48),ps. mysql4.x版本没有经过严格测试,理论上是可以兼容
b. canal的原理是基于mysql binlog技术,所以这里一定需要开启mysql的binlog写入功能,并且配置binlog模式为row.
[mysqld]
log-bin=mysql-bin #添加这一行就ok
binlog-format=ROW #选择row模式
server_id=1 #配置mysql replaction需要定义,不能和canal的slaveId重复
MySQL binlog日志格式有 Row、Statement、Mixed三种
1. Row
日志中会记录成每一行数据被修改的形式,然后在 slave 端再对相同的数据进行修改
优点:row 的日志内容会非常清楚的记录下每一行数据修改的细节,非常容易理解。而且不会出现某些特定情况下的存储过程或 function ,以及 trigger 的调用和触发无法被正确复制的问题。
缺点:在 row 模式下,所有的执行的语句当记录到日志中的时候,都将以每行记录的修改来记录,这样可能会产生大量的日志内容
2. Statement
每一条会修改数据的 SQL 都会记录到 master 的 bin-log 中。slave 在复制的时候 SQL 进程会解析成和原来 master 端执行过的相同的 SQL 再次执行。
优点:在 statement 模式下,首先就是解决了 row 模式的缺点,不需要记录每一行数据的变化,减少了 bin-log 日志量,节省 I/O 以及存储资源,提高性能。因为他只需要记录在 master 上所执行的语句的细节,以及执行语句时候的上下文的信息。
缺点:在 statement 模式下,由于他是记录的执行语句,所以,为了让这些语句在 slave 端也能正确执行,那么他还必须记录每条语句在执行的时候的一些相关信息,也就是上下文信息,以保证所有语句在 slave 端杯执行的时候能够得到和在 master 端执行时候相同的结果;在 statement 中,目前已经发现的就有不少情况会造成 MySQL 的复制出现问题,主要是修改数据的时候使用了某些特定的函数或者功能的时候会出现。
3. Mixed
从 5.1.8 版本开始,MySQL 提供了除 Statement 和 Row 之外的第三种复制模式:Mixed,实际上就是前两种模式的结合。在 Mixed 模式下,MySQL 会根据执行的每一条具体的 SQL 语句来区分对待记录的日志形式,也就是在 statement 和 row 之间选择一种。
新版本中的 statment 还是和以前一样,仅仅记录执行的语句。而新版本的 MySQL 中对 row 模式也被做了优化,并不是所有的修改都会以 row 模式来记录,比如遇到表结构变更的时候就会以 statement 模式来记录,如果 SQL 语句确实就是 update 或者 delete 等修改数据的语句,那么还是会记录所有行的变更。
c.数据库重启后, 简单测试 my.cnf
配置是否生效:
mysql> show variables like 'binlog_format';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| binlog_format | ROW |
+---------------+-------+
mysql> show variables like 'log_bin';
+---------------+-------+
| Variable_name | Value |
+---------------+-------+
| log_bin | ON |
+---------------+-------+
d. canal的原理是模拟自己为mysql slave,所以这里一定需要做为mysql slave的相关权限
CREATE USER canal IDENTIFIED BY 'root';
GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'root'@'%';
-- GRANT ALL PRIVILEGES ON *.* TO 'root'@'%' ;
FLUSH PRIVILEGES;
二:CanalServer部署
1、下载
目前最新的版本是1.1.5,点击此处进行管网下载对应的版本
下载好后上传到对应的服务器上(192.168.111.128),并解压好。
进入conf目录下
canal_local.properties:在使用canalAdmin时,只需配置此配置文件即可,在下篇文件讲解
canal.properties:canalServer的配置文件
example:默认的instance目录,可以自定义,例如 example1 example2
instance.properties:每个instance对应的配置文件,主要配置数据库连接信息
spring:canal组件的配置定义,具体说明可见上篇文章中的配置信息
2、配置
canal.properties介绍:
1. instance列表定义 (列出当前server上有多少个instance,每个instance的加载方式是spring/manager等)
参数名字 | 参数说明 | 默认值 |
canal.destinations | 当前server上部署的instance列表 | 无 |
canal.conf.dir | conf/目录所在的路径 | ../conf |
canal.auto.scan | 开启instance自动扫描 如果配置为true,canal.conf.dir目录下的instance配置变化会自动触发: a. instance目录新增: 触发instance配置载入,lazy为true时则自动启动 b. instance目录删除:卸载对应instance配置,如已启动则进行关闭 c. instance.properties文件变化:reload instance配置,如已启动自动进行重启操作 | true |
canal.auto.scan.interval | instance自动扫描的间隔时间,单位秒 | 5 |
canal.instance.global.mode | 全局配置加载方式 | spring |
canal.instance.global.lazy | 全局lazy模式 | false |
canal.instance.global.manager.address | 全局的manager配置方式的链接信息 | 无 |
canal.instance.global.spring.xml | 全局的spring配置方式的组件文件 | classpath:spring/memory-instance.xml (spring目录相对于canal.conf.dir) |
canal.instance.example.mode canal.instance.example.lazy canal.instance.example.spring.xml ..... | instance级别的配置定义,如有配置,会自动覆盖全局配置定义模式 命名规则:canal.instance.{name}.xxx | 无 |
2. common参数定义,比如可以将instance.properties的公用参数,抽取放置到这里,这样每个instance启动的时候就可以共享. 【instance.properties配置定义优先级高于canal.properties】
参数名字 | 参数说明 | 默认值 |
canal.ip | canal server绑定的本地IP信息,如果不配置,默认选择一个本机IP进行启动服务 | 无 |
canal.register.ip | canal server注册到外部zookeeper、admin的ip信息 (针对docker的外部可见ip) | 无 |
canal.port | canal server提供socket服务的端口 | 11111 |
canal.zkServers | canal server链接zookeeper集群的链接信息 例子:10.20.144.22:2181,10.20.144.51:2181 | 无 |
anal.serverMode | canalServer订阅消费模型: tcp, kafka, rocketMQ, rabbitMQ | tcp |
canal.zookeeper.flush.period | canal持久化数据到zookeeper上的更新频率,单位毫秒 | 1000 |
canal.instance.memory.batch.mode | canal内存store中数据缓存模式 1. ITEMSIZE : 根据buffer.size进行限制,只限制记录的数量 2. MEMSIZE : 根据buffer.size * buffer.memunit的大小,限制缓存记录的大小 | MEMSIZE |
canal.instance.memory.buffer.size | canal内存store中可缓存buffer记录数,需要为2的指数 | 16384 |
canal.instance.memory.buffer.memunit | 内存记录的单位大小,默认1KB,和buffer.size组合决定最终的内存使用大小 | 1024 |
canal.instance.transactionn.size | 最大事务完整解析的长度支持 超过该长度后,一个事务可能会被拆分成多次提交到canal store中,无法保证事务的完整可见性 | 1024 |
canal.instance.fallbackIntervalInSeconds | canal发生mysql切换时,在新的mysql库上查找binlog时需要往前查找的时间,单位秒 说明:mysql主备库可能存在解析延迟或者时钟不统一,需要回退一段时间,保证数据不丢 | 60 |
canal.instance.detecting.enable | 是否开启心跳检查 | false |
canal.instance.detecting.sql | 心跳检查sql | insert into retl.xdual values(1,now()) on duplicate key update x=now() |
canal.instance.detecting.interval.time | 心跳检查频率,单位秒 | 3 |
canal.instance.detecting.retry.threshold | 心跳检查失败重试次数 | 3 |
canal.instance.detecting.heartbeatHaEnable | 心跳检查失败后,是否开启自动mysql自动切换 说明:比如心跳检查失败超过阀值后,如果该配置为true,canal就会自动链到mysql备库获取binlog数据 | false |
canal.instance.filter.query.dcl | 是否忽略dcl语句 | false |
canal.instance.filter.query.dml | 是否忽略dml语句 (mysql5.6之后,在row模式下每条DML语句也会记录SQL到binlog中,可参考MySQL文档) | false |
canal.instance.filter.query.ddl | 是否忽略ddl语句 | false |
canal.instance.filter.table.error | 是否忽略binlog表结构获取失败的异常 (主要解决回溯binlog时,对应表已被删除或者表结构和binlog不一致的情况) | false |
canal.instance.filter.rows | 是否dml的数据变更事件 (主要针对用户只订阅ddl/dcl的操作) | false |
canal.instance.filter.transaction.entry | 是否忽略事务头和尾,比如针对写入kakfa的消息时,不需要写入TransactionBegin/Transactionend事件 | false |
canal.instance.get.ddl.isolation | ddl语句是否单独一个batch返回 (比如下游dml/ddl如果做batch内无序并发处理,会导致结构不一致) | false |
canal.instance.parser.parallel | 是否开启binlog并行解析模式 (串行解析资源占用少,但性能有瓶颈, 并行解析可以提升近2.5倍+) | true |
canal.instance.parser.parallelBufferSize | binlog并行解析的异步ringbuffer队列 (必须为2的指数) | 256 |
canal.instance.rds.accesskey | aliyun账号的ak信息 (如果不需要在本地binlog超过18小时被清理后自动下载oss上的binlog,可以忽略该值 | 无 |
canal.instance.rds.secretkey | aliyun账号的sk信息 (如果不需要在本地binlog超过18小时被清理后自动下载oss上的binlog,可以忽略该值) | 无 |
canal.admin.manager | canal链接canal-admin的地址 (v1.1.4新增) | 无 |
canal.admin.port | admin管理指令链接端口 (v1.1.4新增) | 11110 |
canal.admin.user | admin管理指令链接的ACL配置 (v1.1.4新增) | admin |
canal.admin.passwd | admin管理指令链接的ACL配置 (v1.1.4新增) | 密码默认值为admin的密文 |
canal.user | canal数据端口订阅的ACL配置 (v1.1.4新增) 如果为空,代表不开启 | 无 |
canal.passwd | canal数据端口订阅的ACL配置 (v1.1.4新增) 如果为空,代表不开启 | 无 |
instance.properties介绍:
a. 在canal.properties定义了canal.destinations后,需要在canal.conf.dir对应的目录下建立同名的文件
比如:
canal.destinations = example1,example2
这时需要创建example1和example2两个目录,每个目录里各自有一份instance.properties.
b. 如果canal.properties未定义instance列表,但开启了canal.auto.scan时
- server第一次启动时,会自动扫描conf目录下,将文件名做为instance name,启动对应的instance
- server运行过程中,会根据canal.auto.scan.interval定义的频率,进行扫描
1. 发现目录有新增,启动新的instance
2. 发现目录有删除,关闭老的instance
3. 发现对应目录的instance.properties有变化,重启instance
instance.properties参数列表:
参数名字 | 参数说明 | 默认值 |
canal.instance.mysql.slaveId | mysql集群配置中的serverId概念,需要保证和当前mysql集群中id唯一 (v1.1.x版本之后canal会自动生成,不需要手工指定) | 无 |
canal.instance.master.address | mysql主库链接地址 | 127.0.0.1:3306 |
canal.instance.master.journal.name | mysql主库链接时起始的binlog文件 | 无 |
canal.instance.master.position | mysql主库链接时起始的binlog偏移量 | 无 |
canal.instance.master.timestamp | mysql主库链接时起始的binlog的时间戳 | 无 |
canal.instance.gtidon | 是否启用mysql gtid的订阅模式 | false |
canal.instance.master.gtid | mysql主库链接时对应的gtid位点 | 无 |
canal.instance.dbUsername | mysql数据库帐号 | canal |
canal.instance.dbPassword | mysql数据库密码 | canal |
canal.instance.defaultDatabaseName | mysql链接时默认schema | |
canal.instance.connectionCharset | mysql 数据解析编码 | UTF-8 |
canal.instance.filter.regex | mysql 数据解析关注的表,Perl正则表达式. 多个正则之间以逗号(,)分隔,转义符需要双斜杠(\\)
1. 所有表:.* or .*\\..* 5. 多个规则组合使用:canal\\..*,mysql.test1,mysql.test2 (逗号分隔) | .*\\..* |
canal.instance.filter.black.regex | mysql 数据解析表的黑名单,表达式规则见白名单的规则 | 无 |
canal.instance.rds.instanceId | aliyun rds对应的实例id信息 (如果不需要在本地binlog超过18小时被清理后自动下载oss上的binlog,可以忽略该值) | 无 |
只需要修改部分配置即可
canal.properties修改以下参数
canal.user = canal 客户端连接canal时的用户名
canal.passwd = 518BF4518E6412D1D0D06D38677D861B29F562CF 客户端连接canal时的密码,注意这里用的是密文,可通过sql语句获取 ,去掉结果前缀的*号就是对应的密码
select PASSWORD('123456')
canal.serverMode = tcp 默认tcp,客户端使用ip+port连接的模式,如果使用kafaka 则修改成kafka即可
canal.zkServers = 192.168.111.128:2181,192.168.111.130:2181,192.168.111.131:2181 zookeeper的地址
instance.properties修改以下参数
canal.instance.master.address=192.168.111.128:3306 数据库地址
canal.instance.dbUsername=root 账户
canal.instance.dbPassword=123456 密码
canal.instance.filter.regex=.*\\..* 监听的表
3、启动
进入bin目录下 ./startup.sh
启动后,会在logs目录下生成对应的日志文件
canal日志和instance日志
三:CanalClient连接
在官方代码中也提供客户端连接服务端的几种方式example
具体代码如下
配置文件:
canal:
host: 192.168.111.128
port: 11111
destination: 'example'
userName: canal
password: ribbon
batchSize: 1
@Configuration
public class CanalConnectorConfig {
@Autowired
private CanalProperties canalProperties;
@Bean
public CanalConnector initConnector(){
//目前canal server上的一个instance只能有一个client消费, 当有多个client消费时,会有bug
CanalConnector connector = CanalConnectors.newSingleConnector(new InetSocketAddress(canalProperties.getHost(),
canalProperties.getPort()),
canalProperties.getDestination(),
canalProperties.getUserName(),
canalProperties.getPassword());
connector.connect();
connector.subscribe();
connector.rollback();
return connector;
}
}
package com.ribbon.project.demo.canal.pojo;
import com.alibaba.otter.canal.protocol.CanalEntry;
import lombok.Data;
import lombok.ToString;
/**
* mysql事件变换行为
*/
@Data
@ToString
public class EventInfo {
/**
* 行数据
*/
private CanalEntry.RowData rowData;
/**
* 数据库信息
*/
private TableInfo tableInfo;
/**
* mysql里事件类型
*/
private CanalEntry.EventType eventType;
public EventInfo(CanalEntry.RowData rowData, String schemaName, String tableName, CanalEntry.EventType eventType) {
this.rowData = rowData;
this.tableInfo = new TableInfo(schemaName,tableName);
this.eventType = eventType;
}
}
package com.ribbon.project.demo.canal.pojo;
import lombok.Getter;
import lombok.ToString;
import java.io.Serializable;
/**
* 数据库信息
*/
@Getter
@ToString
public class TableInfo implements Serializable {
/**
* 数据库
*/
private String schemaName;
/**
* 表名
*/
private String tableName;
public TableInfo(String schemaName, String tableName) {
this.schemaName = schemaName;
this.tableName = tableName;
}
}
package com.ribbon.project.demo.canal.event;
import com.alibaba.otter.canal.client.CanalConnector;
import com.alibaba.otter.canal.protocol.CanalEntry;
import com.alibaba.otter.canal.protocol.Message;
import com.ribbon.project.demo.canal.pojo.EventInfo;
import com.ribbon.project.demo.canal.properties.CanalProperties;
import com.ribbon.project.demo.canal.service.EventHandlerService;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.collections4.CollectionUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.util.ArrayList;
import java.util.List;
/**
* 数据同步
*/
@Slf4j
@Component
public class DataSyncApp {
@Autowired
private CanalConnector canalConnector;
@Autowired
private CanalProperties canalProperties;
//状态位
private boolean RUNNING = true;
@Autowired
private EventHandlerService eventHandlerServicel;
/**
* 启动
*/
public void work(){
log.info("启动监听canal....................");
while (RUNNING){
// 获取指定数量的数据
canalConnector.subscribe();
Message message = canalConnector.getWithoutAck(canalProperties.getBatchSize());
long batchId = message.getId();
int size = message.getEntries().size();
if (!(batchId == -1 || size == 0)) {
log.info("处理 batchId 为 {} 的数据",batchId);
log.info("处理数据长度 {} ",size);
List<EventInfo> eventInfos = this.createEventInfos(message.getEntries());
if(CollectionUtils.isEmpty(eventInfos)){
log.info("没有事件产生, 无需处理 .................................");
continue;
}
//同步处理, 处理完后ack(也可以放在一个Queue里按个顺序ack, 处理耗时不大,场景适合,如遇到处理耗时多的场景, 可以考虑放入MQ队列里,削峰消费处理,),
// 放到线程池异步处理时, 可能会出现CanalClientException: deserializer failed, ack error , clientId:1001 batchId:680 is not exist , please check
//出现的原因是异步处理, 可能先提交了大的batchId, 导致另一个batchId小的
// 线程处理完回来ack的时候, 发现batchId不存在了,因为被大的batchId覆盖了
try {
eventHandlerServicel.handle(eventInfos);
}catch (Exception e){
log.error("处理 batchId 为 {} 的数据发生异常 ",batchId);
e.printStackTrace();
canalConnector.rollback();
}
//处理完一个批次的数据,再ack
log.info("ack batck ID:{}",batchId);
if (batchId != -1) {
canalConnector.ack(batchId); // 提交确认
}
}
}
}
/**
* 产生事件信息和数据
* @param entrys
* @return
*/
private List<EventInfo> createEventInfos(List<CanalEntry.Entry> entrys) {
//事件信息集合
List<EventInfo> eventInfoList = new ArrayList<>(entrys.size());
for (CanalEntry.Entry entry : entrys) {
if (entry.getEntryType() == CanalEntry.EntryType.TRANSACTIONBEGIN || entry.getEntryType() == CanalEntry.EntryType.TRANSACTIONEND) {
continue;
}
CanalEntry.RowChange rowChage = null;
try {
rowChage = CanalEntry.RowChange.parseFrom(entry.getStoreValue());
} catch (Exception e) {
throw new RuntimeException("ERROR ## parser of eromanga-event has an error , data:" + entry.toString(),
e);
}
//事件类型
CanalEntry.EventType eventType = rowChage.getEventType();
//数据库
String schemaName = entry.getHeader().getSchemaName();
//表名
String tableName = entry.getHeader().getTableName();
log.info("===========binlog[{} : {}] ,tableName[{} : {}] , eventType : {}",
entry.getHeader().getLogfileName(), entry.getHeader().getLogfileOffset(),
schemaName, tableName, eventType);
for (CanalEntry.RowData rowData : rowChage.getRowDatasList()) {
eventInfoList.add(new EventInfo(rowData,schemaName,tableName,eventType));
}
}
return eventInfoList;
}
}
package com.ribbon.project.demo.canal.service;
import com.ribbon.project.demo.canal.pojo.EventInfo;
import java.util.List;
/**
* TODO
*
* @author yyl
* @date 2021/5/15 19:04
* @desc
*/
public interface EventHandlerService {
void handle(List<EventInfo> eventInfos);
}
package com.ribbon.project.demo.canal.service.impl;
import com.alibaba.otter.canal.protocol.CanalEntry;
import com.ribbon.project.demo.canal.pojo.EventInfo;
import com.ribbon.project.demo.canal.service.EventHandlerService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* TODO
*
* @author yyl
* @date 2021/5/15 21:19
* @desc
*/
@Component
@Slf4j
public class EventHandlerServiceImpl implements EventHandlerService {
@Override
public void handle(List<EventInfo> eventInfos) {
eventInfos.forEach(x->{
CanalEntry.RowData rowData = x.getRowData();
CanalEntry.EventType eventType = x.getEventType();
if (eventType == CanalEntry.EventType.DELETE) {
String name = rowData.getBeforeColumns(0).getValue();
log.info("删除一行数据:{}",name);
// printColumn(rowData.getBeforeColumnsList());
} else if (eventType == CanalEntry.EventType.INSERT) {
// printColumn(rowData.getAfterColumnsList());
String name = rowData.getAfterColumns(0).getValue();
log.info("插入一行数据:{}",name);
} else {
// System.out.println("-------> before");
// printColumn(rowData.getBeforeColumnsList());
// System.out.println("-------> after");
// printColumn(rowData.getAfterColumnsList());
int index = 0;
String after = null;
List<CanalEntry.Column> beforeColumnsList = rowData.getBeforeColumnsList();
Map<Integer, List<CanalEntry.Column>> collect = beforeColumnsList.stream().collect(Collectors.groupingBy(CanalEntry.Column::getIndex));
rowData.getAfterColumnsList().forEach(xx->{
boolean updated = xx.getUpdated();
if(updated){
List<CanalEntry.Column> columns = collect.get(xx.getIndex());
String value = columns.get(0).getValue();
String name = columns.get(0).getName();
log.info("更新前的字段名=字段值:{}={};;更新后的字段名=字段值:{}={}",name,value,xx.getName(),xx.getValue());
}
});
}
});
}
private static void printColumn(List<CanalEntry.Column> columns) {
for (CanalEntry.Column column : columns) {
System.out.println(column.getName() + " : " + column.getValue() + " update=" + column.getUpdated());
}
}
}
package com.ribbon.project.demo.canal;
import com.ribbon.project.demo.canal.event.DataSyncApp;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ConfigurableApplicationContext;
import org.springframework.context.annotation.ComponentScan;
@SpringBootApplication
public class CanalApplication {
public static void main(String[] args) {
ConfigurableApplicationContext context = SpringApplication.run(CanalApplication.class, args);
DataSyncApp redisSyncApp = context.getBean(DataSyncApp.class);
redisSyncApp.work();
}
}
到此,canal就部署完毕,当修改表数据时,控制台会打印出对应的修改记录
四:遇到的问题
1、org.springframework.beans.factory.UnsatisfiedDependencyException: Error creating bean with name 'dataSyncApp': Unsatisfied dependency expressed through field 'canalConnector'; nested exception is org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'initConnector' defined in class path resource [com/ribbon/project/demo/canal/config/CanalConnectorConfig.class]: Bean instantiation via factory method failed; nested exception is org.springframework.beans.BeanInstantiationException: Failed to instantiate [com.alibaba.otter.canal.client.CanalConnector]: Factory method 'initConnector' threw exception; nested exception is com.alibaba.otter.canal.protocol.exception.CanalClientException: something goes wrong when doing authentication: auth failed for user:canal
解决:以上报错是客户端连接服务端认证报错,注意的是客户端连接需要用明文,服务端需要时密文,官方ISSUES
2、Exception in thread "main" com.alibaba.otter.canal.protocol.exception.CanalClientException: failed to subscribe with reason: something goes wrong with channel:[id: 0x6369a08a, /192.168.111.1:62903 => /192.168.111.128:11111], exception=com.alibaba.otter.canal.meta.exception.CanalMetaManagerException: batchId:2 is not the firstly:1
原因:这是因为Canal读取MySQL的时候会记录读取的位置,保存在meta.dat文件中,canal程序长时间停止后又重启,最后一次记录的位置已经在MySQL的bin-log日志中丢失,导致读取不到数据。
解决办法:删掉meta.dat文件,让canal从最新位置读即可(本人测试时一直都报这个错,都试过没有用,不知道是不是我这边机器配置问题)
无论是亲情、友情还是爱情,都会毁于不珍惜三个字,每一段关系,都需要用心经营