2024年4月

什么是客户管理系统?

客户管理系统,也称为CRM(Customer Relationship Management),主要目标是建立、发展和维护好客户关系。

CRM系统围绕客户全生命周期的管理,吸引和留存客户,实现缩短销售周期、降低销售成本、增加销售收入的目的,从而提高企业的盈利能力和竞争力。

CRM系统以客户数据为核心,记录公司在市场推广和销售过程中,与客户的各种互动行为,以及各种活动的状态,为后续的分析和决策提供帮助。

零售商家为什么需要客户管理系统?

  • 增强客户的忠诚度:CRM系统可以让企业精确识别目标客群,深入了解客户需求,并提供优质的服务,包括售前、售中和售后的支持。例如,如果一名导购员能够基于你的购物历史和浏览习惯,推荐你感兴趣的商品,这种个性化服务会让你觉得商家很贴心,促使你再次购买。
  • 提升销售效率:通过优化销售流程管理,跟踪潜在客户的信息,CRM系统有助于提高销售团队的工作效率和整体销售额。例如,线上销售员在服务客户之前,可以用CRM系统获取客户的基本信息和历史购买记录。这些信息可以帮助销售员更好地理解客户的需求,从而提高销售效果。
  • 提升营销效果:CRM系统让企业能够准确把握客户需求,实施精准的营销策略,从而提高营销活动的效果。例如,当你收到感兴趣的产品或服务的短信时,很可能会点击查看详情,这是CRM系统帮助企业实现的精准营销的效果。
  • 促进内部合作:CRM系统能促进企业内部的协同工作,特别是销售、市场和客服等部门之间的合作。例如,客服人员想了解客户的售后服务情况,可以通过CRM系统查询到相关信息,从而更有效地协同解决客户问题。

核心业务流程

为了吸引和留存客户,提升公司业绩,CRM系统的业务流程涵盖了客户全生命周期的管理,包含以下环节:

  • 数据沉淀:这是客户管理系统的基础,它负责收集和保存所有客户的数据,这些数据包括客户的基本信息、购买记录、网页浏览行为,以及反馈等。这些信息对于分析客户,了解他们的需求,改进产品和服务,起到了至关重要的作用,从而提高客户满意度。
  • 客户标签:通过分析客户的购物习惯、购买的商品类型、对商品或服务的反馈等数据,给客户分类和贴标签,这些标签可以让企业更好地了解客户,提供更合适的产品和服务。
  • 人群圈选:根据客户数据和标签,找出有相似特性或行为的人群。这些人群可以用于后续的营销活动,比如促销活动、定向广告、个人化推荐等。另外,也可以帮助企业更好地了解市场趋势,改善产品和服务。
  • 场景营销:根据客户不同场景的行为模式和需求,提供个性化的营销方案。例如,对于经常购买婴儿用品的客户,可以为他们提供相关产品的优惠券。这种方式可以提高营销活动的效果,提高客户的购买意愿。
  • 触达转化:通过多种触达渠道,例如短信、外呼、订阅消息等,将各类营销活动和特定服务推送给潜在客户,促成交易转化。这个过程需要不断地试验和优化,以找到最有效的转化策略。
  • 数据分析:对所有客户运营的数据进行分析,了解哪些策略有效,哪些需要改进。这些结论是非常宝贵的经验,将用于未来的客户运营,优化产品和服务,从而持续提升客户满意度和忠诚度。

客户管理系统的概念模型设计

客户域的实体模型:

  • 客户:代表购买企业产品的人或组织。客户信息包括姓名、手机号、联系方式、地址等信息。
  • 会员:注册成为会员的客户,他们可享有积分、等级、会员专享优惠等会员特权。
  • 客户标签:用于描述客户的特征,例如“高价值”、“新客户”或“潜在流失客户”等。标签作为元数据,为客户分类和营销提供了便捷的操作方式。
  • 标签分类:用于把相似的客户标签放一起,更有效地管理和使用标签。比如,创建一个标签分类包含所有跟高价值客户有关的标签。

客户行为域的实体模型:

  • 客户行为记录:用来记录客户与企业互动的具体行为数据。通常记录客户在企业的各种触点(如网站、移动App、实体门店等)上的行为数据,这些行为数据包括但不限于页面访问、产品浏览、搜索查询、购买行为、反馈建议等。

客户资产域的实体模型:

  • 积分账户:用于跟踪和管理客户通过购买商品、参与活动等方式赚取的积分。积分通常可以兑换商品、服务或特定优惠。
  • 等级账户:记录了客户在企业中的会员等级,通常是基于客户的消费额度、积分或成长值来进行升级。
  • 权益账户:用于记录客户所拥有的特定权益,例如优惠券、购物得更多积分等。
  • 储值账户:用于记录客户在企业中的预付款,客户可以使用这些资金购买商品和服务。

这些账户在客户资产管理中是互相关联的。比如,客户等级可能影响他们在权益账户里获得的特权,他们消费储值账户里的余额,可以用来赚取更多积分等。通过这些关联玩法,公司可以为客户提供更个性化的服务,激励客户更多消费。

客户运营域的实体模型:

  • 人群模板:用于定义目标客户群体的基本框架和属性,这些模板可能包括客户的年龄范围、购买频次、购买偏好等。
  • 人群画像:基于人群模板进一步的细化,包含更具体的客户群体的描述。它通常包含更多的细节规则,如品类偏好、星座、在某门店消费过等规则。
  • 人群规则:定义了用于识别或分类客户群体的具体条件或逻辑,例如客户每月购买频率大于1次、参与过女神节活动等。
  • 运营计划:包含企业针对特定客户群体执行的具体营销计划。

客户管理系统的应用架构设计

应用层定义了软件系统的应用功能,负责接收用户的请求,协调领域层能力来执行任务,并将结果返回给用户,功能模块包括:

  • 客户管理:核心功能模块,负责收集和更新客户信息,包括个人资料、联系方式、消费习惯、会员信息、归属信息(比如销售或顾问)。这个模块是CRM系统的基础,支撑其他模块运作,提供详细的客户信息,帮助企业更好地理解和服务客户。
  • 客户标签:通过对客户进行标签化管理,实现客户的细分和个性化服务。支持创建新标签、删除标签、批量打标签和自动打标签等功能,以及同步到企业微信等三方平台的标签库。
  • 人群运营:针对不同的客户群体,执行有针对性的营销策略。包括人群圈选(根据特定标准选择目标客户群)、场景营销(根据不同的场景需求设计营销活动)、互动营销(通过互动提高客户参与度)、促销工具(如限时折扣、买赠等),实现精准营销。
  • 触达渠道:定义了企业与客户沟通的多种渠道,包括电话外呼、短信、小程序订阅消息、微信群发等。这个模块让企业能通过多种渠道与客户进行有效沟通,提供产品信息、促销和服务等,增强客户体验。
  • 数据分析:对客户数据进行深入分析,包括会员业绩、会员画像、RFM模型分析(基于客户最近一次购买时间、购买频率、购买金额的分析模型)、消费分析(包括消费习惯、复购率等)、积分和储值分析。通过这些分析,企业可以获得关键洞察,以改善营销策略和提升客户服务。
  • 客户资产:管理客户的权益价值,包括储值(预存款)、积分、权益卡、优惠券和自定义权益等。这个模块帮助企业建立和维护客户忠诚度计划,通过提供价值和优惠,来鼓励客户消费和复购。

领域层是业务逻辑的核心,专注于表示业务概念、业务状态流转和业务规则,沉淀可复用的系统能力。

  • 客户基础
    • 客户基本信息:维护客户的基础数据,如姓名、手机号、联系方式、地址等。这是识别和联系客户的核心信息。
    • 自定义资料项:允许企业根据业务需要,添加客户的额外信息,提供灵活性以适应各种业务场景。
    • 客户变更记录:记录客户信息的变更记录,提供历史数据追踪,用于审计和检查服务质量。
    • 客户归属:明确客户与公司内部人员(如销售团队、客户经理)的关系,以便明确客户管理的职责。
    • 客户授权:管理客户授权给企业的权限,如数据访问和处理的权限,确保数据处理的合法性和合规性。
    • 客户合并处理:解决客户记录重复的问题,通过合并相似或重复的客户记录来维护数据的准确性和一致性。
    • 行为明细:采集并记录客户的具体行为数据,如页面访问、产品浏览和购买行为等。
    • 交易行为统计:对客户的交易行为(如购买频次、金额等)进行汇总和统计,支持业务分析和决策。
  • 客户标签
    • 标签元数据:管理标签的定义,包括标签名称、类型和适用范围等,是标签管理的基础。
    • 标签模板管理:提供标签模板的创建、编辑和删除功能,支持标签的快速应用和复用。
    • 自动打标签:根据预定义的规则自动为客户打标,如根据购买行为自动标记为“高价值客户”。
    • 手动打标签:允许用户手动为客户添加或修改标签,提供灵活的客户细分和管理能力。
    • 批量打标签:允许用户一次性为多个客户添加相同的标签,相比单个操作,大大提高了工作效率。
    • 标签同步:标签同步功能可以保持在不同系统和平台间的客户标签一致。比如,同步到其他CRM系统、营销自动化平台或企业微信等三方系统的标签库。
  • 客户资产
    • 积分:管理客户通过购买行为或参与活动获得的积分,以及积分的使用和过期规则。
    • 权益:定义和管理客户拥有的各种权益。
    • 权益卡:管理客户的会员卡或权益卡,及其对应的权益和条件。
    • 等级:根据客户的消费行为划分客户等级,管理等级的升降规则和相应的权益。
    • 权益核销:处理客户使用权益(如优惠券使用、积分兑换)的操作和记录,确保权益的正确核销。
    • 储值:管理客户的预付款余额,支持储值的使用、充值和退款操作。

写在最后

客户管理系统(CRM)的目标是建立、发展和维护良好的客户关系,以提高企业的盈利能力和竞争力。CRM系统可以增强客户忠诚度,提升销售效率和营销效果,以及促进内部合作。

客户管理的业务流程包括数据沉淀、客户标签、人群圈选、场景营销、触达转化和数据分析。

在概念模型设计中,介绍了客户域、客户行为域、客户资产域和客户运营域的实体模型。

CRM系统的应用架构设计包括客户管理、客户标签、人群运营、触达渠道、数据分析和客户资产等功能模块。

前言

多租户的概念是我在毕业后不久进第一家公司接触到的,当时所在部门的业务是计划建设一套基于自研的、基于开放 API 的、基于 PaaS 的、面向企业(ToB)的多租户架构平台,将我们的服务可以成规模地、稳定高效地交付给客户使用。

当时我们就去参考了腾讯云和阿里云的多租户设计,团队经过调研后得出了以下几个基本共识:

  • 要有一定的量:
    即业务规模大到需要使用多租户架构来解决,不然就考虑普通的 SaaS 做交付;
  • 底层的硬件资源:
    需要足够支持这样量级的业务,运维、高可用、监控这几方面可能需要云原生团队的支持;
  • 平台架构设计上:
    一定要保证高隔离性、高可扩展性、高性能,同时可以支持复杂的、高并发的场景;
  • 成本与营收平衡:
    需要有一个可以接受的范围,毕竟投入进去的前期是基本亏损的,稳定后再抱有能赚钱的心态。

当然,作为一个入门系列,本篇文章的内容偏基础概念,并不是多租户技术架构的最佳实践。笔者把从互联网上学习到的相关知识与自身的工作实践相结合,希望能在分享的过程中和大家一起进步。


一、多租户的概念

多租户本质上是一种软件的技术架构,
它最核心的特征是
多个租户可以共享一个系统实例,
并且租户间是可以
实现数据和行为的隔离
,这可以说是多租户技术架构里最重要的两点了。

多租户架构是 SaaS 模式中的重要且常见的架构,通过共享和复用资源降低成本,提高效率和可扩展性。其中最需要关注就是:
数据/行为的隔离、身份/角色的认证与授权、底层硬件资源管理、高性能与高可用、定制化和可扩展、数据一致性、系统安全性等。

这里就不过多赘述了,下面会将概念详细铺开。如果要找一个生活中容易理解的场景做比喻,那么多租户的概念其实就和租房子的概念类似,只不过在各自的专业领域所涉及到的术语和具体实现会不一样。


二、隔离模式

一般来说多租户常见的有3种隔离模式:独立数据库、共享数据但独立数据架构、共享数据库且共享数据架构。

2.1独立数据库模式

独立数据库模式示例

2.1.1特征

一个租户一个数据库,隔离级别最高,对系统底层所涉及到的计算、存储、网络等资源的隔离。

和传统软件模式(SaaS)的区别:

独立数据库模式有标准的租户身份识别、租户入驻流程、计费体系、运营流程等。
除此之外,本质上其提供的服务还是端到端的 SaaS 模式,某种意义上可以看作每一个租户都各自拥有一套端到端的基础设施。

2.1.2优点

  • 满足
    强隔离
    需求:一些租户为了保证系统和数据的安全性,可能会提出非常严格的隔离要求,期望软件产品能够部署在一套完全独立的环境中,不和其它租户的实例、数据放在一起;
  • 计费逻辑简单:
    在这种竖井模式下,计费模型相对是比较简单的;
  • 降低故障影响:因为每个租户的系统都部署在独立的环境中,如果一个环境出现故障,并不会影响其他租户的软件服务。

2.1.3缺点

  • 规模化
    问题:由于租户是各自独立的环境,每入驻一个租户就需要准备、创建、运营一套 SaaS 环境,如果只有少量租户还可以管理,一旦租户的数量多起来,管理和运营这些环境将会是非常大的挑战;
  • 成本问题:
    每个租户都需要单独的部署环境,那么花费在每个租户上的成本就会非常高,会大幅度降低 SaaS 软件服务的盈利能力;
  • 敏捷迭代问题:
    一般来说 SaaS 模式的优势是可以很快响应市场变化,可以迅速迭代产品功能,但是在这种竖井模式下更管理、运维这些租户的 SaaS 环境会变得非常复杂且低效;
  • 基础设施的监控:
    同样地,在这种非中心化的模式下,对每个租户的基础设施的运维与监控也是非常复杂且繁琐的。

2.2共享数据库独立数据架构

独立数据库共享数据架构模式示例

2.2.1

多个租户或者所有租户共享数据库,每个租户会拥有一个 schema 形成逻辑上的隔离,而并不是物理上的隔离(还在一个实例内)。
即多个租户共享一套基础设施,降低 Saas 软件服务的资源成本。

简单介绍下 schema:

schema 就是数据对象的集合,这个集合包含了各种对象如:表、视图、存储过程和索引等。
如果把数据库看作是一个仓库,那么schema就是一个个的房间,表就是 一个个的柜子。user 是 schema 的 administrator,有操控每个 schema 的权限。

但需要说明的是,MySQL 数据库中没有 schema 这个概念,但是一个 MySQL 实例可以有多个数据库。

2.2.2优点

  • 高效管理:
    在上述共享策略下,所有的租户都可以集中管理,同时监控基础设施将更容易,且产品的迭代可以更快;
  • 低成本:
    相对于竖井模式的独立数据库,共享数据库的成本更低,还可以方便地根据用户的使用需求动态地扩展系统;
  • 隔离性较好:
    虽然同在一个实例内,但是做了逻辑区分,租户使用的库不一样,隔离效果还是比较好的。

2.2.3缺点

  • 租户相互影响
    :由于所有租户共享同一资源,
    当一个租户占用大量机器时会消耗很多资源,其它租户的使用很可能会受到影响
    。在这种情况下,对整个系统架构的可用性和扩展性的要求就比较高了,同时可能也考虑适当地
    设计限流、降级和熔断等措施
    来应对;
  • 运维工作量大:
    每增加一个租户,都需要为其需要创建新的业务数据库来进行管理,还可能需要与开发人员共同维护这些数据库;
  • 租户计费困难:
    所有租户共享资源,使得计费可能更加困难,但在研发资源较为充足的时候也不是很大的问题。

2.3共享数据库共享数据架构

共享数据库共享数据架构模式示例

2.3.1特征

所有租户共享一个数据库实例,共享同一个数据库,只不过
在每张表都加上租户标识字段
用以区分。

2.3.2优点

  • 资源成本低:
    一个实例的一个数据库就可以搞定所有租户的数据,
    支持的租户数量理论上可以很多
  • 便于迭代:
    在开发的时候只需要额外关注租户标识字段就好,
    产品迭代维护起来也能很方便;

2.3.3缺点

  • 隔离性差:
    所有租户的数据都放在一起,一旦业务层没有做好对租户标识的区分,
    很容易造成租户的数据混乱;
  • 性能瓶颈:
    随着租户数据量的成倍增加,
    单表的性能一定会逐步下降,
    且性能优化会受限于基础资源的不足;
  • 扩展性差:
    一旦业务变得复杂,业务之间的耦合也会变紧,
    可能会引起分布式事务、数据不一致性等一系列的系统问题。


三、隔离方案选型

关于怎么对上述提到的 3 种隔离模式的选型,可以从以下 4 个维度来做比较:

  1. 资源共享度:
    即多个租户之间的对基础设置的共享程度如何,是竖井还是schema还是共用数据库?
  2. 数据隔离度:
    当租户对于业务数据的隔离要求比较高时可以选择竖井,成本比较紧张或者在初始阶段可以考虑共享数据库;
  3. 业务复杂度:
    有些核心业务是比较复杂的,对整体的服务和底层资源的考验都比较大,其它业务可以适当做一些简化;
  4. 应用成本:
    既然选用多租户技术框架,那么说明用户肯定是达到了一定的量级,运营、维护、硬件等的综合成本一定要考虑。
隔离方案选型对比图示


四、架构模型

4.1模型分层

在这里笔者分为了3个层次:管理层、服务层、基础设施,如多租户架构图示(一)所示,下面展开讲一下这样分层的原因。

多租户架构图示(一)
  • 管理层

    这一层有点类似于阿里云的控制台,阿里云自己内部可以监控每个租户的大致情况,租户自己可以监控到自己付费的资源情况。


    1. 对于开发者而言,这一层主要就是对租户的管理:
      即租户购买了哪些服务、租户之间的隔离、对租户的计费等。就像房东对一栋楼每个房间租户的管理:几层几号房租给了谁、要租多久、租金包含什么等,房东只管出租和维护房子,不会管里面租户的日常生活。
    2. 对于租户而言,就是对花钱租的服务进行管理:
      即具体购买了哪些系统、哪些资源、怎么角色授权等。比如租户租了个一室一厅一卫,客厅怎么布置、厨房要不要做饭、卫生间的垃圾几天丢一次,这些东西房东基本不会干涉的。
  • 服务层

    这一层就是具体提供的系统服务了,这些服务是由开发者开发的,一般情况下所有租户都是共享一套代码和系统的,特殊定制化的服务除外。


    1. 对于开发者而言,普通传统 SaaS 开发模式下,对每一个客户都得定制开发一套系统,虽然定制化的内容可能大同小异,不会有本质上的区别,但受到数据隔离、底层资源和角色授权等方面的限制,只能单独为每个客户部署一套服务和一套资源。

      一旦客户的数量多起来,劣势是非常明显的:开发成本和部署的成本都太高了,且可复用程度低。

      多租户模式下,如果有一套优秀的、成熟的多租户技术架构,那么无论对于开发者还是租户,都是省时省力省钱且高效的。像阿里云提供的 CDN 内容分发、OSS 对象存储、RDS 云数据库、SLB 负载均衡等可供租户购买的服务,都是经过市场打磨的优秀产品。

    2. 对于租户而言,这层就是购买的具体服务了,这些购买的服务一般会作为底座,用于支撑租户的业务发展。
      举个例子:A 公司花 10 万元买了阿里云的一些产品服务来支撑自己公司的业务,A 公司将这些业务投入市场后,销售价格可以为 15 万元,而可能阿里云为一个租户提供产品服务的实际成本仅为 5 万元。

  • 基础设施

    这一层有点类似于 IaaS 基础设施即服务的概念。
    我们知道,无论什么软件服务都要基于 CPU、内存、磁盘、路由器、交换器等一系列的硬件设施。


    1. 对于开发者而言,基础设施要么自建要么采购。就目前来说,市场上只有少数几个厂家拥有成熟的硬件设施解决方案,所以软件服务的开发者一般以采购为主;
    2. 对于租户而言,对基础设施是无感的:租户不必关心具体的底层硬件结构,只需要关注服务层的告警,如有告警可以提出紧急工单对接开发者。

4.2模型关系

这个模型里我理解可以分为 4 种体系:SaaS平台体系、权限角色体系、业务体系与云资源体系。如多租户架构图示(二)所示,每种体系之间都有各自的关联关系。为方便大家理解,每种关系我都展开讲讲。

多租户架构图示(二)
  • SaaS平台与租户的关系:
    这个平台里面有多个租户,一般的话采用共享数据库独立数据架构的模式,容纳几十个租户应该问题不大。
  • 租户与组织用户的关系:
    租户一般指的是企业或者组织,通常会有一些员工担任管理员的角色来管理购买的 SaaS 服务。
  • 用户与权限角色的关系:
    面对众多的 SaaS 服务系统,一般只会选择性地给用户授予某些权限,比如管理员、超级管理员等。
  • 租户与业务的关系:
    一般来说这里的业务指的是租户自己的业务,租户需要依赖购买的 SaaS 服务来支撑这些业务。
  • 业务与底层资源的关系:
    底层资源一般指的是服务器等硬件资源,但是业务通常不关心底层资源。
  • 租户与底层资源的关系:
    租户需要在购买的时候知道底层硬件的配置,其运维和告警等由 SaaS 管理平台的开发者负责。

五、文章小结

文章到这里就结束了,作为一个系列文章的开头,本文的内容篇基础概念,并不是多租户技术架构的最佳实践。
如果文章有不足和错误,还请大家指正。或者你有其它想说的,也欢迎大家在评论区交流!

网上找的没有满意的,决定从
若依前后端分离
其前端vue2中的crontab进行转换,先上效果

若依:

改后:

v2转v3没什么难度,其中有大量的将 this.*** 替换为 ***.value,笔者写了个正则替换,希望可以帮助大家

this.(\w+)         $1.value

需要注意的有,在v2中【this.$refs[refName].cycle01 = indexArr[0]】这样写

在v3中要转换一下,在子组件中用【defineExpose】抛出一个setData方法,然后【proxy.$refs[refName].setData("cycle01", Number(indexArr[0]))】赋值

贴出核心Crontab.vue的代码,其子组件就不一一贴了,需要的可以自己下若依代码进行转换

<template>
  <div class="crontab">
    <el-tabs type="border-card">
      <el-tab-pane label="秒">
        <CrontabSecond
@update
="updateCrontabValue"v-model:check="checkNumber"v-model:cron="crontabValueObj"ref="cronsecond" /> </el-tab-pane> <el-tab-pane label="分钟"> <CrontabMin
@update
="updateCrontabValue"v-model:check="checkNumber"v-model:cron="crontabValueObj"ref="cronmin" /> </el-tab-pane> <el-tab-pane label="小时"> <CrontabHour
@update
="updateCrontabValue"v-model:check="checkNumber"v-model:cron="crontabValueObj"ref="cronhour" /> </el-tab-pane> <el-tab-pane label="日"> <CrontabDay
@update
="updateCrontabValue"v-model:check="checkNumber"v-model:cron="crontabValueObj"ref="cronday" /> </el-tab-pane> <el-tab-pane label="月"> <CrontabMonth
@update
="updateCrontabValue"v-model:check="checkNumber"v-model:cron="crontabValueObj"ref="cronmonth" /> </el-tab-pane> <el-tab-pane label="周"> <CrontabWeek
@update
="updateCrontabValue"v-model:check="checkNumber"v-model:cron="crontabValueObj"ref="cronweek" /> </el-tab-pane> <el-tab-pane label="年"> <CrontabYear
@update
="updateCrontabValue"v-model:check="checkNumber"v-model:cron="crontabValueObj"ref="cronyear" /> </el-tab-pane> </el-tabs> <div class="crontab-main"> <div class="crontab-main-table"> <table> <thead> <th v-for="item of tabTitles" width="40" :key="item">{{item}}</th> <!-- <th>Cron 表达式</th> --> </thead> <tbody> <td> <span>{{crontabValueObj.second}}</span> </td> <td> <span>{{crontabValueObj.min}}</span> </td> <td> <span>{{crontabValueObj.hour}}</span> </td> <td> <span>{{crontabValueObj.day}}</span> </td> <td> <span>{{crontabValueObj.month}}</span> </td> <td> <span>{{crontabValueObj.week}}</span> </td> <td> <span>{{crontabValueObj.year}}</span> </td> <!-- <td> <span>{{crontabValueString}}</span> </td> --> </tbody> </table> <table> <thead> <th>Cron 表达式</th> </thead> <tbody> <td> <span>{{crontabValueString}}</span> </td> </tbody> </table> </div> <div class="crontab-main-result"> <CrontabResult v-model:ex="crontabValueString"></CrontabResult> </div> </div> </div> </template> <script setup name="Crontab">import CrontabSecond from"./crontab/CrontabSecond.vue";
import CrontabMin from
"./crontab/CrontabMin.vue";
import CrontabHour from
"./crontab/CrontabHour.vue";
import CrontabDay from
"./crontab/CrontabDay.vue";
import CrontabMonth from
"./crontab/CrontabMonth.vue";
import CrontabWeek from
"./crontab/CrontabWeek.vue";
import CrontabYear from
"./crontab/CrontabYear.vue";
import CrontabResult from
"./crontab/CrontabResult.vue";

const { proxy }
=getCurrentInstance();

const emits
= defineEmits(["hide", "fill"]);

const props
=defineProps({
expression: {type: String,
default: ""}
})

const tabTitles
= ref(["秒", "分钟", "小时", "日", "月", "周", "年"])
const tabActive
= ref(0)
const crontabValueObj
=ref({
second:
"*",
min:
"*",
hour:
"*",
day:
"*",
month:
"*",
week:
"?",
year:
"",
})

const crontabValueString
= computed(() =>{
let obj
=crontabValueObj.value;
let str
=obj.second+ " " +obj.min+ " " +obj.hour+ " " +obj.day+ " " +obj.month+ " " +obj.week+(obj.year== "" ? "" : " " +obj.year);returnstr;
})

onMounted(()
=>{
resolveExp();
})

watch(()
=> props.expression, (v) =>{
resolveExp();
});
functionresolveExp() {//反解析 表达式 if(props.expression) {
let arr
= props.expression.split(" ");if (arr.length >= 6) {//6 位以上是合法表达式 let obj ={
second: arr[
0],
min: arr[
1],
hour: arr[
2],
day: arr[
3],
month: arr[
4],
week: arr[
5],
year: arr[
6] ? arr[6] : "",
};
crontabValueObj.value
={
...obj,
};
for (let i inobj) {if(obj[i]) changeRadio(i, obj[i]);
}
}
}
else{//没有传入的表达式 则还原 clearCron();
}
}
//tab切换值 functiontabCheck(index) {
tabActive.value
=index;
}
//由子组件触发,更改表达式组成的字段值 functionupdateCrontabValue(name, value, from) {//"updateCrontabValue", name, value, from; crontabValueObj.value[name] =value;if (from && from !==name) {
console.log(`来自组件 ${from} 改变了 ${name} ${value}`);
changeRadio(name, value);
}
}
//赋值到组件 functionchangeRadio(name, value) {
let arr
= ["second", "min", "hour", "month"]
let refName
= "cron" +name
let insValue;
if (!proxy.$refs[refName]) return;if(arr.includes(name)) {if (value === "*") {
insValue
= 1;
}
else if (value.indexOf("-") > -1) {
let indexArr
= value.split("-");
isNaN(indexArr[
0])? (proxy.$refs[refName].setData("cycle01", 0))
: (proxy.$refs[refName].setData(
"cycle01", Number(indexArr[0])));
proxy.$refs[refName].setData(
"cycle02", Number(indexArr[1]));
insValue
= 2;
}
else if (value.indexOf("/") > -1) {
let indexArr
= value.split("/");
isNaN(indexArr[
0])? (proxy.$refs[refName].setData("average01", 0))
: (proxy.$refs[refName].setData(
"average01", Number(indexArr[0])));
proxy.$refs[refName].setData(
"average02", Number(indexArr[1]));
insValue
= 3;
}
else{
insValue
= 4;
let list
= value.split(",");for(let item of list){
item
=String(item)
}
proxy.$refs[refName].setData(
"checkboxList", list);
}
}
else if (name == "day") {if (value === "*") {
insValue
= 1;
}
else if (value == "?") {
insValue
= 2;
}
else if (value.indexOf("-") > -1) {
let indexArr
= value.split("-");
isNaN(indexArr[
0])? (proxy.$refs[refName].setData("cycle01", 0))
: (proxy.$refs[refName].setData(
"cycle01", Number(indexArr[0])));
proxy.$refs[refName].setData(
"cycle02", Number(indexArr[1]));
insValue
= 3;
}
else if (value.indexOf("/") > -1) {
let indexArr
= value.split("/");
isNaN(indexArr[
0])? (proxy.$refs[refName].setData("average01", 0))
: (proxy.$refs[refName].setData(
"average01", Number(indexArr[0])));
proxy.$refs[refName].setData(
"average02", Number(indexArr[1]));
insValue
= 4;
}
else if (value.indexOf("W") > -1) {
let indexArr
= value.split("W");
isNaN(indexArr[
0])? (proxy.$refs[refName].setData("workday", 0))
: (proxy.$refs[refName].setData(
"workday", Number(indexArr[0])));
insValue
= 5;
}
else if (value === "L") {
insValue
= 6;
}
else{
let list
= value.split(",");for(let item of list){
item
=String(item)
}
proxy.$refs[refName].setData(
"checkboxList", list);
insValue
= 7;
}
}
else if (name == "week") {if (value === "*") {
insValue
= 1;
}
else if (value == "?") {
insValue
= 2;
}
else if (value.indexOf("-") > -1) {
let indexArr
= value.split("-");
isNaN(indexArr[
0])? (proxy.$refs[refName].setData("cycle01", "0"))
: (proxy.$refs[refName].setData(
"cycle01", String(indexArr[0])));
proxy.$refs[refName].setData(
"cycle02", String(indexArr[1]));
insValue
= 3;
}
else if (value.indexOf("#") > -1) {
let indexArr
= value.split("#");
isNaN(indexArr[
0])? (proxy.$refs[refName].setData("average01", 1))
: (proxy.$refs[refName].setData(
"average01", Number(indexArr[0])));
proxy.$refs[refName].setData(
"average02", String(indexArr[1]));
insValue
= 4;
}
else if (value.indexOf("L") > -1) {
let indexArr
= value.split("L");
isNaN(indexArr[
0])? (proxy.$refs[refName].setData("weekday", "1"))
: (proxy.$refs[refName].setData(
"weekday", String(indexArr[0])));
insValue
= 5;
}
else{
let list
= value.split(",");for(let item of list){
item
=String(item)
}
proxy.$refs[refName].setData(
"checkboxList", list);
insValue
= 6;
}
}
else if (name == "year") {if (value == "") {
insValue
= 1;
}
else if (value == "*") {
insValue
= 2;
}
else if (value.indexOf("-") > -1) {
insValue
= 3;
}
else if (value.indexOf("/") > -1) {
insValue
= 4;
}
else{
let list
= value.split(",");for(let item of list){
item
=String(item)
}
proxy.$refs[refName].setData(
"checkboxList", list);
insValue
= 5;
}
}
proxy.$refs[refName].setData(
"radioValue", insValue);
}
//表单选项的子组件校验数字格式(通过-props传递) functioncheckNumber(value, minLimit, maxLimit) {//检查必须为整数 value =Math.floor(Number(value));if (value <minLimit) {
value
=minLimit;
}
else if (value >maxLimit) {
value
=maxLimit;
}
returnvalue;
}
//隐藏弹窗 functionhidePopup() {
emits(
"hide");
}
//填充表达式 functionsubmitFill() {
emits(
"fill", crontabValueString);
hidePopup();
}
functionclearCron() {//还原选择项 ("准备还原");
crontabValueObj.value
={
second:
"*",
min:
"*",
hour:
"*",
day:
"*",
month:
"*",
week:
"?",
year:
"",
};
for (let j incrontabValueObj.value) {
changeRadio(j, crontabValueObj.value[j]);
}
}

defineExpose({
submitFill, clearCron
})
</script> <style scoped>.crontab{
flex:
1;
height:
100%;
display: flex;
flex
-direction: column;
}
.crontab
-main {
flex:
1;
width:
100%;
margin: 10px auto;
background: #fff;
border
-radius: 5px;
font
-size: 12px;
border: 1px solid #ccc;
box
-sizing: border-box;
line
-height: 24px;
padding: 5px 10px 5px;
display: flex;
justify
-content: space-between;
overflow
-y: auto;
}
.crontab
-main-table {
box
-sizing: border-box;
line
-height: 24px;
padding: 5px 10px 5px;
width:
50%;
display: flex;
flex
-direction: column;
justify
-content: space-around;
table {
text
-align: center;
width:
100%;
margin:
0;
span {
display: block;
width:
100%;
font
-family: arial;
line
-height: 30px;
height: 30px;
white
-space: nowrap;
overflow: hidden;
border: 1px solid #e8e8e8;
}
}
}

.crontab
-main-result {
box
-sizing: border-box;
padding: 5px 10px 5px;
background
-color: #f1f1f1;
background
-size: cover;
width:
48%;
display: flex;
flex
-direction: column;
.crontab
-result-title{
padding: 5px;
}
:deep(.crontab
-result-scroll) {
font
-size: 12px;
line
-height: 24px;
margin:
0 !important;
padding
-left: 80px;
}
}

.crontab
-footer {
text
-align: right;
height: 25px;
padding: 5px 20px;
}
</style>

将原来组件的按钮移到引用存,引用样例

  <el-dialog title="Cron表达式生成器" v-model="formCrontabOpen" append-to-body destroy-on-close class="nine-tanchuang-001">
      <!-- <crontab @change="cronChange" v-model:value="formData.cronExpression" /> -->
      <Crontab ref="crontabRef" @hide="formCrontabOpen=false" @fill="crontabFill" v-model:expression="formData.cronExpression"></Crontab>
      <template #footer>
        <div class="dialog-footer">
          <el-button type="primary" @click="formCrontSubmit">确 定</el-button>
          <el-button type="warning" @click="formCrontReset">重 置</el-button>
          <el-button @click="formCrontabOpen=false">取 消</el-button>
        </div>
      </template>
    </el-dialog>

作者:vivo 互联网数据库团队 - Wei Haodong

本文介绍了 MySQL5.7 中常见的replace into 操作造成的主从auto_increment不一致现象,一旦触发了主从切换,业务的正常插入操作会触发主键冲突的报错提示。

一、问题描述

1.1 问题现象

在 MySQL 5.7 版本中,REPLACE INTO 操作在表存在自增主键的情况下,可能会出现表的auto_increment值主从不一致现象,如果在此期间发生主从故障切换,当原来的slave节点变成了新的master节点,由于表的auto_increment值是小于原主库的,当业务继续写入时,就会收到主键冲突的报错提示。

相关报错信息如下:

! 报错提示

ERROR 1062 (23000): Duplicate entry 'XXX' for key 'PRIMARY'

1.2 影响评估

在业务逻辑中使用了Replace into,或者INSERT...ON DUPLICATE KEY UPDATE。

一旦出现了表的auto_increment值主从不一致现象,在出现MySQL主从故障切换后,业务的正常写入会报主键冲突的错误,当auto_increment相差不多,或许在业务重试的时候会跳过报错,但是auto_increment相差较多时,会超出业务重试的次数,这样造成的影响会更大。

二、问题复现

2.1 环境搭建

这里在测试环境中,搭建MySQL社区版 5.7 版本,一主一从的架构。

【OS】:CentOS Linux release 7.3

【MySQL】:社区版本 5.7

【主从架构】:一主一从

【库表信息】:库名:test2023

表名:test_autoincrement

表结构如下:

CREATE TABLE `test_autoincrement` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '自增主键',
  `name` varchar(100) NOT NULL DEFAULT 'test' COMMENT '测试名字',
  `uid` int(11) NOT NULL COMMENT '测试表唯一键',
  PRIMARY KEY (`id`),
  UNIQUE KEY `uid` (`uid`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

2.2 准备测试数据

MySQL [test2023]> insert into test_autoincrement(name,uid) select '张三',1001;
Query OK, 1 row affected (0.08 sec)
Records: 1  Duplicates: 0  Warnings: 0
 
MySQL [test2023]> insert into test_autoincrement(name,uid) select '李四',1002;
Query OK, 1 row affected (0.06 sec)
Records: 1  Duplicates: 0  Warnings: 0
 
MySQL [test2023]>
MySQL [test2023]> insert into test_autoincrement(name,uid) select '王五',1003;
Query OK, 1 row affected (0.08 sec)
Records: 1  Duplicates: 0  Warnings: 0

正常情况下,插入一行数据,影响的行数是1。

此时查看主从节点表的autoincrement值,可以看到此时主从的AUTO_INCREMENT是一致的,都是4,即自增主键下一次申请的值是4。

图片

2.3 问题复现模拟

2.3.1 模拟REPLACE INTO操作

MySQL [test2023]> REPLACE INTO test_autoincrement (name,uid) values('张三丰',1001);
Query OK, 2 rows affected (0.01 sec)

这里通过REPLACE INTO操作判断,如果存在唯一ID为1001的记录,那么将name字段的值更改为"张三丰",可发现此时影响的行数是2。现在我们再次查看主从节点表的autoincrement值。

图片

此时出现了主从节点表的AUTO_INCREMENT不一致现象。

2.3.2 模拟主从切换

由于是在测试环境,这里就直接进行了主从关系的更改。

(1)停止当前slave节点的复制线程

MySQL [test2023]> stop slave;
Query OK, 0 rows affected (0.08 sec)

(2)查看当前slave节点的Executed_Gtid_Set值

MySQL [test2023]> show master status\G
*************************** 1. row ***************************
             File: binlog.000002
         Position: 4317
     Binlog_Do_DB:
 Binlog_Ignore_DB:
Executed_Gtid_Set: 9cc90407-ff89-11ed-8b7a-fa163e2d11e1:1-82,
a0c1d6ff-5764-11ee-94ea-fa163e2d11e1:1-11
1 row in set (0.01 sec)

(3)重做主从关系

MySQL [test2023]> CHANGE MASTER TO MASTER_HOST = '原slave节点的IP地址', MASTER_USER = '复制账户', MASTER_PASSWORD = '密码', MASTER_PORT = 端口, MASTER_AUTO_POSITION = 1 ;
Query OK, 0 rows affected, 2 warnings (0.21 sec)
 
MySQL [test2023]> start slave;
Query OK, 0 rows affected (0.05 sec)
MySQL [test2023]> show slave status\G
*************************** 1. row ***************************
               Slave_IO_State: Waiting for master to send event
                  Master_Host: XXX
                  Master_User: XXX
                  Master_Port: XXX
                Connect_Retry: 60
              Master_Log_File: binlog.000002
          Read_Master_Log_Pos: 4317
               Relay_Log_File: relay.000004
                Relay_Log_Pos: 445
        Relay_Master_Log_File: binlog.000002
             Slave_IO_Running: Yes
            Slave_SQL_Running: Yes
              Replicate_Do_DB:
          Replicate_Ignore_DB:
           Replicate_Do_Table:
       Replicate_Ignore_Table:
      Replicate_Wild_Do_Table:
  Replicate_Wild_Ignore_Table:
                   Last_Errno: 0
                   Last_Error:
                 Skip_Counter: 0
          Exec_Master_Log_Pos: 4317
              Relay_Log_Space: 726
              Until_Condition: None
               Until_Log_File:
                Until_Log_Pos: 0
           Master_SSL_Allowed: No
           Master_SSL_CA_File:
           Master_SSL_CA_Path:
              Master_SSL_Cert:
            Master_SSL_Cipher:
               Master_SSL_Key:
        Seconds_Behind_Master: 0
Master_SSL_Verify_Server_Cert: No
                Last_IO_Errno: 0
                Last_IO_Error:
               Last_SQL_Errno: 0
               Last_SQL_Error:
  Replicate_Ignore_Server_Ids:
             Master_Server_Id: 461470011
                  Master_UUID: a0c1d6ff-5764-11ee-94ea-fa163e2d11e1
             Master_Info_File: mysql.slave_master_info
                    SQL_Delay: 0
          SQL_Remaining_Delay: NULL
      Slave_SQL_Running_State: Slave has read all relay log; waiting for more updates
           Master_Retry_Count: 86400
                  Master_Bind:
      Last_IO_Error_Timestamp:
     Last_SQL_Error_Timestamp:
               Master_SSL_Crl:
           Master_SSL_Crlpath:
           Retrieved_Gtid_Set: a0c1d6ff-5764-11ee-94ea-fa163e2d11e1:11
            Executed_Gtid_Set: 9cc90407-ff89-11ed-8b7a-fa163e2d11e1:1-82,
a0c1d6ff-5764-11ee-94ea-fa163e2d11e1:1-11
                Auto_Position: 1
         Replicate_Rewrite_DB:
                 Channel_Name:
           Master_TLS_Version:
1 row in set (0.00 sec)

2.3.3 模拟业务正常写入

MySQL [test2023]> insert into test_autoincrement(name,uid) select '赵六',1004;
ERROR 1062 (23000): Duplicate entry '4' for key 'PRIMARY'

到这里我们看到了预期的报错现象,如果是正常业务系统,这里的主从节点表的AUTO_INCREMENT可能会相差非常大,业务的正常插入就会持续报错了。

意味着真实的操作是先做delete操作,然后再进行insert。

三、原因分析

3.1 为什么从库节点的 autoincrement 没有变化?

# at 10790
#230927 16:23:45 server id 46147000  end_log_pos 10863 CRC32 0x85c60fb7         Update_rows: table id 122 flags: STMT_END_F
 
BINLOG '
keYTZRO4JcACRQAAACYqAAAAAHoAAAAAAAEACHRlc3QyMDIzABJ0ZXN0X2F1dG9pbmNyZW1lbnQA
AwMPAwKQAQCCO6qB
keYTZR+4JcACSQAAAG8qAAAAAHoAAAAAAAEAAgAD///4AQAAAAYA5byg5LiJ6QMAAPgEAAAACQDl
vKDkuInkuLDpAwAAtw/GhQ==
'/*!*/;
### UPDATE `test2023`.`test_autoincrement`
### WHERE
###   @1=1 /* INT meta=0 nullable=0 is_null=0 */
###   @2='张三' /* VARSTRING(400) meta=400 nullable=0 is_null=0 */
###   @3=1001 /* INT meta=0 nullable=0 is_null=0 */
### SET
###   @1=4 /* INT meta=0 nullable=0 is_null=0 */
###   @2='张三丰' /* VARSTRING(400) meta=400 nullable=0 is_null=0 */
###   @3=1001 /* INT meta=0 nullable=0 is_null=0 */
# at 10863
#230927 16:23:45 server id 46147000  end_log_pos 10894 CRC32 0xe204d99b         Xid = 331
COMMIT/*!*/;

这里可以看到REPLACE INTO操作对应的binlog日志记录其实是update操作,从库节点在应用update操作时,发现命中数据时,对应的autoincrement是没有变化的。

3.2 REPLACE INTO 操作的官方定义是什么?

官方对于 REPLACE INTO 的定义如下:

摘选自
https://dev.mysql.com/doc/refman/5.7/en/replace.html

REPLACE works exactly like INSERT, except that if an old row in the table has the same value as a new row for a PRIMARY KEY or a UNIQUE index, the old row is deleted before the new row is inserted. See Section 13.2.5, “INSERT Statement”.

REPLACE is a MySQL extension to the SQL standard. It either inserts, or deletes and inserts. For another MySQL extension to standard SQL—that either inserts or updates—see Section 13.2.5.2, “INSERT ... ON DUPLICATE KEY UPDATE Statement”.

这里可以看到一张表包含主键或者唯一键的情况下,replace操作会判断原有的数据行是否存在,如果存在的话,就先删除旧的数据,然后进行insert操作,如果不存在的话,就和insert操作时一样的。

第二段也提到了INSERT ... ON DUPLICATE KEY UPDATE Statement ,其实这个操作也会造成上面的主从autoincrement不一致现象,这里就不展开讨论了。

! Note

REPLACE makes sense only if a table has a PRIMARY KEY or UNIQUE index. Otherwise, it becomes equivalent to INSERT, because there is no index to be used to determine whether a new row duplicates another.

3.3  为什么REPLACE INTO操作在binlog日志中记录的是update操作?

这里我们通过源码文件sql_insert.cc和log_event.cc进行分析。

sql_insert.cc:
...
/* Check if there is more uniq keys after field */
 
static int last_uniq_key(TABLE *table,uint keynr)
{
  /*
    When an underlying storage engine informs that the unique key
    conflicts are not reported in the ascending order by setting
    the HA_DUPLICATE_KEY_NOT_IN_ORDER flag, we cannot rely on this
    information to determine the last key conflict.
    
    The information about the last key conflict will be used to
    do a replace of the new row on the conflicting row, rather
    than doing a delete (of old row) + insert (of new row).
    
    Hence check for this flag and disable replacing the last row
    by returning 0 always. Returning 0 will result in doing
    a delete + insert always.
  */
  if (table->file->ha_table_flags() & HA_DUPLICATE_KEY_NOT_IN_ORDER){
    return 0;
  }
  while (++keynr < table->s->keys){
    if (table->key_info[keynr].flags & HA_NOSAME){
        return 0;
    }
  }
  return 1;
}
...
 
    /*
      The manual defines the REPLACE semantics that it is either
      an INSERT or DELETE(s) + INSERT; FOREIGN KEY checks in
      InnoDB do not function in the defined way if we allow MySQL
      to convert the latter operation internally to an UPDATE.
          We also should not perform this conversion if we have
          timestamp field with ON UPDATE which is different from DEFAULT.
          Another case when conversion should not be performed is when
          we have ON DELETE trigger on table so user may notice that
          we cheat here. Note that it is ok to do such conversion for
          tables which have ON UPDATE but have no ON DELETE triggers,
          we just should not expose this fact to users by invoking
          ON UPDATE triggers.
    */
    if (last_uniq_key(table,key_nr) &&
        !table->file->referenced_by_foreign_key() &&
            (!table->triggers || !table->triggers->has_delete_triggers()))
        {
          if ((error=table->file->ha_update_row(table->record[1],
                            table->record[0])) &&
              error != HA_ERR_RECORD_IS_THE_SAME)
            goto err;
          if (error != HA_ERR_RECORD_IS_THE_SAME)
            info->stats.deleted++;
          else
            error= 0;
          thd->record_first_successful_insert_id_in_cur_stmt(table->file->insert_id_for_cur_row);
          /*
            Since we pretend that we have done insert we should call
            its after triggers.
          */
          goto after_trg_n_copied_inc;
        }
        else
        {
...
        }
...

上述源码中可以看到在主库中replace 操作其实是insert 或者 delete + insert

The manual defines the REPLACE semantics that it is either an INSERT or DELETE(s) + INSERT;

而 MySQL 在主从同步的binlog日志中,将replace操作转换为update操作的条件为:当发生冲突的键是最后一个唯一键,且没有外键约束,且没有触发器,由于我们的测试表中是没有外键约束,也没有触发器的,所以从库接收到的binlog日志中转化为update的条件即为最后一个唯一键。

这里,我们再进行测试一下(去掉表中的唯一索引uid)。

(1)创建新表

CREATE TABLE `test_autoincrement_2` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '自增主键',
  `name` varchar(100) NOT NULL DEFAULT 'test' COMMENT '测试名字',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8mb4

(2)插入测试数据

insert into test_autoincrement_2(name) select '孙七';

insert into test_autoincrement_2(name) select '周八';

insert into test_autoincrement_2(name) select '吴九';

 
#此时主从表结构是一致的,如下:

CREATE TABLE `test_autoincrement_2` (

  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '自增主键',

  `name` varchar(100) NOT NULL DEFAULT 'test' COMMENT '测试名字',

  PRIMARY KEY (`id`)

) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4

(3)replace into 操作验证主库和从库的AUTO_INCREMENT

MySQL [test2023]> REPLACE INTO test_autoincrement_2 (id,name) values(3,'郑十');
Query OK, 2 rows affected (0.08 sec)

这里我们把id=3的这一行数据对应的name修改为’郑十’,可发现上述影响的行数是2。

再次验证主库和从库的AUTO_INCREMENT,发现并没有发生变化,还是4。

CREATE TABLE `test_autoincrement_2` (
  `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '自增主键',
  `name` varchar(100) NOT NULL DEFAULT 'test' COMMENT '测试名字',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4

(4)分析binlog日志文件

# at 8089
#230928 15:52:08 server id 461470011  end_log_pos 8151 CRC32 0xc2ff85bb         Update_rows: table id 481 flags: STMT_END_F
 
BINLOG '
qDAVZRM7eYEbRgAAAJkfAAAAAOEBAAAAAAEACHRlc3QyMDIzABR0ZXN0X2F1dG9pbmNyZW1lbnRf
MgACAw8CkAEAFSqQxg==
qDAVZR87eYEbPgAAANcfAAAAAOEBAAAAAAEAAgAC///8AwAAAAYA5ZC05Lmd/AMAAAAGAOmDkeWN
gbuF/8I=
'/*!*/;
### UPDATE `test2023`.`test_autoincrement_2`
### WHERE
###   @1=3 /* INT meta=0 nullable=0 is_null=0 */
###   @2='吴九' /* VARSTRING(400) meta=400 nullable=0 is_null=0 */
### SET
###   @1=3 /* INT meta=0 nullable=0 is_null=0 */
###   @2='郑十' /* VARSTRING(400) meta=400 nullable=0 is_null=0 */
# at 8151
#230928 15:52:08 server id 461470011  end_log_pos 8182 CRC32 0xaa39d2a4         Xid = 699
COMMIT/*!*/;
SET @@SESSION.GTID_NEXT= 'AUTOMATIC' /* added by mysqlbinlog */ /*!*/;
DELIMITER ;
# End of log file
/*!50003 SET COMPLETION_TYPE=@OLD_COMPLETION_TYPE*/;
/*!50530 SET @@SESSION.PSEUDO_SLAVE_MODE=0*/;

总结
:可发现binlog日志记录的同样是update 操作。只是当表中除了主键外没有额外的唯一键时,replace into的操作并不会触发从库的auto_increment的异常问题。比如上述的案例REPLACE INTO test_autoincrement_2 (id,name) values(3,'郑十');,这里仅更改了name字段,由‘吴九‘修改为’郑十’。但是主键id是没有变化的,当然也就不需要再次使用auto_increment,这里也可以看到主库的auto_increment当然也没有发现变化(当表中除了主键外含有额外的唯一键时,是会触发申请auto_increment的),binlog接收的仍然是update操作,所以从库的auto_increment也是没有变化的,这样就没法造成auto_increment和主库不一致的问题了。

四、解决方案

到这里,我们是明白了replace into 会造成主从的auto_increment 不一致,但是怎么去解决呢?

4.1 升级到 MySQL 8.0 版本

在 MySQL 8.0 版本中已将AUTO_INCREMENT值做了持久化,且在做更新操作时,会将表上的自增列被更新为比auto_increment更大的值,auto_increment值也将被更新。

4.2 修改 AUTO_INCREMENT 值

线上环境可能已经有很多这种情况,在没有触发业务报错的情况下,一般是很难发现这个隐患,如何在日常巡检中找到这些问题才是关键。

巡检逻辑一
:这里可以通过巡检判断从库的max(id) >= AUTO_INCREMENT的方式来找出已经存在问题的表信息。然后通过SQL语句:ALTER TABLE table_name AUTO_INCREMENT = new_value;  进行修改。

巡检步骤可参考:

(1)仅检测某从节点,包含auto_increment 属性的表,过滤SQL如下:

select TABLE_SCHEMA,TABLE_NAME,AUTO_INCREMENT from information_schema.tables where table_schema not in ('information_schema','mysql','performance_schema','sys') AUTO_INCREMENTis not null \G

(2)加锁后读表信息,语句如下:

① 给表加锁

lock tables table_name write;

②读取数据和表auto_increment值进行比对

MAXID=select max(id) from table_name;
AUTO_INCREMENT=select AUTO_INCREMENT from information_schema.tables where TABLE_NAME='t1' ;

③ 判断条件

如果MAXID >= AUTO_INCREMENT , 判断为异常

巡检逻辑二
:可以在高可用切换的时候增加AUTO_INCREMENT值判断,如果AUTO_INCREMENT值不一致,则不发生切换,不过这里的slave节点AUTO_INCREMENT的值本身可能因为延迟等问题,就会稍落后maste主节点,正常的巡检还是有难度的,还有就是当MySQL主从切换触发时,如果是因为原主库宕机了,不触发切换也会有问题,所以还是需要提前尽快把这个隐患排除掉。

4.3 禁用 replace into 操作

业务侧禁用replace into 或 insert ... on duplicate  key update ,实现方式可以通过代码逻辑来实现。

4.4 replace into操作的表不增加其他唯一索引

这里其实实现还是有难度的,自增id是不可控的,业务一般是不会使用数据库自带的自增id。

五、问题总结

1. REPLACE INTO 操作在表存在自增主键且包含唯一索引的情况下,当出现数据冲突的时候,会触发AUTO_INCREMENT在主从节点的不一致,一旦主从发生切换,就会造成业务的写入报主键冲突的错误。解决建议:业务更改实现方式,避免使用replace into,或者使用MySQL8.0 及以上的版本来解决该问题。

2. 该问题是一个官方的BUG,不过并没有在MySQL5.7的版本中得到修复 。

https://bugs.mysql.com/bug.php?id=83030

参考文献:

  1. https://bugs.mysql.com/bug.php?id=83030

  2. https://dev.mysql.com/worklog/task/?id=6204

  3. https://bugs.mysql.com/bug.php?id=20188

春风轻拂的4月,OpenAtom OpenHarmony(以下简称“OpenHarmony”)4.1 Release版本如期而至,开发套件同步升级到API 11 Release。

相比4.0 Release版本,4.1 Release版本应用开发的开放能力以全新的Kit维度呈现,提供给开发者更清晰的逻辑和场景化视角;新增4000多个API,应用开发能力更加丰富;ArkUI组件开放性和动效能力得到进一步增强;Web能力持续补齐,便于开发者利用Web能力快速构建应用;分布式能力进一步增强了组网稳定性、连接安全性等;媒体支持更丰富的编码、更精细的播控能力等。期待开发者积极体验新特性并给我们提出宝贵意见。

本文仅描述新版本的部分新特性,请您参考OpenHarmony 4.1 Release Notes了解版本所有新增及增强功能。

OpenHarmony 4.1 Release Notes
https://gitee.com/openharmony/docs/blob/master/zh-cn/release-notes/OpenHarmony-v4.1-release.md

ArkUI

  • 新增NodeContainer开放命令式的渲染节点,提升自定义绘制能力。
  • 文本和容器类组件能力增强
    ▸ TextInput/Text支持按字符截断。
    ▸ TextInput和TextArea提供获取光标位置接口。
    ▸ 支持智能分词、新增依据分词结果插入光标逻辑以及替换分词算法。
    ▸ ImageSpan支持自定义长按菜单事件、控件支持缩进/对齐。
    ▸ List:ScrollToIndex支持滚动到ListItemGroup中指定ListItem的能力。
  • 控件AI化能力能力增强
    ▸ TextInput及相关文本输入控件支持视觉输入。
    ▸ Text/RichEditor相关文本控件支持文本实体识别。
  • 状态管理功能增强
    ▸ 支持undefined和null,以及联合类型。
    ▸ ListItem组件在ForEach/LazyForEach中属性可更新方法。
    ▸ 支持@LocalStorageLink/LocalStorageProp 在非激活状态不更新。
  • 新增Chips操作块组件,TextInput、TextArea、List、Grid、Search、CheckBox、Slider、Image、Menu、半模态弹窗等组件的样式、交互和动效增强。
  • 弹窗类和导航类自定义能力增强
    ▸ 支持开发者自定义弹出菜单的圆角、阴影、气泡箭头。
    ▸ bindContextMenu支持isShow参数控制显隐。
    ▸ Navigation支持隐藏NavBar。
    ▸ Navigation组件提供获取路由栈每个页面详细信息,支持根据页面信息销毁或显示页面。
  • 提供全新Style样式对象和组件Style样式属性方法,支持样式复用和动态切换能力,包括:
    ▸ 通用属性样式支持Style样式对象。
    ▸ 组件特有属性样式支持Style样式派生对象。
    ▸ 多态样式切换到Style样式对象。

Web
• 新增支持Web的无障碍节点查询和上报能力。
• 新增页面跳转事件上报接口。
• 支持应用级网络代理、应用证书管理。
• 支持同层渲染能力(仅限XComponent、Button等部分组件)。
• 组件支持DOM构建完成后执行提前被注入的JS脚本。
• 开放RegisterJavaScriptProxy、RunJavaScript能力的C API接口。
• 资源拦截特性支持设置为ArrayBuffer数据类型。

图形图像及窗口
图形图像

  • 系统支持可变帧率,提供API供业务接入。
  • 支持HDR Vivid视频的渲染与显示。
  • 图形NDK能力增强,支持为NativeImage添加OnFrameAvailableListener回调,支持为NativeWindow设置色域,新增支持OpenGL扩展接口,Drawing 能力进一步完善等。
  • 支持录屏不录制特定窗口,以及隐私窗口录制成全黑帧画面的能力。
  • 支持调整系统分辨率。
  • 支持系统根据动画调节帧率,支持开发者调节应用业务帧率。
  • 动效能力增强,支持硬件挖孔、屏幕圆角、Navigation导航转场动画;支持共享元素等。
  • 图形渲染管线支持Vulkan后端。
  • 图形接入Drawing接口。
  • NativeWindow支持设置metadata,适配HDR视频场景动态元数据随帧传递。

窗口

  • 提供Window stage可交互状态通知。
  • 提供全局搜索窗口类型。

安全

  • 证书管理:支持开放用户CA证书路径、支持证书链校验和吊销检查能力、支持证书链构造的能力。
  • 关键资产存储:针对应用开发者需要在本地加密存储关键敏感的短数据(如用户的账号密码、银行卡号等)诉求,系统提供关键资产加密存储的能力,以及相应的安全访问控制能力,包括:
    ▸ 支持关键资产写入、读取。
    ▸ 支持关键资产更新。
    ▸ 支持关键资产安全销毁。
    ▸ 支持关键资产访问控制。

程序访问控制
权限管理

  • 支持在UIExtensionAbility界面上拉起权限弹窗。
  • 权限使用记录增加锁屏状态记录。
  • 支持应用在使用期间弹出允许权限的选项。

DLP权限管理服务

  • 支持以时间维度对受控文件进行访问控制。
  • 支持文档创建者在任意场景下可离线打开文档。
  • 支持帐号未登录状态下,弹框进行帐号登录验证。
  • 提供沙箱应用读取原始应用数据的机制和通路。

分布式数据管理

  • UDMF支持数据类型标准化定义与描述、支持标准数据类型查询、支持数据访问授权与管理、支持应用自定义数据类型。
  • 新增限制数据不打分类分级标签则不允许跨设备同步。
  • Preferences支持Uint8Array格式数据流的修改、查询和持久化。
  • RDB支持在应用指定的沙箱路径子目录下创建数据库。

ArkCompiler

  • 动态import能力支持变量作为参数。
  • 提供运行时对类方法插桩和替换的API。
  • 支持用“#”声明类的私有成员。
  • 支持Sendable类的跨线程序列化传输。
  • 支持Ecmascript2022规范。

测试框架
自动化测试框架arkxtest

  • 支持模拟鼠标滚轮滑动、滑动左右键双击等模拟UI操作能力。
  • 支持Shell命令方式进行UI模拟操作能力,支持点击、滑动、双击、文本输入等常用操作能力。
  • 提升UI测试框架查找控件信息效率。
  • 增强dump信息内容,新增文字大小、文字颜色信息。
  • 增加异步监听能力,监听系统弹框事件,获取其文本信息并返回。

测试调度框架xDevice

  • 新增单次测试过程中自动复测失败项能力,并支持配置复测次数,最终生成一份测试报告。
  • 优化测试报告,统一轻量系统、小型系统、标准系统的测试报告头信息。
  • 新增支持测试套测试资源本地不存在时,可配置远程下载地址。

稳定性测试工具WuKong

  • 新增page页面和Ability页面的配置能力,支持在测试过程中配置页面屏蔽,提升测试效率。
  • 新增单一场景压测能力,支持针对某一控件循环注入操作事件,并支持配置循环次数。

性能开发工具SmartPerf Host

  • 新增支持hilog、hisysevent的采集、分析和展示。
  • 新增支持hiperf event count的分析和展示。
  • 提升抓取trace的能力,动态可暂停可调试。
  • 新增支持线程唤醒关系树的快速跟踪。

性能测试工具 SmartPerf Device

  • 优化已有抓取内存、CPU数据的能力。
  • 新增启动停止采集的命令。
  • 新增定时获取截图、分辨率和刷新率的采集能力。