1、前言
我们在大部分开发场景下,对持久层的建设基于单库单表其实就可以实现当前的产品需求。但是随着业务发展越来越久,数据量、请求量也在不断的增加,只是单库单表可能不足以支撑系统的稳定运行,本文主要给大家分享一下笔者在项目实际迭代过程中对持久层稳定性的建设过程。
2、项目简介
简单来讲就是用户在一些活动场景下获取优惠券信息,领取并绑定到关系表里,后续用户去售卖一些商品的时候可以从领取的优惠券列表里选择一个合适的优惠券来使用。
3、面临的问题
3.1 数据越来越多
项目初期,单表完全可以hold住系统的稳定运行,但是由于优惠券的发放门槛特别低,导致优惠券的数量随着业务的发展激增,用户领券的关系表数量也越来越多,为了避免以后单表数据量过大带来的不必要的麻烦,我们对绑定关系表进行分表处理。
3.1.1 技术选型
目前市面上对于分库分表的方案大体分为三类:
1.基于JDBC进行代理:该方案不需要运维等人员的介入,技术内部即可进行开发优化。
2.基于数据库进行代理:该方案需要DBA或者运维的介入,维护起来不方便。
3.TiDB数据库:支持无限的水平扩展,具备强一致性和高可用性,编码层面的使用跟MYSQL无异。
最终选型
以上三种方案,笔者这边最终选择了基于JDBC进行代理,因为这种方案可以纯内部进行消化,不需要外部部门介入,对于开发成本、时间周期来讲都是比较容易弹性调整的,后续有改造也不需要外部介入。
至于框架的选择选择了ShardingJDBC,原因以下几点:
1.社区活跃,遇到问题可以快速收到反馈。
2.框架经过多年演进,已经是很稳定且成熟的产品。
3.公司内部应用广泛,可以协助共建。
分库分表如何设计?
分库分表扩容涉及到重新hash分片的问题,极其麻烦,所以最好一步到位,短期内不进行扩容操作。
我们基于数据当前的增长速度,简单计算下未来十年可能带来的数据量,计算出8库8表即可满足该场景。
查询场景都是基于用户维度,所以拿uid作为分片键即可。
增长速度远超预期怎么办?
即使增长速度远超预期也不打算进行扩容操作,因为成本过高。优惠券过期时间很短,用户在优惠券过期一定时间后就可以考虑将优惠券进行归档操作,这样即可保证数据量稳定在我们预期之内。
为什么不用TiDB?
由于笔者对TiDB了解不深,考虑到遇到问题不易快速定位、解决,且该表对于业务流程至关重要,所以暂不考虑使用TiDB来存储。
3.1.2 数据迁移流程
迁移流程大体如下:
1 延迟双写
我们先插入或修改旧表数据,成功之后再去写入或修改新表,然后发送一个延迟消息,消息触达之后进行新老数据核对,如果数据存在异常则进行修正,令其保持一致。
2 数据清洗
设置一个时间节点,将该时间点前的主键id全部跑出来,然后在脚本任务里,实时去查询该主键id对应的最新数据,写入到新表中。
3 异步纠错
迁移后的一定时间内,查询的时候对新老数据进行校验,如有不一致数据进行异步修复。
具体流程如图:
3.2 查询越来越复杂
3.2.1 初期方案
优惠券由于查询条件比较复杂(涉及到数组查询、模糊查询),且随着业务发展不断追加新的查询条件,导致不太适合每个查询条件作为单独的字段存储,故而放到了一个json里统一维护,但是这种存储方式查询的时候就无法直接利用mysql进行过滤。
例如:小明想查询一个条件为:iPhone13非全新机、价格满1000元可用、以旧换新场景下、邮寄售卖可用的优惠券。
最初数据量不多的时候直接把配置表全部拿出来机型进行内存过滤,拿着满足条件的配置id去绑定关系表里进行查找。
3.2.2 临时改进方案
随着产品不断创建优惠券进行精细化投放,热门机型都会有对应的优惠券,库里的券大概有几百条。这样每次都要从库里全量拉出几百条进行处理的话显然99.9%的数据都是不必要的,因为用户只需要一张券,所以考虑成本最小的临时改进方案就是将优惠券放到内存中进行缓存,通过内存过滤减少每个请求过来造成的不必要的额外查询,降低gc频率。
这里借鉴了一些中间件同步缓存数据的方案,进行推拉结合的方式,一方面实时广播推送保证时效性,另一方面定时去拉数据来进行兜底处理。
但是本方案也不是长久之计,随着券的不断创建,内存中过滤的id可能会命中的特别多,这样查询的时候性能也会很糟糕,所以在时间充裕的时候考虑介入其他更适合的中间件,虽然成本高,但是能从根本上是解决问题。
3.2.3 接入ElasticSearch中间件
通过调研发现公司内部比较适合的查询中间件只有ElasticSearch,市面上也可能有其他适合的中间件,但还需要考虑额外的搭建、运维维护的成本,使用ElasticSearch就足够解决该问题。
这里实际接入流程不做多赘述,有兴趣的可以参考相关的文章。
不过使用ElasticSearch也有一个缺点,就是数据写入到查询存在一定的延迟,并且我们这边有的场景还对时效性要求很高,例如:系统在请求的开始阶段给用户发一张券,用户拿到后还会再去获取最优券,这张券直接查可能会获取不到。
原来的兼容方案是写入成功后业务内部把id带到上下文在内存中进行过滤,这样需要兼容的地方很多,且每个场景都要单独处理。
那我是如何解决的?
我这边通过Redis+ElasticSearch 联动查询来保证时效性,在写入成功之后将配置id同步保存到Redis的zset结构中,设置个10s的过期时间。
当有查询过来的时候,同时查询ElasticSearch与redis中的数据,然后合并过滤获取出最合适的券。
一些性能优化手段:
1.查询只返回需要的字段信息。
2.定义索引的时候使用合适的字段。
3.限制数据总量,根据实际场景做数据归档。
4.减少索引范围,强制根据uid进行分片路由。
3.3 请求量越来越大
3.3.1 读写分离
随着业务qps越来越高,每逢大促写入、查询的流量都会激增,所以经常收到关于主库流量太高的数据库告警,为了应对各种带来的尖刺流量,保证主库的稳定,进行了读写分离,减缓主库写入的压力。
主从延迟怎么解决?
有一种最简单粗暴的方案,单独提供主库的查询接口,但是这种对于调用方改造成本极大, 况且提供了主库接口之后可能很多人都不会去再使用从库了,从而无法达到读写分离的效果。
Object getByInfoFromMater(Long id);
理想中的方案
我这边调研了下是否有中间件能帮我实现主从选取的能力,即在主从同步成功之后才进行从库的读取,否则都是读取主库。
优点:服务方无感知
缺点:可能对性能造成影响
不过没找到这种中间件,所以我这边针对于这种方案用redis做了个一个简化版:
通过定义注解来控制是否执行该组件:
设置了写入注解的方法:内部全部使用主库进行读操作,保证一致性,且设置2s左右过期时间的TAG。
设置读注解的方法:内部判断TAG是否存在,存在则走主库,否则从库。
这种方案也会带来负面影响:
带有注解的方法都要查询一次redis,耗时会增高, 且如果2s内主从同步失败,还是会存在查询不一致的情况,当然考虑实际场景,这种概率微乎其微,我们业务是可以接受的。
4、总结
在从0到1做一个项目的时候,没必要过度设计,应该快速上线,保证系统正常运行即可。项目初期可以先遇到问题再去解决问题,但是项目具备一定的流量之后,需要提前发现项目痛点并规划如何解决,否则等到真正遇到问题,再去解决可能已经来不及了,留给我们解决的时间已经不多了。
以上都是笔者在实际工作中的总结、归纳,各位如果有更好的方案或是不同的见解,欢迎评论区留言,共同讨论、进步。