注册
环信即时通讯云

环信即时通讯云

单聊、群聊、聊天室...
环信开发文档

环信开发文档

Demo体验

Demo体验

场景Demo,开箱即用
RTE开发者社区

RTE开发者社区

汇聚音视频领域技术干货,分享行业资讯
技术讨论区

技术讨论区

技术交流、答疑
资源下载

资源下载

收集了海量宝藏开发资源
iOS Library

iOS Library

不需要辛辛苦苦的去找轮子, 这里都有
Android Library

Android Library

不需要辛辛苦苦的去找轮子, 这里都有

接手了一个外包开发的项目,我感觉我的头快要裂开了~

嗨,大家好,我是飘渺。 最近,我和小伙伴一起接手了一个由外包团队开发的微服务项目,这个项目采用了当前流行的Spring Cloud Alibaba微服务架构,并且是基于一个“大名鼎鼎”的微服务开源脚手架(附带着模块代码截图,相信很多同学一看就能认出来)。然而,...
继续阅读 »

嗨,大家好,我是飘渺。


最近,我和小伙伴一起接手了一个由外包团队开发的微服务项目,这个项目采用了当前流行的Spring Cloud Alibaba微服务架构,并且是基于一个“大名鼎鼎”的微服务开源脚手架(附带着模块代码截图,相信很多同学一看就能认出来)。然而,在这段时间里,我受到了来自"外包"和"微服务"这双重debuff的折磨。


image-20231016162237399


今天,我想和大家分享一下我在这几天中遇到的问题。希望这几个问题能引起大家的共鸣,以便在未来的微服务开发中避免再次陷入相似的困境。


1、服务模块拆分不合理


绝大部分网上的微服务开源框架都是基于后台管理进行模块拆分的。然而在实际业务开发中,应该以领域建模为基础来划分子服务。


目前的服务拆分方式往往是按照团队或功能来拆分,这种不合理的拆分方式导致了服务调用的混乱,同时增加了分布式事务的风险。


2、微服务拆分后数据库并没拆分


所有服务都共用同一个数据库,这在物理层面无法对数据进行隔离,也导致一些团队为了赶进度,直接读取其他服务的数据表。


这里不禁要问:如果不拆分数据库,那拆分微服务还有何意义?


3、功能复制,不是双倍快乐


在项目中存在一个基础设施模块,其中包括文件上传、数据字典、日志等基础功能。然而,文件上传功能居然在其他模块中重复实现了一遍。就像这样:


image-20231017185809403


4、到处都是无用组件堆彻


在项目的基础模块中,自定义了许多公共的Starter,并且这些组件在各个微服务中被全都引入。比如第三方登录组件、微信支付组件、不明所以的流程引擎组件、验证码组件等等……


image.png


拜托,我们已经有自己的SSO登录,不需要微信支付,还有自己的流程引擎。那些根本用不到的东西,干嘛要引入呢?


5、明显的错误没人解决


这个问题是由上面的问题所导致的,由于引入了一个根本不需要的消息中间件,项目运行时不断出现如下所示的连接异常。


image-20231013223714103


项目开发了这么久,出错了这么久,居然没有一个人去解决,真的让人不得不佩服他们的忍受力。


6、配置文件一团乱麻


你看到服务中这一堆配置文件,是不是心里咯噔了一下?


image-20231017190214587


或许有人会说:"没什么问题呀,按照不同环境划分不同的配置文件”。可是在微服务架构下,已经有了配置中心,为什么还要这么做呢?这不是画蛇添足吗?


7、乱用配置中心


项目一开始就明确要使用Apollo配置中心,一个微服务对应一个appid,appid一般与application.name一致。


但实际上,多个服务却使用了相同的appid,多个服务的配置文件还塞在了同一个appid下。


更让人费解的是,有些微服务又不使用配置中心。


8、Nacos注册中心混乱


由于项目有众多参与的团队,为了联调代码,开发人员在启动服务时不得不修改配置文件中Nacos的spring.cloud.nacos.discovery.group属性,同时需要启动所有相关服务。


这导致了两个问题:一是某个用户提交了自己的配置文件,导致其他人的服务注册到了别的group,影响他人的联调;二是Nacos注册中心会存在一大堆不同的Gr0up,查找服务变得相当麻烦。


其实要解决这个问题只需要重写一下网关的负载均衡策略,让流量调度到指定的服务即可。据我所知,他们使用的开源框架应该支持这个功能,只是他们不知道怎么使用。


9、接口协议混乱


使用的开源脚手架支持Dubbo协议和OpenFeign调用,然而在我们的项目中并不会使用Dubbo协议,微服务之间只使用OpenFeign进行调用。然而,在对外提供接口时,却暴露了一堆支持Dubbo协议的接口。


10、部署方式混乱


项目部署到Kubernetes云环境,一般来说,服务部署到云上的内部服务应该使用ClusterIP的方式进行部署,只有网关服务需要对外访问,网关可以通过NodePort或Ingress进行访问。


这样做可以避免其他人或服务绕过网关直接访问后端微服务。


然而,他们的部署方式是所有服务都开启了NodePort访问,然后在云主机上还要部署一套Nginx来反向代理网关服务的NodePort端口。


image-20231016162150035


结语


网络上涌现着众多微服务开源脚手架,它们吸引用户的方式是将各种功能一股脑地集成进去。然而,它们往往只是告诉你“如何集成”却忽略了“为什么要集成”。


尽管这些开源项目能够在学习微服务方面事半功倍,但在实际微服务项目中,我们不能盲目照搬,而应该根据项目的实际情况来有选择地裁剪或扩展功能。这样,我们才能更好地应对项目的需求,避免陷入不必要的复杂性,从而更加成功地实施微服务架构。


最后,这个开源项目你们认识吗?


image-20231017190633190


作者:飘渺Jam
来源:juejin.cn/post/7291480666087964732
收起阅读 »

localhost和127.0.0.1的区别是什么?

今天在网上逛的时候看到一个问题,没想到大家讨论的很热烈,就是标题中这个: localhost和127.0.0.1的区别是什么? 前端同学本地调试的时候,应该没少和localhost打交道吧,只需要执行 npm run 就能在浏览器中打开你的页面窗口,地址栏显...
继续阅读 »

今天在网上逛的时候看到一个问题,没想到大家讨论的很热烈,就是标题中这个:


localhost和127.0.0.1的区别是什么?



前端同学本地调试的时候,应该没少和localhost打交道吧,只需要执行 npm run 就能在浏览器中打开你的页面窗口,地址栏显示的就是这个 http://localhost:xxx/index.html


可能大家只是用,也没有去想过这个问题。


联想到我之前合作过的一些开发同学对它们俩的区别也没什么概念,所以我觉得有必要普及下。


localhost是什么呢?


localhost是一个域名,和大家上网使用的域名没有什么本质区别,就是方便记忆。


只是这个localhost的有效范围只有本机,看名字也能知道:local就是本地的意思。


张三和李四都可以在各自的机器上使用localhost,但获取到的也是各自的页面内容,不会相互打架。


从域名到程序


要想真正的认清楚localhost,我们还得从用户是如何通过域名访问到程序说起。


以访问百度为例。


1、当我们在浏览器输入 baidu.com 之后,浏览器首先去DNS中查询 baidu.com 的IP地址。


为什么需要IP地址呢?打个比方,有个人要寄快递到你的公司,快递单上会填写:公司的通讯地址、公司名称、收件人等信息,实际运输时快递会根据通信地址进行层层转发,最终送到收件人的手中。网络通讯也是类似的,其中域名就像公司名称,IP地址就像通信地址,在网络的世界中只有通过IP地址才能找到对应的程序。


DNS就像一个公司黄页,其中记录着每个域名对应的IP地址,当然也有一些域名可能没做登记,就找不到对应的IP地址,还有一些域名可能会对应多个IP地址,DNS会按照规则自动返回一个。我们购买了域名之后,一般域名服务商会提供一个域名解析的功能,就是把域名和对应的IP地址登记到DNS中。


这里的IP地址从哪里获取呢?每台上网的电脑都会有1个IP地址,但是个人电脑的IP地址一般是不行的,个人电脑的IP地址只适合内网定位,就像你公司内部的第几栋第几层,公司内部人明白,但是直接发给别人,别人是找不到你的。如果你要对外部提供服务,比如百度这种,你就得有公网的IP地址,这个IP地址一般由网络服务运营商提供,比如你们公司使用联通上网,那就可以让联通给你分配一个公网IP地址,绑定到你们公司的网关服务器上,网关服务器就像电话总机,公司内部的所有网络通信都要通过它,然后再在网关上设置转发规则,将网络请求转发到提供网络服务的机器上。


2、有了IP地址之后,浏览器就会向这个IP地址发起请求,通过操作系统打包成IP请求包,然后发送到网络上。网络传输有一套完整的路由协议,它会根据你提供的IP地址,经过路由器的层层转发,最终抵达绑定该IP的计算机。


3、计算机上可能部署了多个网络应用程序,这个请求应该发给哪个程序呢?这里有一个端口的概念,每个网络应用程序启动的时候可以绑定一个或多个端口,不同的网络应用程序绑定的端口不能重复,再次绑定时会提示端口被占用。通过在请求中指定端口,就可以将消息发送到正确的网络处理程序。


但是我们访问百度的时候没有输入端口啊?这是因为默认不输入就使用80和443端口,http使用80,https使用443。我们在启动网络程序的时候一定要绑定一个端口的,当然有些框架会自动选择一个计算机上未使用的端口。



localhost和127.0.0.1的区别是什么?


有了上边的知识储备,我们就可以很轻松的搞懂这个问题了。


localhost是域名,上文已经说过了。


127.0.0.1 呢?是IP地址,当前机器的本地IP地址,且只能在本机使用,你的计算机不联网也可以用这个IP地址,就是为了方便开发测试网络程序的。我们调试时启动的程序就是绑定到这个IP地址的。


这里简单说下,我们经常看到的IP地址一般都是类似 X.X.X.X 的格式,用"."分成四段。其实它是一个32位的二进制数,分成四段后,每一段是8位,然后每一段再转换为10进制的数进行显示。


那localhost是怎么解析到127.0.0.1的呢?经过DNS了吗?没有。每台计算机都可以使用localhost和127.0.0.1,这没办法让DNS来做解析。


那就让每台计算机自己解决了。每台计算机上都有一个host文件,其中写死了一些DNS解析规则,就包括 localhost 到 127.0.0.1 的解析规则,这是一个约定俗成的规则。


如果你不想用localhost,那也可以,随便起个名字,比如 wodehost,也解析到 127.0.0.1 就行了。


甚至你想使用 baidu.com 也完全可以,只是只能自己自嗨,对别人完全没有影响。


域名的等级划分


localhost不太像我们平常使用的域名,比如 http://www.juejin.cn 、baidu.com、csdn.net, 这里边的 www、cn、com、net都是什么意思?localhost为什么不需要?


域名其实是分等级的,按照等级可以划分为顶级域名、二级域名和三级域名...


顶级域名(TLD):顶级域名是域名系统中最高级别的域名。它位于域名的最右边,通常由几个字母组成。顶级域名分为两种类型:通用顶级域名和国家顶级域名。常见的通用顶级域名包括表示工商企业的.com、表示网络提供商的.net、表示非盈利组织的.org等,而国家顶级域名则代表特定的国家或地区,如.cn代表中国、.uk代表英国等。


二级域名(SLD):二级域名是在顶级域名之下的一级域名。它是由注册人自行选择和注册的,可以是个性化的、易于记忆的名称。例如,juejin.cn 就是二级域名。我们平常能够申请到的也是这种。目前来说申请 xxx.com、xxx.net、xxx.cn等等域名,其实大家不太关心其顶级域名com\net\cn代表的含义,看着简短好记是主要诉求。


三级域名(3LD):三级域名是在二级域名之下的一级域名。它通常用于指向特定的服务器或子网。例如,在blog.example.com中,blog就是三级域名。www是最常见的三级域名,用于代表网站的主页或主站点,不过这只是某种流行习惯,目前很多网站都推荐直接使用二级域名访问了。


域名级别还可以进一步细分,大家可以看看企业微信开放平台这个域名:developer.work.weixin.qq.com,com代表商业,qq代表腾讯,weixin代表微信,work代表企业微信,developer代表开发者。这种逐层递进的方式有利于域名的分配管理。


按照上边的等级定义,我们可以说localhost是一个顶级域名,只不过它是保留的顶级域,其唯一目的是用于访问当前计算机。


多网站共用一个IP和端口


上边我们说不同的网络程序不能使用相同的端口,其实是有办法突破的。


以前个人博客比较火的时候,大家都喜欢买个虚拟主机,然后部署个开源的博客程序,抒发一下自己的感情。为了挣钱,虚拟主机的服务商会在一台计算机上分配N多个虚拟主机,大家使用各自的域名和默认的80端口进行访问,也都相安无事。这是怎么做到的呢?


如果你有使用Nginx、Apache或者IIS等Web服务器的相关经验,你可能会接触到主机头这个概念。主机头其实就是一个域名,通过设置主机头,我们的程序就可以共用1个网络端口。


首先在Nginx等Web程序中部署网站时,我们会进行一些配置,此时在主机头中写入网站要使用的域名。


然后Nginx等Web服务器启动的时候,会把80端口占为己有。


然后当某个网站的请求到达Nginx的80端口时,它会根据请求中携带的域名找到配置了对应主机头的网络程序。


然后再转发到这个网络程序,如果网络程序还没有启动,Nginx会把它拉起来。


私有IP地址


除了127.0.0.1,其实还有很多私有IP地址,比如常见的 192.168.x.x。这些私有IP地址大部分都是为了在局域网内使用而预留的,因为给每台计算机都分配一个独立的IP不太够用,所以只要局域网内不冲突,大家就可劲的用吧。你公司可以用 192.168.1.1,我公司也可以用192.168.1.1,但是如果你要访问我,就得通过公网IP进行转发。


大家常用的IPv4私有IP地址段分为三类:


A类:从10.0.0.0至10.255.255.255


B类:从172.16.0.0至172.31.255.255


C类:从192.168.0.0至192.168.255.255。


这些私有IP地址仅供局域网内部使用,不能在公网上使用。


--


除了上述三个私有的IPv4地址段外,还有一些保留的IPv4地址段:


用于本地回环测试的127.0.0.0至127.255.255.255地址段,其中就包括题目中的127.0.0.1,如果你喜欢也可以给自己分配一个127.0.0.2的IP地址,效果和127.0.0.1一样。


用于局域网内部的169.254.0.0至169.254.255.255地址段,这个很少接触到,如果你的电脑连局域网都上不去,可能会看到这个IP地址,它是临时分配的一个局域网地址。


这些地址段也都不能在公网上使用。


--


近年来,还有一个现象,就是你家里或者公司里上网时,光猫或者路由器对外的IPv4地址也不是公网IP了,这时候获得的可能是一个类似 100.64.x.x 的地址,这是因为随着宽带的普及,运营商手里的公网IP也不够了,所以运营商又加了一层局域网,而100.64.0.0 这个网段是专门分给运营商做局域网用的。如果你使用阿里云等公有云,一些云产品的IP地址也可能是这个,这是为了将客户的私有网段和公有云厂商的私有网段进行有效的区分。


--


其实还有一些不常见的专用IPv4地址段,完整的IP地址段定义可以看这里:http://www.iana.org/assignments…



IPv6


你可能也听说过IPv6,因为IPv4可分配的地址太少了,不够用,使用IPv6甚至可以为地球上的每一粒沙子分配一个IP。只是喊了很多年,大家还是喜欢用IPv4,这里边原因很多,这里就不多谈了。


IPv6地址类似:XXXX:XXXX:XXXX:XXXX:XXXX:XXXX:XXXX:XXXX

它是128位的,用":"分成8段,每个X是一个16进制数(取值范围:0-F),IPv6地址空间相对于IPv4地址有了极大的扩充。比如:2001:0db8:3c4d:0015:0000:0000:1a2f:1a2b 就是一个有效的IPv6地址。


关于IPv6这里就不多说了,有兴趣的可以再去研究下。


关注萤火架构,加速技术提升!


作者:萤火架构
来源:juejin.cn/post/7321049446443417638
收起阅读 »

rpc比http好吗,缪论?

是什么,如何理解 RPC(Remote Procedure Call) 直译就是远程过程调用 HTTP(HyperText Transfer Protorl) 直译就是超文本传输协议 RPC和HTTP都是 请求-响应协议,但是因为出现的时机、设计理念、约定协议...
继续阅读 »

是什么,如何理解


RPC(Remote Procedure Call) 直译就是远程过程调用


HTTP(HyperText Transfer Protorl) 直译就是超文本传输协议


RPC和HTTP都是 请求-响应协议,但是因为出现的时机、设计理念、约定协议、效率、应用范围、使用规则等不同,所以是不同的名字,本质都是为了分布式系统间的通信而生,是一种应用层通信(请求-响应)协议(从OSI网络模型来看)。



  • RPC是 Bruce Jay Nelson 在1981年创造的术语,HTTP是在1990年左右产生的(可以参看维基百科)


RPC协议 和 RPC,到底叫什么?RPC协议=RPC


HTTP协议、HTTP,到底叫什么?HTTP协议=HTTP


RPC|HTTP只是大家的简称



  • HTTP协议不仅仅只有协议,还有超文本,传输,以及很多功能(比如编解码、面试经常背的各种参数的作用)

  • RPC协议也不仅仅只有协议,还有 编解码,服务注册发现,负载均衡等


RPC协议本质上定义了一种通信的流程,而具体的实现技术是没有约束的,每一种RPC框架都有自己的实现方式,我认为HTTP也是RPC的一种实现方式


协议直白来讲是一种约定,rpc和http都是为了服务器间的通信而生,都需要制定一套标准协议来进行通信。不过HTTP比较火,是一个全世界的统一约定,使用比较广泛。但通用也意味着冗余,所以后来又产生了很多RPC框架(自定义协议,具备优秀的性能等)


我们可以自定义RPC请求/响应 包含的消息头和消息体结构,自定义编解码方式,自定义网络通信方式,只要clientserver消息的发送和解析能对应即可,这些问题确认下来,一个RPC框架就设计出来了


下面先从请求过程看一下RPC和HTTP都会经历哪些阶段,然后再分阶段去做对比


一次请求的过程



阶段阶段分层RPCHTTP
client: 业务逻辑xx业务逻辑层
client: 客户端构造请求,发起调用编解码thrift|json|protobuf等json|图片等
client: 根据传输协议构造数据流协议层thrift|gRPC|Kitex|dubbo等HTTP1 |HTTP1.1|HTTP2|QUIC等
client: 服务发现服务发现自定义内部服务发现组件DNS
client: 网络通信:传输数据流网络通信层接口层:netty|netpool,根据OS的API做了一些封装本质:TCP|UDP|HTTP系列接口层:HTTP内部自己实现,目前不清楚咋做的本质:TCP|UDP
server: 把数据流解析为协议结构协议层略,同上略,同上
server: 解析协议中的请求体编解码略,同上略,同上
server: 执行业务逻辑xx业务逻辑层略,同上略,同上

从请求链路可以看到,最核心的只有三层:编解码、协议、网络通信


下面会从这3个角度去对比HTTP和RPC


HTTP VS RPC自定义协议


HTTP和RPC 2个关键词不具备可比较性,因为RPC包含了HTTP。


但是RPC自定义协议(thrift, protobuf, dubbo, kitex-thrift等) 是RPC的具体实现,HTTP也是RPC的具体实现,它们是具备可比较性的


编解码(序列化)



  • 序列化: 指将程序运行过程中的动态内存数据(java的class、go的struct)转化为硬盘中静态二进制数据的过程,以方便网络传输。

  • 反序列化:指将硬盘中静态二进制数据转化为程序运行过程中的动态内存数据的过程,以方便程序计算。


HTTP/1.1 一般用json


自定义RPC协议 一般用 thrift、protobuf


kitex序列化协议


维度json(HTTP/1.1)protobuf(gRPC)
优点1. 可读性好、使用简单,学习成本低1. 序列化后的体积比json小 => 传输效率高
2. 序列化/反序列化速度快 => 性能损耗小
缺点1. JSON 进行序列化的额外空间开销比较大
2. JSON 没有类型,比如无法区分整数和浮点
像 Java 、Go这种强类型语言,不是很友好,解析速度比较慢(需要通过反射解决)
1. 不可读,都是二进制
适用场景适用于服务提供者与服务调用者之间传输的数据量要相对较小的情况,否则会严重影响性能追求高性能的场景

协议层


编码之后,数据转换成字节流,但是RPC通信时,每次请求发送的数据大小不是固定的,那么为了区分消息的边界,避免粘包、半包等现象,我们需要定义一种协议,来使得接收方能够正确地读出不定长的内容。简单点说,通信协议就是约定客户端和服务器端传输什么数据,以及如何解析数据。


维度HTTP/1.1kitex-TTHeader
优点1. 灵活,可以自定义很多字段
2. 几乎所有设备都可以支持HTTP协议
1. 灵活,通用,可以自定义
  • 自定义必要字段即可 => 减小报文体积,提高传输效率
    2. 性能优秀
  • 缺点1. 包含许多为了适应浏览器的冗余字段,这些是内部服务用不到的,性能差1. 部分设备存在不能支持,通用性欠佳

    可参考



    可以思考一下 序列化、传输协议、网络通信的关系,下面以kitex为例进行分析


    kitex codec 接口定义kitex thrift 序列化实现kitex ttheader协议,kitex 发送请求核心代码



    可以发现 Encode中,先根据message构造出header,写入out,然后再把data(实际的业务数据)写到out。


    encode函数完全遵守 ttheader协议去构造数据。


    最后再把out通过网络库发送出去



    网络通信层


    网络通信层主要提供一个易用的网络库,封装了操作系统提供的socket api。


    维度HTTP/1.1kitex框架
    实现方式一般采用短连接需要3次握手(可以配置长链接添加请求头Keep-Alive: timeout=20)- 长连接,指在一个连接上可以连续发送多个数据包,在连接保持期间,如果没有数据包发送,需要双方发链路检测包rpc框架维护一个tcp连接池,每次用完不断开连接,通过心跳检测断开连接(探测服务是否有问题)- 支持短连接、长连接池、连接多路复用以及连接池状态监控。
    优点1. 几乎所有设备都可以支持HTTP协议1. 不用每次请求都经历三次握手&四次挥手,减少延时
    缺点1. 每次请求都要新建连接,性能差1. 部分设备存在不能支持,通用性欠佳

    HTTP的长连接和TCP长连接不是一个东西,需要注意下,TCP Keepalive是操作系统实现的功能,并不是TCP协议的一部分,需要在操作系统下进行相关配置(只能保证网络没问题,不能代表服务没问题)


    其中 HTTP2 拥有多路复用、优先级控制、头部压缩等优势


    可以参考


    kitex:连接类型


    RPC自定义协议 和 HTTP的使用场景


    公司内部的微服务,对客户端提供的服务 适合用RPC,更好的性能


    对外服务、单体服务、为前端提供的服务适合用HTTP


    我的思考


    rpc在编解码、协议层、网络通信 都比HTTP有更大的优势,那为啥不把HTTP换成RPC呢



    1. 人的认知,HTTP已经深入人心(或者说生态好,通用性强),几乎所有的机器、浏览器和语言默认都会支持。但是自定义RPC协议 可能很多人都没听过(比如kitex、dubbo等),还让别人支持,根本不可能。

      • 需要建设全局的DNS等等,HTTP链路中的组件都需要换成 自定义的那一套,成本极高。

      • 但是公司内部可以搞成一套,可以极大提高性能,何乐而不为。

      • 我见过的案例是很多时候并没有深入思考为什么用,而是大家都这么用,我也这么用。



    2. 浏览器只支持 http协议。而且浏览器不支持自定义编解码的解析

      • 为啥大家面向浏览器/前端 不用自定义编解码?

        • 举例:protobuf不支持前端语言,但是支持java

        • 就是自定义编解码框架支持语言有限,很多语言没有工具可以做,并且浏览器也不支持。对于问题排查比较困难。

        • github.com/protocolbuf…



      • http不仅可以传输json、还可以传输二进制、图片等。所以协议层可以用http,编解码用protobuf/thrift也是可行的。

        • 公司内部实际案例:服务端和客户端交互时,为了提高性能,采用protobuf编解码数据,使用http协议传输数据。

          • 但是每次请求/响应数据都是不可读的。服务端会把protobuf编码前的数据转为json,用于打印log/存储,方便排查问题。





      • 参考 丨隋堤倦客丨的评论





    • RPC框架 可以自定义负载均衡,重试机制,高可用,流量控制等策略。这些是HTTP不能支持的

      • 我理解是协议层用的http,但是内部的运行机制还是自定义的。http只是定义了传输数据的格式。举个例子:http的流量控制其实用的是 tcp的滑动窗口,http协议本身不具备这些功能。但是rpc是可以自己加这些功能的。这些功能必然有数据传输,这个传输协议用的http。

      • 参考 leewp同学的评论




    参考


    如何保活主流RPC框架长连接,Dubbo的心跳机制,值得学习_牛客博客


    3.8 既然有 HTTP 协议,为什么还要有 RPC?


    4.15 TCP Keepalive 和 HTTP Keep-Alive 是一个东西吗?


    RPC 漫谈: 连接问题


    聊一聊Go网络编程(一)--TCP连接通信 - 掘金


    Kitex前传:RPC框架那些你不得不知的故事


    kitex 传输协议


    dubbo RPC 协议


    作者:cli
    来源:juejin.cn/post/7264454873588449336
    收起阅读 »

    代码字体 ugly?试试这款高颜值代码字体

    Monaspace 是有 GitHub 开源的代码字体,包含 5 种变形字体的等宽代码字体家族,颜值 Up,很难不喜欢。 来看一下这 5 种字体分别是: 1️⃣ Radon 手写风格字体 2️⃣ Krypton 机械风格字体 3️⃣ Xenon 衬线风格字...
    继续阅读 »

    Monaspace 是有 GitHub 开源的代码字体,包含 5 种变形字体的等宽代码字体家族,颜值 Up,很难不喜欢。


    来看一下这 5 种字体分别是:


    1️⃣ Radon 手写风格字体



    2️⃣ Krypton 机械风格字体

    3️⃣ Xenon 衬线风格字体



    4️⃣ Argon 人文风格字体



    5️⃣ Neon 现代风格字体



    👉 项目地址:github.com/githubnext/…


    下载方式


    MacOS


    使用 brew 安装:


    brew tap homebrew/cask-fonts
    brew install font-monaspace

    Windows


    下载该文件:github.com/githubnext/…


    拖到 C:\Windows\Fonts 中,点击安装


    下载好后,如果是 VSCode 文件,可以在设置中找到 font-family,改为:'Monaspace Radon', monospace





    作者:吴楷鹏
    来源:juejin.cn/post/7332435905925562418
    收起阅读 »

    系统干崩了,只认代码不认人

    各位朋友听我一句劝,写代码提供方法给别人调用时,不管是内部系统调用,还是外部系统调用,还是被动触发调用(比如MQ消费、回调执行等),一定要加上必要的条件校验。千万别信某些同事说的这个条件肯定会传、肯定有值、肯定不为空等等。这不,临过年了我就被坑了一波,弄了个生...
    继续阅读 »

    各位朋友听我一句劝,写代码提供方法给别人调用时,不管是内部系统调用,还是外部系统调用,还是被动触发调用(比如MQ消费、回调执行等),一定要加上必要的条件校验。千万别信某些同事说的这个条件肯定会传、肯定有值、肯定不为空等等。这不,临过年了我就被坑了一波,弄了个生产事故,年终奖基本是凉了半截。


    为了保障系统的高可用和稳定,我发誓以后只认代码不认人。文末总结了几个小教训,希望对你有帮助。


    一、事发经过


    我的业务场景是:业务A有改动时,发送MQ,然后应用自身接受到MQ后,再组合一些数据写入到Elasticsearch。以下是事发经过:



    1. 收到一个业务A的异常告警,当时的告警如下:



    2. 咋一看觉得有点奇怪,怎么会是Redis异常呢?然后自己连了下Redis没有问题,又看了下Redis集群,一切正常。所以就放过了,以为是偶然出现的网络问题。

    3. 然后技术问题群里 客服 反馈有部分用户使用异常,我警觉性的感觉到是系统出问题了。赶紧打开了系统,确实有偶发性的问题。

    4. 于是我习惯性的看了几个核心部件:



      1. 网关情况、核心业务Pod的负载情况、用户中心Pod的负载情况。

      2. Mysql的情况:内存、CPU、慢SQL、死锁、连接数等。



    5. 果然发现了慢SQL和元数据锁时间过长的情况。找到了一张大表的全表查询,数据太大,执行太慢,从而导致元数据锁持续时间太长,最终数据库连接数快被耗尽。


    SELECT xxx,xxx,xxx,xxx FROM 一张大表


    1. 立马Kill掉几个慢会话之后,发现系统仍然没有完全恢复,为啥呢?现在数据库已经正常了,怎么还没完全恢复呢?又继续看了应用监控,发现用户中心的10个Pod里有2个Pod异常了,CPU和内存都爆了。难怪使用时出现偶发性的异常呢。于是赶紧重启Pod,先把应用恢复。

    2. 问题找到了,接下来就继续排查为什么用户中心的Pod挂掉了。从以下几个怀疑点开始分析:



      1. 同步数据到Elasticsearch的代码是不是有问题,怎么会出现连不上Redis的情况呢?

      2. 会不会是异常过多,导致发送异常告警消息的线程池队列满了,然后就OOM?

      3. 哪里会对那张业务A的大表做不带条件的全表查询呢?



    3. 继续排查怀疑点a,刚开始以为:是拿不到Redis链接,导致异常进到了线程池队列,然后队列撑爆,导致OOM了。按照这个设想,修改了代码,升级,继续观察,依旧出现同样的慢SQL 和 用户中心被干爆的情况。因为没有异常了,所以怀疑点b也可以被排除了。

    4. 此时基本可以肯定是怀疑点c了,是哪里调用了业务A的大表的全表查询,然后导致用户中心的内存过大,JVM来不及回收,然后直接干爆了CPU。同时也是因为全表数据太大,导致查询时的元数据锁时间过长造成了连接不能够及时释放,最终几乎被耗尽。

    5. 于是修改了查询业务A的大表必要校验条件,重新部署上线观察。最终定位出了问题。


    二、问题的原因


    因为在变更业务B表时,需要发送MQ消息( 同步业务A表的数据到ES),接受到MQ消息后,查询业务A表相关连的数据,然后同步数据到Elasticsearch。


    但是变更业务B表时,没有传业务A表需要的必要条件,同时我也没有校验必要条件,从而导致了对业务A的大表的全表扫描。因为:


    某些同事说,“这个条件肯定会传、肯定有值、肯定不为空...”,结果我真信了他!!!

    由于业务B表当时变更频繁,发出和消费的MQ消息较多,触发了更多的业务A的大表全表扫描,进而导致了更多的Mysql元数据锁时间过长,最终连接数消耗过多。


    同时每次都是把业务A的大表查询的结果返回到用户中心的内存中,从而触发了JVM垃圾回收,但是又回收不了,最终内存和CPU都被干爆了。


    至于Redis拿不到连接的异常也只是个烟雾弹,因为发送和消费的MQ事件太多,瞬时间有少部分线程确实拿不到Redis连接。


    最终我在消费MQ事件处的代码里增加了条件校验,同时也在查询业务A表处也增加了的必要条件校验,重新部署上线,问题解决。


    三、总结教训


    经过此事,我也总结了一些教训,与君共勉:



    1. 时刻警惕线上问题,一旦出现问题,千万不能放过,赶紧排查。不要再去怀疑网络抖动问题,大部分的问题,都跟网络无关。

    2. 业务大表自身要做好保护意识,查询处一定要增加必须条件校验。

    3. 消费MQ消息时,一定要做必要条件校验,不要相信任何信息来源。

    4. 千万别信某些同事说,“这个条件肯定会传、肯定有值、肯定不为空”等等。为了保障系统的高可用和稳定,咱们只认代码不认人

    5. 一般出现问题时的排查顺序:



      1. 数据库的CPU、死锁、慢SQL。

      2. 应用的网关和核心部件的CPU、内存、日志。



    6. 业务的可观测性和告警必不可少,而且必须要全面,这样才能更快的发现问题和解决问题。




    作者:不焦躁的程序员
    来源:juejin.cn/post/7331628641360248868
    收起阅读 »

    记录一次我们的PostgreSQL数据库被攻击了

    数据库所有表被删除了 这个程序员把我们的数据库表都删除了,然后新建了一个数据库redme_to_recover数据库 里面还有一张表,表里是让你支付,然后给你数据下载地址。 通过查看Docker里部署的PostgreSQL执行日志是没有操作记录的 根据数据...
    继续阅读 »

    数据库所有表被删除了


    微信图片_20240126160520.png


    这个程序员把我们的数据库表都删除了,然后新建了一个数据库redme_to_recover数据库


    里面还有一张表,表里是让你支付,然后给你数据下载地址。


    通过查看Docker里部署的PostgreSQL执行日志是没有操作记录的


    微信图片_20240126162925.png


    根据数据库的日志确定,1月24号13点数据库被重启了。


    25号的日志非常少,错误信息都是客户端连接失败,无法从客户端接收数据。(25号系统还是正常的)


    26号02时的日志就显示tdd表没了(这时候应该是所有表都没了)。


    中间没有删除表的操作日志,跟大佬请教了一下,确定应该是有人登录了我们的Linux系统。然后从Linux系统层面直接删除的表资源数据,没有通过PGSQL操作,没有删除操作记录。


    我对黑客攻击的数据库进行了修改密码,然后发现密码失效了,无论输入什么密码,都能正常登录数据库。


    我是怎么恢复的


    1、将原来的PG数据库镜像删除,重新修改了端口号和数据库密码然后启动数据库容器。


    docker ps -a 列出所有的Docker容器,包括正在运行和已经停止的容器。


    docker rm [容器id/容器名称] 删除PostgreSQL容器。


    docker run 启动一个新的容器。
    image.png


    2、将Linux账户登录密码修改。


    3、修改端口号和数据库配置密码后,重新打包我们的数据处理程序。


    4、修改Nacos里配置的接口服务程序的数据库连接配置。


    5、将表结构恢复,系统表和业务表结构,系统表包括账户角色等信息(幸亏我们同事有备份)


    6、丢失了历史业务数据


    image.png


    作者:悟空啊
    来源:juejin.cn/post/7328003589297291276
    收起阅读 »

    可视化 Java 项目

    有一定规模的 IT 公司,只要几年,必然存在大量的代码,比如腾讯,2019 年一年增加 12.9 亿行代码,现在只会更多。不管是对于公司,还是对于个人,怎么低成本的了解这些代码的对应业务,所提供的能力,都是非常有必要的! 今天,阿七就带大家破解这个难题,根据这...
    继续阅读 »

    有一定规模的 IT 公司,只要几年,必然存在大量的代码,比如腾讯,2019 年一年增加 12.9 亿行代码,现在只会更多。不管是对于公司,还是对于个人,怎么低成本的了解这些代码的对应业务,所提供的能力,都是非常有必要的!


    今天,阿七就带大家破解这个难题,根据这个文档,你能使用 AI 编程技术,根据包含 Java 完整代码的项目实现可视化下面三个方面的内容:



    • 模块和功能:应用内部的业务模块和功能,及相互间的关系,为用户提供应用的整体视图。

    • 类和接口:应用模块提供的业务能力以及对应的类和接口,以及接口对应业务流程语义化。

    • 方法实现语义化:方法实现逻辑的语义化和可视化;


    一、先秀一下成果


    一)Java 项目概览图


    根据一个 Java 项目,可以生成下面这样的项目整体概览图,对于不需要了解实现细节的产品、运营同学,直接看这个图,就能够了解这个 Java 项目在干什么、能提供什么能力。


    对于部分技术同学,不需要了解代码详情的,也可以直接看这个图即可。满足新入职同学对于接手不常变更项目的理解和全局业务的了解!


    PS:由于保密需要,所有的成果图仅为示例图。实际的图会更好看、更震撼,因为一个 Java 项目的功能模块可能很多,提供的能力可能很多。



    对于需要了解技术细节的同学,点击入口,能看到当前方法的流程图,快速了解当前方法提供的能力,具体的细节。还能迅速发现流程上可能存在的问题,快速纠正。


    二)具体方法流程图



    有了上面的两层可视化图表,不管是产品、技术、测试、运营以及小领导,都能快速的根据一个 Java 项目获取到他所需要的层级的信息,降低开发人员通过阅读代码梳理业务逻辑和代码逻辑的时间,尤其是新入职的同学。这个时间据统计,基本上在 25%-30%(百度、阿里等大公司调研数据更大,为 55%-60%),对于新同学,这个比例会更大!


    二、实现步骤


    一)整体概述图怎么生成?


    一个 Java 项目所有对外接口在做的事情,就是一个 Java 项目的核心业务。这个对外接口包括:HTTP 接口、Dubbo 接口、定时任务。


    1、获取一个 Java 项目所有对外接口


    1)通过 Trace 平台


    可以查询到一个 Java 项目所有对外的 HTTP 接口和 Dubbo 接口,通过注解可以查询一个 Java 项目所有定时任务。


    优点:



    • 数据准确,跑出来的数据,一定是还在用的接口;
      缺点:

    • 需要依赖 Trace 平台数据,部分公司可能没有 Trace 平台。


    2)通过 JavaParser 工具


    可以通过 JavaParser 工具,扫描整个 Java 项目代码。找到所有的对外入口。


    优点:



    • 不依赖 Trace 数据;
      缺点:

    • 可能不准确,因为有些接口已经不被使用了。


    2、获取对外接口的方法内容


    1)根据 HTTP 的接口 url 可以反解析出来这个 url 对应的方法的全路径。


    具体来说,在项目中获取 Spring 上下文,Spring 上下文中有一个 Bean 叫 RequestMappingHandlerMapping,这个 Bean 中提供了一个方法 getHandlerMethods,这个方法中保存了一个 Java 项目中所有的对外 HTTP 方法。


    这个方法返回一个 Map对象,key 是 HTTP 接口的 URL,value 就是这个 URL 对应方法的全路径名称。



    2)根据方法全路径,获取方法内容


    根据上面的全路径名,使用 Spoon 框架我们能拿到对应方法的方法体。



    fr.inria.gforge.spoon
    spoon-core


    我们让 ChatGPT 帮我们写代码,提示词:



    写一个 Java 方法,使用 Spoon 框架解析 Java 方法的完整内容
    其中入参是方法全路径名




    PS:这个代码一会还有用,我们往下递归的话,能拿到这个 Controller 方法调用的所有方法体。


    3、根据方法内容生成方法注释


    就和 GitHub Copilot 和百度 Comate 代码助手一样,GPT 可以根据代码生成方法注释,提示词:



    角色: 你是一个 Java 技术专家。

    任务: # 号开头的是一个 Java 方法。请你逐行阅读代码,然后为这个 Java 方法生成一句话注释。

    限制:不要超过 20 个字



    举个例子,我有个工具方法,使用 GPT 为他生成注释,如下:



    4、生成 Java 项目一句话描述



    角色: 你是一个 Java 技术专家。

    任务: --- 符号以上的是一个 Java 项目中所有对外方法的注释,请你逐行阅读这些注释,然后给这个 Java 项目生成一句话描述。

    限制: 结果不要超过两句话。



    这个利用的是 GPT 的总结概要的能力,GPT 能总结论文、总结文章,他也能总结一段描述 Java 项目的文字。这样就能获取对于一个 Java 项目的一句话描述,也就是项目概览图的第一层。


    5、总结:生成项目概览图


    我们要求 GPT 根据 Java 项目的一句话描述,和所有对完方法的方法注释,生成思维导图数据。为了项目概览图的层级更可读、更清晰,我们可以要求 GPT 根据方法注释的相似性进行分类,形成项目概览图的第二层。第三层就是所有项目中对外方法的注释。


    生成思维导图,可以让 GPT 根据结构内容生成 puml 格式的思维导图数据,我们把 puml 格式的数据存储为 puml 文件,然后使用 xmind 或者在线画图工具 processOn 打开就能看到完整的思维导图。


    参考提示词如下:



    应用代码:appCodeValue

    项目描述:appCodeDescValue

    项目描述:appCodeDescValue

    方法描述:methodDescListValue

    角色:你是一个有多年经验的 Java 技术专家,在集成 Java 项目方面有丰富的经验。

    任务:根据 Java 项目中所有公共接口的描述信息生成思维导图。

    要求:思维导图只有四个层级。

    详细要求:思维导图的中心主题是 appCodeValue,第一层分支是 appCodeDescValue;第二层分支是公共接口的分类;下层分支是每个分类下方法的描述信息。

    返回正确格式的 opml 思维导图 xml 数据,并且内容是中文。



    二)流程图怎么生成?


    1、获取递归代码


    直接问 GPT,让 GPT 改造上面的获取方法体的方法。


    prompt;



    {获取方法体的方法}

    上面的 Java 代码是使用 Spoon 框架解析 Java 方法的完整内容
    其中入参是方法全路径名

    任务:现在要求你改造这个方法,除了打印当前方法的完整内容,还要求递归打印所有调用方法的方法体内容,包含被调用方法调用的方法





    这样,我们能获取到一个 controller 方法所有递归调用的方法,每个方法生成自己的流程图,最后通过流程图嵌套的形式进行展示。


    比如这个例子,当前能看到的是当前方法的流程图,带 + 号的内容,是当前方法调用方法的流程图。这样方便我们按照自己需要的深度去了解当前方法的具体实现流程!


    2、无效代码剪枝


    按照上面生成的流程图可能分支很多,还有一些无效的信息,影响用户判断,我们可以通过删除一些业务无关代码的方法,精简流程图。


    比如,我们可以删除日志、监控等与业务逻辑无关的代码,删除没有调用的代码(现在市面上有些这种技术方案,可以检测当前项目中没有被实际调用的代码)。


    3、生成流程图


    先让 GPT 根据代码生成结构化的 Json 数据。



    给你一段 Java 代码,请你使用 spoon 输出结构化的 Json 数据。要求:请你直接输出结构的 json 结果数据,不需要过程代码



    然后,可以让 GPT 根据 Json 数据生成流程图数据,使用流程图工具打开即可。



    给你一段 Spoon 结构化 Java 代码的 Json 数据,整理对应 Java 代码的意思,生成一个流程图数据,流程图使用 PlantUML。现在请输出能直接绘制 PlantUML 图的数据




    三、改进方案


    我们可以从下面几个方面改进这个项目,从而实现真正落地,解决实际公司需求:



    1. 获取代码,修改为从 gitlab 等代码仓库直接拉取,这样使用的时候不需要将工具包导入到具体的 Java 项目中。

    2. 优化生图,提前生成全量图标,通过浏览器的形式进行访问。

    3. 增加图表内容手动校正功能,生成不准确的,支持开发人员手动调整。

    4. 增加检索功能,可以按照自然语言进行检索。

    5. 把项目中的方法和类信息存起来,生成更准确的图标。

    6. 根据完整项目代码,反向生成项目概要图,可能能得到更准确的概要图。

    7. 递归方法流程图,可以使用流程图嵌套,如下进行展示。



    四、总结


    AI 在编程领域,除了大厂都在卷的代码助手,结合自己公司还有很多可探索的地方,比如本文说的可视化 Java 项目,还可以通过分析日志,进行异常、故障的根因分析,做到快速定位问题,帮助快速解决问题,减少影响。


    如果故障根因分析这个工具做出来了,阿里云的 P0 故障,滴滴的 P0 故障,还有很多大中小厂的故障,是不是能更快恢复?减少声誉、金钱损失?


    就说,项目可视化这个需求,据我了解的内部消息,有些互联网中大厂已经在使用这个方式进行落地了。另外,我陪伴群里也有同学接触到了类似不少甲方的类似的强需求,如果想深入这块技术的同学,不管是进互联网大厂还是做自己的副业产品都是不错的方向!


    作者:伍六七AI编程
    来源:juejin.cn/post/7311652298227990563
    收起阅读 »

    简单一招竟把nginx服务器性能提升50倍

    需求背景 接到重点业务需求要分轮次展示数据,预估最高承接 9w 的 QPS,作为后端工程师下意识的就是把接口写好,分级缓存、机器扩容、线程拉满等等一系列连招准备,再因为数据更新频次两只手都数得过来,我们采取了最稳妥的处理方式,直接生成静态文件拿 CDN 抗量 ...
    继续阅读 »

    需求背景


    接到重点业务需求要分轮次展示数据,预估最高承接 9w 的 QPS,作为后端工程师下意识的就是把接口写好,分级缓存、机器扩容、线程拉满等等一系列连招准备,再因为数据更新频次两只手都数得过来,我们采取了最稳妥的处理方式,直接生成静态文件拿 CDN 抗量


    架构流程大致如下所示:



    数据更新后会重新生成新一轮次的文件,刷新 CDN 的时候会触发大量回源请求,应用服务器极端情况得 hold 住这 9w 的 QPS


    第一次压测


    双机房一共 40 台 4C 的机器,25KB 数据文件,5w 的 QPS 直接把 CPU 打到 90%


    这明显不符合业务需求啊,咋办?先无脑加机器试试呗


    就在这时测试同学反馈压测的数据不对,最后一轮文件最大会有 125KB,雪上加霜


    于是乎文件替换,机器数量整体翻一倍扩到 80 台,服务端 CPU 依然是瓶颈,QPS 加不上去了



    到底是哪里在消耗 CPU 资源呢,整体架构已经简单到不能再简单了


    这时候我们注意到为了节省网络带宽 nginx 开启了 gzip 压缩,是不是这小子搞的鬼


    server
    {
    listen 80;

    gzip on;
    gzip_disable "msie6";
    gzip_vary on;
    gzip_proxied any;
    gzip_comp_level 6;
    gzip_buffers 16 8k;
    gzip_http_version 1.1;
    gzip_types text/plain application/css text/css application/xml text/javascript application/javascript application/x-javascript;

    ......
    }



    第二次压测


    为了验证这个猜想,我们把 nginx 中的 gzip 压缩率从 6 调成 2,以减少 CPU 的计算量



    gzip_comp_level 2;



    这轮压下来 CPU 还是很快被打满,但 QPS 勉强能达到 9w,坐实了确实是 gzip 在耗 CPU



    nginx 作为家喻户晓的 web 服务器,以高性能高并发著称,区区一个静态数据文件就把应用服务器压的这么高,一定是哪里不对


    第三次压测


    明确了 gzip 在耗 CPU 之后我们潜下心来查阅了相关资料,发现了一丝进展


    html/css/js 等静态文件通常包含大量空格、标签等重复字符,重复出现的部分使用「距离加长度」表达可以减少字符数,进而大幅降低带宽,这就是 gzip 无损压缩的基本原理


    作为一种端到端的压缩技术,gzip 约定文件在服务端压缩完成,传输中保持不变,直到抵达客户端。这不妥妥的理论依据嘛~


    nginx 中的 gzip 压缩分为动态压缩和静态压缩两种


    •动态压缩


    服务器给客户端返回响应时,消耗自身的资源进行实时压缩,保证客户端拿到 gzip 格式的文件


    这个模块是默认编译的,详情可以查看 nginx.org/en/docs/htt…


    •静态压缩


    直接将预先压缩过的 .gz 文件返回给客户端,不再实时压缩文件,如果找不到 .gz 文件,会使用对应的原始文件


    这个模块需要单独编译,详情可以查看 nginx.org/en/docs/htt…


    如果开启了 gzip_static always,而且客户端不支持 gzip,还可以在服务端加装 gunzip 来帮助客户端解压,这里我们就不需要了


    查了一下 jdos 自带的 nginx 已经编译了 ngx_http_gzip_static_module,省去了重新编译的麻烦事



    接下来通过 GZIPOutputStream 在本地额外生成一个 .gz 的文件,nginx 配置上静态压缩再来一次



    gzip_static on;




    面对 9w 的QPS,40 台机器只用了 7% 的 CPU 使用率完美扛下


    为了探底继续加压,应用服务器 CPU 增长缓慢,直到网络流出速率被拉到了 89MB/s,担心影响宿主机其他容器停止压力,此时 QPS 已经来到 27w


    qps 5w->27w 提升 5 倍,CPU 90%->7% 降低 10 倍,整体性能翻了 50 倍不止,这回舒服了~


    写在最后


    经过一连串的分析实践,似乎静态压缩存在“压倒性”优势,那什么场景适合动态压缩,什么场景适合静态压缩呢?一番探讨后得出以下结论



    纯静态不会变化的文件适合静态压缩,提前使用gzip压缩好避免CPU和带宽的浪费。动态压缩适合API接口返回给前端数据这种动态的场景,数据会发生变化,这时候就需要nginx根据返回内容动态压缩,以节省服务器带宽



    作为一名后端工程师,nginx 是我们的老相识了,抬头不见低头见。日常工作中配一配转发规则,查一查 header 设置,基本都是把 nginx 作为反向代理使用。这次是直接访问静态资源,调整过程的一系列优化加深了我们对 gzip 的动态压缩和静态压缩的基本认识,这在 NG 老炮儿眼里显得微不足道,但对于我们来说却是一次难得的技能拓展机会


    在之前的职业生涯里,我们一直聚焦于业务架构设计与开发,对性能的优化似乎已经形成思维惯性。面对大数据量长事务请求,减少循环变批量,增大并发,增加缓存,实在不行走异步任务解决,一般瓶颈都出现在 I/O 层面,毕竟磁盘慢嘛,减少与数据库的交互次数往往就有效果,其他大概率不是问题。这回有点儿不一样,CPU 被打起来的原因就是出现了大量数据计算,在高并发请求前,任何一个环节都可能产生性能问题


    作者:京东零售 闫创


    来源:京东云开发者社区 转载请注明来源


    作者:京东云开发者
    来源:juejin.cn/post/7328766815101206547
    收起阅读 »

    多租户架构设计思考

    共享数据库,共享表 描述 所有租户的数据都在同一个数据库表内,以租户字段:tenant_id来区分。 优点 成本低,实现方式简单,适合中小型项目的快速实现。 缺点 数据隔离性差,某一个租户的数据量大的时候,会影响其他租户数据的操作效率。 需要在表上增加租户字...
    继续阅读 »

    共享数据库,共享表


    描述


    所有租户的数据都在同一个数据库表内,以租户字段:tenant_id来区分。


    优点


    成本低,实现方式简单,适合中小型项目的快速实现。


    缺点



    • 数据隔离性差,某一个租户的数据量大的时候,会影响其他租户数据的操作效率。

    • 需要在表上增加租户字段,对系统有一定的侵入性。

    • 数据备份困难,因为所有租户的数据混合在一起,所以针对某个租户数据的备份、恢复会比较麻烦。


    实现方式


    **方式一:**编写Mybatis拦截器,拦截增删改查操作,动态的增加租户条件,如:


    SELECT * FROM sys_user;

    修改成:


    SELECTG * FROM sys_user WHERE tenant_id = 100;

    这种方案并不靠谱,因为动态修改SQL语句不是一个好的处理方式,如果SQL解析没有做好,或者出现复杂SQL,那么很容易产生bug。


    **方式二:**编写Mybatis拦截器,拦截增删改查操作,判断是否有租户条件,如:


    SELECT * FROM sys_user WHERE id=1;

    使用jsqlparser工具解析SQL,判断出该SQL语句没有tenant_id的条件,那么抛出异常,不允许执行。


    这种方案比较稳妥,因为只做判断不做修改。


    查询操作的优先级不高,如果不在乎数据敏感,可以不拦截。


    要注意的是修改操作,稍不注意容易被某一个租户影响其他租户的数据。


    共享数据库,独立一张表


    描述


    所有租户的数据都在同一个数据库中,但是各自有一个独立的表,如:


    # 1号租户的用户表
    sys_user_1

    # 2号租户的用户表
    sys_user_2

    ...

    优点


    成本低,数据隔离性比共享表稍好,并且不用新增租户字段,对系统没有侵入性。


    缺点



    • 数据隔离性虽然比共享表好了些,但是因为仍在同一数据库下,所以某一个租户影响其他租户的数据操作效率问题依然存在。

    • 数据备份困难的问题依然存在。


    实现方式


    **方式一:**编写Mybatis拦截器,拦截增删改查操作,动态的修改表名称,如:


    SELECT * FROM sys_user;

    修改成:


    SELECT * FROM sys_user_1;

    同样的,这种动态修改SQL语句的方式并不推荐,所以我们有另一种方式。


    **方式二:**将表名作为参数传入


    本来在Mapper.xml中,查询语句是这样的:


    SELECT * FROM sys_user WHERE id = #{userId};

    现在改成:


    SELECT * FROM #{tableName} WHERE id = #{userId};

    这样可以避免动态修改SQL语句操作。


    独立数据库


    描述


    每个租户都单独分配一个数据库,数据完全独立,如:


    database_1;
    database_2;
    ...

    优点



    • 数据隔离性最好,不需要添加租户id字段,租户之间不会被彼此影响。

    • 便于数据备份和恢复。

    • 便于扩展。


    缺点



    • 经费成本高,尤其在有多个租户的情况下。

    • 运维成本高。


    结论


    一般来说,当数据量不高的时候,选择共享数据库共享表的方式,表内加个租户id字段做区分,数据量或者用户量多起来,就可以直接升级到独立数据库的方式,因为独立表的方式处理起来是有些麻烦的,倒不如加个字段来的方便。


    作者:失败的面
    来源:juejin.cn/post/7282953307529953291
    收起阅读 »

    一种好用的KV存储封装方案

    一、 概述 众所周知,用kotlin委托属性去封装KV存储库,可以优化数据的访问。 封装方法有多种,各有优劣。 通过反复实践,笔者摸索出一套比较好用的方案,借此文做个简单的分享。 代码已上传Github: github.com/BillyWei01/… 项目...
    继续阅读 »

    一、 概述


    众所周知,用kotlin委托属性去封装KV存储库,可以优化数据的访问。

    封装方法有多种,各有优劣。

    通过反复实践,笔者摸索出一套比较好用的方案,借此文做个简单的分享。


    代码已上传Github: github.com/BillyWei01/…

    项目中是基于SharePreferences封装的,但这套方案也适用于其他类型的KV存储框架。


    二、 封装方法


    此方案封装了两类委托:



    1. 基础类型

      基础类型包括 [boolean, int, float, long, double, String, Set<String>, Object] 等类型。

      其中,Set<String> 本可以通过 Object 类型囊括,

      但因为Set<String>是 SharePreferences 内置支持的类型,这里我们就直接内置支持了。

    2. 扩展key的基础类型

      基础类型的委托,定义属性时需传入常量的key,通过委托所访问到的是key对应的value

      而开发中有时候需要【常量+变量】的key,基础类型的委托无法实现。

      为此,方案中实现了一个 CombineKV 类。

      CombineKV通过组合[key+extKey]实现通过两级key来访问value的效果。

      此外,方案基于CombineKV封装了各种基础类型的委托,用于简化API,以及约束所访问的value的类型。


    2.1 委托实现


    基础类型BasicDelegate.kt

    扩展key的基础类型: ExtDelegate.kt


    这里举例一下基础类型中的Boolean类型的委托实现:


    class BooleanProperty(private val key: String, private val defValue: Boolean) :
    ReadWriteProperty<KVData, Boolean> {
    override fun getValue(thisRef: KVData, property: KProperty<*>): Boolean {
    return thisRef.kv.getBoolean(key, defValue)
    }

    override fun setValue(thisRef: KVData, property: KProperty<*>, value: Boolean) {
    thisRef.kv.putBoolean(key, value)
    }
    }

    class NullableBooleanProperty(private val key: String) :
    ReadWriteProperty<KVData, Boolean?> {
    override fun getValue(thisRef: KVData, property: KProperty<*>): Boolean? {
    return thisRef.kv.getBoolean(key)
    }

    override fun setValue(thisRef: KVData, property: KProperty<*>, value: Boolean?) {
    thisRef.kv.putBoolean(key, value)
    }
    }

    经典的 ReadWriteProperty 实现:

    分别重写 getValue 和 setValue 方法,方法中调用KV存储的读写API。

    由于kotlin区分了可空类型和非空类型,方案中也分别封装了可空和非空两种委托。


    2.2 基类定义


    实现了委托之后,我们将各种委托API封装到一个基类中:KVData


    abstract class KVData {
    // 存储接口
    abstract val kv: KVStore

    // 基础类型
    protected fun boolean(key: String, defValue: Boolean = false) = BooleanProperty(key, defValue)
    protected fun int(key: String, defValue: Int = 0) = IntProperty(key, defValue)
    protected fun float(key: String, defValue: Float = 0f) = FloatProperty(key, defValue)
    protected fun long(key: String, defValue: Long = 0L) = LongProperty(key, defValue)
    protected fun double(key: String, defValue: Double = 0.0) = DoubleProperty(key, defValue)
    protected fun string(key: String, defValue: String = "") = StringProperty(key, defValue)
    protected fun stringSet(key: String, defValue: Set<String> = emptySet()) = StringSetProperty(key, defValue)
    protected fun <T> obj(key: String, encoder: ObjectEncoder<T>, defValue: T) = ObjectProperty(key, encoder, defValue)

    // 可空的基础类型
    protected fun nullableBoolean(key: String) = NullableBooleanProperty(key)
    protected fun nullableInt(key: String) = NullableIntProperty(key)
    protected fun nullableFloat(key: String) = NullableFloatProperty(key)
    protected fun nullableLong(key: String) = NullableLongProperty(key)
    protected fun nullableDouble(key: String) = NullableDoubleProperty(key)
    protected fun nullableString(key: String) = NullableStringProperty(key)
    protected fun nullableStringSet(key: String) = NullableStringSetProperty(key)
    protected fun <T> nullableObj(key: String, encoder: NullableObjectEncoder<T>) = NullableObjectProperty(key, encoder)

    // 扩展key的基础类型
    protected fun extBoolean(key: String, defValue: Boolean = false) = ExtBooleanProperty(key, defValue)
    protected fun extInt(key: String, defValue: Int = 0) = ExtIntProperty(key, defValue)
    protected fun extFloat(key: String, defValue: Float = 0f) = ExtFloatProperty(key, defValue)
    protected fun extLong(key: String, defValue: Long = 0L) = ExtLongProperty(key, defValue)
    protected fun extDouble(key: String, defValue: Double = 0.0) = ExtDoubleProperty(key, defValue)
    protected fun extString(key: String, defValue: String = "") = ExtStringProperty(key, defValue)
    protected fun extStringSet(key: String, defValue: Set<String> = emptySet()) = ExtStringSetProperty(key, defValue)
    protected fun <T> extObj(key: String, encoder: ObjectEncoder<T>, defValue: T) = ExtObjectProperty(key, encoder, defValue)

    // 扩展key的可空的基础类型
    protected fun extNullableBoolean(key: String) = ExtNullableBooleanProperty(key)
    protected fun extNullableInt(key: String) = ExtNullableIntProperty(key)
    protected fun extNullableFloat(key: String) = ExtNullableFloatProperty(key)
    protected fun extNullableLong(key: String) = ExtNullableLongProperty(key)
    protected fun extNullableDouble(key: String) = ExtNullableDoubleProperty(key)
    protected fun extNullableString(key: String) = ExtNullableStringProperty(key)
    protected fun extNullableStringSet(key: String) = ExtNullableStringSetProperty(key)
    protected fun <T> extNullableObj(key: String, encoder: NullableObjectEncoder<T>) = ExtNullableObjectProperty(key, encoder)

    // CombineKV
    protected fun combineKV(key: String) = CombineKVProperty(key)
    }

    使用时,继承KVData,然后实现kv, 返回一个KVStore的实现类即可。


    举例,如果用SharedPreferences实现KVStore,可如下实现:


    class SpKV(name: String): KVStore {
    private val sp: SharedPreferences =
    AppContext.context.getSharedPreferences(name, Context.MODE_PRIVATE)
    private val editor: SharedPreferences.Editor = sp.edit()

    override fun putBoolean(key: String, value: Boolean?) {
    if (value == null) {
    editor.remove(key).apply()
    } else {
    editor.putBoolean(key, value).apply()
    }
    }

    override fun getBoolean(key: String): Boolean? {
    return if (sp.contains(key)) sp.getBoolean(key, false) else null
    }

    // ...... 其他类型
    }


    更多实现可参考: SpKV


    三、 使用方法


    object LocalSetting : KVData("local_setting") {
    override val kv: KVStore by lazy {
    SpKV(name)
    }
    // 是否开启开发者入口
    var enableDeveloper by boolean("enable_developer")

    // 用户ID
    var userId by long("user_id")

    // id -> name 的映射。
    val idToName by extNullableString("id_to_name")

    // 收藏
    val favorites by extStringSet("favorites")

    var gender by obj("gender", Gender.CONVERTER, Gender.UNKNOWN)
    }


    定义委托属性的方法很简单:



    • 和定义变量类似,需要声明变量名类型

    • 和变量声明不同,需要传入key

    • 如果要定义自定义类型,需要传入转换器(实现字符串和对象类型的转换),以及默认值


    基本类型的读写,和变量的读写一样。

    例如:


    fun test1(){
    // 写入
    LocalSetting.userId = 10001L
    LocalSetting.gender = Gender.FEMALE

    // 读取
    val uid = LocalSetting.userId
    val gender = LocalSetting.gender
    }

    读写扩展key的基本类型,则和Map的语法类似:


    fun test2() {
    if (LocalSetting.idToName[1] == null || LocalSetting.idToName[2] == null) {
    Log.d("TAG", "Put values to idToName")
    LocalSetting.idToName[1] = "Jonn"
    LocalSetting.idToName[2] = "Mary"
    } else {
    Log.d("TAG", "There are values in idToName")
    }
    Log.d("TAG", "idToName values: " +
    "1 -> ${LocalSetting.idToName[1]}, " +
    "2 -> ${LocalSetting.idToName[2]}"
    )
    }

    扩展key的基本类型,extKey是Any类型,也就是说,以上代码的[],可以传入任意类型的参数。


    四、数据隔离


    4.1 用户隔离


    不同环境(开发环境/测试环境),不同用户,最好数据实例是分开的,相互不干扰。

    比方说有 uid='001' 和 uid='002' 两个用户的数据,如果需要隔离两者的数据,有多种方法,例如:



    1. 拼接uid到key中。


      如果是在原始的SharePreferences的基础上,是比较好实现的,直接put(key+uid, value)即可;

      但是如果用委托属性定义,可以用上面定义的扩展key的类型。


    2. 拼接uid到文件名中。


      但是不同用户的数据糅合到一个文件中,对性能多少有些影响:



      • 在多用户的情况下,实例的数据膨胀;

      • 每次访问value, 都需要拼接uid到key上。


      因此,可以将不同用户的数据保存到不同的实例中。

      具体的做法,就是拼接uid到路径或者文件名上。



    基于此分析,我们定义两种类型的基类:



    • GlobalKV: 全局数据,切换环境和用户,不影响GlobalKV所访问的数据实例。

    • UserKV: 用户数据,需要同时区分 “服务器环境“ 和 ”用户ID“。


    open class GlobalKV(name: String) : KVData() {
    override val kv: KVStore by lazy {
    SpKV(name)
    }
    }

    abstract class UserKV(
    private val name: String,
    private val userId: Long
    ) : KVData() {
    override val kv: SpKV by lazy {
    // 拼接UID作为文件名
    val fileName = "${name}_${userId}_${AppContext.env.tag}"
    if (AppContext.debug) {
    SpKV(fileName)
    } else {
    // 如果是release包,可以对文件名做个md5,以便匿藏uid等信息
    SpKV(Utils.getMD5(fileName.toByteArray()))
    }
    }
    }

    UserKV实例:


    /**
    * 用户信息
    */

    class UserInfo(uid: Long) : UserKV("user_info", uid) {
    companion object {
    private val map = ArrayMap<Long, UserInfo>()

    // 返回当前用户的实例
    fun get(): UserInfo {
    return get(AppContext.uid)
    }

    // 根据uid返回对应的实例
    @Synchronized
    fun get(uid: Long): UserInfo {
    return map.getOrPut(uid) {
    UserInfo(uid)
    }
    }
    }

    var gender by intEnum("gender", Gender.CONVERTER)
    var isVip by boolean("is_vip")

    // ... 其他变量
    }

    UserKV的实例不能是单例(不同的uid对应不同的实例)。

    因此,可以定义companion对象,用来缓存实例,以及提供获取实例的API。


    保存和读取方法如下:

    先调用get()方法获取,然后其他用法就和前面描述的用法一样了。


    UserInfo.get().gender = Gender.FEMALE

    val gender = UserInfo.get().gender

    4.2 环境隔离


    有一类数据,需要区分环境,但是和用户无关。

    这种情况,可以用UserKV, 然后uid传0(或者其他的uid用不到的数值)。


    /**
    * 远程设置
    */

    object RemoteSetting : UserKV("remote_setting", 0L) {
    // 某项功能的AB测试分组
    val fun1ABTestGr0up by int("fun1_ab_test_group")

    // 服务端下发的配置项
    val setting by combineKV("setting")
    }

    五、小结


    通过属性委托封装KV存储的API,可使原来“类名 + 操作 + key”的方式,变更为“类名 + 属性”的方式,从而简化KV存储的使用。
    另外,这套方案也提到了保存不同用户数据到不同实例的演示。


    方案内容不多,但其中包含一些比较实用的技巧,希望对各位读者有所帮助。


    作者:呼啸长风
    来源:juejin.cn/post/7323449163420303370
    收起阅读 »

    java 实现后缀表达式

    一、概述 后缀表达式(也称为逆波兰表达式)是一种数学表达式的表示方法,其中操作符位于操作数的后面。这种表示法消除了括号,并且在计算机科学和计算中非常有用,因为它更容易计算和解析。 与中缀表达式(通常我们使用的数学表达式,例如"a * (b + c)")不同,后...
    继续阅读 »

    一、概述


    后缀表达式(也称为逆波兰表达式)是一种数学表达式的表示方法,其中操作符位于操作数的后面。这种表示法消除了括号,并且在计算机科学和计算中非常有用,因为它更容易计算和解析。


    与中缀表达式(通常我们使用的数学表达式,例如"a * (b + c)")不同,后缀表达式的运算符放在操作数之后,例如:“a b c + *”。后缀表达式的计算方法是从左到右遍历表达式,遇到操作数时将其压入栈,遇到操作符时从栈中弹出所需数量的操作数进行计算,然后将结果重新压入栈。这个过程一直持续到整个表达式处理完毕,最终栈中只剩下一个结果,即表达式的计算结果。


    后缀表达式具有以下优点:



    1. 不需要括号,因此消除了歧义。

    2. 更容易计算,因为遵循一定的计算顺序。

    3. 适用于计算机的堆栈操作,因此在编译器和计算器中经常使用。


    转换中缀表达式为后缀表达式需要使用算法,通常是栈数据结构。


    二、后缀表达式的运算顺序


    后缀表达式的运算顺序是从左到右遍历表达式,遇到操作数时将其压入栈,遇到操作符时从栈中弹出所需数量的操作数进行计算,然后将计算结果重新压入栈。这个过程一直持续到整个表达式处理完毕,最终栈中只剩下一个结果,即表达式的计算结果。


    后缀表达式的运算顺序是非常直观的,它遵循从左到右的顺序。当计算后缀表达式时,按照以下规则:



    1. 从左到右扫描后缀表达式中的每个元素(操作数或操作符)。

    2. 如果遇到操作数,将其推入栈。

    3. 如果遇到操作符,从栈中弹出所需数量的操作数进行计算,然后将计算结果推回栈中。

    4. 重复这个过程,直到遍历完整个后缀表达式。


    三、常规表达式转化为后缀表达式



    • 创建两个栈,一个用于操作符(操作符栈),另一个用于输出后缀表达式(输出栈)。

    • 从左到右遍历中缀表达式的每个元素。

    • 如果是操作数,将其添加到输出栈。

    • 如果是操作符:

    • 如果操作符栈为空,直接将该操作符推入操作符栈。

      否则,比较该操作符与操作符栈栈顶的操作符的优先级。如果当前操作符的优先级较高,将其推入操作符栈。

      如果当前操作符的优先级较低或相等,从操作符栈中弹出并添加到输出栈,然后重复比较直到可以推入操作符栈。

      如果遇到左括号"(“,直接推入操作符栈。

      如果遇到右括号”)“,将操作符栈中的操作符弹出并添加到输出栈,直到遇到匹配的左括号”("。

      最后,将操作符栈中的剩余操作符全部弹出并添加到输出栈。

      完成遍历后,输出栈中的内容就是中缀表达式转化为后缀表达式的结果。


    四、代码实现


    /**
    * 定义操作符的优先级
    */

    private Map<String, Integer> opList =
    Map.of("(",3,")",3,"*",2,"/",2,"+",1,"-",1);

    public List<String> getPostExp(List<String> source) {

    // 数字栈
    Stack<String> dataStack = new Stack<>();
    // 操作数栈
    Stack<String> opStack = new Stack<>();
    // 操作数集合
    for (int i = 0; i < source.size(); i++) {
    String d = source.get(i).trim();
    // 操作符的操作
    if (opList.containsKey(d)) {
    operHandler(d,opStack,dataStack);
    } else {
    // 操作数直接入栈
    dataStack.push(d);
    }
    }
    // 操作数栈中的数据,到压入到栈中
    while (!opStack.isEmpty()) {
    dataStack.push(opStack.pop());
    }
    List<String> result = new ArrayList<>();
    while (!dataStack.isEmpty()) {
    String pop = dataStack.pop();
    result.add(pop);
    }
    // 对数组进行翻转
    return CollUtil.reverse(result);
    }

    /**
    * 对操作数栈的操作
    * @param d,当前操作符
    * @param opStack 操作数栈
    */

    private void operHandler(String d, Stack<String> opStack,Stack<String> dataStack) {
    // 操作数栈为空
    if (opStack.isEmpty()) {
    opStack.push(d);
    return;
    }
    // 如果遇到左括号"(“,直接推入操作符栈。
    if (d.equals("(")) {
    opStack.push(d);
    return;
    }
    // 如果遇到右括号”)“,将操作符栈中的操作符弹出并添加到输出栈,直到遇到匹配的左括号”("。
    if (d.equals(")")) {
    while (!opStack.isEmpty()) {
    String pop = opStack.pop();
    // 不是左括号
    if (!pop.equals("(")) {
    dataStack.push(pop);
    } else {
    return;
    }
    }
    }
    // 操作数栈不为空
    while (!opStack.isEmpty()) {
    // 获取栈顶元素和优先级
    String peek = opStack.peek();
    Integer v = opList.get(peek);
    // 获取当前元素优先级
    Integer c = opList.get(d);
    // 如果当前操作符的优先级较低或相等,且不为(),从操作符栈中弹出并添加到输出栈,然后重复比较直到可以推入操作符栈
    if (c < v && v != 3) {
    // 出栈
    opStack.pop();
    // 压入结果集栈
    dataStack.push(peek);
    } else {
    // 操作符与操作符栈栈顶的操作符的优先级。如果当前操作符的优先级较高,将其推入操作符栈。
    opStack.push(d);
    break;
    }
    }
    }

    测试代码如下:


    PostfixExpre postfixExpre = new PostfixExpre();

    List<String> postExp = postfixExpre.getPostExp(
    Arrays.asList("9", "+", "(" , "3", "-", "1", ")", "*", "3", "+", "10", "/", "2"));

    System.out.println(postExp);

    输出如下:


    [9, 3, 1, -, 3, *, 10, 2, /, +, +]


    五、求后缀表示值


    使用栈来实现


        /****
    * 计算后缀表达式的值
    * @param source
    * @return
    */

    public double calcPostfixExpe(List<String> source) {

    Stack<String> data = new Stack<>();
    for (int i = 0; i < source.size(); i++) {
    String s = source.get(i);
    // 如果是操作数
    if (opList.containsKey(s)) {
    String d2 = data.pop();
    String d1 = data.pop();
    Double i1 = Double.valueOf(d1);
    Double i2 = Double.valueOf(d2);
    Double result = null;
    switch (s) {
    case "+":
    result = i1 + i2;break;
    case "-":
    result = i1 - i2;break;
    case "*":
    result = i1 * i2;break;
    case "/":
    result = i1 / i2;break;
    }
    data.push(String.valueOf(result));
    } else {
    // 如果是操作数,进栈操作
    data.push(s);
    }
    }
    // 获取结果
    String pop = data.pop();
    return Double.valueOf(pop);
    }

    测试


    PostfixExpre postfixExpre = new PostfixExpre();

    List<String> postExp = postfixExpre.getPostExp(
    Arrays.asList("9", "+", "(" , "3", "-", "1", ")", "*", "3", "+", "10", "/", "2"));

    System.out.println(postExp);

    double v = postfixExpre.calcPostfixExpe(postExp);

    System.out.println(v);

    结果如下:


    [9, 3, 1, -, 3, *, 10, 2, /, +, +]
    20.0

    作者:小希爸爸
    来源:juejin.cn/post/7330583100059762697
    收起阅读 »

    什么是Spring Boot中的@Async

    异步方法 随着硬件和软件的高度发展,现代应用变得更加复杂和要求更高。由于 高需求,工程师总是试图寻找新的方法来提高应用程序性能和响应能力。慢节奏应用程序的一种解决方案是实施异步方法。异步处理是一种执行任务并发运行的进程或函数,无需等待一个任务完成后再开始另一个...
    继续阅读 »

    异步方法


    随着硬件和软件的高度发展,现代应用变得更加复杂和要求更高。由于

    高需求,工程师总是试图寻找新的方法来提高应用程序性能和响应能力。慢节奏应用程序的一种解决方案是实施异步方法。异步处理是一种执行任务并发运行的进程或函数,无需等待一个任务完成后再开始另一个任务。在本文中,我将尝试探索 Spring Boot 中的异步方法和 @Async 注解,试图解释多线程和并发之间的区别,以及何时使用或避免它。


    Spring中的@Async是什么?


    Spring 中的 @Async 注解支持方法调用的异步处理。它指示框架在单独的线程中执行该方法,允许调用者继续执行而无需等待该方法完成。这

    提高了应用程序的整体响应能力和吞吐量。


    要使用@Async,您必须首先通过将@EnableAsync注释添加到配置类来在应用程序中启用异步处理:


    @Configuration
    @EnableAsync
    public class AppConfig {
    }

    接下来,用@Async注解来注解你想要异步执行的方法:



    @Service
    public class AsyncService {
    @Async
    public void asyncMethod() {
    // Perform time-consuming task
    }
    }

    @Async 与多线程和并发有何不同?


    有时,区分多线程和并发与并行执行可能会让人感到困惑,但是,两者都与并行执行相关。他们每个人都有自己的用例和实现:



    • @Async 注解是 Spring 框架特定的抽象,它支持异步执行。它提供了轻松使用异步的能力,在后台处理所有艰苦的工作,例如线程创建、管理和执行。这使用户能够专注于业务逻辑而不是底层细节。

    • 多线程是一个通用概念,通常指操作系统或程序同时管理多个线程的能力。由于 @Async 帮助我们自动完成所有艰苦的工作,在这种情况下,我们可以手动处理所有这些工作并创建一个多线程环境。 Java 具有ThreadExecutorService等必要的类来创建和使用多线程。

    • 并发是一个更广泛的概念,它涵盖多线程和并行执行技术。它是

      系统在一个或多个处理器上同时执行多个任务的能力。


    综上所述,@Async是一种更高层次的抽象,它为开发人员简化了异步处理,而多线程和并发更多的是手动管理并行执行。


    何时使用 @Async 以及何时避免它。


    使用异步方法似乎非常直观,但是,必须考虑到这种方法也有注意事项。


    在以下情况下使用@Async:



    • 您拥有可以并发运行的独立且耗时的任务,而不会影响应用程序的响应能力。

    • 您需要一种简单而干净的方法来启用异步处理,而无需深入研究低级线程管理。


    在以下情况下避免使用 @Async:



    • 您想要异步执行的任务具有复杂的依赖性或需要大量的协调。在这种情况下,您可能需要使用更高级的并发 API,例如CompletableFuture或反应式编程库,例如 Project Reactor。

    • 您必须精确控制线程的管理方式,例如自定义线程池或高级同步机制。在这些情况下,请考虑使用 Java 的ExecutorService或其他并发实用程序。


    在 Spring Boot 应用程序中使用 @Async。


    在此示例中,我们将创建一个简单的 Spring Boot 应用程序来演示 @Async 的使用。

    让我们创建一个简单的订单管理服务。



    1. 创建一个具有最低依赖要求的新 Spring Boot 项目:


      org.springframework.boot:spring-boot-starter

      org.springframework.boot:spring-boot-starter-web

      Web 依赖用于 REST 端点演示目的。 @Async 带有引导启动程序。


    2. 将 @EnableAsync 注释添加到主类或应用程序配置类(如果我们使用它):


    @SpringBootApplication
    @EnableAsync
    public class AsyncDemoApplication {
    public static void main(String[] args) {
    SpringApplication.run(AsyncDemoApplication.class, args);
    }
    }

    @Configuration
    @EnableAsync
    public class ApplicationConfig {}


    1. 对于最佳解决方案,我们可以做的是,创建一个自定义 Executor bean 并根据我们的需要在同一个 Configuration 类中对其进行自定义:


       @Configuration
    @EnableAsync
    public class ApplicationConfig {

    @Bean
    public Executor getAsyncExecutor() {
    ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
    executor.setCorePoolSize(5);
    executor.setMaxPoolSize(10);
    executor.setQueueCapacity(100);
    executor.setThreadNamePrefix("");
    executor.initialize();
    return executor;
    }
    }

    通过此配置,我们可以控制最大和默认线程池大小。以及其他有用的定制。



    1. 使用 @Async 方法创建 OrderService 类:


    @Service
    public class OrderService {

    @Async
    public void saveOrderDetails(Order order) throws InterruptedException {
    Thread.sleep(2000);
    System.out.println(order.name());
    }

    @Async
    public CompletableFuture<String> saveOrderDetailsFuture(Order order) throws InterruptedException {
    System.out.println("Execute method with return type + " + Thread.currentThread().getName());
    String result = "Hello From CompletableFuture. Order: ".concat(order.name());
    Thread.sleep(5000);
    return CompletableFuture.completedFuture(result);
    }

    @Async
    public CompletableFuture<String> compute(Order order) throws InterruptedException {
    String result = "Hello From CompletableFuture CHAIN. Order: ".concat(order.name());
    Thread.sleep(5000);
    return CompletableFuture.completedFuture(result);
    }
    }

    我们在这里所做的是创建 3 种不同的异步方法。第一个saveOrderDetails服务是一个简单的异步

    服务,它将开始异步计算。如果我们想使用现代异步Java功能,

    例如CompletableFuture,我们可以通过服务来实现saveOrderDetailsFuture。通过这个服务,我们可以调用一个线程来等待@Async的结果。应该注意的是,CompletableFuture.get()在结果可用之前会阻塞。如果我们想在结果可用时执行进一步的异步操作,我们可以使用thenApplythenAccept或 CompletableFuture 提供的其他方法。



    1. 创建一个 REST 控制器来触发异步方法:


    @RestController
    public class AsyncController {

    private final OrderService orderService;

    public OrderController(OrderService orderService) {
    this.orderService = orderService;
    }

    @PostMapping("/process")
    public ResponseEntity<Void> process(@RequestBody Order order) throws InterruptedException {
    System.out.println("PROCESSING STARTED");
    orderService.saveOrderDetails(order);
    return ResponseEntity.ok(null);
    }

    @PostMapping("/process/future")
    public ResponseEntity<String> processFuture(@RequestBody Order order) throws InterruptedException, ExecutionException {
    System.out.println("PROCESSING STARTED");
    CompletableFuture<String> orderDetailsFuture = orderService.saveOrderDetailsFuture(order);
    return ResponseEntity.ok(orderDetailsFuture.get());
    }

    @PostMapping("/process/future/chain")
    public ResponseEntity<Void> processFutureChain(@RequestBody Order order) throws InterruptedException, ExecutionException {
    System.out.println("PROCESSING STARTED");
    CompletableFuture<String> computeResult = orderService.compute(order);
    computeResult.thenApply(result -> result).thenAccept(System.out::println);
    return ResponseEntity.ok(null);
    }
    }

    现在,当我们访问/process端点时,服务器将立即返回响应,同时

    继续saveOrderDetails()在后台执行。 2秒后,服务完成。第二个端点 -/process/future将使用我们的第二个选项,CompletableFuture在这种情况下,5 秒后,服务将完成,并将结果存储在CompletableFuture我们可以进一步使用future.get()来访问结果。在最后一个端点 - 中/process/future/chain,我们优化并使用了异步计算。控制器使用相同的服务方法CompletableFuture,但不久之后,我们将使用thenApply,thenAccept方法。服务器立即返回响应,我们不需要等待5秒,计算将在后台完成。在这种情况下,最重要的一点是对异步服务的调用,在我们的例子中compute()必须从同一类的外部完成。如果我们在一个方法上使用@Async并在同一个类中调用它,它将不起作用。这是因为Spring使用代理来添加异步行为,并且在内部调用方法会绕过代理。为了使其发挥作用,我们可以:



    • 将 @Async 方法移至单独的服务或组件。

    • 使用 ApplicationContext 获取代理并调用其上的方法。


    总结


    Spring 中的 @Async 注解是在应用程序中启用异步处理的强大工具。通过使用@Async,我们不需要陷入并发管理和多线程的复杂性来增强应用程序的响应能力和性能。但要决定何时使用 @Async 或使用替代并发

    使用程序,了解其局限性和用例非常重要。


    作者:it键盘侠
    来源:juejin.cn/post/7330227149176881161
    收起阅读 »

    新来个架构师,把xxl-job原理讲的炉火纯青~~

    大家好,我是三友~~ 今天来继续探秘系列,扒一扒轻量级的分布式任务调度平台Xxl-Job背后的架构原理 公众号:三友的java日记 核心概念 这里还是老样子,为了保证文章的完整性和连贯性,方便那些没有使用过的小伙伴更加容易接受文章的内容,快速讲一讲Xxl-...
    继续阅读 »

    大家好,我是三友~~


    今天来继续探秘系列,扒一扒轻量级的分布式任务调度平台Xxl-Job背后的架构原理



    公众号:三友的java日记



    核心概念


    这里还是老样子,为了保证文章的完整性和连贯性,方便那些没有使用过的小伙伴更加容易接受文章的内容,快速讲一讲Xxl-Job中的概念和使用


    如果你已经使用过了,可直接跳过本节和下一节,快进到后面原理部分讲解


    1、调度中心


    调度中心是一个单独的Web服务,主要是用来触发定时任务的执行


    它提供了一些页面操作,我们可以很方便地去管理这些定时任务的触发逻辑


    调度中心依赖数据库,所以数据都是存在数据库中的


    调度中心也支持集群模式,但是它们所依赖的数据库必须是同一个


    所以同一个集群中的调度中心实例之间是没有任何通信的,数据都是通过数据库共享的



    2、执行器


    执行器是用来执行具体的任务逻辑的


    执行器你可以理解为就是平时开发的服务,一个服务实例对应一个执行器实例


    每个执行器有自己的名字,为了方便,你可以将执行器的名字设置成服务名


    3、任务


    任务什么意思就不用多说了


    一个执行器中也是可以有多个任务的



    总的来说,调用中心是用来控制定时任务的触发逻辑,而执行器是具体执行任务的,这是一种任务和触发逻辑分离的设计思想,这种方式的好处就是使任务更加灵活,可以随时被调用,还可以被不同的调度规则触发。




    来个Demo


    1、搭建调度中心


    调度中心搭建很简单,先下载源码



    github.com/xuxueli/xxl…



    然后改一下数据库连接信息,执行一下在项目源码中的/doc/db下的sql文件



    启动可以打成一个jar包,或者本地启动就是可以的


    启动完成之后,访问下面这个地址就可以访问到控制台页面了



    http://localhost:8080/xxl-job-admin/toLogin



    用户名密码默认是 admin/123456


    2、执行器和任务添加


    添加一个名为sanyou-xxljob-demo执行器



    任务添加



    执行器选择我们刚刚添加的,指定任务名称为TestJob,corn表达式的意思是每秒执行一次


    创建完之后需要启动一下任务,默认是关闭状态,也就不会执行




    创建执行器和任务其实就是CRUD,并没有复杂的业务逻辑



    按照如上配置的整个Demo的意思就是


    每隔1s,执行一次sanyou-xxljob-demo这个执行器中的TestJob任务


    3、创建执行器和任务


    引入依赖


    <dependencies>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-web</artifactId>
            <version>2.2.5.RELEASE</version>
        </dependency>
        <dependency>
            <groupId>com.xuxueli</groupId>
            <artifactId>xxl-job-core</artifactId>
            <version>2.4.0</version>
        </dependency>
    </dependencies>

    配置XxlJobSpringExecutor这个Bean


    @Configuration
    public class XxlJobConfiguration {

        @Bean
        public XxlJobSpringExecutor xxlJobExecutor() {
            XxlJobSpringExecutor xxlJobSpringExecutor = new XxlJobSpringExecutor();
            //设置调用中心的连接地址
            xxlJobSpringExecutor.setAdminAddresses("http://localhost:8080/xxl-job-admin");
            //设置执行器的名称
            xxlJobSpringExecutor.setAppname("sanyou-xxljob-demo");
            //设置一个端口,后面会讲作用
            xxlJobSpringExecutor.setPort(9999);
            //这个token是保证访问安全的,默认是这个,当然可以自定义,
            // 但需要保证调度中心配置的xxl.job.accessToken属性跟这个token是一样的
            xxlJobSpringExecutor.setAccessToken("default_token");
            //任务执行日志存放的目录
            xxlJobSpringExecutor.setLogPath("./");
            return xxlJobSpringExecutor;
        }

    }

    XxlJobSpringExecutor这个类的作用,后面会着重讲


    通过@XxlJob指定一个名为TestJob的任务,这个任务名需要跟前面页面配置的对应上


    @Component
    public class TestJob {

        private static final Logger logger = LoggerFactory.getLogger(TestJob.class);

        @XxlJob("TestJob")
        public void testJob() {
            logger.info("TestJob任务执行了。。。");
        }

    }

    所以如果顺利的话,每隔1s钟就会打印一句TestJob任务执行了。。。


    启动项目,注意修改一下端口,因为调用中心默认也是8080,本地起会端口冲突


    最终执行结果如下,符合预期



    讲完概念和使用部分,接下来就来好好讲一讲Xxl-Job核心的实现原理


    从执行器启动说起


    前面Demo中使用到了一个很重要的一个类



    XxlJobSpringExecutor



    这个类就是整个执行器启动的入口



    这个类实现了SmartInitializingSingleton接口


    所以经过Bean的生命周期,一定会调用afterSingletonsInstantiated这个方法的实现


    这个方法干了很多初始化的事,这里我挑三个重要的讲,其余的等到具体的功能的时候再提


    1、初始化JobHandler


    JobHandler是个什么?


    所谓的JobHandler其实就是一个定时任务的封装



    一个定时任务会对应一个JobHandler对象


    当执行器执行任务的时候,就会调用JobHandler的execute方法


    JobHandler有三种实现:



    • MethodJobHandler

    • GlueJobHandler

    • ScriptJobHandler


    MethodJobHandler是通过反射来调用方法执行任务



    所以MethodJobHandler的任务的实现就是一个方法,刚好我们demo中的例子任务其实就是一个方法


    所以Demo中的任务最终被封装成一个MethodJobHandler


    GlueJobHandler比较有意思,它支持动态修改任务执行的代码


    当你在创建任务的时候,需要指定运行模式为GLUE(Java)



    之后需要在操作按钮点击GLUE IDE编写Java代码



    代码必须得实现IJobHandler接口,之后任务执行的时候就会执行execute方法的实现


    如果你需要修改任务的逻辑,只需要重新编辑即可,不需要重启服务


    ScriptJobHandler,通过名字也可以看出,是专门处理一些脚本的


    运行模式除了BEANGLUE(Java)之外,其余都是脚本模式


    而本节的主旨,所谓的初始化JobHandler就是指,执行器启动的时候会去Spring容器中找到加了@XxlJob注解的Bean


    解析注解,然后封装成一个MethodJobHandler对象,最终存到XxlJobSpringExecutor成员变量的一个本地的Map缓存中



    缓存key就是任务的名字



    至于GlueJobHandler和ScriptJobHandler都是任务触发时才会创建


    除了上面这几种,你也自己实现JobHandler,手动注册到JobHandler的缓存中,也是可以通过调度中心触发的


    2、创建一个Http服务器


    除了初始化JobHandler之外,执行器还会创建一个Http服务器


    这个服务器端口号就是通过XxlJobSpringExecutor配置的端口,demo中就是设置的是9999,底层是基于Netty实现的



    这个Http服务端会接收来自调度中心的请求


    当执行器接收到调度中心的请求时,会把请求交给ExecutorBizImpl来处理



    这个类非常重要,所有调度中心的请求都是这里处理的


    ExecutorBizImpl实现了ExecutorBiz接口


    当你翻源码的时候会发现,ExecutorBiz还有一个ExecutorBizClient实现



    ExecutorBizClient的实现就是发送http请求,所以这个实现类是在调度中心使用的,用来访问执行器提供的http接口



    3、注册到调度中心


    当执行器启动的时候,会启动一个注册线程,这个线程会往调度中心注册当前执行器的信息,包括两部分数据



    • 执行器的名字,也就是设置的appname

    • 执行器所在机器的ip和端口,这样调度中心就可以访问到这个执行器提供的Http接口


    前面提到每个服务实例都会对应一个执行器实例,所以调用中心会保存每个执行器实例的地址




    这里你可以把调度中心的功能类比成注册中心



    任务触发原理


    弄明白执行器启动时干了哪些事,接下来讲一讲Xxl-Job最最核心的功能,那就是任务触发的原理


    任务触发原理我会分下面5个小点来讲解



    • 任务如何触发?

    • 快慢线程池的异步触发任务优化

    • 如何选择执行器实例?

    • 执行器如何去执行任务?

    • 任务执行结果的回调


    1、任务如何触发?


    调度中心在启动的时候,会开启一个线程,这个线程的作用就是来计算任务触发时机,这里我把这个线程称为调度线程


    这个调度线程会去查询xxl_job_info这张表


    这张表存了任务的一些基本信息和任务下一次执行的时间


    调度线程会去查询下一次执行的时间 <= 当前时间 + 5s的任务


    这个5s是XxlJob写死的,被称为预读时间,提前读出来,保证任务能准时触发


    举个例子,假设当前时间是2023-11-29 08:00:10,这里的查询就会查出下一次任务执行时间在2023-11-29 08:00:15之前执行的任务



    查询到任务之后,调度线程会去将这些任务根据执行时间划分为三个部分:



    • 当前时间已经超过任务下一次执行时间5s以上,也就是需要在2023-11-29 08:00:05(不包括05s)之前的执行的任务

    • 当前时间已经超过任务下一次执行时间,但是但不足5s,也就是在2023-11-29 08:00:052023-11-29 08:00:10(不包括10s)之间执行的任务

    • 还未到触发时间,但是一定是5s内就会触发执行的



    对于第一部分的已经超过5s以上时间的任务,会根据任务配置的调度过期策略来选择要不要执行



    调度过期策略就两种,就是字面意思



    • 直接忽略这个已经过期的任务

    • 立马执行一次这个过期的任务


    对于第二部分的超时时间在5s以内的任务,就直接立马执行一次,之后如果判断任务下一次执行时间就在5s内,会直接放到一个时间轮里面,等待下一次触发执行


    对于第三部分任务,由于还没到执行时间,所以不会立马执行,也是直接放到时间轮里面,等待触发执行


    当这批任务处理完成之后,不论是前面是什么情况,调度线程都会去重新计算每个任务的下一次触发时间,然后更新xxl_job_info这张表的下一次执行时间


    到此,一次调度的计算就算完成了


    之后调度线程还会继续重复上面的步骤,查任务,调度任务,更新任务下次执行时间,一直死循环下去,这就实现了任务到了执行时间就会触发的功能


    这里在任务触发的时候还有一个很有意思的细节


    由于调度中心可以是集群的形式,每个调度中心实例都有调度线程,那么如何保证任务在同一时间只会被其中的一个调度中心触发一次?


    我猜你第一时间肯定想到分布式锁,但是怎么加呢?


    XxlJob实现就比较有意思了,它是基于八股文中常说的通过数据库来实现的分布式锁的


    在调度之前,调度线程会尝试执行下面这句sql



    就是这个sql



    select * from xxl_job_lock where lock_name = 'schedule_lock' for update



    一旦执行成功,说明当前调度中心成功抢到了锁,接下来就可以执行调度任务了


    当调度任务执行完之后再去关闭连接,从而释放锁


    由于每次执行之前都需要去获取锁,这样就保证在调度中心集群中,同时只有一个调度中心执行调度任务


    最后画一张图来总结一下这一小节



    2、快慢线程池的异步触发任务优化


    当任务达到了触发条件,并不是由调度线程直接去触发执行器的任务执行


    调度线程会将这个触发的任务交给线程池去执行


    所以上图中的最后一部分触发任务执行其实是线程池异步去执行的


    那么,为什么要使用线程池异步呢?


    主要是因为触发任务,需要通过Http接口调用具体的执行器实例去触发任务



    这一过程必然会耗费时间,如果调度线程去做,就会耽误调度的效率


    所以就通过异步线程去做,调度线程只负责判断任务是否需要执行


    并且,Xxl-Job为了进一步优化任务的触发,将这个触发任务执行的线程池划分成快线程池慢线程池两个线程池



    在调用执行器的Http接口触发任务执行的时候,Xxl-Job会去记录每个任务的触发所耗费的时间


    注意并不是任务执行时间,只是整个Http请求耗时时间,这是因为执行器执行任务是异步执行的,所以整个时间不包括任务执行时间,这个后面会详细说


    当任务一次触发的时间超过500ms,那么这个任务的慢次数就会加1


    如果这个任务一分钟内触发的慢次数超过10次,接下来就会将触发任务交给慢线程池去执行


    所以快慢线程池就是避免那种频繁触发并且每次触发时间还很长的任务阻塞其它任务的触发的情况发生


    3、如何选择执行器实例?


    上一节说到,当任务需要触发的时候,调度中心会向执行器发送Http请求,执行器去执行具体的任务


    那么问题来了



    由于一个执行器会有很多实例,那么应该向哪个实例请求?



    这其实就跟任务配置时设置的路由策略有关了



    从图上可以看出xxljob支持多种路由策略


    除了分片广播,其余的具体的算法实现都是通过ExecutorRouter的实现类来实现的



    这里简单讲一讲各种算法的原理,有兴趣的小伙伴可以去看看内部的实现细节


    第一个、最后一个、轮询、随机都很简单,没什么好说的


    一致性Hash讲起来比较复杂,你可以先看看这篇文章,再去查看Xxl-Job的代码实现



    zhuanlan.zhihu.com/p/470368641



    最不经常使用(LFU:Least Frequently Used):Xxl-Job内部会有一个缓存,统计每个任务每个地址的使用次数,每次都选择使用次数最少的地址,这个缓存每隔24小时重置一次


    最近最久未使用(LRU:Least Recently Used):将地址存到LinkedHashMap中,它利用LinkedHashMap可以根据元素访问(get/put)顺序来给元素排序的特性,快速找到最近最久未使用(未访问)的节点


    故障转移:调度中心都会去请求每个执行器,只要能接收到响应,说明执行器正常,那么任务就会交给这个执行器去执行


    忙碌转移:调度中心也会去请求每个执行器,判断执行器是不是正在执行当前需要执行的任务(任务执行时间过长,导致上一次任务还没执行完,下一次又触发了),如果在执行,说明忙碌,不能用,否则就可以用


    分片广播:XxlJob给每个执行器分配一个编号,从0开始递增,然后向所有执行器触发任务,告诉每个执行器自己的编号和总共执行器的数据


    我们可以通过XxlJobHelper#getShardIndex获取到编号,XxlJobHelper#getShardTotal获取到执行器的总数据量


    分片广播就是将任务量分散到各个执行器,每个执行器只执行一部分任务,加快任务的处理


    举个例子,比如你现在需要处理30w条数据,有3个执行器,此时使用分片广播,那么此时可将任务分成3分,每份10w条数据,执行器根据自己的编号选择对应的那份10w数据处理



    当选择好了具体的执行器实例之后,调用中心就会携带一些触发的参数,发送Http请求,触发任务


    4、执行器如何去执行任务?


    相信你一定记得我前面在说执行器启动是会创建一个Http服务器的时候提到这么一句



    当执行器接收到调度中心的请求时,会把请求交给ExecutorBizImpl来处理



    所以前面提到的故障转移和忙碌转移请求执行器进行判断,最终执行器也是交给ExecutorBizImpl处理的


    执行器处理触发请求是这个ExecutorBizImpl的run方法实现的



    当执行器接收到请求,在正常情况下,执行器会去为这个任务创建一个单独的线程,这个线程被称为JobThread



    每个任务在触发的时候都有单独的线程去执行,保证不同的任务执行互不影响



    之后任务并不是直接交给线程处理的,而是直接放到一个内存队列中,线程直接从队列中获取任务



    这里我相信你一定有个疑惑



    为什么不直接处理,而是交给队列,从队列中获取任务呢?



    那就得讲讲不正常的情况了


    如果调度中心选择的执行器实例正在处理定时任务,那么此时该怎么处理呢?**


    这时就跟阻塞处理策略有关了



    阻塞处理策略总共有三种:



    • 单机串行

    • 丢弃后续调度

    • 覆盖之前调度


    单机串行的实现就是将任务放到队列中,由于队列是先进先出的,所以就实现串行,这也是为什么放在队列的原因


    丢弃调度的实现就是执行器什么事都不用干就可以了,自然而然任务就丢了


    覆盖之前调度的实现就很暴力了,他是直接重新创建一个JobThread来执行任务,并且尝试打断之前的正在处理任务的JobThread,丢弃之前队列中的任务



    打断是通过Thread#interrupt方法实现的,所以正在处理的任务还是有可能继续运行,并不是说一打断正在运行的任务就终止了



    这里需要注意的一点就是,阻塞处理策略是对于单个执行器上的任务来生效的,不同执行器实例上的同一个任务是互不影响的


    比如说,有一个任务有两个执行器A和B,路由策略是轮询


    任务第一次触发的时候选择了执行器实例A,由于任务执行时间长,任务第二次触发的时候,执行器的路由到了B,此时A的任务还在执行,但是B感知不到A的任务在执行,所以此时B就直接执行了任务


    所以此时你配置的什么阻塞处理策略就没什么用了


    如果业务中需要保证定时任务同一时间只有一个能运行,需要把任务路由到同一个执行器上,比如路由策略就选择第一个


    5、任务执行结果的回调


    当任务处理完成之后,执行器会将任务执行的结果发送给调度中心



    如上图所示,这整个过程也是异步化的



    • JobThread会将任务执行的结果发送到一个内存队列中

    • 执行器启动的时候会开启一个处发送任务执行结果的线程:TriggerCallbackThread

    • 这个线程会不停地从队列中获取所有的执行结果,将执行结果批量发送给调度中心

    • 调用中心接收到请求时,会根据执行的结果修改这次任务的执行状态和进行一些后续的事,比如失败了是否需要重试,是否有子任务需要触发等等


    到此,一次任务的就算真正处理完成了


    最后


    最后我从官网捞了一张Xxl-Job架构图



    奈何作者不更新呐,导致这个图稍微有点老了,有点跟现有的架构对不上


    比如说图中的自研RPC(xxl-rpc)部分已经替换成了Http协议,这主要是拥抱生态,方便跨语言接入


    但是不要紧,大体还是符合现在的整个的架构


    从架构图中也可以看出来,本文除了日志部分的内容没有提到,其它的整个核心逻辑基本上都讲到了


    而日志部分其实是个辅助的作用,让你更方便查看任务的运行情况,对任务的触发逻辑是没有影响的,所以就没讲了


    所以从本文的讲解再到官方架构图,你会发现整个Xxl-Job不论是使用还是实现都是比较简单的,非常的轻量级


    说点什么


    好了,到这又又成功讲完了一款框架或者说是中间件的核心架构原理,不知道你有没有什么一点收获


    如果你觉得有点收获,欢迎点赞、在看、收藏、转发分享给其他需要的人


    你的支持就是我更新文章最大的动力,非常地感谢!


    其实这篇文章我在十一月上旬的时候我就打算写了


    但是由于十一月上旬之后我遇到一系列烦心事,导致我实在是没有精力去写


    现在到月底了,虽然烦心事只增不少,但是我还是想了想,觉得不能再拖了,最后也是连续肝了几个晚上,才算真正完成


    所以如果你发现文章有什么不足和问题,也欢迎批评指正


    好了,本文就讲到这里了,让我们下期再见,拜拜!


    作者:zzyang90
    来源:juejin.cn/post/7329860521241640971
    收起阅读 »

    为什么不推荐用 UUID 作为 Mysql 的主键

    学习改变命运,技术铸就辉煌。 大家好,我是銘,全栈开发程序员。 UUID 是什么 我们先来了解一下 UUID 是什么?UUID 是指Universally Unique Identifier,翻译为中文是通用唯一识别码,UUID 的目的是让分布式系统中的所有...
    继续阅读 »

    学习改变命运,技术铸就辉煌。



    大家好,我是銘,全栈开发程序员。


    UUID 是什么


    我们先来了解一下 UUID 是什么?UUID 是指Universally Unique Identifier,翻译为中文是通用唯一识别码,UUID 的目的是让分布式系统中的所有元素都能有唯一的识别信息。如此一来,每个人都可以创建不与其它人冲突的 UUID,就不需考虑数据库创建时的名称重复问题。


    UUID 的十六个八位字节被表示为 32个十六进制数字,以连字号分隔的五组来显示,形式为 8-4-4-4-12,总共有 36个字符(即三十二个英数字母和四个连字号)。例如:


    123e4567-e89b-12d3-a456-426655440000
    xxxxxxxx-xxxx-Mxxx-Nxxx-xxxxxxxxxxxx

    能否用 UUID 做主键


    先说答案 , 能,但是性能会比使用自增主键差一些,那原因是什么,我们具体分析:


    我们平时建表的时候,一般都像下面这样,不会去使用 UUID,使用AUTO INCREMENT直接把主键 id 设置成自增,每次 +1


    CREATE TABLE `user`(
    `id` int NOT NULL AUTO INCREMENT COMMENT '主键',
    `name` char(10NOT NULL DEFAULT '' COMMENT '名字',
     PRIMARY KEY (`id`)
     )ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

    那为什么把主键设置成自增呢, 我们在数据库保存数据的时候,就类似与下面的表格一样,这每一行数据,都是**保存在一个 16K 大小的页里 **。


    idnameage
    1张三11
    2李四22
    3王五33

    每次都去遍历所有的行性能会不好,于是为了加速搜索,我们可以根据主键 id,从小到大排列这些行数据,将这些数据页用双向链表的形式组织起来,再将这些页里的部分信息提取出来放到一个新的 16kb 的数据页里,再加入层级的概念。于是,一个个数据页就被组织起来了,成为了一棵 B+ 树索引。


    当我们在建表 sql 里面声明 AUTO INCREMENT 的时候,myqsl 的 innodb 引擎,就会为主键 id 生成一个主键索引,里面就是通过 B+ 树的形式来维护这套索引。


    那么现在,我们需要关注两个点,



    1. 数据页大小是固定的 16k

    2. 数据页内,以及数据页之间,数据主键 id 是从小到大排序的


    所以,由于数据页大小固定了 16k ,当我们需要插入一条数据的时候,数据页就会慢慢的被放满,当超过 16k 的时候,这个数据页就可能会进行分裂。


    针对 B+ 树的叶子节点,如果主键是自增的,那么它产生的 id 每次都比前次要大,所以每次都会将数据家在 B+ 树的尾部,B+ 树的叶子节点本质是双向链表,查找它的首部和尾部,时间复杂度 O(1),如果此时最末尾的数据也满了,那创建个新的页就好。


    如果bb,上次 id=12111111,这次 id=343435455,那么为了让新加入数据后 B+ 树的叶子节点海涅那个保持有序,那么就需要旺叶子节点的中间找,查找的时间复杂度是 O(lgn),如果这个页满了,那就需要进行页分裂,并且页分裂的操作是需要加悲观锁的。


    所以,我们一般都建议把主键设置成自增,这样可以提高效率,提高性能


    那什么情况下不设置主键自增


    mysql分库分表下的id


    在分库分表的情况下,插入的 id 都是专门的 id 服务生成的,如果要严格按照自增的话,那么一般就会通过 redis 来生成,按批次去获得,比如一次性获取几百个,用完了再去获取,但是如果 redis 服务挂了,功能就完全没法用了,那有么有不依赖与第三方组件的方法呢?


    雪花算法


    使用时间戳+机器码+流水号,一个字段实现了时间顺序、机器编码、创建时间。去中心化,方便排序,随便多表多库复制,并可抽取出生成时间,雪花ID主要是用在数据库集群上,去中心化,ID不会冲突又能相对排序。


    总结


    一般情况下,我们不推荐使用 UUID 来作为数据库的主键,只有分库分表的时候,才建议使用 UUID 来作为主键。


    作者:銘聊技术
    来源:juejin.cn/post/7328366295091200038
    收起阅读 »

    为什么要用雪花ID替代数据库自增ID?

    今天咱们来看一道数据库中比较经典的面试问题:为什么要使用雪花 ID 替代数据库自增 ID?同时这道题也出现在了浩鲸科技的 Java 面试中,下面我们一起来看吧。 浩鲸科技的面试题如下:   1.什么是雪花 ID? 雪花 ID(Snowflake ID...
    继续阅读 »

    今天咱们来看一道数据库中比较经典的面试问题:为什么要使用雪花 ID 替代数据库自增 ID?同时这道题也出现在了浩鲸科技的 Java 面试中,下面我们一起来看吧。


    浩鲸科技的面试题如下:
    image.png 


    1.什么是雪花 ID?


    雪花 ID(Snowflake ID)是一个用于分布式系统中生成唯一 ID 的算法,由 Twitter 公司提出。它的设计目标是在分布式环境下高效地生成全局唯一的 ID,具有一定的有序性。


    雪花 ID 的结构如下所示:
    image.png
    这四部分代表的含义



    1. 符号位:最高位是符号位,始终为 0,1 表示负数,0 表示正数,ID 都是正整数,所以固定为 0。

    2. 时间戳部分:由 41 位组成,精确到毫秒级。可以使用该 41 位表示的时间戳来表示的时间可以使用 69 年。

    3. 节点 ID 部分:由 10 位组成,用于表示机器节点的唯一标识符。在同一毫秒内,不同的节点生成的 ID 会有所不同。

    4. 序列号部分:由 12 位组成,用于标识同一毫秒内生成的不同 ID 序列。在同一毫秒内,可以生成 4096 个不同的 ID。


    2.Java 版雪花算法实现


    接下来,我们来实现一个 Java 版的雪花算法:


    public class SnowflakeIdGenerator {

    // 定义雪花 ID 的各部分位数
    private static final long TIMESTAMP_BITS = 41L;
    private static final long NODE_ID_BITS = 10L;
    private static final long SEQUENCE_BITS = 12L;

    // 定义起始时间戳(可根据实际情况调整)
    private static final long EPOCH = 1609459200000L;

    // 定义最大取值范围
    private static final long MAX_NODE_ID = (1L << NODE_ID_BITS) - 1;
    private static final long MAX_SEQUENCE = (1L << SEQUENCE_BITS) - 1;

    // 定义偏移量
    private static final long TIMESTAMP_SHIFT = NODE_ID_BITS + SEQUENCE_BITS;
    private static final long NODE_ID_SHIFT = SEQUENCE_BITS;

    private final long nodeId;
    private long lastTimestamp = -1L;
    private long sequence = 0L;

    public SnowflakeIdGenerator(long nodeId) {
    if (nodeId < 0 || nodeId > MAX_NODE_ID) {
    throw new IllegalArgumentException("Invalid node ID");
    }
    this.nodeId = nodeId;
    }

    public synchronized long generateId() {
    long currentTimestamp = timestamp();
    if (currentTimestamp < lastTimestamp) {
    throw new IllegalStateException("Clock moved backwards");
    }
    if (currentTimestamp == lastTimestamp) {
    sequence = (sequence + 1) & MAX_SEQUENCE;
    if (sequence == 0) {
    currentTimestamp = untilNextMillis(lastTimestamp);
    }
    } else {
    sequence = 0L;
    }
    lastTimestamp = currentTimestamp;
    return ((currentTimestamp - EPOCH) << TIMESTAMP_SHIFT) |
    (nodeId << NODE_ID_SHIFT) |
    sequence;
    }

    private long timestamp() {
    return System.currentTimeMillis();
    }

    private long untilNextMillis(long lastTimestamp) {
    long currentTimestamp = timestamp();
    while (currentTimestamp <= lastTimestamp) {
    currentTimestamp = timestamp();
    }
    return currentTimestamp;
    }
    }

    调用代码如下:


    public class Main {
    public static void main(String[] args) {
    // 创建一个雪花 ID 生成器实例,传入节点 ID
    SnowflakeIdGenerator idGenerator = new SnowflakeIdGenerator(1);
    // 生成 ID
    long id = idGenerator.generateId();
    System.out.println(id);
    }
    }

    其中,nodeId 表示当前节点的唯一标识,可以根据实际情况进行设置。generateId 方法用于生成雪花 ID,采用同步方式确保线程安全。具体的生成逻辑遵循雪花 ID 的位运算规则,结合当前时间戳、节点 ID 和序列号生成唯一的 ID。



    需要注意的是,示例中的时间戳获取方法使用了 System.currentTimeMillis(),根据实际需要可以替换为其他更精确的时间戳获取方式。同时,需要确保节点 ID 的唯一性,避免不同节点生成的 ID 重复。



    3.雪花算法问题


    虽然雪花算法是一种被广泛采用的分布式唯一 ID 生成算法,但它也存在以下几个问题:



    1. 时间回拨问题:雪花算法生成的 ID 依赖于系统的时间戳,要求系统的时钟必须是单调递增的。如果系统的时钟发生回拨,可能导致生成的 ID 重复。时间回拨是指系统的时钟在某个时间点之后突然往回走(人为设置),即出现了时间上的逆流情况。

    2. 时钟回拨带来的可用性和性能问题:由于时间依赖性,当系统时钟发生回拨时,雪花算法需要进行额外的处理,如等待系统时钟追上上一次生成 ID 的时间戳或抛出异常。这种处理会对算法的可用性和性能产生一定影响。

    3. 节点 ID 依赖问题:雪花算法需要为每个节点分配唯一的节点 ID 来保证生成的 ID 的全局唯一性。节点 ID 的分配需要有一定的管理和调度,特别是在动态扩容或缩容时,节点 ID 的管理可能较为复杂。


    4.如何解决时间回拨问题?


    百度 UidGenerator 框架中解决了时间回拨的问题,并且解决方案比较经典,所以咱们这里就来给大家分享一下百度 UidGenerator 是怎么解决时间回拨问题的?



    UidGenerator 介绍:UidGenerator 是百度开源的一个分布式唯一 ID 生成器,它是基于 Snowflake 算法的改进版本。与传统的 Snowflake 算法相比,UidGenerator 在高并发场景下具有更好的性能和可用性。它的实现源码在:github.com/baidu/uid-g…



    UidGenerator 是这样解决时间回拨问题的:UidGenerator 的每个实例中,都维护一个本地时钟缓存,用于记录当前时间戳。这个本地时钟会定期与系统时钟进行同步,如果检测到系统时钟往前走了(出现了时钟回拨),则将本地时钟调整为系统时钟。


    4.为什么要使用雪花 ID 替代数据库自增 ID?


    数据库自增 ID 只适用于单机环境,但如果是分布式环境,是将数据库进行分库、分表或数据库分片等操作时,那么数据库自增 ID 就有问题了。


    例如,数据库分片之后,会在同一张业务表的分片数据库中产生相同 ID(数据库自增 ID 是由每个数据库单独记录和增加的),这样就会导致,同一个业务表的竟然有相同的 ID,而且相同 ID 背后存储的数据又完全不同,这样业务查询的时候就出问题了。


    所以为了解决这个问题,就必须使用分布式中能保证唯一性的雪花 ID 来替代数据库的自增 ID。


    5.扩展:使用 UUID 替代雪花 ID 行不行?


    如果单从唯一性来考虑的话,那么 UUID 和雪花 ID 的效果是一致的,二者都能保证分布式系统下的数据唯一性,但是即使这样,也不建议使用 UUID 替代雪花 ID,因为这样做的问题有以下两个:



    1. 可读性问题:UUID 内容很长,但没有业务含义,就是一堆看不懂的“字母”。

    2. 性能问题:UUID 是字符串类型,而字符串类型在数据库的查询中效率很低。


    所以,基于以上两个原因,不建议使用 UUID 来替代雪花 ID。


    小结


    数据库自增 ID 只适用于单机数据库环境,而对于分库、分表、数据分片来说,自增 ID 不具备唯一性,所以要要使用雪花 ID 来替代数据库自增 ID。但雪花算法依然存在一些问题,例如时间回拨问题、节点过度依赖问题等,所以此时,可以使用雪花算法的改进框架,如百度的 UidGenerator 来作为数据库的 ID 生成方案会比较好。


    作者:Java中文社群
    来源:juejin.cn/post/7307066138487521289
    收起阅读 »

    好坑啊,调用了同事写的基础代码,bug藏得还挺深!!

    起因 事情的起因是我调用了同事的一个函数,这个函数返回了一个map[string]string结构体的变量optionMap(请忽略为什么要返回map结构体,后面有机会再讲),这个函数主要是查DB取获取当前系统的space_id和pkey,返回的内容基本上如下...
    继续阅读 »

    起因


    事情的起因是我调用了同事的一个函数,这个函数返回了一个map[string]string结构体的变量optionMap(请忽略为什么要返回map结构体,后面有机会再讲),这个函数主要是查DB取获取当前系统的space_id和pkey,返回的内容基本上如下


     // 返回
    optionMap = map[string]string{
    "space_id":"xxx",
    "pkey": "xxxx",
    }

    然后我修改了这个变量,添加了



    optionMap["is_base"] = 1

    然后我就return出我当前的函数了,然后在同一个请求内,但是当我再一次请求同事的函数时,返回给我的却是


     optionMap = map[string]string{
    "space_id":"xxx",
    "pkey": "xxxx",
    "is_base": 1,
    }

    what! 怎么后面再次请求同事的的函数总是会多一个参数呢!!!!


    经过


    看了同事写的函数我才发现,原来他在内部使用了gin上下文去做了一个缓存,大概的代码意思减少重复space基础信息的查询,存入上下文中做缓存,提高代码效率,这里我写了一个示例大家可以看下


    // 同事的代码
    func BadReturnMap(ctx *gin.Context, key string) map[string]interface{} {
    m := make(map[string]interface{})
    // 查询缓存
    value, ok := ctx.Get(key)
    if ok {
    bm, ok := value.(map[string]interface{})
    if ok {
    return bm
    }
    }
    // io查询后存入变量
    m["a"] = 1
    // 保存缓存
    fmt.Println("set cache: ")
    fmt.Println(m) // map[a:1]
    ctx.Set(key, m)
    return m
    }

    // 我的使用
    func TestBadReturnMap() {
    fmt.Println("bad return map start")
    ctx := &gin.Context{}
    key := "cached:map_key"
    mapOpt := BadReturnMap(ctx, key)
    fmt.Printf("%p\n", mapOpt) // 0xc0003a6750 指向地址
    mapOpt["b"] = 1
    value, ok := ctx.Get(key)
    fmt.Printf("%p\n", mapOpt) // 0xc0003a6750 指向地址
    if ok {
    fmt.Println("get cache: ")
    fmt.Println(value.(map[string]interface{})) // map[a:1 def:1]
    } else {
    fmt.Println("unknown")
    }
    fmt.Println("bad return map end")
    }

    打印结果是


    bad return map start
    set cache:
    map[a:1]
    0xc0003a6750
    0xc0003a6750
    get cache:
    map[a:1 b:1]
    bad return map end

    解释


    在Go语言中,map是引用类型,当将一个map赋值给另一个变量时,实际上是将它们指向同一个底层的map对象。因此,当你修改其中一个变量的map时,另一个变量也会受到影响。


    当你将函数内m变量赋值给外部函数内的变量时,它们实际上指向同一个map对象。所以当你在外部函数内修改mapOpt的值时,原始的缓存也会被修改。


    如图所示


    image.png


    如何修改


    当然修改方式有很多种,我这里列举了一种就是序列化存储到缓存然后反序列化取,如果你有更好的方式可下方留言



    func ReturnMap(ctx *gin.Context, key string) map[string]interface{} {
    m := make(map[string]interface{})
    value, ok := ctx.Get(key)
    if ok {
    bytes := value.([]byte)
    err := json.Unmarshal(bytes, &m)
    if err != nil {
    panic(err)
    }
    return m
    }
    // io查询后存入变量
    m["a"] = 1
    jsonBytes, err := json.Marshal(m)
    if err != nil {
    panic(err)
    }
    fmt.Println("set cache: ")
    fmt.Println(m)
    ctx.Set(key, jsonBytes)

    return m
    }

    func TestReturnMap() {
    fmt.Println("return map start")
    ctx := &gin.Context{}
    key := "cached:map_key"
    mapOpt := ReturnMap(ctx, key)
    fmt.Printf("%p\n", mapOpt)

    mapOpt["b"] = 1
    fmt.Printf("%p\n", mapOpt)

    value, ok := ctx.Get(key)
    if ok {
    m := make(map[string]interface{})
    bytes := value.([]byte)
    err := json.Unmarshal(bytes, &m)
    if err != nil {
    panic(err)
    }
    fmt.Println("get cache: ")
    fmt.Println(m)
    } else {
    fmt.Println("unknown")
    }
    fmt.Println("return map end")
    }


    打印的结果为:


    return map start
    set cache:
    map[a:1]
    0xc0003a6870
    0xc0003a6870
    get cache:
    map[a:1]
    return map end

    知识点


    作者:沙蒿同学
    来源:juejin.cn/post/7330869056411058239
    收起阅读 »

    大公司如何做 APP:背后的开发流程和技术

    我记得五六年前,当我在 Android 开发领域尚处初出茅庐阶段之时,我曾有一个执念——想看下大公司在研发一款产品的流程和技术上跟小公司有什么区别。公司之大,对开发来说,不在于员工规模,而在于产品的用户量级。只有用户量级够大,研发过程中的小问题才会被放大。当用...
    继续阅读 »

    我记得五六年前,当我在 Android 开发领域尚处初出茅庐阶段之时,我曾有一个执念——想看下大公司在研发一款产品的流程和技术上跟小公司有什么区别。公司之大,对开发来说,不在于员工规模,而在于产品的用户量级。只有用户量级够大,研发过程中的小问题才会被放大。当用户量级够大,公司才愿意在技术上投入更多的人力资源。因此,在大公司里做技术,对个人的眼界、技术细节和深度的提升都有帮助。


    我记得之前我曾跟同事调侃说,有一天我离职了,我可以说我毕业了,因为我这几年学到了很多。现在我想借这个机会总结下这些年在公司里经历的让我印象深刻的技术。


    1、研发流程


    首先在产品的研发流程上,我把过去公司的研发模式分成两种。


    第一种是按需求排期的。在评审阶段一次性评审很多需求,和开发沟通后可能删掉优先级较低的需求,剩下的需求先开发,再测试,最后上线。上线的时间根据开发和测试最终完成的时间确定。


    第二种是双周迭代模式,属于敏捷开发的一种。这种开发机制里,两周一个版本,时间是固定的。开发、测试和产品不断往时间周期里插入需求。如下图,第一周和第三周的时间是存在重叠的。具体每个阶段留多少时间,可以根据自身的情况决定。如果需求比较大,则可以跨迭代,但发布的时间窗口基本是固定的。


    截屏2023-12-30 13.00.33.png


    有意思的是,第二种开发机制一直是我之前的一家公司里负责人羡慕的“跑火车”模式。深度参与过两种开发模式之后,我说下我的看法。


    首先,第一种开发模式适合排期时间比较长的需求。但是这种方式时间利用率相对较低。比如,在测试阶段,开发一般是没什么事情做的(有的会在这个时间阶段布置支线需求)。这种开发流程也有其好处,即沟通和协调成本相对较低。


    注意!在这里,我们比较时间利用率的时候是默认两种模式的每日工作时间是相等的且在法律允许范围内。毕竟,不论哪一种研发流程,强制加班之后,时间利用率都“高”(至少老板这么觉得)。


    第二种开发方式的好处:



    1. 响应速度快。可以快速发现问题并修复,适合快速试错。

    2. 时间利用率高。相比于按需求排期的方式,不存在开发和测试的间隙期。


    但这种开发方式也有缺点:



    1. 员工压力大,容易造成人员流失。开发和测试时间穿插,开发需要保证开发的质量,否则容易影响整个迭代内开发的进度。

    2. 沟通成本高。排期阶段出现人力冲突需要协调。开发过程中出现问题也需要及时、有效的沟通。因此,在这种开发模式里还有一个角色叫项目经理,负责在中间协调,而第一种开发模式里项目经理的存在感很低。

    3. 这种开发模式中,产品要不断想需求,很容易导致开发的需求本身价值并不大。


    做了这么多年开发,让人很难拒绝一个事实是,绝大多数互联网公司的壁垒既不是技术,也不是产品,而是“快速迭代,快速试错”。从这个角度讲,双周迭代开发机制更适应互联网公司的要求。就像我们调侃公司是给电脑配个人,这种开发模式里就是给“研发流水线”配个人,从产品、到开发、到测试,所有人都像是流水线上的一员。


    2、一个需求的闭环


    以上是需求的研发流程。如果把一个需求从产品提出、到上线、到线上数据回收……整个生命周期列出来,将如下图所示,


    需求闭环.drawio.png


    这里我整合了几个公司的研发过程。我用颜色分成了几个大的流程。相信每个公司的研发流程里或多或少都会包含其中的几个。在这个闭环里,我说一下我印象比较深刻的几个。


    2.1 产品流程


    大公司做产品一个显著的特点是数据驱动,一切都拿数据说话。一个需求的提出只是一个假设,开发上线之后效果评估依赖于数据。数据来源主要有埋点上报和舆情监控。


    1. 数据埋点


    埋点数据不仅用于产品需求的验证,也用于推荐算法的训练。因此,大公司对数据埋点的重视可以说是深入骨髓的。埋点数据也经常被纳入到绩效考核里。


    开发埋点大致要经过如下流程,



    • 1). 产品提出需要埋的点。埋点的类型主要包括曝光和点击等,此外还附带一些上报的参数,统计的维度包括用户 uv 和次数 pv.

    • 2). 数据设计埋点。数据拿到产品要埋的点之后,设计埋点,并在埋点平台录入。

    • 3). 端上开发埋点。端上包括移动客户端和 Web,当然埋点框架也要支持 RN 和 H5.

    • 4). 端上验证埋点。端上埋点完成之后需要测试,上报埋点,然后再在平台做埋点校验。

    • 5). 产品提取埋点数据。

    • 6). 异常埋点数据修复。


    由此可见,埋点及其校验对开发来说也是需要花费精力的一环。它不仅需要多个角色参与,还需要一个大数据平台,一个录入、校验和数据提取平台,以及端上的上报框架,可以说成本并不低。


    2. 舆情监控


    老实说,初次接触舆情监控的时候,它还是给了我一点小震撼的。没想到大公司已经把舆情监控做到了软件身上。


    舆情监控就是对网络上关于该 APP 的舆情的监控,数据来源不仅包括应用内、外用户提交的反馈,还包括主流社交平台上关于该软件的消息。所有数据在整合到舆情平台之后会经过大数据分析和分类,然后进行监控。舆情监控工具可以做到对产品的负面信息预警,帮助产品经理优化产品,是产品研发流程中重要的一环。


    3. AB 实验


    很多同学可能对 AB 实验都不陌生。AB 实验就相当于同时提出多套方案,然后左右手博弈,从中择优录用。AB 实验的一个槽点是,它使得你代码中同时存在多份作用相同的代码,像狗皮膏药一样,也不能删除,非常别扭,最后导致的结果是代码堆积如山。


    4. 路由体系建设


    路由即组件化开发中的页面路由。但是在有些应用里,会通过动态下发路由协议支持运营场景。这在偏运营的应用里比较常见,比如页面的推荐流。一个推荐流里下发的模块可能打开不同的页面,此时,只需要为每个页面配置一个路由路径,然后推荐流里根据需要下发即可。所以,路由体系也需要 Android 和 iOS 双端统一,同时还要兼容 H5 和 RN.


    mdn-url-all.png


    在路由协议的定义上,我们可以参考 URL 的格式,定义自己的协议、域名、路径以及参数。以 Android 端为例,可以在一个方法里根据路由的协议、域名对原生、RN 和 H5 等进行统一分发。


    2.2 开发流程


    在开发侧的流程里,我印象深的有以下几个。


    1. 重视技术方案和文档


    我记得之前在一家公司里只文档平台就换了几个,足见对文档的重视。产品侧当然更重文档,而对研发侧,文档主要有如下几类:1). 周会文档;2).流程和规范;3).技术方案;4).复盘资料等。


    对技术方案,现在即便我自己做技术也保留了写大需求技术方案先行的习惯。提前写技术方案有几个好处:



    • 1). 便于事后回忆:当我们对代码模糊的时候,可以通过技术方案快速回忆。

    • 2). 便于风险预知:技术方案也有助于提前预知开发过程中的风险点。前面我们说敏捷开发提前发现风险很重要,而做技术方案就可以做到这点。

    • 3). 便于全面思考:技术方案能帮助我们更全面地思考技术问题。一上来就写代码很容易陷入“只见树木,不见森林”的困境。


    2. Mock 开发


    Mock 开发也就是基于 Mock 的数据进行开发和测试。在这里它不局限于个人层面(很多人可能有自己 Mock 数据开发的习惯),而是在公司层面将其作为一种开发模式,以实现前后端分离。典型的场景是客户端先上线预埋,而后端开发可能滞后一段时间。为了支持 Mock 开发模式,公司需要专门的平台,提供以接口为维度的 Mock 工具。当客户端切换到 Mock 模式之后,上传到网络请求在后端的网关直接走 Mock 服务器,拉取 Mock 数据而不是真实数据。


    这种开发模式显然也是为了适应敏捷开发模式而提出的。它可以避免前后端依赖,减轻人力资源协调的压力。这种开发方式也有其缺点:



    • 1). 数据结构定义之后无法修改。客户端上线之后后端就无法再修改数据结构。因此,即便后端不开发,也需要先投入人力进行方案设计,定义数据结构,并拉客户端进行评审。

    • 2). 缺少真实数据的验证。在传统的开发模式中,测试要经过测试和 UAT 两个环境,而 UAT 本身已经比较接近线上环境,而使用 Mock 开发就完全做不到这么严谨。当我们使用 Mock 数据测试时,如果我们自己的 Mock 的数据本身失真比较严重,那么在意识上你也不会在意数据的合理性,因此容易忽视一些潜在的问题。


    3. 灰度和热修复


    灰度的机制是,在用户群体中选择部分用户进行应用更新提示的推送。这要求应用本身支持自动更新,同时需要对推送的达到率、用户的更新率进行统计。需要前后端一套机制配合。灰度有助于提前发现应用中存在的问题,这对超大型应用非常有帮助,毕竟,现在上架之后发现问题再修复的成本非常高。


    但如果上架之后确实出现了问题就需要走热修复流程。热修复的难点在于热修复包的下发,同时还需要审核流程,因此需要搭建一个平台。这里涉及的细节比较多,后面有时间再梳理吧。


    4. 配置下发


    配置下发就是通过平台录入配置,推送,然后在客户端读取配置信息。这也是应用非常灵活的一个功能,可以用来下发比如固定的图片、文案等。我之前做个人开发的时候也在服务器上做了配置下发的功能,主要用来绕过某些应用商店的审核,但是在数据结构的抽象上做得比较随意。这里梳理下配置下发的细节。



    • 首先,下发的配置是区分平台特征的。这包括,应用的目标版本(一个范围)、目标平台(Android、iOS、Web、H5 或者 RN)。

    • 其次,为了适应组件化开发,也为了更好地分组管理,下发的配置命名时采用 模块#配置名称 的形式。

    • 最后,下发的数据结构支持,整型、布尔类型、浮点数、字符串和 Json.


    我自己在做配置下发的时候还遇到一个比较棘手的问题——多语言适配。国内公司的产品一般只支持中文,这方面就省事得多。


    5. 复盘文化


    对于敏捷开发,复盘是不可或缺的一环。有助于及时发现问题,纠正和解决问题。复盘的时间可以是定期的,在一个大需求上线之后,或者出现线上问题之后。


    3、技术特点


    3.1 组件化开发的痛点


    在大型应用开发过程中,组件化开发的意义不仅局限于代码结构层面。组件化的作用体现在以下几个层面:



    • 1). 团队配合的利器。想想几十个人往同一份代码仓库里提交代码的场景。组件化可以避免无意义的代码冲突。

    • 2). 提高编译效率。对于大型应用,全源码编译一次的时间可能要几十分钟。将组件打包成 aar 之后可以减少需要编译的代码的数量,提升编译效率。

    • 3). 适应组织架构。将代码细分为各个组件,每个小团队只维护自己的组件,更方便代码权限划分。


    那么,在实际开发过程中组件化开发会存在哪些问题呢?


    1. 组件拆分不合理


    这在从单体开发过渡到组件化开发的应用比较常见,即组件化拆分之后仍然存在某些模块彼此共用,导致提交代码的时候仍然会出现冲突问题。冲突包含两个层面的含义,一是代码文件的 Git 冲突,二是在打包合入过程中发布的 aar 版本冲突。比较常见的是,a 同学合入了代码到主干之后,b 同学没有合并主干到自己的分支就打包,导致发布的 aar 没有包含最新的代码。这涉及打包的问题,是另一个痛点问题,后面再总结。


    单就拆分问题来看,避免上述冲突的一个解决办法是在拆分组件过程中尽可能解耦。根据我之前的观察,存在冲突的组件主要是数据结构和 SPI 接口。这是我之前公司没做好的地方——数据结构仓库和 SPI 接口是共用的。对于它们的组件化拆分,我待过的另一家公司做得更好。他们是如下拆分的,这里以 A 和 B 来命名两个业务模块。那么,在拆分的时候做如下处理,


    模块:A-api
    模块:A
    模块:B-api
    模块:B

    即每个业务模块拆分成 api 和实现两部分。api 模块里包含需要共享的数据结构和 SPI 接口,实现模块里是接口的具体实现。当模块 A 需要和模块 B 进行交互的时候,只需要依赖 B 的 api 模块。可以参考开源项目:arch-android.


    2. 打包合入的痛点


    上面我们提到了一种冲突的情况。在我之前的公司里,每个组件有明确的负责人,在每个迭代开发的时候,组件负责人负责拉最新 release 分支。其他同学在该分支的开发需要经过负责人同意再合入到该分支。那么在最终打包的过程中,只需要保证这个分支的 aar 包含了全部最新的代码即可。也就是说,这种打包方式只关心每个 aar 的版本,而不关心实际的代码。因为它最终打包是基于 aar 而不是全源码编译。


    这种打包方式存在最新的分支代码没有被打包的风险。一种可行的规避方法是,在平台通过 Git tag 和 commit 判断该分支是否已经包含最新代码。此外,还可能存在某个模块修改了 SPI 接口,而另一个模块没有更新,导致运行时异常的风险。


    另一个公司是基于全源码编译的。不过,全源码编译只在最终打包阶段或者某个固定的时间点进行,而不是每次合入都全源码编译(一次耗时太久)。同时,虽然每个模块有明确的负责人,但是打包的 aar 不是基于当前 release 分支,而是自己的开发分支。这是为了保障当前 release 分支始终是可用的。合并代码到 release 分支的同时需要更新 aar 的版本。但它也存在问题,如果合并到 release 而没有打包 aar,那么可能导致 release 分支无法使用。如果打包了 aar 但是此时其他同学也打包了 aar,则可能导致本次打包的 aar 落后,需要重新打包。因此,这种合入方式也是苦不堪言。


    有一种方法可以避免上述问题,即将打包和合入事件设计成一个消息队列。每次合入之前自动化执行上述操作,那么自然就可以保证每次操作的原子性(因为本身就是单线程的)。


    对比两种打包和合入流程,显然第二种方式更靠谱。不过,它需要设计一个流程。这需要花费一点功夫。


    3. 自动化切源码


    我在之前的一家公司开发时,在开发过程中需要引用另一个模块的修改时,需要对另一个模块打 SNAPSHOT 包。这可行,但有些麻烦。之前我也尝试过手动修改 settings.gradle 文件进行源码依赖开发。不过,太麻烦了。


    后来在另一个公司里看到一个方案,即动态切换到源码开发。可以将某个依赖替换为源码而只需要修改脚本即可。这个实践很棒,我已经把它应用到独立开发中。之前已经梳理过《组件化开发必备:Gradle 依赖切换源码的实践》.


    3.2 大前端化开发


    1. React Native


    如今的就业环境,哪个 Android 开发不是同时会五六门手艺。跨平台开发几乎是不可避免的。


    之前的公司为什么选择 React Native 而不是 Flutter 等新锐跨平台技术呢?我当时还刻意问了这个问题。主要原因:



    • 1). 首先是 React Native 相对更加成熟,毕竟我看了下 Github 第一个版本发布已经是 9 年前的事情了,并且至今依旧非常活跃。

    • 2). React Native 最近更新了 JavaScript 引擎,页面启动时间、包大小和内存占用性能都有显著提升。参考这篇文章《干货 | 加载速度提升15%,携程对RN新一代JS引擎Hermes的调研》.

    • 3). 从团队人才配置上,对 React Native 熟悉的更多。


    React Native 开发是另一个领域的东西,不在本文讨论范围内。每个公司选择 React Native 可能有它的目的。比如,我之前的一家公司存粹是为了提效,即一次开发双端运行。而另一家公司,则是为了兼顾提效和动态化。如果只为提效,那么本地编译和打包 js bundle 就可以满足需求。若要追求动态化,就需要搭建一个 RN 包下发平台。实际上,在这个公司开发 RN 的整个流程,除了编码环节,从代码 clone 到最终发布都是在平台上执行的。平台搭建涉及的细节比较多,以后用到再总结。对于端侧,RN 的动态化依赖本地路由以及 RN 容器。


    2. BFF + DSL


    DSL 是一种 UI 动态下发的方案。相比于 React Native,DSL 下发的维度更细,是控件级别的(而 RN 是页面级别的)。简单的理解是,客户端和后端约定 UI 格式,然后按照预定的格式下发的数据。客户端获取到数据之后渲染。DSL 不适合需要复杂动画的场景。若确实要复杂动画,则需要自定义控件。


    工作流程如下图中左侧部分所示,右侧部分是每个角色的责任。


    DSL workflow.drawio.png


    客户端将当前页面和位置信息传给 DSL 服务器。服务器根据上传的信息和位置信息找到业务接口,调用业务接口拉取数据。获取到数据后根据开发过程中配置的脚本对数据进行处理。数据处理完成之后再交给 DSL 服务器渲染。渲染完成之后将数据下发给客户端。客户端再根据下发的 UI 信息进行渲染。其中接口数据的处理是通过 BFF 实现的,由客户端通过编写 Groovy 脚本实现数据结构的转换。


    这种工作流程中,大部分逻辑在客户端这边,需要预埋点位信息。预埋之后可以根据需求进行下发。这种开发的一个痛点在于调试成本高。因为 DSL 服务器是一个黑盒调用。中间需要配置的信息过多,搭建 UI 和编写脚本的平台分散,出现问题不易排查。


    总结


    所谓他山之石,可以攻玉。在这篇文章中,我只是选取了几个自己印象深刻的技术点,零零碎碎地写了很多,比较散。对于有这方面需求的人,会有借鉴意义。


    作者:开发者如是说
    来源:juejin.cn/post/7326268908984434697
    收起阅读 »

    功能问题:如何限制同一账号只能在一处登录?

    大家好,我是大澈! 本文约1200+字,整篇阅读大约需要2分钟。 感谢关注微信公众号:“程序员大澈”,免费领取"面试礼包"一份,然后免费加入问答群,从此让解决问题的你不再孤单! 1. 需求分析 前阵子,和问答群里一个前端朋友,随便唠了唠。期间他问了我一个问题,...
    继续阅读 »

    大家好,我是大澈!


    本文约1200+字,整篇阅读大约需要2分钟。


    感谢关注微信公众号:“程序员大澈”,免费领取"面试礼包"一份,然后免费加入问答群,从此让解决问题的你不再孤单!


    1. 需求分析


    前阵子,和问答群里一个前端朋友,随便唠了唠。期间他问了我一个问题,让我印象深刻。


    他问的是,限制同一账号只能在一处设备上登录,是如何实现的?并且,他还把这个功能称为“单点登录”。


    我说这不叫“单点登录”,这是“单设备登录”。


    于是,当时对此概念区分不清的他,和我在语言上开始了深度纠缠。


    所以在后面我就想,这个功能问题有必要整理一下,分享给现在还不清楚两者概念的朋友们。


    图片



    2. 功能实现


    先聊聊“单点登录”和“单设备登录”区别,再说说实现“单设备登录”的步骤。


    2.1 单点登录和单设备登录的区别


    “单点登录”和“单设备登录”是两个完全不同的概念。


    单设备登录指:在某个给定的时间,同一用户只能在一台设备上进行登录,如果在其他设备上尝试登录,先前的会话将被中断或注销。


    单点登录(简称SSO)指:允许用户使用一组凭据(如用户名和密码)登录到一个系统,然后可以在多个相关系统中,无需重新登录即可访问受保护的资源。


    关于“单点登录”的实现,这里简单说一下。一般有两种方式:若后端处理,部署一个认证中心,这是标准做法;若前端处理,可以用LocalStorage做跨域缓存。


    2.2 单设备登录的实现


    要实现单设备登录,一般来说,有两种方式:使用数据库记录登录状态 和 使用令牌验证机制 。


    使用令牌验证机制 的实现步骤如下:


    • 用户登录时生成token,将账号作为key,token作为value,并设置过期时间存入redis中。


    • 当用户访问应用时,在拦截器中解析token,获取账号,然后用账号去redis中获取相应的value。


    • 如果获取到的value的token与当前用户携带的token一致,则允许访问;如果不一致,则提示前端重复登录,让前端清除token,并跳转到登录页面。


    • 当用户在另一台设备登录时,其token也会存入redis中,这样就刷新了token的值和redis的过期时间。


    图片


    使用数据库记录登录状态 的实现步骤如下:


    • 在用户登录时,记录用户的账号信息、登录设备的唯一标识符(如设备ID或IP地址)以及登录时间等信息到数据库中的一个登录表。


    • 每次用户的登录请求都会查询数据库中的登录表,检查是否存在该用户的登录记录。如果存在记录,则比对登录设备的标识符和当前设备的标识符是否相同。


    • 如果当前设备与登录设备不匹配,拒绝登录并提示用户在其他设备上已登录。若匹配,则更新登录时间。


    • 当用户主动退出登录或超过一定时间没有操作时,清除该用户的登录记录。




    作者:程序员大澈
    来源:juejin.cn/post/7320166206215340072
    收起阅读 »

    Java项目要不要部署在Docker里?

    部署Java项目有很多种方式,传统的方式是直接在物理机或虚拟机上部署应用,但为什么现在容器化部署变得越来越流行, 个人觉得原因有以下几个: 1、 环境一致性:使用Docker可以确保开发、测试和生产环境的一致性,避免出现“在我机器上能跑”的问题。 2、 快速部...
    继续阅读 »

    部署Java项目有很多种方式,传统的方式是直接在物理机或虚拟机上部署应用,但为什么现在容器化部署变得越来越流行,
    个人觉得原因有以下几个:


    1、 环境一致性:使用Docker可以确保开发、测试和生产环境的一致性,避免出现“在我机器上能跑”的问题。

    2、 快速部署:Docker镜像一旦构建完成,可以快速部署到任何支持Docker的宿主机上。

    3、 易于扩展:结合编排工具如 Kubernetes,可以轻松管理服务的伸缩和负载均衡。

    4、 资源隔离:容器化可以提供更好的资源使用隔离和限制,提高系统的稳定性。

    5、 更轻便地微服务化:容器很适合微服务架构,每个服务可以单独打包、部署和扩展。


    至于是否要在Docker里部署,这取决于项目和团队的具体需求。


    如果你的团队追求快速迭代、想要环境一致性,或者计划实现微服务架构,那么使用Docker是一个很好的选择。


    但如果项目比较小,或者团队对容器技术不熟,想使用容器化部署应用,可能会增加学习和维护的成本,那就需要权衡利弊了。


     


    如果你决定使用Docker来部署Java项目,大概的步骤是这样的:


    1、 编写Dockerfile:这是一个文本文件,包含了从基础镜像获取、复制应用文件、设置环境变量到运行应用的所有命令。

    2、 构建镜像:使用docker build命令根据Dockerfile构建成一个可运行的镜像。

    3、 运行容器:使用docker run命令从镜像启动一个或多个容器实例。

    4、 (可选)使用Docker Compose或Kubernetes等工具部署和管理容器。


    部署在Docker里的Java项目,通常都会需要一个精心编写的Dockerfile和一些配置管理,确保应用可以无障碍地在容器中运行。




    下面简单演示一个如何使用Docker来部署一个简单的Spring Boot Java项目。


     


    首先,我们需要安装Docker,你可以从Docker官网下载合适的版本安装,安装完后可以通过运行docker --version来检查是否安装成功。


    Docker 安装步骤在在这里就不详细说明了,可以参考这篇文章:CentOS Docker 安装


    项目部署步骤:


    步骤1:编写Dockerfile


    Dockerfile是一个文本文件,它包含了一系列的指令和参数,用于定义如何构建你的Docker镜像。
    以下是一个典型的Dockerfile示例,用于部署一个Spring Boot应用:


    # 使用官方提供的Java运行环境作为基础镜像,根据自己的需求,选择合适的JDK版本,这里以 1.8 为例
    FROM openjdk:8-jdk-alpine

    # 配置环境变量
    ENV APP_FILE myapp.jar
    ENV APP_HOME /usr/app

    # 在容器内创建一个目录作为工作目录
    WORKDIR $APP_HOME

    # 将构建好的jar包复制到容器内的工作目录下
    COPY target/*.jar $APP_FILE

    # 暴露容器内部的端口给外部使用
    EXPOSE 8080

    # 启动Java应用
    ENTRYPOINT ["java","-jar","${APP_FILE}"]

    注释解释:



    • FROM openjdk:8-jdk-alpine:这告诉Docker使用一个轻量级的Java 8 JDK版本作为基础镜像。

    • ENV:设置环境变量,这里设置了应用的jar包名称和存放路径。

    • WORKDIR:设定工作目录,之后的COPY等命令都会在这个目录下执行。

    • COPY:将本地的jar文件复制到镜像中。

    • EXPOSE:将容器的8080端口暴露出去,以便外部可以访问容器内的应用。

    • ENTRYPOINT:容器启动时执行的命令,这里是运行Java应用的命令。


    步骤2:构建镜像


    在Dockerfile所在的目录运行下面的命令来构建你的镜像:


    docker build -t my-java-app .

    这里的-t标记用于给新创建的镜像设置一个名称,.是上下文路径,指向Dockerfile所在的当前目录。


    步骤3:运行容器


    构建好镜像后,你可以使用下面的命令来运行容器:


    docker run -d -p 8080:8080 --name my-running-app my-java-app

    这里的-d标记意味着在后台运行容器,-p标记用于将容器的8080端口映射到宿主机的8080端口,--name用于给容器设置名字。


    到这里,如果一切顺利,你的Spring Boot应用就会在Docker容器中启动,
    并且宿主机的8080端口会转发到容器内部的同一端口上,你可以通过访问http://xxxx:8080来查看应用是否在运行。


    步骤4:使用Docker Compose或Kubernetes等工具部署和管理容器


    接下来我们来讲讲如何使用Docker Compose来管理和部署容器。
    Docker Compose是一个用于定义和运行多容器Docker应用的工具。使用Compose,你可以通过一个YAML文件来配置你的应用的服务,然后只需要一个简单的命令即可创建和启动所有的服务。


    就拿上面的例子来说,我们来创建一个docker-compose.yml 文件来运行Spring Boot应用。


    先确保你已经安装了Docker Compose,然后创建以下内容的docker-compose.yml文件:


    version: '3'
    services:
    my-java-app:
    build: .
    ports:
    - "8080:8080"
    environment:
    SPRING_PROFILES_ACTIVE: "prod"
    volumes:
    - "app-logs:/var/log/my-java-app"

    volumes:
    app-logs:

    注释解释:



    • version:指定了我们使用的Compose文件版本。

    • services:定义了我们需要运行的服务。

      • my-java-app:这是我们服务的名称。

      • build: .:告诉Compose在当前目录下查找Dockerfile来构建镜像。

      • ports:将容器端口映射到主机端口。

      • environment:设置环境变量,这里我们假设应用使用Spring Profiles,定义了prod作为激活的配置文件。

      • volumes:定义了数据卷,这里我们将宿主机的一个卷挂载到容器中,用于存储日志等数据。




    创建好docker-compose.yml文件后,只需要运行以下命令即可:


    docker-compose up -d

    这条命令会根据你的docker-compose.yml文件启动所有定义的服务。 -d 参数表明要在后台运行服务。


    如果你需要停止并移除所有服务,可以使用:


    docker-compose down

    使用Docker Compose的好处是,你可以在一个文件中定义整个应用的服务以及它们之间的依赖,然后一键启动或停止所有服务,非常适合本地开发和测试。


    至于Kubernetes,它是一个开源的容器编排系统,用于自动部署、扩展和管理容器化应用。


     


    Kubernetes的学习曲线相对陡峭,适合用于更复杂的生产环境。如果你想要进一步了解Kubernetes:


    推荐几个 Kubernetes 学习的文章



    总结


    总的来说,容器化是Java项目部署的一种高效、现代化方式,适合于追求快速迭代和微服务架构的团队。
    对于不熟悉容器技术的团队或者个人开发者而言,需要考虑学习和维护的成本,合适自己的才是最好的,也不必追求别人用什么你就用什么,得不偿失。


    作者:小郑说编程i
    来源:juejin.cn/post/7330102782538055689
    收起阅读 »

    提升网站性能的秘诀:为什么Nginx是高效服务器的代名词?

    在这个信息爆炸的时代,每当你在浏览器中输入一个网址,背后都有一个强大的服务器在默默地工作。而在这些服务器中,有一个名字你可能听说过无数次——Nginx。今天,就让我们一起探索这个神奇的工具。一、Nginx是什么Nginx(发音为“enginex”)是一个开源的...
    继续阅读 »

    在这个信息爆炸的时代,每当你在浏览器中输入一个网址,背后都有一个强大的服务器在默默地工作。而在这些服务器中,有一个名字你可能听说过无数次——Nginx。今天,就让我们一起探索这个神奇的工具。

    一、Nginx是什么

    Nginx(发音为“enginex”)是一个开源的高性能HTTP和反向代理服务器。它由伊戈尔·赛索耶夫(IgorSysoev)于2002年创建,自那时起,Nginx因其稳定性、丰富的功能集、简单的配置文件以及低资源消耗而受到广大开发者和企业的喜爱。

    Description

    Nginx是一款轻量级的Web服务器/反向代理服务器及电子邮件(IMAP/POP3)代理服务器,在BSD-like协议下发行。

    其特点是占有内存少,并发能力强,事实上nginx的并发能力在同类型的网页服务器中表现较好,中国大陆使用nginx网站用户有:百度、京东、新浪、网易、腾讯、淘宝等。

    二、Nginx的反向代理与正向代理

    Description

    正向代理:

    我们平时需要访问国外的浏览器是不是很慢,比如我们要看推特,看GitHub等等。我们直接用国内的服务器无法访问国外的服务器,或者是访问很慢。

    所以我们需要在本地搭建一个服务器来帮助我们去访问。那这种就是正向代理。(浏览器中配置代理服务器)

    反向代理:

    那什么是反向代理呢。比如:我们访问淘宝的时候,淘宝内部肯定不是只有一台服务器,它的内部有很多台服务器,那我们进行访问的时候,因为服务器中间session不共享,那我们是不是在服务器之间访问需要频繁登录。

    这个时候淘宝搭建一个过渡服务器,对我们是没有任何影响的,我们是登录一次,但是访问所有,这种情况就是反向代理。

    对我们来说,客户端对代理是无感知的,客户端不需要任何配置就可以访问,我们只需要把请求发送给反向代理服务器,由反向代理服务器去选择目标服务器获取数据后,再返回给客户端。

    此时反向代理服务器和目标服务器对外就是一个服务器,暴露的是代理服务器地址,隐藏了真实服务器的地址。(在服务器中配置代理服务器)

    三、Nginx的负载均衡

    什么是负载均衡?

    负载均衡建立在现有网络结构之上,它提供了一种廉价有效透明的方法扩展网络设备和服务器的带宽、增加吞吐量、加强网络数据处理能力、提高网络的灵活性和可用性。

    负载均衡(LoadBalance)其意思就是分摊到多个操作单元上进行执行,例如Web服务器、FTP服务器、企业关键应用服务器和其它关键任务服务器等,从而共同完成工作任务。

    Description

    负载均衡的主要目的是确保网络流量被平均分发到多个节点,从而提高整体系统的响应速度和可用性。它对于处理高并发请求非常重要,因为它可以防止任何单一节点过载,导致服务中断或性能下降。

    Nginx给出来三种关于负载均衡的方式:

    轮询法(默认方法):

    每个请求按时间顺序逐一分配到不同的后端服务器,如果后端服务器down掉,能自动剔除。

    适合服务器配置相当,无状态且短平快的服务使用。也适用于图片服务器集群和纯静态页面服务器集群。

    weight权重模式(加权轮询):

    指定轮询几率,weight和访问比率成正比,用于后端服务器性能不均的情况。

    这种方式比较灵活,当后端服务器性能存在差异的时候,通过配置权重,可以让服务器的性能得到充分发挥,有效利用资源。weight和访问比率成正比,用于后端服务器性能不均的情况。权重越高,在被访问的概率越大。

    ip_hash:

    上述方式存在一个问题就是说,在负载均衡系统中,假如用户在某台服务器上登录了,那么该用户第二次请求的时候,因为我们是负载均衡系统,每次请求都会重新定位到服务器集群中的某一个。

    那么已经登录某一个服务器的用户再重新定位到另一个服务器,其登录信息将会丢失,这样显然是不妥的。

    你还在苦恼找不到真正免费的编程学习平台吗?可以试试【云端源想】!课程视频、知识库、微实战、云实验室、一对一咨询……你想要的全部学习资源这里都有,重点是现在还是免费的!

    点这里即可查看!

    我们可以采用ip_hash指令解决这个问题,如果客户已经访问了某个服务器,当用户再次访问时,会将该请求通过哈希算法,自动定位到该服务器。每个请求按访问ip的hash结果分配,这样每个访客固定访问一个后端服务器,可以解决session的问题。

    四、Nginx的动静分离

    为了加快网站的解析速度,可以把动态页面和静态页面由不同的服务器来解析,加快解析速度。降低原来单个服务器的压力。

    Description

    Nginx的静态处理能力很强,但是动态处理能力不足,因此,在企业中常用动静分离技术。

    动静分离技术其实是采用代理的方式,在server{}段中加入带正则匹配的location来指定匹配项针对PHP的动静分离:

    静态页面交给Nginx处理,动态页面交给PHP-FPM模块或Apache处理。在Nginx的配置中,是通过location配置段配合正则匹配实现静态与动态页面的不同处理方式。

    五、Nginx特点

    那么,Nginx到底有哪些特点让它如此受欢迎呢?让我们一起来探索。

    1、高性能与低消耗

    Nginx采用了事件驱动的异步非阻塞模型,这意味着它在处理大量并发连接时,可以有效地使用系统资源。与传统的服务器相比,Nginx可以在较低的硬件配置下提供更高的性能。这对于成本敏感的企业来说,无疑是一个巨大的优势。

    2、高并发处理能力

    得益于其独特的设计,Nginx能够轻松处理数万甚至数十万的并发连接,而不会对性能造成太大影响。这一点对于流量高峰期的网站尤为重要,它可以保证用户在任何时候访问网站都能获得良好的体验。

    3、灵活的配置

    Nginx的配置文件非常灵活,支持各种复杂的设置。无论是负载均衡、缓存静态内容,还是SSL/TLS加密,Nginx都能通过简单的配置来实现。这种灵活性使得Nginx可以轻松适应各种不同的使用场景。

    4、社区支持与模块扩展

    Nginx拥有一个活跃的开发社区,不断有新的功能和优化被加入到官方版本中。此外,Nginx还支持第三方模块,这些模块可以扩展Nginx的功能,使其更加强大和多样化。

    5、广泛的应用场景

    从传统的Web服务器到反向代理、负载均衡器,再到API网关,Nginx几乎可以应用于任何需要处理HTTP请求的场景。它的可靠性和多功能性使得它成为了许多大型互联网公司的基础设施中不可或缺的一部分。

    Nginx以其卓越的性能和灵活的配置,赢得了全球开发者的青睐。它不仅仅是一个简单的Web服务器,更是一个强大的工具,能够帮助我们构建更加稳定、高效的网络应用。

    无论是初创公司还是大型企业,Nginx都能在其中发挥重要作用。那么,你准备好探索Nginx的世界了吗?让我们一起开启这场技术之旅吧!

    收起阅读 »

    502故障,你是怎么解决的?

    在现代网络应用的开发和维护中,502 Bad Gateway错误是一个常见而令人头疼的问题。这种错误通常意味着代理服务器或网关在尝试访问上游服务器时未能获取有效的响应。本文将深入探讨502故障的原因、可能的解决方案,并提供基于实际案例的客观凭证。 1. 原因深...
    继续阅读 »

    在现代网络应用的开发和维护中,502 Bad Gateway错误是一个常见而令人头疼的问题。这种错误通常意味着代理服务器或网关在尝试访问上游服务器时未能获取有效的响应。本文将深入探讨502故障的原因、可能的解决方案,并提供基于实际案例的客观凭证。


    1. 原因深入解析


    a. 上游服务器问题


    502错误的最常见原因之一是上游服务器出现问题。这可能包括服务器崩溃、过载、应用程序错误或者数据库连接故障。具体而言,通过观察服务器的系统日志、应用程序日志以及数据库连接状态,可以深入分析问题的根本原因。


    b. 网络问题


    网络中断、代理服务器配置错误或者防火墙问题都可能导致502错误。使用网络诊断工具,如traceroute或ping,可以检查服务器之间的连接是否畅通。同时,审查代理服务器和防火墙的配置,确保网络通信正常。


    c. 超时问题


    502错误还可能是由于上游服务器响应时间超过了网关或代理服务器的超时设置而引起的。深入了解请求的性能特征和服务器响应时间,调整超时设置可以是一项有效的解决方案。


    2. 解决方案的客观凭证


    a. 上游服务器状态监控


    使用监控工具,例如Prometheus、New Relic或Datadog,对上游服务器进行状态监控。通过设置警报规则,可以及时发现服务器性能下降或者异常情况。


    b. 网络连接分析


    借助Wireshark等网络分析工具,捕获和分析服务器之间的网络通信数据包。这有助于定位网络中断、数据包丢失或防火墙阻塞等问题。


    c. 超时设置调整


    通过监控工具收集请求的响应时间数据,识别潜在的性能瓶颈。根据实际情况,逐步调整代理服务器的超时设置,以确保其适应上游服务器的响应时间。


    3. 实例代码分析


    循环引用问题


    gc_enabled 是否开启gc
    gc_active 垃圾回收算法是否运行
    gc_full 垃圾缓冲区是否满了,在debug模式下有用
    buf 垃圾缓冲区,php7默认大小为10000个节点位置,第0个位置保留,既不会使用
    roots: 指向缓冲区中最新加入的可能是垃圾的元素
    unused 指向缓冲区中没有使用的位置,在没有启动垃圾回收算法前,指向空
    first_unused 指向缓冲区第一个为未使用的位置。新的元素插入缓冲区后,指向会向后移动一位
    last_unused 指向缓冲区最后一个位置
    to_free 带释放的列表
    next_to_free 下一个待释放的列表
    gc_runs 记录gc算法运行的次数,当缓冲区满了,才会运行gc算法
    collected 记录gc算法回收的垃圾数

    Nginx配置


    location / {
    proxy_pass http://backend_server;

    proxy_connect_timeout 5s;
    proxy_read_timeout 30s;
    proxy_send_timeout 12s;

    # 其他代理配置项...
    }

    上述Nginx配置中,通过设置proxy_connect_timeoutproxy_read_timeoutproxy_send_timeout,可以调整代理服务器的超时设置,从而适应上游服务器的响应时间。


    PHP代码


    try {
    // 执行与上游服务器交互的操作
    // ...

    // 如果一切正常,输出响应
    echo "Success!";
    } catch (Exception $e) {
    // 捕获异常并处理
    header("HTTP/1.1 502 Bad Gateway");
    echo "502 Bad Gateway: " . $e->getMessage();
    }

    在PHP代码中,通过捕获异常并返回502错误响应,实现了对异常情况的处理,提高了系统的健壮性。


    4. 结语


    502 Bad Gateway错误是一个综合性的问题,需要从多个角度进行深入分析。通过监控、网络分析和超时设置调整等手段,可以提高对502故障的解决效率。在实际应用中,结合客观的凭证和系统实时监控,开发者和运维人员能够更加迅速、准确地定位问题,确保网络应用的稳定性和可用性。通过以上深度透析和实际案例的代码分析,我们希望读者能够更好地理解502错误,并在面对此类问题时能够快速而有效地解决。


    作者:Student_Li
    来源:juejin.cn/post/7328766815101108243
    收起阅读 »

    转转流量录制与回放的原理及实践

    1 需求背景 随着转转业务规模和复杂度不断提高,业务服务增加,服务之间依赖关系也逐渐复杂。在开发和测试中遇到如下问题: 参数构造:转转接口测试平台可以很方便调用SCF(转转RPC框架),但是接口的参数很多是复杂model,手动构造参数成本不小,希望能够录制稳...
    继续阅读 »

    1 需求背景


    随着转转业务规模和复杂度不断提高,业务服务增加,服务之间依赖关系也逐渐复杂。在开发和测试中遇到如下问题:



    • 参数构造:转转接口测试平台可以很方便调用SCF(转转RPC框架),但是接口的参数很多是复杂model,手动构造参数成本不小,希望能够录制稳定测试环境流量,从流量中抽取接口参数,方便使用者选择参数进行接口测试。

    • 压测流量构造:转转是二手电商平台,有许多促销活动的压测需求,人工构造压测流量既不能模拟真实访问,又成本高昂。所以有录制线上流量的需求,然后压测平台通过策略二次加工形成压测case。

    • 自动化回归测试:业务迭代速度很快,每次迭代会不会影响原有逻辑?希望有一个平台能够提供筛选保存case,自动化回归,case通过率报告通知等能力。


    这些问题每个互联网公司都会遇到,如何才能优雅解决这些问题呢?首先定义一下优雅:不增加业务成本,业务基本无感,对业务性能影响要足够小。阿里开源的jvm-sandbox-repeater(简称Repeater)正是为解决这些问题而生,能够做到业务无感,但是性能问题需要特别定制处理。本文重点介绍:



    • Repeater流量录制和回放业务无感实现原理(第2、3章节)

    • 线上服务流量录制时,如何减少对正常业务的性能影响(第4章节)


    希望能够揭秘Repeater如何做到业务无感的流量录制和回放,进而让使用流量录制的同学对Repeater内部做了哪些工作以及对性能有哪些影响做到心中有数,最后介绍在流量录制时,为了保证对线上服务的性能影响相对可控,我们做了哪些工作,让大家会用敢用。


    2 流量录制和回放概念


    2.1 流量录制


    对于Java调用,一次流量录制包括一次入口调用(entranceInvocation)(eg:HTTP/Dubbo/Java)和若干次子调用(subInvocations)。流量的录制过程就是把入口调用和子调用绑定成一次完整的记录。


        /**
    * 获取商品价格,先从redis中获取,如果redis中没有,再用rpc调用获取,
    *
    @param productId
    *
    @return
    */

    public Integer getProductPrice(Long productId){ //入口调用

    //1.redis获取价格
    Integer price = redis.get(productId); //redis远程子调用
    if(Objects.isNull(price)){
    //2.远程调用获取价格
    price = daoRpc.getProductCount(productId); //rpc远程子调用
    redis.set(productId, price); //redis远程子调用
    }
    //3.价格策略处理
    price = process(price); //本地子调用
    return price;

    }

    private Integer process(Long price){
    //价格策略远程调用
    return logicRpc.process(productId); //rpc远程子调用
    }

    getProductPrice流量录制图解


    以获取产品价格方法为例,流量录制的内容简单来说是入口调用(getProductPrice)的入参和返回值,远程子调用(redis.get,daoRpc.getProductCount,redis.set,logicRpc.process)的入参和返回值,用于流量回放。注意并不会录制本地子调用(process)。


    下图是转转流量回放平台录制好的单个流量的线上效果,帮助理解流量录制概念。
    流量录制


    2.2 流量回放


    流量回放,获取录制流量的入口调用入参,再次发起调用,并且对于子调用,直接使用流量录制时记录的入参和返回值,根据入参(简单来说)匹配子调用后,直接返回录制的数据。这样就还原了流量录制时的环境,如果回放后返回值和录制时返回值不一致,那么本条回放case标记为失败。
    还以getProductPrice为例,假设录制时入口调用参数productId=1,返回值为1;redis.get子调用参数productId=1,返回值为1。那么回放时,redis.get不会访问redis,而是直接返回1。假设该函数有逻辑更新,回放返回值是2,与录制时返回值1不相等,那么次此流量回放标记为失败。


    下图是转转流量回放平台的流量回放的线上效果,帮助理解流量回放概念
    流量回放


    明白流量录制和回放概念后,下面看看业务无感实现流量录制和回放的实现原理。


    3 Repeater实现原理


    Repeater架构图



    • Repeater Console模块

      • 流量录制和回放的配置管理

      • 心跳管理

      • 录制和回放调用入口



    • Repeater agent plugin模块:Repeater核心功能是流量录制回放,其实现核心是agent插件,开源框架已经实现redis、mybatis、http、okhttp、dubbo、mq、guava cache等插件。由于录制和回放逻辑是以字节码增强的方式在程序运行时织入,所以无需业务编码。换句话说,agent技术是业务无感的关键。


    下面我们就进入无感的关键环节,介绍Repeater如何织入流量录制和回放逻辑代码,以及梳理流量录制和回放的核心代码。


    3.1 流量录制和回放逻辑如何织入


    用一句话来说,Repeater本身并没有实现代码织入功能,它依赖另一个阿里开源项目JVM-Sandbox。详细来讲,Repeater的核心逻辑录制协议基于JVM-Sandbox的BEFORERETRUNTHROW事件机制进行录制流程控制。本质上来说,JVM-Sandbox实现了java agent级别的spring aop功能,是一个通用增强框架。JVM-Sandbox的基于字节码增强的事件机制原理见下图:JVM-Sandbox事件机制


    上图以add方法为例,揭示JVM-Sandbox增强前后的代码变化,方便大家理解。下面的代码是对图中增强代码相关重点的注释


    public int add(int a, int b) {
    try {
    Object[] params = new Object[]{a, b};
    //BEFORE事件
    Spy.Ret retOnBefore = Spy.onBefore(10001,
    "com.taobao.test.Test", "add", this, params);
    //BEFORE结果可以直接返回结果或者抛出异常,是实现mock(阻断真实远程调用)的关键
    if (retOnBefore.state == I_RETURN) return (int) retOnBefore.object;
    if (retOnBefore.state == I_THROWS) throws(Throwable) retOnBefore.object;
    a = params[0];
    b = params[1];
    int r = a + b;
    //RETRUN事件
    Spy.Ret retOnReturn = Spy.onReturn(10001, r);
    if (retOnReturn.state == I RETURN)return (int) retOnReturn.object;
    if (retOnReturn.state == I_THROWS) throws(Throwable) retOnReturn.object;
    return r;
    } catch (Throwable cause) {
    //THROW事件
    Spy.Ret retOnIhrows = Spy.onThrows(10001, cause);
    if (retOnThrows.state == I RETURN)return (int) retOnThrows.object;
    if (retOnThrows.state == I THROWS) throws(Throwable) retOnThrows.object;
    throws cause;
    }
    }

    由上可知,Repeater是利用jvm agent字节码增强技术为目标方法织入BEFORERETRUNTHROW逻辑。


    3.2 流量录制和回放的核心代码


    既然Repeater利用JVM-Sandbox aop框架编写流量录制和回放逻辑,那么让我们看看它的核心代码doBefore。先来一张流程图。


    录制和回放插件逻辑图解


    再重点介绍doBeforedoMock的核心代码,它们是实现录制和回放的关键,注意阅读注释。为了方便理解,我对开源代码做了大量删减,只保留核心逻辑。


        /**
    * 处理before事件
    * 流量录制时记录函数元信息和参数,缓存录制数据
    * 流量回放时,调用回放逻辑,直接返回录制时的数据,后面会对processor.doMock进行展开讲解
    *
    @param event before事件
    */

    protected void doBefore(BeforeEvent event) throws ProcessControlException {
    // 回放流量;如果是入口则放弃;子调用则进行mock
    if (RepeatCache.isRepeatFlow(Tracer.getTraceId())) {
    processor.doMock(event, entrance, invokeType);
    return;
    }
    //非回放流量,进行流量录制,主要元信息、参数、返回值
    Invocation invocation = initInvocation(event);
    //记录是否为入口流量
    invocation.setEntrance(entrance);
    //记录参数
    invocation.setRequest(processor.assembleRequest(event));
    //记录返回值
    invocation.setResponse(processor.assembleResponse(event));

    }

    @Override
    public void doMock(BeforeEvent event, Boolean entrance, InvokeType type) throws ProcessControlException {

    try {

    //通过录制数据构建mock请求
    final MockRequest request = MockRequest.builder().build();
    //执行mock动作
    final MockResponse mr = StrategyProvider.instance().provide(context.getMeta().getStrategyType()).execute(request);
    //根据mock结果,阻断真实远程调用
    switch (mr.action) {
    case SKIP_IMMEDIATELY:
    break;
    case THROWS_IMMEDIATELY:
    //直接抛出异常,映射到JVM-Sandbox的事件机制原理的add函数
    //也就是代码走到if (retOnBefore.state == I_THROWS) throws(Throwable) retOnBefore.object;
    //而不再执行后面的代码(JVM-Sandbox框架机制,调用如下代码会触发阻断真实调用)
    ProcessControlException.throwThrowsImmediately(mr.throwable);
    break;
    case RETURN_IMMEDIATELY:
    //直接返回录制结果,映射到JVM-Sandbox的事件机制原理的add函数,同理,也不再执行后面的代码(阻断真实调用)
    ProcessControlException.throwReturnImmediately(assembleMockResponse(event, mr.invocation));
    break;
    default:
    ProcessControlException.throwThrowsImmediately(new RepeatException("invalid action"));
    break;
    }
    } catch (ProcessControlException pce) {
    throw pce;
    } catch (Throwable throwable) {
    ProcessControlException.throwThrowsImmediately(new RepeatException("unexpected code snippet here.", throwable));
    }
    }

    通过上面的2、3章节介绍了Repeater流量录制和回放业务无感的实现原理,下面说一下应用过程中需要哪些改造点。


    4 Repeater落地实践


    4.1 改造点



    • Rpeater开源管理后台仅仅是个Demo,需要重新设计和实现。

    • SCF(转转RPC框架)插件扩展,支持SCF应用的流量录制和回放。

    • DB由MySQL改造为ES,Repeater原生使用MySQL作为流量录制和回放的数据库,仅用于Demo演示,性能和容量无法满足实际需求。

    • Docker环境下频繁更换ip时不中断录制。

    • 回放结果Diff支持字段过滤。

    • 大批量回放。

    • 线上环境录制。


    4.2 线上环境录制


    流量录制很大一部分应用场景在线下,但是线上也有录制场景。从流量录制的原理可知,由于要记录入口调用和各种远程子调用,开启流量录制后,对于该请求占用内存资源会大大增加,并且会增加耗cpu的序列化操作(用于上报流量录制结果)。既然流量录制是一个天然的耗内存和性能操作,对于线上服务的录制除了保持敬畏之心之外,还有设计一种机制减少录制时对线上服务的性能影响。下面开始介绍如果做到录制时减少对线上服务性能的影响。


    线上录制减少性能影响的方案:



    • 从流程上,线上录制需要申请。

    • 从技术上,与发布系统联动,为录制服务增加专门的节点进行录制,并且设置权重为正常节点的1/10,正常节点不会挂载流量录制代码。

    • 从回滚上,如果线上录制节点遇到问题,可以从发布系统直接删除录制节点。


      线上录制效果



    5 总结


    本文旨在介绍Repeater流量录制和回放的实现原理,以及在落地过程中改造点,希望达到让大家懂原理、会使用、敢使用的目的。


    作者:转转技术团队
    来源:juejin.cn/post/7327538517528068106
    收起阅读 »

    Java 世界的法外狂徒:反射

    概述 反射(Reflection)机制是指在运行时动态地获取类的信息以及操作类的成员(字段、方法、构造函数等)的能力。通过反射,我们可以在编译时期未知具体类型的情况下,通过运行时的动态查找和调用。 虽然 Java 是静态的编译型语言,但是反射特性的加入,提供...
    继续阅读 »

    Reflection Title


    概述


    反射(Reflection)机制是指在运行时动态地获取类的信息以及操作类的成员(字段、方法、构造函数等)的能力。通过反射,我们可以在编译时期未知具体类型的情况下,通过运行时的动态查找和调用。 虽然 Java 是静态的编译型语言,但是反射特性的加入,提供一种直接操作对象外的另一种方式,让 Java 具备的一些灵活性和动态性,我们可以通过本篇文章来详细了解它


    为什么需要反射 ?


    Java 需要用到反射的主要原因包括以下几点:



    1. 运行时动态加载,创建类:Java中的类是在编译时加载的,但有时希望在运行时根据某些条件来动态加载和创建所需要类。反射就提供这种能力,这样的能力让程序可以更加的灵活,动态

    2. 动态的方法调用:根据反射获取的类和对象,动态调用类中的方法,这对于一些类增强框架(例如 Spring 的 AOP),还有安全框架(方法调用前进行权限验证),还有在业务代码中注入一些通用的业务逻辑(例如一些日志,等,动态调用的能力都非常有用

    3. 获取类的信息:通过反射,可以获取类的各种信息,如类名、父类、接口、字段、方法等。这使得我们可以在运行时检查类的属性和方法,并根据需要进行操作


    一段示例代码


    以下是一个简单的代码示例,展示基本的反射操作:


    import java.lang.reflect.Method;

    public class ReflectionExample {
    public static void main(String[] args) {
    // 假设在运行时需要调用某个类的方法,但该类在编译时未知
    String className = "com.example.MyClass";

    try {
    // 使用反射动态加载类
    Class<?> clazz = Class.forName(className);

    // 使用反射获取指定方法
    Method method = clazz.getMethod("myMethod");

    // 使用反射创建对象
    Object obj = clazz.newInstance();

    // 使用反射调用方法
    method.invoke(obj);

    } catch (ClassNotFoundException e) {
    System.out.println("类未找到:" + className);
    } catch (NoSuchMethodException e) {
    System.out.println("方法未找到");
    } catch (IllegalAccessException | InstantiationException e) {
    System.out.println("无法实例化对象");
    } catch (Exception e) {
    System.out.println("其他异常:" + e.getMessage());
    }
    }
    }

    在这个示例中,我们假设在编译时并不知道具体的类名和方法名,但在运行时需要根据动态情况来加载类、创建对象并调用方法。使用反射机制,我们可以通过字符串形式传递类名,使用 Class.forName() 动态加载类。然后,通过 getMethod() 方法获取指定的方法对象,使用 newInstance() 创建类的实例,最后通过 invoke() 方法调用方法。


    使用场景


    技术再好,如果无法落地,那么始终都是空中楼阁,在日常开发中,我们常常可以在以下的场景中看到反射的应用:



    1. 框架和库:许多框架和库使用反射来实现插件化架构或扩展机制。例如,Java 的 Spring 框架使用反射来实现依赖注入(Dependency Injection)和 AOP(Aspect-Oriented Programming)等功能。

    2. ORM(对象关系映射):ORM 框架用于将对象模型和关系数据库之间进行映射。通过反射,ORM 框架可以在运行时动态地读取对象的属性和注解信息,从而生成相应的 SQL 语句并执行数据库操作。

    3. 动态代理:动态代理是一种常见的设计模式,通过反射可以实现动态代理。动态代理允许在运行时创建代理对象,并拦截对原始对象方法的调用。这在实现日志记录、性能统计、事务管理等方面非常有用

    4. 反射调试工具:在开发和调试过程中,有时需要查看对象的结构和属性,或者动态调用对象的方法来进行测试。反射提供了一种方便的方式来检查和操作对象的内部信息,例如使用getDeclaredFields()获取对象的所有字段,或使用getMethod()获取对象的方法

    5. 单元测试:在单元测试中,有时需要模拟或替换某些对象的行为,以便进行有效的测试。通过反射,可以在运行时创建对象的模拟实例,并在测试中替换原始对象,以便控制和验证测试的行为


    Class 对象


    Class 对象是反射的第一步,我们先从 Class 对象聊起,因为在反射中,只要你想在运行时使用类型信息,就必须先得到那个 Class 对象的引用,他是反射的核心,它代表了Java类的元数据信息,包含了类的结构、属性、方法和其他相关信息。通过Class对象,我们可以获取和操作类的成员,实现动态加载和操作类的能力。


    常见的获取 Class 对象的方式几种:


    // 使用类名获取
    Class<?> clazz = Class.forName("com.example.MyClass");

    // 使用类字面常量获取
    Class<?> clazz = MyClass.class;

    // 使用对象的 getClass() 方法获取
    MyClass obj = new MyClass();
    Class<?> clazz = obj.getClass();


    需要注意的是,如果 Class.forName() 找不到要加载的类,它就会抛出异常 ClassNotFoundException



    正如上面所说,获取 Class 对象是第一步,一旦获取了Class对象,我们可以使用它来执行各种反射操作,例如获取类的属性、方法、构造函数等。示例:


    String className = clazz.getName(); // 获取类的全限定名
    int modifiers = clazz.getModifiers(); // 获取类的修饰符,如 public、abstract 等
    Class<?> superClass = clazz.getSuperclass(); // 获取类的直接父类
    Class<?> superClass = clazz.getSuperclass(); // 获取类的直接父类
    Class<?>[] interfaces = clazz.getInterfaces(); // 获取类实现的接口数组
    Constructor<?>[] constructors = clazz.getConstructors(); // 获取类的公共构造函数数组
    Method[] methods = clazz.getMethods(); // 获取类的公共方法数组
    Field[] fields = clazz.getFields(); // 获取类的公共字段数组
    Object obj = clazz.newInstance(); // 创建类的实例,相当于调用无参构造函数

    上述示例仅展示了Class对象的一小部分使用方法,还有许多其他方法可用于获取和操作类的各个方面。通过Class对象,我们可以在运行时动态地获取和操作类的信息,实现反射的强大功能。


    类型检查


    在反射的代码中,经常会对类型进行检查和判断,从而对进行对应的逻辑操作,下面介绍几种 Java 中对类型检查的方法


    instanceof 关键字


    instanceof 是 Java 中的一个运算符,用于判断一个对象是否属于某个特定类或其子类的实例。它返回一个布尔值,如果对象是指定类的实例或其子类的实例,则返回true,否则返回false。下面来看看它的使用示例


    1:避免类型转换错误


    在进行强制类型转换之前,使用 instanceof 可以检查对象的实际类型,以避免类型转换错误或 ClassCastException 异常的发生:


    if (obj instanceof MyClass) {
    MyClass myObj = (MyClass) obj;
    // 执行针对 MyClass 类型的操作
    }

    2:多态性判断


    使用 instanceof 可以判断对象的具体类型,以便根据不同类型执行不同的逻辑。例如:


    if (animal instanceof Dog) {
    Dog dog = (Dog) animal;
    dog.bark();
    } else if (animal instanceof Cat) {
    Cat cat = (Cat) animal;
    cat.meow();
    }

    3:接口实现判断


    在使用接口时,可以使用 instanceof 判断对象是否实现了某个接口,以便根据接口进行不同的处理


    if (obj instanceof MyInterface) {
    MyInterface myObj = (MyInterface) obj;
    myObj.doSomething();
    }

    4:继承关系判断


    instanceof 可以用于判断对象是否是某个类的子类的实例。这在处理继承关系时非常有用,可以根据对象的具体类型执行相应的操作


    if (obj instanceof MyBaseClass) {
    MyBaseClass myObj = (MyBaseClass) obj;
    // 执行 MyBaseClass 类型的操作
    }

    instanceof 看似可以做很多事情,但是在使用时也有很多限制,例如:



    1. 无法和基本类型进行匹配:instanceof 运算符只能用于引用类型,无法用于原始类型

    2. 不能和 Class 对象类型匹配:只可以将它与命名类型进行比较

    3. 无法判断泛型类型参数:由于Java的泛型在运行时会进行类型擦除,instanceof 无法直接判断对象是否是某个泛型类型的实例



    instanceof 看似方便,但过度使用它可能表明设计上的缺陷,可能违反了良好的面向对象原则。应尽量使用多态性和接口来实现对象行为的差异,而不是过度依赖类型检查。



    isInstance() 函数


    java.lang.Class 类也提供 isInstance() 类型检查方法,用于判断一个对象是否是指定类或其子类的实例。更适合在反射的场景下使用,代码示例:


    Class<?> clazz = MyClass.class;
    boolean result = clazz.isInstance(obj);

    如上所述,相比 instanceof 关键字,isInstance() 提供更灵活的类型检查,它们的区别如下:



    1. isInstance() 方法的参数是一个对象,而 instanceof 关键字的操作数是一个引用类型。因此,使用 isInstance() 方法时,可以动态地确定对象的类型,而 instanceof 关键字需要在编译时指定类型。

    2. isInstance()方法可以应用于任何Class对象。它是一个通用的类型检查方法。而instanceof关键字只能应用于引用类型,用于检查对象是否是某个类或其子类的实例。

    3. isInstance()方法是在运行时进行类型检查,它的结果取决于实际对象的类型。而instanceof关键字在编译时进行类型检查,结果取决于代码中指定的类型。

    4. 由于Java的泛型在运行时会进行类型擦除,instanceof无法直接检查泛型类型参数。而isInstance()方法可以使用通配符类型(<?>)进行泛型类型参数的检查。


    总体而言,isInstance()方法是一个动态的、通用的类型检查方法,可以在运行时根据实际对象的类型来判断对象是否属于某个类或其子类的实例。与之相比,instanceof关键字是在编译时进行的类型检查,用于检查对象是否是指定类型或其子类的实例。它们在表达方式、使用范围和检查方式等方面有所差异。在具体的使用场景中,可以根据需要选择合适的方式进行类型检查。


    代理


    代理模式


    代理模式是一种结构型设计模式,其目的是通过引入一个代理对象,控制对原始对象的访问。代理对象充当了原始对象的中间人,可以在不改变原始对象的情况下,对其进行额外的控制和扩展。这是一个简单的代理模式示例:


    // 定义抽象对象接口
    interface Image {
    void display();
    }

    // 定义原始对象
    class RealImage implements Image {
    private String fileName;

    public RealImage(String fileName) {
    this.fileName = fileName;
    loadFromDisk();
    }

    private void loadFromDisk() {
    System.out.println("Loading image:" + fileName);
    }

    @Override
    public void display() {
    System.out.println("Displaying image:" + fileName);
    }
    }

    // 定义代理对象
    class ImageProxy implements Image {
    private String filename;
    private RealImage realImage;

    public ImageProxy(String filename) {
    this.filename = filename;
    }

    @Override
    public void display() {
    if (realImage == null) {
    realImage = new RealImage(filename);
    }
    realImage.display();
    }
    }

    public class ProxyPatternExample {
    public static void main(String[] args) {
    // 使用代理对象访问实际对象
    Image image = new ImageProxy("test_10mb.jpg");
    // 第一次访问,加载实际对象
    image.display();
    // 第二次访问,直接使用已加载的实际对象
    image.display();
    }
    }

    输出结果:


    Loading image:test_10mb.jpg
    Displaying image:test_10mb.jpg
    Displaying image:test_10mb.jpg

    在上述代码中,我们定义了一个抽象对象接口 Image,并有两个实现类:RealImage 代表实际的图片对象,ImageProxy 代表图片的代理对象。在代理对象中,通过控制实际对象的加载和访问,实现了延迟加载和额外操作的功能。客户端代码通过代理对象来访问图片,实现了对实际对象的间接访问。


    动态代理


    Java的动态代理是一种在运行时动态生成代理类和代理对象的机制,它可以在不事先定义代理类的情况下,根据接口或父类来动态创建代理对象。动态代理使用Java的反射机制来实现,通过动态生成的代理类,可以在方法调用前后插入额外的逻辑。


    以下是使用动态代理改写上述代码的示例:


    import java.lang.reflect.InvocationHandler;
    import java.lang.reflect.Method;
    import java.lang.reflect.Proxy;

    // 定义抽象对象接口
    interface Image {
    void display();
    }

    // 定义原始对象
    class RealImage implements Image {
    private String filename;

    public RealImage(String filename) {
    this.filename = filename;
    loadFromDisk();
    }

    private void loadFromDisk() {
    System.out.println("Loading image: " + filename);
    }

    public void display() {
    System.out.println("Displaying image: " + filename);
    }
    }

    // 实现 InvocationHandler 接口的代理处理类
    class ImageProxyHandler implements InvocationHandler {

    private Object realObject;

    public ImageProxyHandler(Object realObject) {
    this.realObject = realObject;
    }

    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    Object result = null;
    if (method.getName().equals("display")) {
    System.out.println("Proxy: before display");
    result = method.invoke(realObject, args);
    System.out.println("Proxy: after display");
    }
    return result;
    }
    }

    public class DynamicProxyExample {

    public static void main(String[] args) {
    // 创建原始对象
    Image realImage = new RealImage("image.jpg");
    // 创建动态代理对象
    Image proxyImage = (Image) Proxy.newProxyInstance(Image.class.getClassLoader(), new Class[]{Image.class}, new ImageProxyHandler(realImage));
    // 使用代理对象访问实际对象
    proxyImage.display();
    }
    }

    在上述代码中,我们使用 java.lang.reflect.Proxy 类创建动态代理对象。我们定义了一个 ImageProxyHandler 类,实现了 java.lang.reflect.InvocationHandler 接口,用于处理代理对象的方法调用。在 invoke() 方法中,我们可以在调用实际对象的方法之前和之后执行一些额外的逻辑。


    输出结果:


    Loading image: image.jpg
    Proxy: before display
    Displaying image: image.jpg
    Proxy: after display

    在客户端代码中,我们首先创建了实际对象 RealImage,然后通过 Proxy.newProxyInstance() 方法创建了动态代理对象 proxyImage,并指定了代理对象的处理类为 ImageProxyHandler。最后,我们使用代理对象来访问实际对象的 display() 方法。


    通过动态代理,我们可以更加灵活地对实际对象的方法进行控制和扩展,而无需显式地创建代理类。动态代理在实际开发中常用于 AOP(面向切面编程)等场景,可以在方法调用前后添加额外的逻辑,如日志记录、事务管理等。


    违反访问权限


    在 Java 中,通过反射机制可以突破对私有成员的访问限制。以下是一个示例代码,展示了如何使用反射来访问和修改私有字段:


    import java.lang.reflect.Field;

    class MyClass {
    private String privateField = "Private Field Value";
    }

    public class ReflectionExample {

    public static void main(String[] args) throws NoSuchFieldException, IllegalAccessException {
    MyClass myObj = new MyClass();
    // 获取私有字段对象
    Field privateField = MyClass.class.getDeclaredField("privateField");

    // 取消对私有字段的访问限制
    privateField.setAccessible(true);

    // 获取私有字段的值
    String fieldValue = (String) privateField.get(myObj);
    System.out.println("Original value of privateField: " + fieldValue);

    // 修改私有字段的值
    privateField.set(myObj, "New Field Value");

    // 再次获取私有字段的值
    fieldValue = (String) privateField.get(myObj);
    System.out.println("Modified value of privateField: " + fieldValue);
    }
    }

    在上述代码中,我们定义了一个 MyClass 类,其中包含一个私有字段 privateField。在 ReflectionExample 类的 main 方法中,我们使用反射获取了 privateField 字段,并通过 setAccessible(true) 方法取消了对私有字段的访问限制。然后,我们使用 get() 方法获取私有字段的值并输出,接着使用 set() 方法修改私有字段的值。最后,再次获取私有字段的值并输出,验证字段值的修改。


    输出结果:


    Original value of privateField: Private Field Value
    Modified value of privateField: New Field Value

    除了字段,通过反射还可以实现以下违反访问权限的操作:



    • 调用私有方法

    • 实例化非公开的构造函数

    • 访问和修改静态字段和方法

    • 绕过访问修饰符检查


    虽然反射机制可以突破私有成员的访问限制,但应该慎重使用。私有成员通常被设计为内部实现细节,并且具有一定的安全性和封装性。过度依赖反射访问私有成员可能会破坏代码的可读性、稳定性和安全性。因此,在使用反射突破私有成员限制时,请确保了解代码的设计意图和潜在风险,并谨慎操作。


    总结


    反射技术自 JDK 1.1 版本引入以来,一直被广泛使用。它为开发人员提供了一种在运行时动态获取类的信息、调用类的方法、访问和修改类的字段等能力。在过去的应用开发中,反射常被用于框架、工具和库的开发,以及动态加载类、实现注解处理、实现代理模式等场景。反射技术为Java的灵活性、可扩展性和动态性增添了强大的工具。


    当下,反射技术仍然发挥着重要的作用。它被广泛应用于诸多领域,如框架、ORM(对象关系映射)、AOP(面向切面编程)、依赖注入、单元测试等。反射技术为这些领域提供了灵活性和可扩展性,使得开发人员能够在运行时动态地获取和操作类的信息,以实现更加灵活和可定制的功能。同时,许多流行的开源框架和库,如 Spring、Hibernate、JUnit 等,也广泛使用了反射技术。


    反射技术可能继续发展和演进。随着 Java 平台的不断发展和语言特性的增强,反射技术可能会在性能优化,安全性,模块化等方面进一步完善和改进反射的应用。然而,需要注意的是,反射技术应该谨慎使用。由于反射涉及动态生成代码、绕过访问限制等操作,如果使用不当,可能导致代码的可读性和性能下降,甚至引入安全漏洞。因此,开发人员在使用反射时应该充分理解其工作原理和潜在的风险,并且遵循最佳实践。


    作者:小二十七
    来源:juejin.cn/post/7235513984556220476
    收起阅读 »

    防御性编程失败,我开始优化我写的多重 if-else 代码

    前言 最近防御性编程比较火,不信邪的我在开发中我进行了尝试,然后我写下了如下的代码: public static void main(String[] args) { // do something if ("满足条...
    继续阅读 »

    前言



    • 最近防御性编程比较火,不信邪的我在开发中我进行了尝试,然后我写下了如下的代码:


        public static void main(String[] args) {
    // do something
    if ("满足条件A") {
    // 查询权限
    if ("是否具备权限A" && "是否具备权限B") {
    // 查询配置
    if ("配置是否开启"){
    // do something
    }
    }
    }
    // do something
    }


    • 不出意外我被逮捕了,组内另外一位同事对我的代码进行了 CodeReview,我的防御性编程编程没有幸运逃脱,被标记上了“多重 if-else ”需要进行优化,至此我的第一次防御性编程失败,开始了优化多重 if-else 之路,下面是我总结出的常用几种优化方式。


    版本



    • Java8


    几种常用的优化方式


    提前使用 return 返回去除不必要的 else



    • 如果我们的代码块中需要使用 return 返回,我们应该尽可能早的使用 return 返回而不是使用 else

    • 优化前


        private static boolean extracted(boolean condition) {
    if (condition) {
    // do something
    return false;
    }else {
    // do something
    return true;
    }
    }


    • 优化后


        private static boolean extracted(boolean condition) {
    if (condition) {
    // do something
    return false;
    }

    // do something
    return true;
    }

    使用三目运算符



    • 一些简单的逻辑我们可以使用三目运算符替代 if-else ,这样可以让我们的代码更加简洁

    • 优化前


            int num = 0;
    if (condition) {
    num = 1;
    } else {
    num = 2;
    }


    • 优化后


    int num = condition ? 1 : 2;

    使用枚举



    • 在某一些场景我们也可以使用枚举来优化多重 if-else 代码,使我们的代码更加简洁、具备更多的可读性和可维护性。

    • 优化前


            String OrderStatusDes;
    if (orderStatus == 0) {
    OrderStatusDes = "订单未支付";
    } else if (orderStatus == 1) {
    OrderStatusDes = "订单已支付";
    } else if (orderStatus == 2) {
    OrderStatusDes = "已发货";
    } else {
    throw new Exception("Invalid order status");
    }


    • 优化后


    public enum OrderStatusEnum {
    UN_PAID(0, "订单未支付"),
    PAIDED(1, "订单已支付"),
    SENDED(2, "已发货"),
    ;

    private final int code;
    private final String desc;

    public int getCode() {
    return code;
    }

    public String getDesc() {
    return desc;
    }

    OrderStatusEnum(int index, String desc) {
    this.code = index;
    this.desc = desc;
    }

    public static OrderStatusEnum getOrderStatusEnum(int orderStatusCode) {
    for (OrderStatusEnum statusEnum : OrderStatusEnum.values()) {
    if (statusEnum.getCode() == orderStatusCode) {
    return statusEnum;
    }
    }
    return null;
    }
    }


    // 当然你需要根据业务场景对异常值做出合适的处理
    OrderStatusEnum.getOrderStatusEnum(2)

    抽取条件判断作为单独的方法



    • 当我们某个逻辑条件判断比较复杂时,可以考虑将判断条件抽离为单独的方法,这样可以使我们主流程逻辑更加清晰

    • 优化前


            // do something
    if ("满足条件A" && "满足条件B") {
    // 查询权限
    if ("是否具备权限A" && "是否具备权限B") {
    // do something
    }
    }
    // do something


    • 优化后


        public static void main(String[] args) {
    // do something
    if (hasSomePermission()) {
    // do something
    }
    // do something
    }

    private static boolean hasSomePermission() {
    if (!"满足条件A" || !"满足条件B") {
    return false;
    }
    // 查询权限
    return "是否具备权限A" && "是否具备权限B";
    }

    有时候 switch 比 if-else 更加合适



    • 当条件为清晰的变量和枚举、或者单值匹配时,switch 比 if-else 更加合适,可以我们带好更好的可读性以及更好的性能 O(1)

    • 优化前


    if (day == Day.MONDAY) {
    // 处理星期一的逻辑
    } else if (day == Day.TUESDAY) {
    // 处理星期二的逻辑
    } else if (day == Day.WEDNESDAY) {
    // 处理星期三的逻辑
    } else if (day == Day.THURSDAY) {
    // 处理星期四的逻辑
    } else if (day == Day.FRIDAY) {
    // 处理星期五的逻辑
    } else if (day == Day.SATURDAY) {
    // 处理星期六的逻辑
    } else if (day == Day.SUNDAY) {
    // 处理星期日的逻辑
    } else {
    // 处理其他情况
    }


    • 优化后


    // 使用 switch 处理枚举类型
    switch (day) {
    case MONDAY:
    // 处理星期一的逻辑
    break;
    case TUESDAY:
    // 处理星期二的逻辑
    break;
    // ...
    default:
    // 处理其他情况
    break;
    }

    策略模式 + 简单工厂模式



    • 前面我们介绍一些常规、比较简单的优化方法,但是在一些更加复杂的场景(比如多渠道对接、多方案实现等)我们可以结合一些场景的设计模式来实现让我们的代码更加优雅和可维护性,比如策略模式 + 简单工厂模式。

    • 优化前


        public static void main(String[] args) {
    // 比如我们商场有多个通知渠道
    // 我们需要根据不同的条件使用不同的通知渠道
    if ("满足条件A") {
    // 构建渠道A
    // 通知
    } else if ("满足条件B") {
    // 构建渠道B
    // 通知
    } else {
    // 构建渠道C
    // 通知
    }
    }
    // 上面的代码不仅维护起来麻烦同时可读性也比较差,我们可以使用策略模式 + 简单工厂模式


    • 优化后


    import java.util.HashMap;
    import java.util.Map;

    // 定义通知渠道接口
    interface NotificationChannel {
    void notifyUser(String message);
    }

    // 实现具体的通知渠道A
    class ChannelA implements NotificationChannel {
    @Override
    public void notifyUser(String message) {
    System.out.println("通过渠道A发送通知:" + message);
    }
    }

    // 实现具体的通知渠道B
    class ChannelB implements NotificationChannel {
    @Override
    public void notifyUser(String message) {
    System.out.println("通过渠道B发送通知:" + message);
    }
    }

    // 实现具体的通知渠道C
    class ChannelC implements NotificationChannel {
    @Override
    public void notifyUser(String message) {
    System.out.println("通过渠道C发送通知:" + message);
    }
    }

    // 通知渠道工厂
    class NotificationChannelFactory {
    private static final Mapextends NotificationChannel>> channelMap = new HashMap<>();

    static {
    channelMap.put("A", ChannelA.class);
    channelMap.put("B", ChannelB.class);
    channelMap.put("C", ChannelC.class);
    }

    public static NotificationChannel createChannel(String channelType) {
    try {
    Classextends NotificationChannel> channelClass = channelMap.get(channelType);
    if (channelClass == null) {
    throw new IllegalArgumentException("不支持的通知渠道类型");
    }
    return channelClass.getDeclaredConstructor().newInstance();
    } catch (Exception e) {
    throw new RuntimeException("无法创建通知渠道", e);
    }
    }
    }

    // 客户端代码
    public class NotificationClient {
    public static void main(String[] args) {
    // 根据条件选择通知渠道类型
    String channelType = "A";
    // 使用简单工厂创建通知渠道
    NotificationChannel channel = NotificationChannelFactory.createChannel(channelType);

    // 执行通知
    channel.notifyUser("这是一条通知消息");
    }
    }


    • 有时候我们还可以借助 Spring IOC 能力的自动实现策略类的导入,然后使用 getBean() 方法获取对应的策略类实例,可以根据我们的实际情况灵活选择。


    如何优化开头的代码



    • 好了现在回到开头,如果是你会进行怎么优化,下面是我交出的答卷,大家也可以在评论区发表自己的看法,欢迎一起交流:


       public static void main(String[] args) {
    // do something
    if (isMeetCondition()) {
    // 查询配置
    // 此处查询配置的值需要在具体的任务中使用,所有并没抽离
    if ("配置是否开启") {
    // do something
    }
    }
    // do something
    }

    /**
    * 判断是否满足执行条件
    */

    private static boolean isMeetCondition() {
    if (!"满足条件A") {
    return false;
    }
    // 查询权限
    return "是否具备权限A" && "是否具备权限B";
    }



    作者:Lorin洛林
    来源:juejin.cn/post/7325353198591672359
    收起阅读 »

    火烧眉毛,我是如何在周六删了公司的数据库

    这本是一个安静的星期六。 我收到了支持团队的一条消息,说我们一个客户遇到了问题。我认为这个问题很重要,值得开始调试。15 分钟后,我明白了问题所在 - 在数据库中有一些损坏的订单需要删除。 听起来小菜一碟。 事故还原 如果你不给创业公司打工,请不要嘲笑我 😅 ...
    继续阅读 »


    这本是一个安静的星期六。


    我收到了支持团队的一条消息,说我们一个客户遇到了问题。我认为这个问题很重要,值得开始调试。15 分钟后,我明白了问题所在 - 在数据库中有一些损坏的订单需要删除。


    听起来小菜一碟。


    事故还原


    如果你不给创业公司打工,请不要嘲笑我 😅


    有几百个订单需要删除,所以我决定不手动操作,而是编写一个简单的 SQL 查询语句(警告 🚩)


    实际上比这复杂一些,但这里简化一下:


    UPDATE orders
    SET is_deleted = true

    WHERE id in (1, 2, 3)

    你大概已经猜到这场灾难的规模了...


    我按下了 CTRL + Enter 并运行了命令。当它花费超过一秒钟时,我明白发生了什么。我的客户端 DBeaver 看到空的第三行,并忽略了第四行。


    是的,我删除了数据库中所有的订单 😢


    我整个人都不好了。


    恢复


    深吸一口气后,我知道我必须快速行动起来。不能犯更多错误浪费时间了。


    恢复工作做得很好。



    1. 停止系统 - 约 5 分钟

    2. 创建变更前数据库(幸运的是我们有 PITR)的克隆 - 约 20 分钟

    3. 在等待期间给我的老板打电话 😨

    4. 根据克隆更新生产数据库的信息* - 约 15 分钟

    5. 启动系统 - 约 5 分钟


    *我决定不还原整个数据库,因为无法停止所有系统,因为我们有多个独立的系统。我不想在恢复过程中丢失所做的更改。我们用 GCP 提供的托管 PostgreSQL,所以我从更新之前创建了一个新的克隆。然后,我只导出了克隆中的 idis_deleted 列,并将结果导入到生产数据库中。之后,就是简单的 update + select 语句。


    所以显然本可以很容易避免这 45 分钟的停机时间...


    发生了什么?


    这可能听起来像是一个你永远不会犯的愚蠢错误(甚至在大公司中,根本不能犯)。确实。问题不在于错误的 SQL 语句。**一个小小的人为失误从来都不是真正的问题。**我运行那个命令只是整个失败链条的终点。



    1. 为什么要在周末处理生产环境?在这种情况下,事情并没有那么紧急。没有人要求我立即修复它。我本可以等到星期一再处理。

    2. 谁会在生产数据库上更改而不先在 QA 环境上运行一下呢?

    3. 为什么我手动编辑了数据库而不是通过调用 API?

    4. 如果没有 API,为什么我没打电话给队友,在如此敏感的操作上进行双重检查?

    5. **最糟糕的是,为什么我没使用事务?**其实只要用了 Begin,万一出错时使用 Rollback 就可以了。


    错误一层层叠加,其中任何一个被避免了 - 整件事就不会发生。大多数问题答案都很简单:我太自信了。
    不过还好通过有章法的恢复程序,阻止了连锁反应。想象一下如果无法将数据库恢复到正确状态会发生什么灾难……


    这与切尔诺贝利有什么关系?


    几个月前,我阅读了「切尔诺贝利:一部悲剧史」。那里发生的一系列错误使我想起了那个被诅咒的周末(并不是要低估或与切尔诺贝利灾难相比较)。



    1. RBMK 反应堆存在根本技术问题。

    2. 这个问题没有得到恰当传达。之前有涉及该问题的事件,但切尔诺贝利团队对此并不熟悉。

    3. 在安全检查期间,团队没有按程序操作。

    4. 爆炸后,苏联政府试图掩盖事实,从而大大加剧了损害程度。


    谁应该负责?


    反应堆设计师?其他电厂团队未能传达他们遇到的问题?切尔诺贝利团队?苏联政府?


    所有人都有责任。灾难从来不是由单一错误引起的,而是由一连串错误造成的。我们的工作就是尽早打断这条链条,并做到最好。


    后续


    我对周一与老板的谈话本没有什么期待。


    但他让我惊讶:「确保不再发生这种情况。但是我更喜欢这样 - 你犯了错误是因为你专注并且喜欢快速行动。做得越多,砸得越多。」


    那正是我需要听到的。如果以过于「亲切」的方式说:没关系,别担心,谢谢你修复它!我反而会感觉虚伪。另一方面,我已经感觉很糟糕了,所以没有必要进一步吐槽我。


    从那时起:



    • 我们减少了对数据库直接访问的需求,并创建相关的 API。

    • 我总是先在 QA 上运行查询(显而易见吧?没有比灾难更能教训人了)。

    • 我与产品经理商量,了解真正紧急和可以等待的事项。

    • 任何对生产环境进行更删改操作都需要两个人来完成。这实际上防止了其他错误!

    • 我开始使用事务处理机制。


    可以应用在你的团队中的经验教训


    事发后,我和团队详细分享了过程,没有隐瞒任何事情,也没有淡化我的过错。
    在责备他人和不追究责任之间有一个微妙的平衡。当你犯错误时,这是一个传递正确信息的好机会。


    如果你道歉 1000 次,他们会认为你期望当事情发生在他们身上时,他们也需要给出同样的回应。


    如果你一笑了之,并忽视其影响,他们会认为这是可以接受的。


    如果你承担责任、学习并改进自己 - 他们也会以同样的方式行事。


    file


    总结一下



    • 鼓励行动派,关心客户,并解决问题。这就是初创企业成功的方式。

    • 当犯错时,要追究责任。一起理解如何避免这种情况发生。

    • 没必要落井下石。有些人需要更多的责任感,而有些人则需要更多的鼓励。我倾向于以鼓励为主。


    顺便说一句,如果团队采用了 Bytebase 的话,这个事故是大概率可以被避免的,因为 Bytebase 有好几道防线:



    1. 用户不能随意通过使用 DBeaver 这样的本地客户端直连数据库,而必须通过 Bytebase 提交变更工单。

    2. 变更工单的 SQL 会经过自动审查,如果影响范围有异常,会有提示。

    3. 变更工单只有通过人工审核后才能发布。

    作者:Bytebase
    来源:juejin.cn/post/7322156771614507059
    收起阅读 »

    年底了,出了P0级故障,人肉运维不可靠

    翻车现场 5年前,大概是2018年12月份的一个晚上,我接到数据组同事的消息,要求将A用户的磁盘快照共享给B用户。我对这个线上运维工作早已轻车熟路,登录线上服务器仅用了2分钟就完成了。 我继续忙着其他事情,3分钟后,我正要打开新的控制台页面,猛然发现控制台上的...
    继续阅读 »

    翻车现场


    5年前,大概是2018年12月份的一个晚上,我接到数据组同事的消息,要求将A用户的磁盘快照共享给B用户。我对这个线上运维工作早已轻车熟路,登录线上服务器仅用了2分钟就完成了。


    我继续忙着其他事情,3分钟后,我正要打开新的控制台页面,猛然发现控制台上的“ public = true”。我惊慌地查看磁盘快照状态,发现磁盘快照已经共享给了所有用户。任何用户都可以在自己的快照列表中看到这个快照,并用快照创建新的磁盘,这意味着这些快照数据已经泄露了。这可是公司重要客户的磁盘数据啊!!!!


    我心里明白,对于云计算行业,数据安全问题比线上bug还要严重!


    我立刻就慌了,心脏砰砰的跳,手也开始颤抖。我心里很忐忑,一开始试图偷偷回滚,纠结之后,最终选择告诉了组长。


    我倒吸一口气,一边进行回滚,一边试图平静的说,“我把刚才的快照共享给了所有租户”。瞬间,组长瞪大眼睛了看着我,“回滚了吗,赶紧回滚……”。 我颤抖地编辑SQL,手都麻木了,心脏还在怦怦跳个不停,开始担心这件事的后果。


    领导边看我回滚,边小声对我说,“赶紧回滚,下次小心点”,看的出来,组长不想声张,他想先看看影响。


    ”嗯,好“,我努力嗯了一声,组长没大声骂我,我很感动。本以为回滚了,就没事了。



    (后来这家小公司黄了,这是被我干黄的第二家公司,你们干黄了几家?)



    然而,这远远没有结束。


    原本宁静的办公室突然变得热闹起来,周围的同事们纷纷接到了报警通知。他们“兴高采烈”地讨论着报警的原因,我的注意力也被吸引了过去,听起来似乎与我有关,但我却没有心情去理会他们。


    最终,快照被共享 5 分钟后,回滚完成,我长舒一口气,心想幸好我多看了一眼控制台,否则不知道被泄露多久。


    与此同时,邻居组的成员钱哥找到了我,问道:“刚才快照计费数据暴涨了,你们这边有做过什么操作吗?”


    随后,邻居组的组长王哥也过来了,询问情况如何。


    我的组长苦笑着告诉他们:“刚才一个磁盘快照错误地被共享给了所有租户,不过现在已经回滚了。”


    邻居组的王哥听后惊愕地说道:“卧槽,谁干的?”他的脸上露出了一丝微笑,似乎是看热闹的微笑。


    我实在不知道该怎么说了,苦着脸问他们:“计费数据能回滚吗?”


    邻居组的王哥没有回答我的问题,看了我一眼,说:“我叫上老板,先找个会议室讨论一下吧。”


    万幸的是这 5分钟里没有用户使用此快照创建磁盘,这意味快照数据没有发生实质性泄露。


    至暗时刻


    接下来的两天里,我只做了两件事,参加复盘会议和去会议室的路上。这两天是我人生中最难忘的时刻,我尴尬得连脚丫子都能拧成麻花。


    我真希望能立刻辞职离开这个地方。”别再鞭尸了,老子不干了,行不行。md,不就是共享个快照嘛!“ 我的心理状态从忐忑变得暴躁~



    (每次造成线上故障,我都有类似的想法,我不想干了,不就是个bug吗,不干了,还不行吗?你们有类似想法吗?)



    后来我开始后悔 ,为什么不早点下班,九点多还帮同事进行高危的线上操作,我图个啥


    对,我图个啥。我脑子被驴踢了,才提出这个人肉运维方案,一周运维十几次,自己坑自己……


    背景


    2个月前,组长接到一个大客户需求,要求在两个租户之间共享云磁盘数据,当时提出很多个方案,其中包括分布式存储系统提供工具共享两个云磁盘数据等非常复杂的方案。 我当时听到这个需求,就立马想到, 我们的云管理系统可以实现两个租户的资源共享啊,通过给云磁盘打快照、共享快照等,就实现两个云磁盘的数据共享。


    当时我非常得意,虽然我对存储并不是很了解,但是我相信我的方案比存储团队的底层方案更加简单且可行性更高。经过与客户的沟通,确定了这个方案能够满足他们的诉求,于是我们定下了这个方案。


    由于大客户要的比较急,我改了代码就急匆匆上线,这个需求甚至没有产品参与,当客户需要共享数据时,需要我构造请求参数,在线上服务器上命令行执行共享操作。第一版方案在线上验证非常顺利,客户对这样快速的交付速度非常满意


    因为我们使用了开源的框架,资源共享能力是现成的,所以改动起来很快。只不过有一个核弹级feature,我忽略了它的风险。


    public = true时,资源将共享给全部用户。“只要不设置这个参数就不会有什么问题。” 这是我的想法,我没有考虑误操作的可能,更没有想到自己会犯下这个错误。


    本以为只是低频的一次性操作,没想到后来客户经常性使用。我不得不一次次在线上执行高危操作,刚开始我非常小心谨慎,仔细的检查每个参数,反复确认后才执行命令。


    然而,后来我感到这个工作太过枯燥乏味,于是开始集中处理,一次性执行一批操作。随着时间的推移,我越来越熟悉这件事。这种运维操作我两分钟就能完成……之所以这么快,是因为我不再仔细检查参数,只是机械地构造参数,随手执行。正是我松懈的态度导致闯下了大祸,在那个日常性加班的晚上。


    后来我开始反思,从需求提出到故障发生前,我有哪些做的不对的地方。我认为有如下问题。



    1. 技术方案不能仅限于提供基本的资源共享能力,还要提供可视页面,提供产品化能力。

    2. 高危接口,一定要严格隔离成 单独的接口,不能和其他接口混合在一起,即使功能类似

    3. 线上重要操作要提供审核能力!或者有double check 的机制!


    深刻的反思


    任何工作都是有风险的,尤其是程序员无时无刻都在担心发生线上问题,如果不学会保护自己,那么多干一件事就多增加很多风险,增加背锅的风险。


    拿我来说,本来这个需求不需要我参与,我提出了一个更简单的方案,高效的响应了大客户需求,是给自己长脸的事情。然而,我犯了一个巨大的错误,之前所做的努力都付之一炬。大领导根本不知道我提出的方案更简洁高效,他只认为我办事不可靠。在复盘会议上,我给大领导留下了非常糟糕的印象。


    话说回来,在这个事情上如何保护自己呢?



    1. 技术方案一定要避免人肉运维,对于高危运维操作要求产品提供可视化页面运维。一定要尽全力争取,虽然很多时候,因为排期不足,前端资源不足等原因无法做到。

    2. 如果没有运维页面,等基础能力上线后,继续寻求组长帮助,协调产品提供操作页面,避免一直依赖自己人肉运维去执行高危操作。

    3. 在还没有产品化之前,要求客户或上游同事将所有的需求整理到文档上,使用文档进行沟通交流,记录自己的工作量,留存一份自己的”苦劳“。

    4. 在低频操作,变为高频操作时,不应该压迫自己更加“高效运维”,而是将压力和风险再次传达给产品和组长,让他们意识到我的人肉运维存在极大危险,需要要尽快提供产品化能力。让他们明白:“如果不尽快排期,他们也会承担风险!”

    5. 任何时候,对于线上高危操作,一定要小心谨慎。万万不可麻痹大意!


    总之,千万不要独自承担所有的压力和风险。在工作中,我们可以付出辛勤努力,承受一定的风险,但是必须得到相应的回报。



    风浪越大,鱼越贵。但是如果大风大浪,鱼还是很便宜,就不要出海了!风险收益要对等



    就这个事情来说,每天我都要执行高风险的运维操作,是一种辛苦而不太受重视的工作。尽管如此,我却必须承担着巨大的风险,并自愿地让自己不断追求更高效的人工运维方式。然而结果却让人啼笑皆非,我终究翻车了。实在是可笑。



    挣着卖白菜的钱,操着卖白粉的心,这是我的真实写照。



    吾日三省吾身、这事能不能不干、这事能不能明天干、这事能不能推给别人干。


    程序员不善于沟通,往往通过加班、忍一忍等方式默默地承担了很多苦活、脏活、累活。但是我们要明白,苦活可以,脏活等高风险的活 千万不要自己扛。


    你干好十件事不一定传到大领导耳朵里,但是你出了一次线上大事故,他肯定第一时间知道。


    好事不出门,坏事传千里。


    我们一定要对 高危的人工运维,勇敢说不!


    作者:五阳神功
    来源:juejin.cn/post/7285673629526753316
    收起阅读 »

    幻兽帕鲁Palworld服务端最佳一键搭建教程

    幻兽帕鲁Palworld最近彻底火了,忍不住也自己去搭建了一下,找了很多教程,发现还是目前找的这个最方便,结合腾讯云服务器新人优惠套餐,这里给出我搭建的详细步骤,包你轻松搞定。 此方案适合新用户,毕竟老用户购买服务器价格还是很贵的,自己如果已经是老用户了记得用...
    继续阅读 »

    幻兽帕鲁.jpg


    幻兽帕鲁Palworld最近彻底火了,忍不住也自己去搭建了一下,找了很多教程,发现还是目前找的这个最方便,结合腾讯云服务器新人优惠套餐,这里给出我搭建的详细步骤,包你轻松搞定。


    此方案适合新用户,毕竟老用户购买服务器价格还是很贵的,自己如果已经是老用户了记得用身边的人帮你买一个即可。


    服务器选择


    目前发现各大厂家都推出了自家的新人首单优惠,官方入场,最为致命!太便宜了,这里推荐三家主流的



    image.png


    腾讯云的点击进来后,可以看到很明显的一栏关于帕鲁游戏的,点击后面的前往部署就可以进入优惠的服务器了


    ,推荐新人使用66元这一档,我个人也是买了这档来测试。


    image.png



    阿里云也推出了幻兽帕鲁专属云服务器,还是针对新用户的,如果你进来看到的价格也是入下图这样,那推荐入手


    image.png



    华为云也推出新用户一个月的优惠价,一个比一个卷


    image.png


    教程推荐


    我这次操作的教程脚本是参考github.com/2lifetop/Pa… 这个项目
    之所以用这个教程因为足够简单,也有界面可视化来配置私服的参数,


    image.png


    搭建步骤详细说明


    这里我用的是腾讯云服务器,所以流程介绍腾讯云上面的搭建方式,如果你买的是其他家的也类似,核心步骤都是以下2点:



    • 一键安装脚本

    • 服务端配置(可选)

    • 端口8211开放


    服务器购买


    因为脚本推荐的是用 Debian 12,所以我购买腾讯云服务器的时候,直接选择了 Debian12带Docker的版本。


    image.png


    购买后就可以进入服务器的界面了,如果找不到,可以搜索轻量应用服务器


    image.png


    image.png


    这里你可以用第三方ssh登录或者直接直接网页登录都行。我推荐用第三方登录,我用的是FinalShell这个软件,我第一步是进入修改密码。


    image.png


    然后就用FinalShell登录上了,稳的一批。


    一键安装脚本


    以root用户登陆到服务器然后运行以下命令即可。该脚本目前只在Debian12系统上验证过。如果遇上非网络问题则请自行更换系统或者寻求其他解决方案。


    非root用户请先运行 sudo su命令。


    1.  wget -O PalServerInstall.sh https://www.xuehaiwu.com/wp-content/uploads/shell/Pal/PalServerInstall.sh --no-check-certificate && chmod +x PalServerInstall.sh && ./PalServerInstall.sh

    出现下面这个画面了,选择1安装即可


    image.png


    正常等待几分钟就可以安装好了, 不过我自己安装的时候出现过问题,提示安装失败,然后我就执行11删除,然后重新执行脚本安装就成功了。


    服务端配置(可选)


    因为搭建的是私服嘛,所以为了体验更加,这个脚本提供了在线参数修改,步骤也很简单
    先打开 http://www.xuehaiwu.com/Pal/
    把你想调整的参数自行设置


    image.png


    其中比较重要的配置有



    • 服务器名称

    • 服务器上允许的最大玩家数(上限为 32)

    • 用于授予管理员访问权限的密码

    • 普通玩家加入所需的密码


    如果要使用管理员命令需要加上管理员密码,普通玩家加入密码暂时不推荐设置,因为可能会造成玩家进不来。


    服务器配置生成也挺麻烦的,所以我简单的做了个生成网页。要修改哪个直接在网页上修改就行。配备了中文介绍。


    都设置好了就可以点击下面的【生成配置文件】,然后复制下生成的wget这一行命令。


    image.png


    然后切回到SSH那边,黏贴执行即可,这样就会生成一个叫 PalWorldSettings.ini配置文件,这个时候就重新执行下脚本命令 ./PalServerInstall.sh ,调出命令窗口,选择4 就行,这样就会覆盖配置了。


    修改之后不是立即生效的,要重启帕鲁的服务端才能生效,对应数字8


    端口8211开放


    到此还差最后一步,就是要开放8211端口,我们进入到腾讯云网页端,点击进入详情


    image.png


    切换到防火墙,配置两条,TCP、UDP端口8211开放即可。


    image.png


    到此就算搞定了服务端的搭建了,这时候复制下公网IP,一会要用到


    登录游戏


    游戏也是需要大家自己购买的,打开游戏后,会看到一个【加入多人游戏(专用服务器)】选项,点击这个


    8b463bab9f2b026c77afaf711f79448.png


    进来后看到底部这里了没,把你服务器公网的ip去替换下 :8211前面的ip数字即可
    比如我的ip是:106.54.6.86,那我输入的就是 106.54.6.86:8211


    image.png


    总结


    ok,到此就是我搭建幻兽帕鲁Palworld服务端的全部流程,这游戏还是挺有意思的,缺点是缝合怪,优点是缝的还不错,我昨天自己搭建完玩了2个小时,大部分在搭建我的房子,盖着停不下来哈哈,感觉可以盖个10层楼。


    499f598cf68efdf9486e23424e65f44.png


    别人盖的比我好看多了。


    image.png


    这游戏其实火起来还有一个梗:帕鲁大陆最不缺的就是帕鲁,你不干有的是帕鲁干。
    图片


    我体验了一下也发现很真实,在游戏里面和帕鲁交朋友哈哈哈,其实是在压榨它们,让它们帮我们干活,累倒了就换一个,帕鲁多的是不缺你一个。现实中我们不也是帕鲁吗,所以大家突然找到了共鸣。


    各位上班的时候就是帕鲁,下班了在游戏里面压榨帕鲁。


    作者:嘟嘟MD
    来源:juejin.cn/post/7328621062727122944
    收起阅读 »

    大厂真实 Git 开发工作流程

    记得之前也写过一篇类似的文章,来到我东后感觉不够详尽,很多流程还是太局限了。大厂的开发流程还是比较规范的。如果你来不及学习长篇大论的 git 文档,这篇文章够你入职时用一段时间了。 一、开发分支模型分类 目前所在部门使用是主要是四种:dev(开发)、test(...
    继续阅读 »

    记得之前也写过一篇类似的文章,来到我东后感觉不够详尽,很多流程还是太局限了。大厂的开发流程还是比较规范的。如果你来不及学习长篇大论的 git 文档,这篇文章够你入职时用一段时间了。


    一、开发分支模型分类


    目前所在部门使用是主要是四种:dev(开发)、test(测试)、uat(预发)、release(生产)



    小公司可能就一个 dev、一个 master 就搞定了,测试都是开发人员自己来🤣。



    二、开发主体流程



    1. 需求评审

    2. 开发排期

    3. 编码开发

    4. 冒烟测试(自检验)

    5. 冒烟通过,提交测试,合并代码到测试分支,部署测试环境

    6. 测试环境测试,开发修 bug

    7. 测试完成,提交预发,合并代码到预发分支,部署预发环境

    8. 预发环境测试,开发修 bug(修完的 bug 要重新走测试再走预发,这个下面会解释)

    9. 测试完成,产品验收

    10. 验收完成,提交生产,合并代码到生产分支,部署生产环境

    11. 生产运营(客户)验收

    12. 验收完成,结项


    三、具体操作


    1. 拉取代码


    一般都会在本地默认创建一个 master 分支


    git clone https://code.xxx.com/xxx/xxx.git

    2. 初次开发需求前,要先拉取生产/预发分支,然后基于这个分支之上,创建自己的特性分支进行开发


    git fetch origin release:release

    git checkout release

    git checkout -b feat-0131-jie

    此时,在你本地已经有了一个 release 分支对应着远程仓库的 release 分支,还有一个内容基于 release 分支的特性分支,之后便可以在这个特性分支上进行需求开发了。


    注意1:分支名称是有规范和含义的,不能乱取。

    推荐格式:分支责任-需求日期/需求号-开发人姓名,一般按部门规范来,常见的有以下几种。


      - feat:新功能

    - fix:修补bug

    - doc:文档

    - refactor:重构(即不是新增功能,也不是修改bug的代码变动)

    - test:测试

    - chore:构建过程或辅助工具的变动

    注意2:为啥拉取的是生产/预发分支

    之所以要拉取 release/uat 分支而不是拉取 dev/test,是因为后者可能包含着一些其他成员还未上线或者可能有 bug 的需求代码,这些代码没有通过验证,如果被你给拉取了,然后又基于此进行新的需求开发,那当你需求开发完成,而其他成员的需求还没上线,你将会把这些未验证的代码一起发送到 uat/release 上,导致一系列问题。


    3. 需求开发完成,提交&合并代码


    首先先在本地把新的改动提交,提交描述的格式可以参考着分支名的格式



    • 如果是新需求的提交,可以写成 "feat: 需求0131-新增账期"

    • 如果是 bug 修复,可以写成 "fix: 禅道3387-重复请求"


    git add .

    git commit -m "提交描述"

    此时,本地当前分支已经记录了你的提交记录,接下来进行代码合并了


    在代码合并之前,我们先要梳理一下我们应该如何对分支进行管理(非常重要!)


    1. 首先,我们需要认知到的是,每一个分支应该只对应一个功能,例如当我们开发需求 01 时,那么就创建一个 feat-01-jie 分支进行开发;开发需求 02 时,就另外创建一个 feat-02-jie 分支进行开发;修改生产环境的某个 bug 时,就创建 fix-jie-3378 进行开发,等等。


      这样做的目的是,能够把不同的功能/需求/修改分离开来。想象一下这样一个场景,如果有某些紧急的需求是需要提前上线的,而此时你的分支里既包含了这些紧急的需求,又包含了其他未开发好的需求,那么这两种需求就不能拆开来分别进行提测和上线了。


    2. 其次,在合并代码时,我们要将四种分支模型(dev、test、uat、release)作为参照物,而不是把关注点放在自己的分支上。比如我们要在 dev 上调试,那就需要把自己的分支合并到 dev 分支上;如果我们需要提测,则把自己的分支合并到 test 分支上,以此类推。


      即,我们要关注到,这四个环境的分支上,会有什么内容,会新增什么内容。切记不能反过来将这四个分支合并到自己的代码上!! 如果其他成员将自己的代码也提交到 dev 分支上,但是这个代码是没有通过验证的,此时你将 dev 往自己的分支上合,那之后的提测、上预发、生产则很大概率会出问题。所以一定要保持自己的分支是干净的!



    接下来介绍合并代码的方式:


    第一种:线上合并,也是推荐的规范操作

    git push origin feat-0131-jie

    先接着上面的提交步骤,将自己的分支推送到远程仓库。


    然后在线上代码仓库中,申请将自己的分支合并到 xx 分支(具体是哪个分支就根据你当前的开发进度来,如 test),然后在线上解决冲突。如果有权限就自己通过了,如果没有就得找 mt 啥的


    第二种,本地合并(前提你要有对应环境分支 push 的权限)

    ## 先切换到你要提交的环境分支上,如果本地还没有就先拉取下来
    git fetch origin test:test

    git checkout test

    #
    # 然后将自己的分支合并到环境分支上(在编辑器解决冲突)
    git merge feat-0131-jie

    #
    # 最后将环境分支推送到远程仓库
    git push origin test

    ## 先切换到你要提交的环境分支上,如果本地已有该分支,则需要先拉取最新代码
    git checkout test

    git pull origin test

    #
    # 然后将自己的分支合并到环境分支上(在编辑器解决冲突)
    git merge feat-0131-jie

    #
    # 最后将环境分支推送到远程仓库
    git push origin test

    两种方式有何区别?为什么推荐第一种?

    这是因为在团队协作开发的过程中,将合并操作限制在线上环境有以下几个好处:



    1. 避免本地合并冲突:如果多个开发人员同时在本地进行合并操作,并且对同一段代码进行了修改,可能会导致冲突。将合并操作集中在线上环境可以减少此类冲突的发生,因为不同开发人员的修改会先在线上进行合并,然后再通过更新拉取到本地。

    2. 更好的代码审查:将合并操作放在线上环境可以方便其他开发人员进行代码审查。其他人员可以在线上查看合并请求的代码变动、注释和讨论,并提供反馈和建议。这样可以确保代码的质量和可维护性。

    3. 提高可追溯性和可回滚性:将合并操作记录在线上可以更容易地进行版本控制和管理。如果出现问题或需要回滚到之前的版本,可以更轻松地找到相关的合并记录并进行处理。


    当然,并非所有情况都适用于第一种方式。在某些特定情况下,例如个人项目或小团队内部开发,允许本地合并也是可以的。但在大多数团队协作的场景中,将合并操作集中在线上环境具有更多优势。


    4. 验收完成,删除分支


    当我们这一版的需求完成后,本地肯定已经留有很多分支了,这些分支对于之后的开发已经意义不大了,留下来只会看着一团糟。


    git branch -d <分支名>

    #
    # 如果要强制删除分支(即使分支上有未合并的修改)
    git branch -D <分支名>

    四、一些小问题


    1. 前面提到,预发环境修完的 bug 要重新走测试再走预发,为什么呢?


    预生产环境是介于测试和生产环境之间的一个环境,它的目的是模拟生产环境并进行更真实的测试。
    它是一个重要的测试环境,需要保持稳定和可靠。通过对修复的bug再次提交到测试环境测试,可以确保预生产环境中的软件版本是经过验证的,并且没有明显的问题。


    当然,也不是非要这么做不可,紧急情况下,也可以选择直接发到预生产重新测试,只要你保证你的代码 99% 没问题了。


    2. 代码合并错误,并且已经推送到远程分支,如何解决?


    假设是在本地合并,本来要把特性分支合并到 uat 分支,结果不小心合到了 release 分支(绝对不是我自己的案例,绝对不是。。。虽然好在最后同事本地有我提交前的版本,事情就简单很多了)


    首先切换到特性分支合并到的错误分支,比如是 release


    git checkout release

    然后查看最近的合并信息


    git log --merges

    撤销合并


    git revert -m 1 <merge commit ID>


    • 这里的 merge commit ID 就是上一步查询出来的 ID 或者 ID 的前几个字符


    最后,撤销远程仓库的推送


    git push -f origin release


    • 这个命令会强制推送本地撤销合并后的 release 分支到远程仓库,覆盖掉远程仓库上的内容。(即,得通过一个新的提交来“撤销”上一次的提交,本质上是覆盖)


    3. 当前分支有未提交的修改,但是暂时不想提交,想要切换到另一个分支该怎么做?


    例如:你正在开发 B 需求,突然产品说 A 需求有点问题,让你赶紧改改,但是当前 B 需求还没开发完成,你又不想留下过多无用的提交记录,此时就可以按照下面这样做:


    首先,可以将当前修改暂存起来,以便之后恢复


    git stash

    然后切换到目标分支,例如需求 A 所在分支


    git checkout feat-a-jie

    修改完 A 需求后,需要先切换回之前的分支,例如需求 B 所在分支


    git checkout feat-b-jie

    如果你不确定之前所在的分支名,可以使用以下命令列出暂存的修改以及它们所属的分支:


    git stash list

    最后从暂存中恢复之前的修改


    git stash pop

    此时你的工作区就恢复如初了!




    喜欢本文的话,可以点赞收藏呀~😘


    如果有疑问,欢迎评论区留言探讨~🤔


    作者:JIE
    来源:juejin.cn/post/7327863960008392738
    收起阅读 »

    支付系统的心脏:简洁而精妙的状态机设计与核心代码实现

    本篇主要讲清楚什么是状态机,简洁的状态机对支付系统的重要性,状态机设计常见误区,以及如何设计出简洁而精妙的状态机,核心的状态机代码实现等。 我前段时间面试一个工作过4年的同学竟然没有听过状态机。假如你没有听过状态机,或者你听过但没有写过,或者你是使用if el...
    继续阅读 »



    本篇主要讲清楚什么是状态机,简洁的状态机对支付系统的重要性,状态机设计常见误区,以及如何设计出简洁而精妙的状态机,核心的状态机代码实现等。


    我前段时间面试一个工作过4年的同学竟然没有听过状态机。假如你没有听过状态机,或者你听过但没有写过,或者你是使用if else 或switch case来写状态机的代码实现,建议花点时间看看,一定会有不一样的收获。


    1. 前言


    在线支付系统作为当今数字经济的基石,每年支撑几十万亿的交易规模,其稳定性至关重要。在这背后,是一种被誉为支付系统“心脏”的技术——状态机。本文将一步步介绍状态机的概念、其在支付系统中的重要性、设计原则、常见误区、最佳实践,以及一个实际的Java代码实现。


    2. 什么是状态机


    状态机,也称为有限状态机(FSM, Finite State Machine),是一种行为模型,由一组定义良好的状态、状态之间的转换规则和一个初始状态组成。它根据当前的状态和输入的事件,从一个状态转移到另一个状态。


    下图就是在《支付交易的三重奏:收单、结算与拒付在支付系统中的协奏曲》中提到的交易单的状态机。



    从图中可以看到,一共4个状态,每个状态之间的转换由指定的事件触发。


    3. 状态机对支付系统的重要性


    想像一下,如果没有状态机,支付系统如何知道你的订单已经支付成功了呢?如果你的订单已经被一个线程更新为“成功”,另一个线程又更新成“失败”,你会不会跳起来?


    在支付系统中,状态机管理着每笔交易的生命周期,从初始化到完成或失败。它确保交易在正确的时间点,以正确的顺序流转到正确的状态。这不仅提高了交易处理的效率和一致性,还增强了系统的鲁棒性,使其能够有效处理异常和错误,确保支付流程的顺畅。


    4. 状态机设计基本原则


    无论是设计支付类的系统,还是电商类的系统,在设计状态机时,都建议遵循以下原则:


    明确性: 状态和转换必须清晰定义,避免含糊不清的状态。


    完备性: 为所有可能的事件-状态组合定义转换逻辑。


    可预测性: 系统应根据当前状态和给定事件可预测地响应。


    最小化: 状态数应保持最小,避免不必要的复杂性。


    5. 状态机常见设计误区


    工作多年,见过很多设计得不好的状态机,导致运维特别麻烦,还容易出故障,总结出来一共有这么几条:


    过度设计: 引入不必要的状态和复杂性,使系统难以理解和维护。


    不完备的处理: 未能处理所有可能的状态转换,导致系统行为不确定。


    硬编码逻辑: 过多的硬编码转换逻辑,使系统不具备灵活性和可扩展性。


    举一个例子感受一下。下面是亲眼见过的一个交易单的状态机设计,而且一眼看过去,好像除了复杂一点,整体还是合理的,比如初始化,受理成功就到ACCEPT,然后到PAYING,如果直接成功就到PAIED,退款成功就到REFUND。



    我说说这个状态机有几个不合理的地方:



    1. 过于复杂。一些不必要的状态可以去掉,比如ACCEPT没有存在的必要。

    2. 职责不明确。支付单就只管支付,到PAIED就支付成功,就是终态不再改变。REFUND应该由退款单来负责处理,否则部分退款怎么办。


    我们需要的改造方案:



    1. 精简掉不必要的状态,比如ACCEPT。

    2. 把一些退款、请款等单据单独抽出去,这样状态机虽然多了,但是架构更加清晰合理。


    主单:



    普通支付单:



    预授权单:



    请款单:



    退款单:



    6. 状态机设计的最佳实践


    在代码实现层面,需要做到以下几点:


    分离状态和处理逻辑:使用状态模式,将每个状态的行为封装在各自的类中。


    使用事件驱动模型:通过事件来触发状态转换,而不是直接调用状态方法。


    确保可追踪性:状态转换应该能被记录和追踪,以便于故障排查和审计。


    具体的实现参考第7部分的“JAVA版本状态机核心代码实现”。


    7. 常见代码实现误区


    经常看到工作几年的同学实现状态机时,仍然使用if else或switch case来写。这是不对的,会让实现变得复杂,且容易出现问题。


    甚至直接在订单的领域模型里面使用String来定义,而不是把状态模式封装单独的类。


    还有就是直接调用领域模型更新状态,而不是通过事件来驱动。


    错误的代码示例:


    if (status.equals("PAYING") {
    status = "SUCCESS";
    } else if (...) {
    ...
    }

    或者:


    class OrderDomainService {
    public void notify(PaymentNotifyMessage message) {
    PaymentModel paymentModel = loadPaymentModel(message.getPaymentId());
    // 直接设置状态
    paymentModel.setStatus(PaymentStatus.valueOf(message.status);
    // 其它业务处理
    ... ...
    }
    }

    或者:


    public void transition(Event event) {
    switch (currentState) {
    case INIT:
    if (event == Event.PAYING) {
    currentState = State.PAYING;
    } else if (event == Event.SUCESS) {
    currentState = State.SUCESS;
    } else if (event == Event.FAIL) {
    currentState = State.FAIL;
    }
    break;
    // Add other case statements for different states and events
    }
    }

    8. JAVA版本状态机核心代码实现


    使用Java实现一个简单的状态机,我们将采用枚举来定义状态和事件,以及一个状态机类来管理状态转换。


    定义状态基类


    /**
    * 状态基类
    */

    public interface BaseStatus {
    }

    定义事件基类


    /**
    * 事件基类
    */

    public interface BaseEvent {
    }

    定义“状态-事件对”,指定的状态只能接受指定的事件


    /**
    * 状态事件对,指定的状态只能接受指定的事件
    */

    public class StatusEventPairextends BaseStatus, E extends BaseEvent> {
    /**
    * 指定的状态
    */

    private final S status;
    /**
    * 可接受的事件
    */

    private final E event;

    public StatusEventPair(S status, E event) {
    this.status = status;
    this.event = event;
    }

    @Override
    public boolean equals(Object obj) {
    if (obj instanceof StatusEventPair) {
    StatusEventPair other = (StatusEventPair)obj;
    return this.status.equals(other.status) && this.event.equals(other.event);
    }
    return false;
    }

    @Override
    public int hashCode() {
    // 这里使用的是google的guava包。com.google.common.base.Objects
    return Objects.hashCode(status, event);
    }
    }

    定义状态机


    /**
    * 状态机
    */

    public class StateMachineextends BaseStatus, E extends BaseEvent> {
    private final Map, S> statusEventMap = new HashMap<>();

    /**
    * 只接受指定的当前状态下,指定的事件触发,可以到达的指定目标状态
    */

    public void accept(S sourceStatus, E event, S targetStatus) {
    statusEventMap.put(new StatusEventPair<>(sourceStatus, event), targetStatus);
    }

    /**
    * 通过源状态和事件,获取目标状态
    */

    public S getTargetStatus(S sourceStatus, E event) {
    return statusEventMap.get(new StatusEventPair<>(sourceStatus, event));
    }
    }

    定义支付的状态机。注:支付、退款等不同的业务状态机是独立的


    /**
    * 支付状态机
    */

    public enum PaymentStatus implements BaseStatus {

    INIT("INIT", "初始化"),
    PAYING("PAYING", "支付中"),
    PAID("PAID", "支付成功"),
    FAILED("FAILED", "支付失败"),
    ;

    // 支付状态机内容
    private static final StateMachine STATE_MACHINE = new StateMachine<>();
    static {
    // 初始状态
    STATE_MACHINE.accept(null, PaymentEvent.PAY_CREATE, INIT);
    // 支付中
    STATE_MACHINE.accept(INIT, PaymentEvent.PAY_PROCESS, PAYING);
    // 支付成功
    STATE_MACHINE.accept(PAYING, PaymentEvent.PAY_SUCCESS, PAID);
    // 支付失败
    STATE_MACHINE.accept(PAYING, PaymentEvent.PAY_FAIL, FAILED);
    }

    // 状态
    private final String status;
    // 描述
    private final String description;

    PaymentStatus(String status, String description) {
    this.status = status;
    this.description = description;
    }

    /**
    * 通过源状态和事件类型获取目标状态
    */

    public static PaymentStatus getTargetStatus(PaymentStatus sourceStatus, PaymentEvent event) {
    return STATE_MACHINE.getTargetStatus(sourceStatus, event);
    }
    }

    定义支付事件。注:支付、退款等不同业务的事件是不一样的


    /**
    * 支付事件
    */

    public enum PaymentEvent implements BaseEvent {
    // 支付创建
    PAY_CREATE("PAY_CREATE", "支付创建"),
    // 支付中
    PAY_PROCESS("PAY_PROCESS", "支付中"),
    // 支付成功
    PAY_SUCCESS("PAY_SUCCESS", "支付成功"),
    // 支付失败
    PAY_FAIL("PAY_FAIL", "支付失败");

    /**
    * 事件
    */

    private String event;
    /**
    * 事件描述
    */

    private String description;

    PaymentEvent(String event, String description) {
    this.event = event;
    this.description = description;
    }
    }

    在支付单模型中声明状态和根据事件推进状态的方法:


    /**
    * 支付单模型
    */

    public class PaymentModel {
    /**
    * 其它所有字段省略
    */


    // 上次状态
    private PaymentStatus lastStatus;
    // 当前状态
    private PaymentStatus currentStatus;


    /**
    * 根据事件推进状态
    */

    public void transferStatusByEvent(PaymentEvent event) {
    // 根据当前状态和事件,去获取目标状态
    PaymentStatus targetStatus = PaymentStatus.getTargetStatus(currentStatus, event);
    // 如果目标状态不为空,说明是可以推进的
    if (targetStatus != null) {
    lastStatus = currentStatus;
    currentStatus = targetStatus;
    } else {
    // 目标状态为空,说明是非法推进,进入异常处理,这里只是抛出去,由调用者去具体处理
    throw new StateMachineException(currentStatus, event, "状态转换失败");
    }
    }
    }

    代码注释已经写得很清楚,其中StateMachineException是自定义,不想定义的话,直接使用RuntimeException也是可以的。


    在支付业务代码中的使用:只需要paymentModel.transferStatusByEvent(PaymentEvent.valueOf(message.getEvent()))


    /**
    * 支付领域域服务
    */

    public class PaymentDomainServiceImpl implements PaymentDomainService {

    /**
    * 支付结果通知
    */

    public void notify(PaymentNotifyMessage message) {
    PaymentModel paymentModel = loadPaymentModel(message.getPaymentId());
    try {

    // 状态推进
    paymentModel.transferStatusByEvent(PaymentEvent.valueOf(message.getEvent()));
    savePaymentModel(paymentModel);
    // 其它业务处理
    ... ...
    } catch (StateMachineException e) {
    // 异常处理
    ... ...
    } catch (Exception e) {
    // 异常处理
    ... ...
    }
    }
    }

    上面的代码只需要加完善异常处理,优化一下注释,就可以直接用起来。


    好处:



    1. 定义了明确的状态、事件。

    2. 状态机的推进,只能通过“当前状态、事件、目标状态”来推进,不能通过if else 或case switch来直接写。比如:STATE_MACHINE.accept(INIT, PaymentEvent.PAY_PROCESS, PAYING);

    3. 避免终态变更。比如线上碰到if else写状态机,渠道异步通知比同步返回还快,异步通知回来把订单更新为“PAIED”,然后同步返回的代码把单据重新推进到PAYING。


    9. 并发更新问题


    留言中“月朦胧”同学提到:“状态机领域模型同时被两个线程操作怎么避免状态幂等问题?”


    这是一个好问题。在分布式场景下,这种情况太过于常见。同一机器有可能多个线程处理同一笔业务,不同机器也可能处理同一笔业务。


    业内通常的做法是设计良好的状态机 + 数据库锁 + 数据版本号解决。



    简要说明:



    1. 状态机一定要设计好,只有特定的原始状态 + 特定的事件才可以推进到指定的状态。比如 INIT + 支付成功才能推进到sucess。

    2. 更新数据库之前,先使用select for update进行锁行记录,同时在更新时判断版本号是否是之前取出来的版本号,更新成功就结束,更新失败就组成消息发到消息队列,后面再消费。

    3. 通过补偿机制兜底,比如查询补单。

    4. 通过上述三个步骤,正常情况下,最终的数据状态一定是正确的。除非是某个系统有异常,比如外部渠道开始返回支付成功,然后又返回支付失败,说明依赖的外部系统已经异常,这样只能进人工差错处理流程。


    10. 结束语


    状态机在支付系统中扮演着不可或缺的角色。一个专业、精妙的状态机设计能够确保支付流程的稳定性和安全性。本文提供的设计原则、常见误区警示和最佳实践,旨在帮助开发者构建出更加健壮和高效的支付系统。而随附的Java代码则为实现这一关键组件提供了一个清晰、灵活的起点。希望这些内容能够对你有用。



    作者:隐墨星辰
    来源:juejin.cn/post/7321569896453521419
    收起阅读 »

    加密的手机号,如何模糊查询?

    前言 前几天,知识星球中有位小伙伴,问了我一个问题:加密的手机号如何模糊查询? 我们都知道,在做系统设计时,考虑到系统的安全性,需要对用户的一些个人隐私信息,比如:登录密码、身-份-证号、银彳亍卡号、手机号等,做加密处理,防止用户的个人信息被泄露。 很早之前,...
    继续阅读 »

    前言


    前几天,知识星球中有位小伙伴,问了我一个问题:加密的手机号如何模糊查询?


    我们都知道,在做系统设计时,考虑到系统的安全性,需要对用户的一些个人隐私信息,比如:登录密码、身-份-证号、银彳亍卡号、手机号等,做加密处理,防止用户的个人信息被泄露。


    很早之前,CSDN遭遇了SQL注入,导致了600多万条明文保存的用户信息被泄。


    因此,我们在做系统设计的时候,要考虑要把用户的隐私信息加密保存。


    常见的对称加密算法有 AES、SM4、ChaCha20、3DES、DES、Blowfish、IDEA、RC5、RC6、Camellia等。


    目前国际主流的对称加密算法是AES,国内主推的则是SM4


    无论是用哪种算法,加密前的字符串,和加密后的字符串,差别还是比较大的。


    比如加密前的字符串:苏三说技术,使用密钥:123,生成加密后的字符串为:U2FsdGVkX1+q7g9npbydGL1HXzaZZ6uYYtXyug83jHA=


    如何对加密后的字符串做模糊查询呢?


    比如:假设查询苏三关键字,加密后的字符串是:U2FsdGVkX19eCv+xt2WkQb5auYo0ckyw


    上面生成的两个加密字符串差异看起来比较大,根本没办法直接通过SQL语句中的like关键字模糊查询。


    那我们该怎么实现加密的手机号的模糊查询功能呢?


    1 一次加载到内存


    实现这个功能,我们第一个想到的办法可能是:把个人隐私数据一次性加载到内存中缓存起来,然后在内存中先解密,然后在代码中实现模糊搜索的功能。


    图片这样做的好处是:实现起来比较简单,成本非常低。


    但带来的问题是:如果个人隐私数据非常多的话,应用服务器的内存不一定够用,可能会出现OOM问题。


    还有另外一个问题是:数据一致性问题。


    如果用户修改了手机号,数据库更新成功了,需要同步更新内存中的缓存,否则用户查询的结果可能会跟实际情况不一致。


    比如:数据库更新成功了,内存中的缓存更新失败了。


    或者你的应用,部署了多个服务器节点,有一部分内存缓存更新成功了,另外一部分刚好在重启,导致更新失败了。


    该方案不仅可能会导致应用服务器出现OOM问题,也可能会导致系统的复杂度提升许多,总体来说,有点得不偿失。


    2 使用数据库函数


    既然数据库中保存的是加密后的字符串,还有一种方案是使用数据库的函数解密。


    我们可以使用MySQL的DES_ENCRYPT函数加密,使用DES_DECRYPT函数解密:


    SELECT 
    DES_DECRYPT('U2FsdGVkX1+q7g9npbydGL1HXzaZZ6uYYtXyug83jHA=''123')


    应用系统重所有的用户隐私信息的加解密都在MySQL层实现,不存在加解密不一致的情况。


    该方案中保存数据时,只对单个用户的数据进行操作,数据量比较小,性能还好。


    但模糊查询数据时,每一次都需要通过DES_DECRYPT函数,把数据库中用户某个隐私信息字段的所有数据都解密了,然后再通过解密后的数据,做模糊查询。


    如果该字段的数据量非常大,这样每次查询的性能会非常差。


    3 分段保存


    我们可以将一个完整的字符串,拆分成多个小的字符串。


    以手机号为例:18200256007,按每3位为一组,进行拆分,拆分后的字符串为:182,820,200,002,025,256,560,600,007,这9组数据。


    然后建一张表:


    CREATE TABLE `encrypt_value_mapping` (
      `id` bigint NOT NULL COMMENT '系统编号',
      `ref_id` bigint NOT NULL COMMENT '关联系统编号',
      `encrypt_value` varchar(255NOT NULL COMMENT '加密后的字符串'
    ENGINE=InnoDB  CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='分段加密映射表'

    这张表有三个字段:



    • id:系统编号。

    • ref_id:主业务表的系统编号,比如用户表的系统编号。

    • encrypt_value:拆分后的加密字符串。


    用户在写入手机号的时候,同步把拆分之后的手机号分组数据,也一起写入,可以保证在同一个事务当中,保证数据的一致性。


    如果要模糊查询手机号,可以直接通过encrypt_value_mapping的encrypt_value模糊查询出用户表的ref_id,再通过ref_id查询用户信息。


    具体sql如下:


    select s2.id,s2.name,s2.phone 
    from encrypt_value_mapping s1
    inner join `user` s2 on s1.ref_id=s2.id
    where s1.encrypt_value = 'U2FsdGVkX19Se8cEpSLVGTkLw/yiNhcB'
    limit 0,20;

    这样就能轻松的通过模糊查询,搜索出我们想要的手机号了。


    注意这里的encrypt_value用的等于号,由于是等值查询,效率比较高。


    注意:这里通过sql语句查询出来的手机号是加密的,在接口返回给前端之前,需要在代码中统一做解密处理。


    为了安全性,还可以将加密后的明文密码,用*号增加一些干扰项,防止手机号被泄露,最后展示给用户的内容,可以显示成这样的:182***07


    4 其他的模糊查询


    如果除了用户手机号,还有其他的用户隐私字段需要模糊查询的场景,该怎么办?


    我们可以将encrypt_value_mapping表扩展一下,增加一个type字段。


    该字段表示数据的类型,比如:1.手机号 2.身-份-证 3.银彳亍卡号等。


    这样如果有身-份-证和银彳亍卡号模块查询的业务场景,我们可以通过type字段做区分,也可以使用这套方案,将数据写入到encrypt_value_mapping表,最后根据不同的type查询出不同的分组数据。


    如果业务表中的数据量少,这套方案是可以满足需求的。


    但如果业务表中的数据量很大,一个手机号就需要保存9条数据,一个身-份-证或者银彳亍卡号也需要保存很多条数据,这样会导致encrypt_value_mapping表的数据急剧增加,可能会导致这张表非常大。


    最后的后果是非常影响查询性能。


    那么,这种情况该怎么办呢?
    最近我建了新的技术交流群,打算将它打造成高质量的活跃群,欢迎小伙伴们加入。


    我以往的技术群里技术氛围非常不错,大佬很多。


    image.png


    加微信:su_san_java,备注:加群,即可加入该群。


    5 增加模糊查询字段


    如果数据量多的情况下,将所有用户隐私信息字段,分组之后,都集中到一张表中,确实非常影响查询的性能。


    那么,该如何优化呢?


    答:我们可以增加模糊查询字段。


    还是以手机模糊查询为例。


    我们可以在用户表中,在手机号旁边,增加一个encrypt_phone字段。


    CREATE TABLE `user` (
      `id` int NOT NULL,
      `code` varchar(20)  NOT NULL,
      `age` int NOT NULL DEFAULT '0',
      `name` varchar(30NOT NULL,
      `height` int NOT NULL DEFAULT '0',
      `address` varchar(30)  DEFAULT NULL,
      `phone` varchar(11DEFAULT NULL,
      `encrypt_phone` varchar(255)  DEFAULT NULL,
      PRIMARY KEY (`id`)
    ENGINE=InnoDB CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='用户表'

    然后我们在保存数据的时候,将分组之后的数据拼接起来。


    还是以手机号为例:


    18200256007,按每3位为一组,进行拆分,拆分后的字符串为:182,820,200,002,025,256,560,600,007,这9组数据。


    分组之后,加密之后,用逗号分割之后拼接成这样的数据:,U2FsdGVkX19Se8cEpSLVGTkLw/yiNhcB,U2FsdGVkX1+qysCDyVMm/aYXMRpCEmBD,U2FsdGVkX19oXuv8m4ZAjz+AGhfXlsQk,U2FsdGVkX19VFs60R26BLFzv5nDZX40U,U2FsdGVkX19XPO0by9pVw4GKnGI3Z5Zs,U2FsdGVkX1/FIIaYpHlIlrngIYEnuwlM,U2FsdGVkX19s6WTtqngdAM9sgo5xKvld,U2FsdGVkX19PmLyjtuOpsMYKe2pmf+XW,U2FsdGVkX1+cJ/qussMgdPQq3WGdp16Q。


    以后可以直接通过sql模糊查询字段encrypt_phone了:


    select id,name,phone
    from user where encrypt_phone like '%U2FsdGVkX19Se8cEpSLVGTkLw/yiNhcB%'
    limit 0,20;

    注意这里的encrypt_value用的like


    这里为什么要用逗号分割呢?


    答:是为了防止直接字符串拼接,在极端情况下,两个分组的数据,原本都不满足模糊搜索条件,但拼接在一起,却有一部分满足条件的情况发生。


    当然你也可以根据实际情况,将逗号改成其他的特殊字符。


    此外,其他的用户隐私字段,如果要实现模糊查询功能,也可以使用类似的方案。


    最后说一句,虽说本文介绍了多种加密手机号实现模糊查询功能的方案,但我们要根据实际业务场景来选择,没有最好的方案,只有最合适的。


    作者:苏三说技术
    来源:juejin.cn/post/7288963208408563773
    收起阅读 »

    面试理想汽车,给我整懵了。。。

    理想汽车 今天看到一个帖子,挺有意思的。 先别急着骂草台班子。 像理想汽车这种情况,其实还挺常见的。 就是:面试官说出一个错误的结论,我们该咋办? 比较好的做法还是先沟通确认清楚,看看大家是否针对的为同一场景,对某些名词的认识是否统一,其实就是对错误结论的再...
    继续阅读 »

    理想汽车


    今天看到一个帖子,挺有意思的。



    先别急着骂草台班子。


    像理想汽车这种情况,其实还挺常见的。


    就是:面试官说出一个错误的结论,我们该咋办?


    比较好的做法还是先沟通确认清楚,看看大家是否针对的为同一场景,对某些名词的认识是否统一,其实就是对错误结论的再次确认。


    如果确定清楚是面试官的错误,仅做一次不直白的提醒后,看对方是否会陷入不确定,然后进入下一个问题,如果是的话,那就接着往下走。


    如果对方还是揪着那个错误结论不放,不断追问。


    此时千万不要只拿你认为正确的结论出来和对方辩论。


    因为他只有一个结论,你也只有一个结论的话,场面就成了没有理据的争论,谁也说服不了谁。


    我们可以从两个方向进行解释:



    • 用逻辑进行正向推导,证明你的结论的正确性

    • 用类似反证法的手段进行解释,试图从他的错误结论出发,往回推,直到推出一个对方能理解的,与常识相违背的基本知识


    那么对应今天这个例子,关于「后序遍历」的属于一个定义类的认识。


    我们可以用正向推导的方法,试图纠正对方。


    可以从另外两种遍历方式进行入手,帮助对方理解。


    比如你说:


    "您看,前序遍历是「中/根 - 左 - 右」,中序遍历是「左 - 中/根 - 右」"


    "所以它这个「X序遍历」的命名规则,主要是看对于一棵子树来说,根节点被何时访问。"


    "所以我理解的后序遍历应该是「左 - 右 - 中/根」。"


    "这几个遍历确实容易混,所以我都是这样的记忆理解的。"


    大家需要搞清楚,这一段的主要目的,不是真的为了教面试官知识,因此适当舍弃一点点的严谨性,提高易懂性,十分重要。


    因为我们的主要目的是:想通过有理据的解释,让他不要再在这个问题下纠缠下去


    如果是单纯想争对错,就不会有前面的「先进行友好提示,对方如果进行下一问,就接着往下」的前置处理环节。


    搞清楚这一段表达的实际目的之后,你大概知道用什么口吻进行解释了,包括上述的最后一句,给对方台阶下,我觉得也是必要的。


    对方是错了,但是你没必要给别人落一个「得理不饶人」的印象。


    还是谦逊一些,面试场上争对错,赢没赢都是候选人输。


    可能会有一些刚毕业的同学,心高气傲,觉得连二叉树这么简单的问题都搞岔的面试官,不值得被尊重。


    你要知道,Homebrew 作者去面谷歌的时候,也不会翻转二叉树呢。


    难道你要说这世上只有那些知识面是你知识面超集的人,才值得被尊重吗?


    显然不是的,大家还是要学会带着同理心的去看待世界。


    ...


    看了一眼,底下评论点赞最高的那位:



    什么高情商说法,还得是网友。


    所以面试官说的后序遍历是「右 - 左 - 中」?interesting。


    ...


    回归主线。


    也别二叉树后续遍历了,直接来个 nn 叉树的后序遍历。


    题目描述


    平台:LeetCode


    题号:590


    给定一个 nn 叉树的根节点 rootroot ,返回 其节点值的后序遍历


    nn 叉树在输入中按层序遍历进行序列化表示,每组子节点由空值 null 分隔(请参见示例)。


    示例 1:


    输入:root = [1,null,3,2,4,null,5,6]

    输出:[5,6,3,2,4,1]

    示例 2:


    输入:root = [1,null,2,3,4,5,null,null,6,7,null,8,null,9,10,null,null,11,null,12,null,13,null,null,14]

    输出:[2,6,14,11,7,3,12,8,4,13,9,10,5,1]

    提示:



    • 节点总数在范围 [0,104][0, 10^4]

    • 0<=Node.val<=1040 <= Node.val <= 10^4

    • nn 叉树的高度小于或等于 10001000


    进阶:递归法很简单,你可以使用迭代法完成此题吗?


    递归


    常规做法,不再赘述。


    Java 代码:


    class Solution {
    List ans = new ArrayList<>();
    public List postorder(Node root) {
    dfs(root);
    return ans;
    }
    void dfs(Node root) {
    if (root == null) return;
    for (Node node : root.children) dfs(node);
    ans.add(root.val);
    }
    }

    C++ 代码:


    class Solution {
    public:
    vector<int> postorder(Node* root) {
    vector<int> ans;
    dfs(root, ans);
    return ans;
    }
    void dfs(Node* root, vector<int>& ans) {
    if (!root) return;
    for (Node* child : root->children) dfs(child, ans);
    ans.push_back(root->val);
    }
    };

    Python 代码:


    class Solution:
    def postorder(self, root: 'Node') -> List[int]:
    def dfs(root, ans):
    if not root: return
    for child in root.children:
    dfs(child, ans)
    ans.append(root.val)
    ans = []
    dfs(root, ans)
    return ans

    TypeScript 代码:


    function postorder(root: Node | null): number[] {
    const dfs = function(root: Node | null, ans: number[]): void {
    if (!root) return ;
    for (const child of root.children) dfs(child, ans);
    ans.push(root.val);
    };
    const ans: number[] = [];
    dfs(root, ans);
    return ans;
    };


    • 时间复杂度:O(n)O(n)

    • 空间复杂度:忽略递归带来的额外空间开销,复杂度为 O(1)O(1)


    非递归


    针对本题,使用「栈」模拟递归过程。


    迭代过程中记录 (cnt = 当前节点遍历过的子节点数量, node = 当前节点) 二元组,每次取出栈顶元素,如果当前节点已经遍历完所有的子节点(当前遍历过的子节点数量为 cnt=子节点数量cnt = 子节点数量),则将当前节点的值加入答案。


    否则更新当前元素遍历过的子节点数量,并重新入队,即将 (cnt+1,node)(cnt + 1, node) 入队,以及将下一子节点 (0,node.children[cnt])(0, node.children[cnt]) 进行首次入队。


    Java 代码:


    class Solution {
    public List postorder(Node root) {
    List ans = new ArrayList<>();
    Deque d = new ArrayDeque<>();
    d.addLast(new Object[]{0, root});
    while (!d.isEmpty()) {
    Object[] poll = d.pollLast();
    Integer cnt = (Integer)poll[0]; Node t = (Node)poll[1];
    if (t == null) continue;
    if (cnt == t.children.size()) ans.add(t.val);
    if (cnt < t.children.size()) {
    d.addLast(new Object[]{cnt + 1, t});
    d.addLast(new Object[]{0, t.children.get(cnt)});
    }
    }
    return ans;
    }
    }

    C++ 代码:


    class Solution {
    public:
    vector<int> postorder(Node* root) {
    vector<int> ans;
    stackint, Node*>> st;
    st.push({0, root});
    while (!st.empty()) {
    auto [cnt, t] = st.top();
    st.pop();
    if (!t) continue;
    if (cnt == t->children.size()) ans.push_back(t->val);
    if (cnt < t->children.size()) {
    st.push({cnt + 1, t});
    st.push({0, t->children[cnt]});
    }
    }
    return ans;
    }
    };

    Python 代码:


    class Solution:
    def postorder(self, root: 'Node') -> List[int]:
    ans = []
    stack = [(0, root)]
    while stack:
    cnt, t = stack.pop()
    if not t: continue
    if cnt == len(t.children):
    ans.append(t.val)
    if cnt < len(t.children):
    stack.append((cnt + 1, t))
    stack.append((0, t.children[cnt]))
    return ans

    TypeScript 代码:


    function postorder(root: Node | null): number[] {
    const ans = [], stack = [];
    stack.push([0, root]);
    while (stack.length > 0) {
    const [cnt, t] = stack.pop()!;
    if (!t) continue;
    if (cnt === t.children.length) ans.push(t.val);
    if (cnt < t.children.length) {
    stack.push([cnt + 1, t]);
    stack.push([0, t.children[cnt]]);
    }
    }
    return ans;
    };


    • 时间复杂度:O(n)O(n)

    • 空间复杂度:O(n)O(n)


    通用「非递归」


    另外一种「递归」转「迭代」的做法,是直接模拟系统执行「递归」的过程,这是一种更为通用的做法。


    由于现代编译器已经做了很多关于递归的优化,现在这种技巧已经无须掌握。


    在迭代过程中记录当前栈帧位置状态 loc,在每个状态流转节点做相应操作。


    Java 代码:


    class Solution {
    public List postorder(Node root) {
    List ans = new ArrayList<>();
    Deque d = new ArrayDeque<>();
    d.addLast(new Object[]{0, root});
    while (!d.isEmpty()) {
    Object[] poll = d.pollLast();
    Integer loc = (Integer)poll[0]; Node t = (Node)poll[1];
    if (t == null) continue;
    if (loc == 0) {
    d.addLast(new Object[]{1, t});
    int n = t.children.size();
    for (int i = n - 1; i >= 0; i--) d.addLast(new Object[]{0, t.children.get(i)});
    } else if (loc == 1) {
    ans.add(t.val);
    }
    }
    return ans;
    }
    }

    C++ 代码:


    class Solution {
    public:
    vector<int> postorder(Node* root) {
    vector<int> ans;
    stackint, Node*>> st;
    st.push({0, root});
    while (!st.empty()) {
    int loc = st.top().first;
    Node* t = st.top().second;
    st.pop();
    if (!t) continue;
    if (loc == 0) {
    st.push({1, t});
    for (int i = t->children.size() - 1; i >= 0; i--) {
    st.push({0, t->children[i]});
    }
    } else if (loc == 1) {
    ans.push_back(t->val);
    }
    }
    return ans;
    }
    };

    Python 代码:


    class Solution:
    def postorder(self, root: 'Node') -> List[int]:
    ans = []
    stack = [(0, root)]
    while stack:
    loc, t = stack.pop()
    if not t: continue
    if loc == 0:
    stack.append((1, t))
    for child in reversed(t.children):
    stack.append((0, child))
    elif loc == 1:
    ans.append(t.val)
    return ans

    TypeScript 代码:


    function postorder(root: Node | null): number[] {
    const ans: number[] = [];
    const stack: [number, Node | null][] = [[0, root]];
    while (stack.length > 0) {
    const [loc, t] = stack.pop()!;
    if (!t) continue;
    if (loc === 0) {
    stack.push([1, t]);
    for (let i = t.children.length - 1; i >= 0; i--) {
    stack.push([0, t.children[i]]);
    }
    } else if (loc === 1) {
    ans.push(t.val);
    }
    }
    return ans;
    };


    • 时间复杂度:O(n)O(n)

    • 空间复杂度:O(n)O(n)

    作者:宫水三叶的刷题日记
    来源:juejin.cn/post/7327188195770351635
    收起阅读 »

    Linux新手村必备!这些常用操作命令你掌握了吗?

    在计算机的世界里,Linux操作系统以其强大的功能和灵活性受到了广大程序员和IT爱好者的喜爱。然而,对于初学者来说,Linux的操作命令可能会显得有些复杂和难以理解。今天,我们就来一起探索一些Linux常用操作命令,让你的计算机操作更加流畅。一、目录操作首先带...
    继续阅读 »

    在计算机的世界里,Linux操作系统以其强大的功能和灵活性受到了广大程序员和IT爱好者的喜爱。然而,对于初学者来说,Linux的操作命令可能会显得有些复杂和难以理解。

    今天,我们就来一起探索一些Linux常用操作命令,让你的计算机操作更加流畅。

    一、目录操作

    首先带大家了解一下Linux 系统目录:

    ├── bin -> usr/bin # 用于存放二进制命令
    ├── boot # 内核及引导系统程序所在的目录
    ├── dev # 所有设备文件的目录(如磁盘、光驱等)
    ├── etc # 配置文件默认路径、服务启动命令存放目录
    ├── home # 用户家目录,root用户为/root
    ├── lib -> usr/lib # 32位库文件存放目录
    ├── lib64 -> usr/lib64 # 64位库文件存放目录
    ├── media # 媒体文件存放目录
    ├── mnt # 临时挂载设备目录
    ├── opt # 自定义软件安装存放目录
    ├── proc # 进程及内核信息存放目录
    ├── root # Root用户家目录
    ├── run # 系统运行时产生临时文件,存放目录
    ├── sbin -> usr/sbin # 系统管理命令存放目录
    ├── srv # 服务启动之后需要访问的数据目录
    ├── sys # 系统使用目录
    ├── tmp # 临时文件目录
    ├── usr # 系统命令和帮助文件目录
    └── var # 存放内容易变的文件的目录

    下面我们来看目录操作命令有哪些

    pwd    查看当前工作目录
    clear 清除屏幕
    cd ~ 当前用户目录
    cd / 根目录
    cd - 上一次访问的目录
    cd .. 上一级目录

    查看目录内信息

    ll    查看当前目录下内容(LL的小写)

    创建目录

    • mkdir aaa 在当前目录下创建aaa目录,相对路径;
    • mkdir ./bbb 在当前目录下创建bbb目录,相对路径;
    • mkdir /ccc 在根目录下创建ccc目录,绝对路径;

    递归创建目录(会创建里面没有的目录文件夹)

    mkdir -p temp/nginx

    搜索命令

    • find / -name ‘b’ 查询根目录下(包括子目录),名以b的目录和文件;
    • find / -name ‘b*’ 查询根目录下(包括子目录),名以b开头的目录和文件;
    • find . -name ‘b’ 查询当前目录下(包括子目录),名以b的目录和文件;

    重命名

    mv 原先目录 文件的名称   mv tomcat001 tomcat

    剪切命令(有目录剪切到制定目录下,没有的话剪切为指定目录)

    mv /aaa /bbb      将根目录下的aaa目录,移动到bbb目录下(假如没有bbb目录,则重命名为bbb);
    mv bbbb usr/bbb 将当前目录下的bbbb目录,移动到usr目录下,并且修改名称为bbb;
    mv bbb usr/aaa 将当前目录下的bbbb目录,移动到usr目录下,并且修改名称为aaa;

    复制目录

    cp -r /aaa /bbb:将/目录下的aaa目录复制到/bbb目录下,在/bbb目录下的名称为aaa
    cp -r /aaa /bbb/aaa:将/目录下的aa目录复制到/bbb目录下,且修改名为aaa;

    强制式删除指定目录

    rm -rf /bbb:强制删除/目录下的bbb目录。如果bbb目录中还有子目录,也会被强制删除,不会提示;

    删除目录

    • rm -r /bbb:普通删除。会询问你是否删除每一个文件
    • rmdir test01:目录的删除

    查看树状目录结构

    tree test01/

    批量操作

    需要采用{}进行参数的传入了。

    mkdir {dirA,dirB}  # 批量创建测试目录
    touch dirA/{A1,A2,A3} # dirA创建三个文件dirA/A1,dirA/A2,dirA/A3

    二、文件操作

    删除

    rm -r a.java  删除当前目录下的a.java文件(每次会询问是否删除y:同意)

    强制删除

    • rm -rf a.java 强制删除当前目录下的a.java文件
    • rm -rf ./a* 强制删除当前目录下以a开头的所有文件;
    • rm -rf ./* 强制删除当前目录下所有文件(慎用);

    创建文件

    touch testFile

    递归删除.pyc格式的文件

    find . -name '*.pyc' -exec rm -rf {} \;

    打印当前文件夹下指定大小的文件

    find . -name "*" -size 145800c -print

    递归删除指定大小的文件(145800)

    find . -name "*" -size 145800c -exec rm -rf {} \;

    递归删除指定大小的文件,并打印出来

    find . -name "*" -size 145800c -print -exec rm -rf {} \;
    • “.” 表示从当前目录开始递归查找
    • “ -name ‘*.exe’ "根据名称来查找,要查找所有以.exe结尾的文件夹或者文件
    • " -type f "查找的类型为文件
    • “-print” 输出查找的文件目录名
    • -size 145800c 指定文件的大小
    • -exec rm -rf {} ; 递归删除(前面查询出来的结果)

    split拆分文件

    split命令:可以将一个大文件分割成很多个小文件,有时需要将文件分割成更小的片段,比如为提高可读性,生成日志等。

    1. b:值为每一输出档案的大小,单位为 byte。
    2. -C:每一输出档中,单行的最大 byte 数。
    3. -d:使用数字作为后缀。
    4. -l:值为每一输出档的行数大小。
    5. -a:指定后缀长度(默认为2)。

    使用split命令将上面创建的date.file文件分割成大小为10KB的小文件:

    [root@localhost split]# split -b 10k date.file
    [root@localhost split]# ls
    date.file xaa xab xac xad xae xaf xag xah xai xaj

    文件被分割成多个带有字母的后缀文件,如果想用数字后缀可使用-d参数,同时可以使用-a length来指定后缀的长度:

    [root@localhost split]# split -b 10k date.file -d -a 3
    [root@localhost split]# ls
    date.file x000 x001 x002 x003 x004 x005 x006 x007 x008 x009

    为分割后的文件指定文件名的前缀:

    [root@localhost split]# split -b 10k date.file -d -a 3 split_file
    [root@localhost split]# ls
    date.file split_file000 split_file001 split_file002 split_file003 split_file004 split_file005 split_file006 split_file007 split_file008 split_file009

    使用-l选项根据文件的行数来分割文件,例如把文件分割成每个包含10行的小文件:

    split -l 10 date.file

    三、文件内容操作

    修改文件内容

    • vim a.java:进入一般模式
    • i(按键):进入插入模式(编辑模式)
    • ESC(按键):退出
    • :wq:保存退出(shift+:调起输入框)
    • :q!:不保存退出(shift+:调起输入框)(内容有更改)(强制退出,不保留更改内容)
    • :q:不保存退出(shift+:调起输入框)(没有内容更改)
      文件内容的查看
    cat a.java   查看a.java文件的最后一页内容;
    more a.java从 第一页开始查看a.java文件内容,按回车键一行一行进行查看,按空格键一页一页进行查看,q退出;
    less a.java 从第一页开始查看a.java文件内容,按回车键一行一行的看,按空格键一页一页的看,支持使用PageDown和PageUp翻页,q退出。

    总结下more和less的区别

    • less可以按键盘上下方向键显示上下内容,more不能通过上下方向键控制显示。
    • less不必读整个文件,加载速度会比more更快。
    • less退出后shell不会留下刚显示的内容,而more退出后会在shell上留下刚显示的内容。

    实时查看文件后几行(实时查看日志)

    tail -f a.java   查看a.java文件的后10行内容;

    前后几行查看

    • head a.java:查看a.java文件的前10行内容;
    • tail -f a.java:查看a.java文件的后10行内容;
    • head -n 7 a.java:查看a.java文件的前7行内容;
    • tail -n 7 a.java:查看a.java文件的后7行内容;

    文件内部搜索指定的内容

    • grep under 123.txt:在123.txt文件中搜索under字符串,大小写敏感,显示行;
    • grep -n under 123.txt:在123.txt文件中搜索under字符串,大小写敏感,显示行及行号;
    • grep -v under 123.txt:在123.txt文件中搜索under字符串,大小写敏感,显示没搜索到的行;
    • grep -i under 123.txt:在123.txt文件中搜索under字符串,大小写敏感,显示行;
    • grep -ni under 123.txt:在123.txt文件中搜索under字符串,大小写敏感,显示行及行号;

    终止当前操作

    Ctrl+c和Ctrl+z都是中断命令,但是作用却不一样。

    Ctrl+Z就扮演了类似的角色,将任务中断,但是任务并没有结束,在进程中只是维持挂起的状态,用户可以使用fg/bg操作前台或后台的任务,fg命令重新启动前台被中断的任务,bg命令把被中断的任务放在后台执行。

    Ctrl+C也扮演类似的角色,强制终端程序的执行。

    你还在苦恼找不到真正免费的编程学习平台吗?可以试试【云端源想】!课程视频、知识库、微实战、云实验室、一对一咨询……你想要的全部学习资源这里都有,重点是现在还是免费的!点这里即可免费学习!

    重定向功能

    可以使用 > 或 < 将命令的输出的命令重定向到test.txt文件中(没有则创建一个)

    echo 'Hello World' > /root/test.txt

    1、grep(检索文件内容)

    grep [options] pattern file
    • 全称:Global Regular Expression Print。
    • 作用:查找文件里符合条件的字符串。
    // 从test开头文件中,查找含有start的行
    grep "start" test*
    // 查看包含https的行,并展示前1行(-A),后1行(-B)
    grep -A 1 -B 1 "https" wget-log

    2、awk(数据统计)

    awk [options] 'cmd' file
    • 一次读取一行文本,按输入分隔符进行切片,切成多个组成部分。
    • 将切片直接保存在内建的变量中,$1,$2…($0表示行的全部)。
    • 支持对单个切片的判断,支持循环判断,默认分隔符为空格。
    • -F 指定分隔符(默认为空格)
      1)将email.out进行切分,打印出第1/3列内容
    awk '{print $1,$3}' email.out

    2)将email.out进行切分,当第1列为tcp,第2列为1的列,全部打印

    awk '$1=="tcp" && $2==1{print $0}' email.out

    3)在上面的基础上将表头进行打印(NR表头)

    awk '($1=="tcp" && $2==1)|| NR==1 {print $0}' email.out

    4) 以,为分隔符,切分数据,并打印第二列的内容

    awk -F "," '{print $2}' test.txt

    5)将日志中第1/3列进行打印,并对第1列的数据进行分类统计

    awk '{print $1,$3}' email.out | awk '{count[$1]++} END {for(i in count) print i "\t" count[i]}'

    6)根据逗号,切分数据,并将第一列存在文件test01.txt中

    awk -F "," '{print $1 >> "test01.txt"}

    3、sed(替换文件内容)

    • sed [option] ‘sed commond’ filename
    • 全名Stream Editor,流编辑器
    • 适合用于对文本行内容进行处理
    • sed commond为正则表达式
    • sed commond中为三个/,分别为源内容,替换后的内容

    sed替换标记

    g # 表示行内全面替换。
    p # 表示打印行。
    w # 表示把行写入一个文件。
    x # 表示互换模板块中的文本和缓冲区中的文本。
    y # 表示把一个字符翻译为另外的字符(但是不用于正则表达式)
    \1 # 子串匹配标记
    & # 已匹配字符串标记

    1)替换解析

    sed -i 's/^Str/String/' replace.java

    Description

    2)将末尾的.替换为;(转义.)

    sed -i 's/\.$/\;/'

    3)全文将Jack替换为me(g是全部替换,不加只替换首个)

    sed -i 's/Jack/me/g/ replace.java

    4)删除replace.java中的空格(d是删除)

    sed -i '/^ *$/d' replace.java

    5)删除包含Interger的行(d是删除)

    sed -i '/Interger/d' replace.java

    6)多命令一起执行

    grep 'input' 123.txt | sed 's/\"//g; s/,/\n/g'

    7)替换后将数据保存在文中

    grep  123.txt | sed -n 's/\"//gw test01.txt'

    4、管道操作符|

    可将指令连接起来,前一个指令的输出作为后一个指令的输入

    find ~ |grep "test"
    find ~ //查找当前用户所有文件
    grep "test" //从文件中

    使用管道注意的要点

    • 只处理前一个命令正确输出,不处理错误输出。
    • 右边命令必须能够接收标准输入流,否则传递过程中数据会被抛弃
    • sed,awk,grep,cut,head,top,less,more,c,join,sort,split等

    1)从email.log文件中查询包含error的行

    grep 'error' email.log

    2)获取到error的行,并取[]含有数字的

    grep 'error' email.log | grep -o '\[0-9\]'

    3)并过滤掉含有当前进程

    ps -ef|grep tomcat |grep -v

    4)替换后将数据保存在文中

    grep  123.txt | sed -n 's/\"//gw test01.txt'

    5)将文件123.txt,按,切分,去除",按:切分后,将第一列存到文件test01.txt中

    grep 'input' 123.txt | awk -F ',' '{print $2}' | sed 's/\"//g; s/,/\n/g' | awk -F ":" 

    5、cut(数据裁剪)

    • 从文件的每一行剪切字节、字符和字段并将这些字节、字符和字段输出。
    • 也可采用管道输入。

    Description
    文件截取

    [root@VM-0-9-centos shell]# cut -d ":" -f 1 cut.txt

    管道截取

    [root@VM-0-9-centos shell]# echo $PATH
    /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/root/bin

    # 按:分割。截取第3列
    [root@VM-0-9-centos shell]# echo $PATH | cut -d ":" -f 3
    /usr/sbin

    # 按:分割。截取第3列之后数据
    [root@VM-0-9-centos shell]# echo $PATH | cut -d ":" -f 3-
    /usr/sbin:/usr/bin:/root/bin
    [root@VM-0-9-centos shell]#

    四、系统日志位置

    • cat /etc/redhat-release:查看操作系统版本
    • /var/log/message:系统启动后的信息和错误日志,是Red Hat Linux中最常用的日志之一
    • /var/log/message:系统启动后的信息和错误日志,是Red Hat Linux中最常用的日志之一
    • /var/log/secure:与安全相关的日志信息
    • /var/log/maillog:与邮件相关的日志信息
    • /var/log/cron:与定时任务相关的日志信息
    • /var/log/spooler:与UUCP和news设备相关的日志信息
    • /var/log/boot.log:守护进程启动和停止相关的日志消息

    查看某文件下的用户操作日志
    到达操作的目录下,执行下面的程序:

    cat .bash_history

    五、创建与删除软连接

    1、创建软连接

    ln -s /usr/local/app /data

    注意:创建软连接时,data目录后不加 / (加上后是查找其下一级目录);
    Description

    2、删除软连接

    rm -rf /data

    注意:取消软连接最后没有/,rm -rf 软连接。加上/是删除文件夹;
    Description

    六、压缩和解压缩

    tar
    Description
    压缩(-c)

    tar -cvf start.tar a.java b.java  //将当前目录下a.java、b.java打包
    tar -cvf start.tar ./* //将当前目录下的所欲文件打包压缩成haha.tar文件

    tar -zcvf start.tar.gz a.java b.java //将当前目录下a.java、b.java打包
    tar -zcvf start.tar.gz ./* //将当前目录下的所欲文件打包压缩成start.tar.gz文件

    解压缩(-x)

    tar -xvf start.tar      //解压start.tar压缩包,到当前文件夹下;
    tar -xvf start.tar -C usr/local //(C为大写,中间无空格)
    //解压start.tar压缩包,到/usr/local目录下;
    tar -zxvf start.tar.gz //解压start.tar.gz压缩包,到当前文件夹下;
    tar -zxvf start.tar.gz -C usr/local //(C为大写,中间无空格)
    //解压start.tar.gz压缩包,到/usr/local目录下;

    解压缩tar.xz文件

    tar xf node-v12.18.1-linux-x64.tar.xz

    unzip/zip

    压缩(zip)

    zip lib.zip tomcat.jar       //将单个文件压缩(lib.zip)
    zip -r lib.zip lib/ //将目录进行压缩(lib.zip)
    zip -r lib.zip tomcat-embed.jar xml-aps.jar //将多个文件压缩为zip文件(lib.zip)

    解压缩(unzip)

    unzip file1.zip          //解压一个zip格式压缩包
    unzip -d /usr/app/com.lydms.english.zip //将`english.zip`包,解压到指定目录下`/usr/app/`

    七、Linux下文件的详细信息

    R:Read  w:write  x: execute执行
    -rw-r--r-- 1 root root 34942 Jan 19 2018 bootstrap.jar
    • 前三位代表当前用户对文件权限:可以读/可以写/不能执行
    • 中间三位代表当前组的其他用户对当前文件的操作权限:可以读/不能写/不能执行
    • 后三位其他用户对当前文件权限:可以读/不能写/不能执行图片

    Description

    更改文件的权限

    chmod u+x web.xml (---x------)  为文件拥有者(user)添加执行权限;
    chmod g+x web.xml (------x---) 为文件拥有者所在组(group)添加执行权限;
    chmod 111 web.xml (---x--x--x) 为所有用户分类,添加可执行权限;
    chmod 222 web.xml (--w--w--w-) 为所有用户分类,添加可写入权限;
    chmod 444 web.xml (-r--r--r--) 为所有用户分类,添加可读取权限;

    八、Linux终端命令格式

    command [-options] [parameter]

    说明:

    • command :命令名,相应功能的英文单词或单词的缩写
    • [-options] :选项,可用来对命令进行控制,也可以省略
    • parameter :传给命令的参数,可以是0个、1个或者多个

    查阅命令帮助信息

    -help: 显示 command 命令的帮助信息;
    -man: 查阅 command 命令的使用手册,man 是 manual 的缩写,是 Linux 提供的一个手册,包含了绝大部分的命令、函数的详细使用。

    使用 man 时的操作键

    Description

    以上就是一些Linux常用操作命令的介绍,希望对你有所帮助。

    虽然这些只是Linux命令的冰山一角,但它们足以让你自如地运用Linux操作系统,记住,每一个命令都有其独特的用途和魅力。掌握了这些命令,你就能更加自如地在Linux世界中遨游。愿你在探索的道路上,发现更多的惊喜和乐趣!

    收起阅读 »

    一万八千条线程,线程为啥释放不了?

    一万八千条线程,线程为啥释放不了?大家好,我是魔性的茶叶,今天和大家带来的是我在公司里面排查的另一个性能问题的过程和结果,相当有意思,分享给大家,为大家以后有可能的排查增加一些些思路。当然,最重要的是排查出来问题,解决问题的成就感和解决问题的快乐,拽句英文,那...
    继续阅读 »

    一万八千条线程,线程为啥释放不了?

    大家好,我是魔性的茶叶,今天和大家带来的是我在公司里面排查的另一个性能问题的过程和结果,相当有意思,分享给大家,为大家以后有可能的排查增加一些些思路。当然,最重要的是排查出来问题,解决问题的成就感和解决问题的快乐,拽句英文,那就是 its all about fun。

    噢对了,谢绝没有同意的转载。

    事情发生在某个艳阳高照的下午,我正在一遍打瞌睡一边写无聊的curd。坐在我身边的郑网友突然神秘一笑。 "有个你会感兴趣的东西,要不要看看",他笑着说,脸上带着自信揣测掌握我的表情。

    我还以为他准备说啥点杯奶茶,最近有啥有意思的游戏,放在平时我可能确实感兴趣,可是昨天晚上我凌晨二点才睡,中午休息时间又被某个无良领导叫去加班,困得想死,现在只想赶紧码完代回家睡觉。

    "没兴趣",我说。他脸上的表情就像被一只臭皮鞋梗住了喉咙,当然那只臭皮鞋大概率是我。

    "可是这是之前隔壁部门那个很多线程的问题,隔壁部门来找我们了",他强调了下。

    "噢!是吗,那我确实有兴趣",我一下子来了精神,趴过去看他的屏幕。屏幕上面是他和隔壁部门的聊天,隔壁部门的同事说他们看了比较久时间都找不到问题,找我们部门看看。让我臊的不行的是这货居然直接还没看问题,就开始打包票,说什么"我们部门是排查这种性能问题的行家"这种高斯林看了都会脸红的话。

    "不是说没兴趣吗?"他嘿嘿一笑。我尬笑了一下,这个问题确实纠结我很久了,因为一个星期前运维同事把隔壁部门的应用告警发到了公共群,一下子就吸引到了我:

    image-20230812225219774

    这个实例的线程数去到差不多两万(对,就是两万,你没看错)的线程数量,1w9的线程处于runnable状态。说实话,这个确实挺吸引我的 ,我还悄悄地地去下载了线程快照,但是这是个棘手的问题,只看线程快照完全看不出来,因为gitlab的权限问题我没有隔壁部门的代码,所以只能作罢。但是这个问题就如我的眼中钉,拉起了我的好奇心,我隔一会就想起这个问题,我整天都在想怎么会导致这么多条线程,还有就是jvm真的扛得住这么多条线程?

    正好这次隔壁部门找到我们,那就奉旨除bug,顺便解决我的困惑。

    等待代码下拉的过程,我打开skywalking观察这个应用的状态。这次倒没到一万八千条线程,因为找不到为啥线程数量这么多的原因,每次jvm快被线程数量撑破的时候运维就重启一遍,所以这次只有接近6000条,哈哈。

    image-20230812232110511

    可以看到应用的线程在一天内保持增加的状态,而且是一直增加的趋势。应用没有fgc,只有ygc,配合服务的调用数量很低,tomcat几乎没有繁忙线程来看并不是突发流量。jvm的cpu居高不下,很正常,因为线程太多,僧多粥少的抢占时间片,不高才怪。

    拿下线程快照导入,导入imb analyzer tool查看线程快照。

    直接看最可疑的地方,有1w9千条的线程都处于runnbale线程,并且都有相同的堆栈,也就是说,大概率是同一段代码产生的线程:

    image-20230817100520850

    这些线程的名字都以I/O dispatcher 开头,翻译成中文就是io分配者,说实话出现在dubbo应用里面我是一点都不意外,可是我们这是springmvc应用,这个代码堆栈看上去比较像一种io多路轮询的任务,用人话说就是一种异步任务,不能找到是哪里产生的这种线程。说实话这个线程名也比较大众,网上一搜一大把,也没啥一看就能定位到的问题。

    这种堆栈全是源码没有一点业务代码堆栈的问题最难找了。

    我继续往下看线程,试图再找一点线索。接着我找到了大量以pool-命名开头的线程,虽然没有1w9千条这么多,也是实打实几百条:

    image-20230813000451059

    这两条线程的堆栈很相近,都是一个类里面的东西,直觉告诉我是同一个问题导致的。看到这个pool开头,我第一个反应是有人用了类似new fixThreadPool()这种api,这种api新建出来的线程池因为没有自定义threadFactory,导致建立出来的线程都是pool开头的名字。

    于是我在代码中全局搜索pool这个单词,想检查下项目中的线程池是否设置有误:

    image-20230817093407354

    咦,这不是刚刚看到的堆栈里面的东西吗。虽然不能非常确定是不是这里,但是点进去看看又不会掉块肉。

    这是个工具类,我直接把代码拷过来:

    private static class HttpHelperAsyncClient {
    private CloseableHttpAsyncClient httpClient;
    private PoolingNHttpClientConnectionManager cm;
    private HttpHelperAsyncClient() {}
    private DefaultConnectingIOReactor ioReactor;
    private static HttpHelperAsyncClient instance;
    private Logger logger = LoggerFactory.getLogger(HttpHelperAsyncClient.class);
       

    public static HttpHelperAsyncClient getInstance() {

    instance = HttpHelperAsyncClientHolder.instance;
    try {
    instance.init();
    } catch (Exception e) {
                       
    }
    return instance;
    }

    private void init() throws Exception {

    ioReactor = new DefaultConnectingIOReactor();
    ioReactor.setExceptionHandler(new IOReactorExceptionHandler() {
    public boolean handle(IOException ex) {
           if (ex instanceof BindException) {
               return true;
          }
           return false;
      }
    public boolean handle(RuntimeException ex) {
           if (ex instanceof UnsupportedOperationException) {
               return true;
          }
           return false;
      }
    });

    cm=new PoolingNHttpClientConnectionManager(ioReactor);
    cm.setMaxTotal(MAX_TOTEL);
    cm.setDefaultMaxPerRoute(MAX_CONNECTION_PER_ROUTE);
    httpClient = HttpAsyncClients.custom()
    .addInterceptorFirst(new HttpRequestInterceptor() {

                       public void process(
                               final HttpRequest request,
                               final HttpContext context)
    throws HttpException, IOException {
                           if (!request.containsHeader("Accept-Encoding")) {
                               request.addHeader("Accept-Encoding", "gzip");
                          }
                      }}).addInterceptorFirst(new HttpResponseInterceptor() {

                       public void process(
                               final HttpResponse response,
                               final HttpContext context)
    throws HttpException, IOException {

                           HttpEntity entity = response.getEntity();
                           if (entity != null) {
                               Header ceheader = entity.getContentEncoding();
                               if (ceheader != null) {
                                   HeaderElement[] codecs = ceheader.getElements();
                                   for (int i = 0; i < codecs.length; i++) {
                                       if (codecs[i].getName().equalsIgnoreCase("gzip")) {
                                           response.setEntity(
                                                   new GzipDecompressingEntity(response.getEntity()));
                                           return;
                                      }
                                  }
                              }
                          }
                      }
                  })
                  .setConnectionManager(cm)
                  .build();
    httpClient.start();
      }




    private Response execute(HttpUriRequest request, long timeoutmillis) throws Exception {
           HttpEntity entity = null;
           Future rsp = null;
           Response respObject=new Response();
           //default error code
           respObject.setCode(400);
           if (request == null) {
          closeClient(httpClient);
          return respObject;
          }

           try{
          if(httpClient == null){
          StringBuilder sbuilder=new StringBuilder();
              sbuilder.append("\n{").append(request.getURI().toString()).append("}\nreturn error "
              + "{HttpHelperAsync.httpClient 获取异常!}");
              System.out.println(sbuilder.toString());
              respObject.setError(sbuilder.toString());
          return respObject;
          }
          rsp = httpClient.execute(request, null);
          HttpResponse resp = null;
          if(timeoutmillis > 0){
          resp = rsp.get(timeoutmillis,TimeUnit.MILLISECONDS);
          }else{
          resp = rsp.get(DEFAULT_ASYNC_TIME_OUT,TimeUnit.MILLISECONDS);
          }
          System.out.println("获取返回值的resp----->"+resp);
               entity = resp.getEntity();
               StatusLine statusLine = resp.getStatusLine();
               respObject.setCode(statusLine.getStatusCode());
               System.out.println("Response:");
               System.out.println(statusLine.toString());
               headerLog(resp);
               String result = new String();
               if (respObject.getCode() == 200) {
                   String encoding = ("" + resp.getFirstHeader("Content-Encoding")).toLowerCase();
                   if (encoding.indexOf("gzip") > 0) {
                       entity = new GzipDecompressingEntity(entity);
                  }
                   result = new String(EntityUtils.toByteArray(entity),UTF8);
                   respObject.setContent(result);
              } else {
              StringBuilder sbuilder=new StringBuilder();
              sbuilder.append("\n{").append(request.getURI().toString()).append("}\nreturn error "
              + "{").append(resp.getStatusLine().getStatusCode()).append("}");
              System.out.println(sbuilder.toString());
              try {
              result = new String(EntityUtils.toByteArray(entity),UTF8);
              respObject.setError(result);
              } catch(Exception e) {
              logger.error(e.getMessage(), e);
              result = e.getMessage();
              }
              }
               System.out.println(result);

          } catch (Exception e) {
          logger.error("httpClient.execute异常", e);
    } finally {
               EntityUtils.consumeQuietly(entity);
               System.out.println("执行finally中的 closeClient(httpClient)");
               closeClient(httpClient);
          }
           return respObject;
      }
       
           private static void closeClient(CloseableHttpAsyncClient httpClient) {

               if (httpClient != null) {
                   try {
                       httpClient.close();
                  } catch (IOException e) {
                       e.printStackTrace();
                  }
              }
          }
    }

    这段代码里面用到了CloseableHttpAsyncClient的api,我大概的查了下这个玩意,这个应该是一个异步的httpClient,作用就是用于执行一些不需要立刻收到回复的http请求,CloseableHttpAsyncClient就是用来帮你管理异步化的这些http的请求的。

    代码里面是这么调用这个类的:

    HttpHelperAsyncClient.getInstance().execute(request, timeoutMillis)

    捋一下逻辑,就是通过HttpHelperAsyncClient.getInstance()拿到HttpHelperAsyncClient的实例,然后在excute方法里面执行请求并且释放httpClient对象。按我的理解,其实就是一个httpClient的工具类

    我直接把代码拷贝出来,试图复现一下,直接在mian方法进行一个无限循环的调用

    while (true){
    post("https://www.baidu.com",new Headers(),new HashMap<>(),0);
    }

    从idea直接拿一份dump:

    image-20230814180513126

    耶?怎么和我想的不一样,只有一条主线程,并没有复现上万线程的壮观。

    就在我懵逼的时候,旁边的郑网友开口了:"你要不要试试多线程调用,这个请求很有可能从tomcat进来的"。

    有道理,我迅速撸出来一个多线程调用的demo:

    ThreadPoolExecutor threadPoolExecutor = new ThreadPoolExecutor(10,20,100,TimeUnit.DAYS,new ArrayBlockingQueue<>(100));
           while (true) {
               Thread.sleep(100);
               threadPoolExecutor.execute(new Runnable() {
                   @Override
                   public void run() {

                       try {

                           post("https://www.baidu.com", new Headers(), new JSONObject(), 0);
                      } catch (Exception e) {
                           throw new RuntimeException(e);
                      }
                  }
              });
          }

    因为线程涨的太猛,这次idea都没办法拿下线程快照,我借助JvisualVM监控应用状态,线程数目如同脱缰的野马, 迅速的涨了起来,并且确实是I/O dispatcher线程居多

    image-20230816221807385

    到这里,基本能说明问题就出现在这里。我们再深究一下。

    可能有的朋友已经发现了,HttpHelperAsyncClient类中的httpclient是线程不安全的,这个HttpHelperAsyncClient这个类里面有个httpclient的类对象变量,每次请求都会new一个新的httpclient赋值到类对象httpclient中,在excute方法执行完会调用closeClient()方法释放httpclient对象,但是closeClient的入参直接从类的成员对象中取,这就有可能导致并发问题。

    简单的画个图解释下:

    image-20230816224815066

    1. http-1-thread调用方法init()把类变量httpclient设置为自己的实例对象,http-1-client
    2. 此时紧接着http-2-thread进来,调用方法init()把类变量httpclient设置为自己的实例对象,http-2-client
    3. 接着http-1-thread执行完请求,调用closeHttpclient()方法释放httpclient,但是因为http-2线程已经设置过类变量,所以它释放的是http-2-client
    4. http-2-thread执行完请求,也去调用closeHttpClient()方法释放httpclient,但是大概率会因为http-2-client已经释放过报错

      不管http-2-client如何,http-1-client是完完全全的被忘记了,得不到释放,于是他们无止境的堆积了起来。

      如何解决呢?其实也很简单,这里httpclient对象其实是属于逃逸了,我们把它变回成局部变量,就可以解决这个问题,在不影响大部分的代码情况下,我们把生成httpclient的代码从HttpHelperAsyncClient.getInstance()移动到execute()中,并且在释放资源的地方传入局部变量而不是类变量:

      private CloseableHttpAsyncClient init() throws Exception {

      //省略部分代码
      httpClient.start();
        //现在init方法返回CloseableHttpAsyncClient
      return httpClient;
        }
      private Response execute(HttpUriRequest request, long timeoutmillis) throws Exception {
             //省略部分代码
        //改动在这里 client直接new出来
         CloseableHttpAsyncClient httpClient = init();
      //省略部分代码
         
                 closeClient(httpClient);
             //省略部分代码
        }

      经过改造后的代码升级后登录skywalking查看效果:

      image-20230816230729842

    可以看到线程数量恢复成了180条,并且三天内都没有增加,比之前一天内增加到6000条好多了。也就是区区一百倍的优化,哈哈。

    总结

    其实这个算比较低级的错误,很简单的并发问题,但是一不注意就容易写出来。但是排查难度挺高的,因为大量的线程都是没有我们一点业务代码堆栈,根本不知道线程是从哪里创建出来的,和以往的排查方法算是完全不同。这次是属于运气爆棚然后找到的代码,排查完问题我也想过,有没有其他的方法来定位这么多相同的线程是从哪里创建出来的呢?我试着用内存快照去定位,确实有一点线索,但是这属于是马后炮了,是我先读过源码才知道内存快照可以定位到问题,有点从结果来推过程的意思,没啥好说的。

    总而言之,在定义这种敏感资源(文件流,各种client)时,我们一定要注意并发创建及释放资源的问题,变量能不逃逸就不逃逸,最好是局部变量。


    作者:魔性的茶叶
    来源:juejin.cn/post/7268049978928611347VV
    收起阅读 »

    Hutool:WeakCache导致的内存泄漏

    闲聊 感谢各位居然有生之年上了一次榜单。没想到一次bug定位这么火,身为电商网站的后台开发,别的不敢说,jvm调优啊,bug定位啊,sql调优啊简直是家(ri)常(chang)便(chan)饭(shi)。后续也会努力给大家带来更多文章的 就在上篇文章发了没...
    继续阅读 »

    闲聊



    感谢各位居然有生之年上了一次榜单。没想到一次bug定位这么火,身为电商网站的后台开发,别的不敢说,jvm调优啊,bug定位啊,sql调优啊简直是家(ri)常(chang)便(chan)饭(shi)。后续也会努力给大家带来更多文章的
    image.png
    就在上篇文章发了没几天,生产又出问题了,一台服务cpu使用率飙到20%以上



    查看gc日志发现,fullgc频繁,通过jstat排查,并没有释放多少内存【当时我再外面没有图】


    通过dump出来的内存分析,是hutool的WeakCache导致的,涉及业务逻辑修改,就不透露解决方案了,下面为大家分析下为啥会内存泄漏。


    问题分析


    WeakHashMap


    「前置知识」之前写过一篇强软弱虚分析,感兴趣的可以点击看下。


    我粗略的看了下,介不是弱引用吗,怎么会内存泄漏呢


    「启动参数设置」-Xms50m -Xmx50m -XX:+PrintGCDetails不嫌麻烦可以调大一点




    这个是没问题的,不会发生OOM





    WeakCache


    下面有请下一位参赛选手WeakCache

    凭借我一次次手点,发现,根本不回收,cacheMap不也是WeakHashMap咋不回收呢


    搜了下issue,果然有人提过了,


    「原文链接」 gitee.com/dromara/hut…




    那么我们来实验下,把CacheObj拷贝出来,强制走我的



    问题得到了解决,dalao牛逼


    既然不会删除,那是什么时候删除的呢?


    是类似于懒删。




    彩蛋


    那么这行代码是怎么存在这么久而不出问题的


    image.png


    不在那天爬的紫金山=。=
    image.png


    作者:山间小僧
    来源:juejin.cn/post/7267445093836128314
    收起阅读 »

    原神UID300000000诞生,有人以高价购买!那么UID是怎么生成的?

    原神UID有人要高价购买? 在原神的广袤世界中,每位冒险者都被赋予一个独特的身份标识——UID(User ID)。这个数字串既是你在游戏中独一无二的身-份-证明,也承载着无数冒险的记忆。然而,有一个UID格外引人注目——300000000,最近它在原神的世界中...
    继续阅读 »

    原神UID有人要高价购买?


    在原神的广袤世界中,每位冒险者都被赋予一个独特的身份标识——UID(User ID)。这个数字串既是你在游戏中独一无二的身-份-证明,也承载着无数冒险的记忆。然而,有一个UID格外引人注目——300000000,最近它在原神的世界中诞生,成为了众人瞩目的焦点,因为有人要以高价购买。


    查阅资料我们知道,UID不同开头代表不同的含义。


    UID服务
    uid1、2开头官服
    uid5开头B服、小米服等,国内渠道服都是5开头
    uid6开头美服
    uid7开头欧服
    uid8开头亚服
    uid9开头港澳服

    首先UID是固定的9位数,也就是100000000这样的,前面的1是固定的,所有玩家开头都是这个1,然后剩下的8位数才是注册顺序。比如:100000001,这个就是开服第一位玩家,100000013,这个就是第13位注册玩家。


    300000000说明官服已经有2亿用户了!!!


    我们先看下UID的生成的策略吧。


    系统中UID需要怎么设计呢?


    什么是UID?


    UID是一个系统内用户的唯一标识(Unique Identifier),唯一标识成为了数字世界中不可或缺的一部分。无论是在数据库中管理记录,还是在分布式系统中追踪实体,唯一标识都是保障数据一致性和可追溯性的关键。为了满足各种需求,各种唯一标识生成方法应运而生。


    UID如何设计


    UUID模式


    UUID (Universally Unique Identifier),通用唯一识别码的缩写。目前UUID的产生方式有5种版本,每个版本的算法不同,应用范围也不同。其中最常见的是基于时间戳的版本(Version 1)和基于随机数的版本(Version 4)。版本1的UUID包含了时间戳和节点信息,而版本4的UUID则是纯粹的随机数生成。


    •基于时间的UUID:这个一般是通过当前时间,随机数,和本地Mac地址来计算出来,可以通过 org.apache.logging.log4j.core.util包中的 UuidUtil.getTimeBasedUuid()来使用或者其他包中工具。由于使用了MAC地址,因此能够确保唯一性,但是同时也暴露了MAC地址,私密性不够好。•基于随机数UUID :这种UUID产生重复的概率是可以计算出来的,但是重复的可能性可以忽略不计,因此该版本也是被经常使用的版本。JDK中使用的就是这个版本。


    Java中可通过UUID uuid = UUID.randomUUID();生成。


    虽然 UUID 生成方便,本地生成没有网络消耗,但是使用起来也有一些缺点,不易于存储:UUID太长,16字节128位,通常以36长度的字符串表示,很多场景不适用。信息不安全:基于MAC地址生成UUID的算法可能会造成MAC地址泄露,暴露使用者的位置。对MySQL索引不利:如果作为数据库主键,在InnoDB引擎下,UUID的无序性可能会引起数据位置频繁变动,严重影响性能。


    表ID自增


    将user表的id设置为auto_increment,插入会自动生成ID,将表的主键ID作为UID.


    这种方式的优势在于简单易实现,不需要引入额外的中心化服务。但也存在一些潜在的问题,比如数据库的性能瓶颈、数据量大需要分库分表等。


    使用redis实现


    Redis实现分布式唯一ID主要是通过提供像 INCR 和 INCRBY 这样的自增原子命令,由于Redis自身的单线程的特点所以能保证生成的 ID 肯定是唯一有序的。


    但是单机存在性能瓶颈,无法满足高并发的业务需求,所以可以采用集群的方式来实现。集群的方式又会涉及到和数据库集群同样的问题,所以也需要设置分段和步长来实现。


    为了避免长期自增后数字过大可以通过与当前时间戳组合起来使用,另外为了保证并发和业务多线程的问题可以采用 Redis + Lua的方式进行编码,保证安全。Redis 实现分布式全局唯一ID,它的性能比较高,生成的数据是有序的,对排序业务有利,但是同样它依赖于redis,需要系统引进redis组件,增加了系统的配置复杂性。当然现在Redis的使用性很普遍,所以如果其他业务已经引进了Redis集群,则可以考虑使用Redis来实现。


    号段模式


    号段模式是一种常见的分布式ID生成策略,也被称为Segment模式。该模式通过预先分配一段连续的ID范围(号段),并在每个节点上使用这个号段,以减少对全局资源的竞争,提高生成ID的性能。以下是一个简单的号段模式生成分布式ID的步骤:


    1.预分配号段: 一个中心化的服务(通常是一个分布式协调服务,比如Zookeeper或etcd)负责为每个节点预分配一段连续的ID号段。这个号段可以是一段整数范围,如[1, 1000],[1001, 2000]等。2.本地取ID: 每个节点在本地维护一个当前可用的ID范围(号段)。节点在需要生成ID时,首先使用本地的号段,而不是向中心化的服务请求。这可以减少对中心化服务的压力和延迟。3.号段用尽时重新申请: 当本地的号段用尽时,节点会向中心化服务请求一个新的号段。中心化服务会为节点分配一个新的号段,并通知节点更新本地的号段范围。4.处理节点故障: 在节点发生故障或失效时,中心化服务会将未使用的号段重新分配给其他正常运行的节点,以确保所有的ID都被充分利用。5.定期刷新: 节点可能定期地或在某个条件下触发,向中心化服务查询是否有新的号段可用。这有助于节点及时获取新的号段,避免在用尽号段时的阻塞。


    这种号段模式的优点在于降低了对中心化服务的依赖,减少了因为频繁请求中心化服务而产生的性能瓶颈。同时,由于每个节点都在本地维护一个号段,生成ID的效率相对较高。


    需要注意的是,号段模式并不保证全局的递增性或绝对的唯一性,但在实际应用中,通过合理设置号段的大小和定期刷新机制,可以在性能和唯一性之间找到一个平衡点。


    Snowflake模式


    Snowflake是一个经典的号段生成算法,同时市面上存在大量的XXXflake算法.一般用作订单号。主要讲一下Snowflake的原理。


    arch-z-id-3.png



    • 第1位占用1bit,其值始终是0,可看做是符号位不使用。

    • 第2位开始的41位是时间戳,41-bit位可表示2^41个数,每个数代表毫秒,那么雪花算法可用的时间年限是(1L<<41)/(1000L360024*365)=69 年的时间。

    • 中间的10-bit位可表示机器数,即2^10 = 1024台机器,但是一般情况下我们不会部署这么台机器。如果我们对IDC(互联网数据中心)有需求,还可以将 10-bit 分 5-bit 给 IDC,分5-bit给工作机器。这样就可以表示32个IDC,每个IDC下可以有32台机器,具体的划分可以根据自身需求定义。

    • 最后12-bit位是自增序列,可表示2^12 = 4096个数。


    不过Snowflake需要依赖于时钟,可能受到时钟回拨的影响。同时,如果并发生成ID的速度过快,可能导致序列号用尽。


    总结


    在选择UID生成方法时,需要根据具体的应用场景和需求权衡其优缺点。不同的场景可能需要不同的解决方案,以满足系统的唯一性要求和性能需求。那么你觉得原神的UID是如何生成的呢?如果是你该如何设计呢?


    作者:半亩方塘立身
    来源:juejin.cn/post/7324633501244063782
    收起阅读 »

    300块成本从零开始搭建自己的家庭版NAS还可以自动备份,懂点代码有手就行!

    前言 300块成本从零开始搭建自己的家庭版NAS,还可以手机上文件照片音乐自动备份,完全实现了自己的网盘效果,可以设置用户权限分配,目录上传、断点续传、并行上传、拖拽文件上传等日常操作。 为什么要搭建NAS? 现在的手机性能比以前强多了,所以每次换手机的...
    继续阅读 »

    前言



    300块成本从零开始搭建自己的家庭版NAS,还可以手机上文件照片音乐自动备份,完全实现了自己的网盘效果,可以设置用户权限分配,目录上传、断点续传、并行上传、拖拽文件上传等日常操作。



    PixPin_2024-01-14_21-24-12.png


    为什么要搭建NAS?


    现在的手机性能比以前强多了,所以每次换手机的原因居然是存储空间满了,不得不更换一个存储空间更大的手机,加上手机拍照,摄影,工作,生活,有娃的视频等,数据越来越多,我们需要一个性价比高的安全的存储介质。


    目前市场上可选的方式很多,在线网盘,移动硬盘,U盘,私人NAS等。这些优缺点很明显,在线网盘,优点是最方便,下载个app完事,但缺点更多,大家懂的,空间大小要充值会员,下载速度要充值会员,一旦数据放上去了将会被收割个不停,更惨的是,完全没有个人隐私,想想都可怕,别人用你的数据去训练AI,你还在给他充值会员。移动硬盘和U盘,用起来最不方便,最后只能是选择NAS。


    市面上的NAS分析


    某宝一搜,市面上的NAS琳琅满目,经过我花了一个星期仔细筛查,主要分3种,群晖NAS(黑群晖),网络盒子,第三方公司销售的NAS云盘。大致如下:


    image.png


    (非广告,打码处理)



    • 群晖NAS,专业级别的NAS,性能高,效果好,价格也很感人,非公司级别也用不着,大炮打鸟的感觉

    • 网络盒子,看起来价格低廉,充值会员,流量,账号,空间,全都会卡着你

    • 第三方NAS云盘,经过研究,其所谓的外网链接都必须走他们公司的服务器转发,这意味着,你所有的数据都被别人看光光,这种还要看公司运营,还会有小公司倒闭等风险


    我的私人NAS实现方式




    1. 购买一台微型服务器,接入到家庭路由

    2. 买几块硬盘挂载到服务器

    3. 部署开源的网盘系统,经过多种实验和研究,作者推荐Cloudreve社区开源网盘

    4. 通过内网穿透方式,把服务暴露出去

    5. 通过安装配置WebDAV协议访问的第三方文件管理器管理手机,通过web服务管理网盘所有数据



    image.png


    image.png


    详细实现步骤


    第一步:


    购买一个微服务器,这里仅展示作者买的微服务器,不做广告和推荐,个人根据实际情况购买(如有需要可以和作者私下沟通)。大概100多即可购买一台,配置不同价格不同。买回来让商家预先安装了centos操作系统,买回来后插上路由器,连上家里的内网,在电脑上通过ssh连接上去。
    PS:初始化系统相关信息可以问商家要。



    image.png


    第二步:


    买一块硬盘通过USB接口接上去,这个完全有个人喜好,推荐机械硬盘,买个可插入多个盘位的硬盘外接盒子,安全又高效,这里可以参考之前的图,有示例,作者就买了个便宜货先用着。大约1个T,临时够用。



    第三步:


    部署开源网盘,我这里选择的是Cloudreve,原因如下:



    1. 开源系统,截止今日Star20.1K

    2. 中文支持的好,国产,Go语言架构,效率还行

    3. 支持WebDAV协议,可以用第三方app对接,研究了ES文件管理器,可以自动备份资料到服务器上去,IOS有专用app

    4. 前端UI做的不错,基础功能齐全

    5. 可以多用户权限管理,存储管理



    image.png


    部署文档参见官网,下期将会描述技术细节


    第四步:


    内网穿透,这里用的FRP,这个配置也折腾了我好久,要求我们要有一个服务器和域名,这个作者之前有几台非常便宜的服务器和域名在手,顺便做个部署即可,一般用户可以购买下各个云服务商的优惠版本,几百块1年非常便宜。



    • 第一个是要配置好服务端即我们的云服务器,开通ssh隧道,一个是开通转接http和https的接口,私人用无需https

    • 第二个是要配置客户端我们要放开的服务,即ssh和Cloudreve部署地址。



    FRP部署技术将新开一个专题介绍


    第五步:


    WebDAV配置手机,我们先配置一个内网版本的网盘,然后根据内网穿透映射到外面的地址再配置一个外网的网盘,这样在家的时候我们通过连上路由器,用内网访问,速度快,建议备份都在内网时候传输,平时不在家的时候用外网来查看。



    image.png


    基于这个服务打通,我们可以干更多事情了,建个网站如何?



    内外网打通,服务器有了,我们甚至可以做更多事情,建个网站,把家里的设备全部用服务器来管理,如果你家有视频监控,也可以备份到服务器!



    更多部署软件部分细节,将在下期分享,



    • Cloudreve部署

    • FRP部署

    • WebDAV配置

    • 等等...

    作者:天问cc
    来源:juejin.cn/post/7323599971214802956
    收起阅读 »

    前任开发在代码里下毒了,支付下单居然没加幂等

    分享是最有效的学习方式。 故事 又是一个风和日丽没好的一天,小猫戴着耳机,安逸地听着音乐,撸着代码,这种没有会议的日子真的是巴适得板。 不料祸从天降,组长火急火燎地跑过来找到了小猫。“快排查一下,目前有A公司用户反馈积分被多扣了”。 小猫回忆了一下“不对啊,...
    继续阅读 »

    分享是最有效的学习方式。



    故事


    又是一个风和日丽没好的一天,小猫戴着耳机,安逸地听着音乐,撸着代码,这种没有会议的日子真的是巴适得板。


    不料祸从天降,组长火急火燎地跑过来找到了小猫。“快排查一下,目前有A公司用户反馈积分被多扣了”。


    小猫回忆了一下“不对啊,这接口我也没动过啊,前几天对外平台的老六直接找我要个支付接口,我就给他了的,以前的代码,我都没有动过的......”。


    于是小猫一边疑惑一边翻看着以前的代码,越看脸色越差......


    42175B273A64E95B1B5B66D392256552.jpg


    小猫做的是一个标准的积分兑换商城,以前和客户合作的时候,客户直接用的是小猫单位自己定制的h5页面。这次合作了一家公司有点特殊,由于公司想要定制化自己个性化的H5,加上本身A公司自己有开发能力,所以经过讨论就以接口的方式直接将相关接口给出去,A客户H5开发完成之后自己来对接。


    慢慢地,原因也水落石出,之前好好的业务一直没有问题是因为商城的本身H5页面做了防重复提交,由于量小,并且一般对接方式用的都是纯H5,所以都没有什么问题,然后这次是直接将接口给出去了,完了接口居然没有加幂等......


    小猫躺枪,数据订正当然是少不了了,事故报告当然也少不了了。


    正所谓前人挖坑,后人遭殃,前人锅后人背。


    聊聊幂等


    接口幂等梗概


    这个案例其实就是一个典型的接口幂等案例。那么老猫就和大家从以下几个方面好好剖析一下接口幂等吧。


    interfacemd.png


    什么是接口幂等


    比较专业的术语:其任意多次执行所产生的影响均与第一次执行的影响相同。
    大白话:多次调用的情况下,接口最终得到的结果是一致的。


    那么为什么需要幂等呢?



    1. 用户进行提交动作的时候,由于网络波动等原因导致后端同步响应不及时,这样用户就会一直点点点,这样机会发生重复提交的情况。

    2. 分布式系统之间调用的情况下,例如RPC调用,为了防止网络波动超时等造成的请求失败,都会添加重试机制,导致一个请求提交多次。

    3. 分布式系统经常会用到消息中间件,当由于网络原因,mq没有收到ack的情况下,就会导致消息的重复投递,从而就会导致重复提交行为。

    4. 还有就是恶意攻击了,有些业务接口做的比较粗糙,黑客找到漏洞之后会发起重复提交,这样就会导致业务出现问题。打个比方,老猫曾经干过,邻居小孩报名了一个画画比赛,估计是机构培训发起的,功能做的也差,需要靠投票赢得某些礼品,然后老猫抓到接口信息之后就模拟投票进行重复刷了投票。


    那么哪些接口需要做幂等呢?


    首先我们说是不是所有的接口都需要幂等?是不是加了幂等就好呢?显然不是。
    因为接口幂等的实现某种意义上是要消耗系统性能的,我们没有必要针对所有业务接口都加上幂等。


    这个其实并不能做一个完全的定义说哪个就不用幂等,因为很多时候其实还是得结合业务逻辑一起看。但是其中也是有规律可循的。


    既然我们说幂等就是多次调用,接口最终得到结果一致,那么很显然,查询接口肯定是不要加幂等的,另外一些简单删除数据的接口,无论是逻辑删除还是物理删除,看场景的情况下其实也不用加幂等。


    但是大部分涉及到多表更新行为的接口,咱们最好还是得加上幂等。


    接口幂等实战方案


    前端防抖处理


    前端防抖主要可以有两种方案,一种是技术层面的,一种是产品层面的:



    1. 技术层面:例如提交控制在100ms内,同一个用户最多只能做一次订单提交的操作。

    2. 产品层面:当然用户点击提交之后,按钮直接置灰。


    基于数据库唯一索引



    1. 利用数据库唯一索引。我们具体来看一下流程,咱们就用小猫遇到的例子。如下:


    unique-key.png


    过程描述:



    • 建立一张去重表,其中某个字段需要建立唯一索引,例如小猫这个场景中,咱们就可以将订单提交流水单号作为唯一索引存储到我们的数据库中,就模型上而言,可以将其定义为支付请求流水表。

    • 客户端携带相关流水信息到后端,如果发现编号重复,那么此时就会插入失败,报主键冲突的错误,此时我们针对该错误做一下业务报错的二次封装给到客户另一个友好的提示即可。


    数据库乐观锁实现


    什么是乐观锁,它假设多用户并发的事务在处理时不会彼此互相影响,各事务能够在不产生锁的情况下处理各自影响的那部分数据。
    说得直白一点乐观锁就是一个马大哈。总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁,只在更新的时候会判断一下在此期间别人有没有去更新这个数据。


    例如提交订单的进行支付扣款的时候,本来可能更新账户金额扣款的动作是这样的:


    update Account set balance = balance-#{payAmount} where accountCode = #{accountCode}

    加上版本号之后,咱们的代码就是这样的。


    update Account set balance = balance-#{payAmount},version=version +1 where accountCode = #{accountCode} and version = #{currVersion}

    这种情况下其实就要求客户端每次在请求支付下单的时候都需要上层客户端指定好当前的版本信息。
    不过这种幂等的处理方式,老猫用的比较少。


    数据库悲观锁实现


    悲观锁的话具有强烈的独占和排他特性。大白话谁都不信的主。所以我们就用select ... for update这样的语法进行行锁,当然老猫觉得单纯的select ... for update只能解决同一时刻大并发的幂等,所以要保证单号重试这样非并发的幂等请求还是得去校验当前数据的状态才行。就拿当前的小猫遇到的场景来说,流程如下:


    pessimistic.png


    begin;  # 1.开始事务
    select * from order where order_code='666' for update # 查询订单,判断状态,锁住这条记录
    if(status !=处理中){
    //非处理中状态,直接返回;
    return ;
    }
    ## 处理业务逻辑
    update order set status='完成' where order_code='666' # 更新完成
    update stock set num = num - 1 where spu='xxx' # 库存更新
    commit; # 5.提交事务

    这里老猫一再想要强调的是在校验的时候还是得带上本身的业务状态去做校验,select ... for update并非万能幂等。


    后端生成token


    这个方案的本质其实是引入了令牌桶的机制,当提交订单的时候,前端优先会调用后端接口获取一个token,token是由后端发放的。当然token的生成方式有很多种,例如定时刷新令牌桶,或者定时生成令牌并放到令牌池中,当然目的只有一个就是保住token的唯一性即可。


    生成token之后将token放到redis中,当然需要给token设置一个失效时间,超时的token也会被删除。


    当后端接收到订单提交的请求的时候,会先判断token在缓存中是否存在,第一次请求的时候,token一定存在,也会正常返回结果,但是第二次携带同一个token的时候被拒绝了。


    流程如下:


    token.png


    有个注意点大家可以思考一下:
    如果用户用程序恶意刷单,同一个token发起了多次请求怎么办?
    想要实现这个功能,就需要借助分布式锁以及Lua脚本了,分布式锁可以保证同一个token不能有多个请求同时过来访问,lua脚本保证从redis中获取令牌->比对令牌->生成单号->删除令牌这一系列行为的原子性。


    分布式锁+状态机(订单状态)


    现在很多的业务服务都是分布式系统,所以就拿分布式锁来说,关于分布式锁,老猫在此不做赘述,之前老猫写过redis的分布式锁和实现,还有zk锁和实现,具体可见链接:



    当然和上述的数据库悲观锁类似,咱们的分布式锁也只能保证同一个订单在同一时间的处理。其次也是要去校订单的状态,防止其重复支付的,也就是说,只要支付的订单进入后端,都要将原先的订单修改为支付中,防止后续支付中断之后的重复支付。


    在上述小猫的流程中还没有涉及到现金补充,如果涉及到现金补充的话,例如对接了微信或者支付宝的情况,还需要根据最终的支付回调结果来最终将订单状态进行流转成支付完成或者是支付失败。


    总结


    在我们日常的开发中,一些重要的接口还是需要大家谨慎对待,即使是前任开发留下的接口,没有任何改动,当有人咨询的时候,其实就要好好去了解一下里面的实现,看看方案有没有问题,看看技术实现有没有问题,这应该也是每一个程序员的基本素养。


    另外的,在一些重要的接口上,尤其是资金相关的接口上,幂等真的是相当的重要。小伙伴们,你们觉得呢?如果大家还有好的解决方案,或者有其他思考或者意见也欢迎大家的留言。


    作者:程序员老猫
    来源:juejin.cn/post/7324186292297482290
    收起阅读 »

    Java 中为什么要设计 throws 关键词,是故意的还是不小心

    我们平时在写代码的时候经常会遇到这样的一种情况 提示说没有处理xxx异常 然后解决办法可以在外面加上try-catch,就像这样 所以我之前经常这样处理 //重新抛出 RuntimeException public class ThrowsDemo { ...
    继续阅读 »

    我们平时在写代码的时候经常会遇到这样的一种情况


    throws.png


    提示说没有处理xxx异常


    然后解决办法可以在外面加上try-catch,就像这样


    trycatch.png


    所以我之前经常这样处理


    //重新抛出 RuntimeException
    public class ThrowsDemo {

    public void demo4throws() {
    try {
    new ThrowsSample().sample4throws();
    } catch (IOException e) {
    throw new RuntimeException(e);
    }
    }
    }

    //打印日志
    @Slf4j
    public class ThrowsDemo {

    public void demo4throws() {
    try {
    new ThrowsSample().sample4throws();
    } catch (IOException e) {
    log.error("sample4throws", e);
    }
    }
    }

    //继续往外抛,但是需要每个方法都添加 throws
    public class ThrowsDemo {

    public void demo4throws() throws IOException {
    new ThrowsSample().sample4throws();
    }
    }

    但是我一直不明白


    这个方法为什么不直接帮我做


    反而要让我很多余的加上一步


    我处理和它处理有什么区别吗?


    而且变的好不美观


    本来缩进就多,现在加个try-catch更是火上浇油


    public class ThrowsDemo {

    public void demo4throws() {
    try {
    if (xxx) {
    try {
    if (yyy) {

    } else {

    }
    } catch (Throwable e) {
    }
    } else {

    }
    } catch (IOException e) {

    }
    }
    }

    上面的代码,就算里面没有业务,看起来也已经比较乱了,分不清哪个括号和哪个括号是一对


    还有就是对Lambda很不友好


    lambda.png


    没有办法直接用::来优化代码,所以就变成了下面这样


    lambdatry.png


    本来看起来很简单很舒服的Lambda,现在又变得又臭又长


    为什么会强制 try-catch


    为什么我们平时写的方法不需要强制try-catch,而很多jdk中的方法却要呢


    那是因为那些方法在方法的定义上添加了throws关键字,并且后面跟的异常不是RuntimeException


    一旦你显式的添加了这个关键字在方法上,同时后面跟的异常不是RuntimeException,那么使用这个方法的时候就必须要显示的处理


    比如使用try-catch或者是给调用这个方法的方法也添加throws以及对应的异常


    throws 是用来干什么的


    那么为什么要给方法添加throws关键字呢?


    给方法添加throws关键字是为了表明这个方法可能会抛出哪些异常


    就像一个风险告知


    这样你在看到这个方法的定义的时候就一目了然了:这个方法可能会出现什么异常


    为什么 RuntimeException 不强制 try-catch


    那为什么RuntimeException不强制try-catch呢?


    因为很多的RuntimeException都是因为程序的BUG而产生的


    比如我们调用Integer.parseInt("A")会抛出NumberFormatException


    当我们的代码中出现了这个异常,那么我们就需要修复这个异常


    当我们修复了这个异常之后,就不会再抛出这个异常了,所以try-catch就没有必要了


    当然像下面这种代码除外


    public boolean isInteger(String s) {
    try {
    Integer.parseInt(s);
    return true;
    } catch (NumberFormatException e) {
    return false;
    }
    }

    这是我们利用这个异常来达成我们的需求,是有意为之的


    而另外一些异常是属于没办法用代码解决的异常,比如IOException


    我们在进行网络请求的时候就有可能抛出这类异常


    因为网络可能会出现不稳定的情况,而我们对这个情况是无法干预的


    所以我们需要提前考虑各种突发情况


    强制try-catch相当于间接的保证了程序的健壮性


    毕竟我们平时写代码,如果IDE没有提示异常处理,我们完全不会认为这个方法会抛出异常


    我的代码怎么可能有问题.gif


    我的代码怎么可能有问题!


    不可能绝对不可能.gif


    看来Java之父完全预判到了程序员的脑回路


    throws 和 throw 的区别


    java中还有一个关键词throw,和throws只有一个s的差别


    throw是用来主动抛出一个异常


    public class ThrowsDemo {

    public void demo4throws() throws RuntimeException {
    throw new RuntimeException();
    }
    }

    两者完全是不同的功能,大家不要弄错了


    什么场景用 throws


    我们可以发现我们平时写代码的时候其实很少使用throws


    因为当我们在开发业务的时候,所有的分支都已经确定了


    比如网络请求出现异常的时候,我们常用的方式可能是打印日志,或是进行重试,把异常往外抛等等


    所以我们没有那么有必要去使用throws这个关键字来说明异常信息


    但是当我们没有办法确定异常要怎么处理的时候呢?


    比如我在GitHub上维护了一个功能库,本身没有什么业务属性,主要就是对于一些复杂的功能做了相应的封装,提供给自己或别人使用(如果有兴趣可以看看我的库,顺便给Star,嘿嘿


    对我来说,当我的方法中出现异常时,我是不清楚调用这个方法的人是想要怎么处理的


    可能有的想要重试,有的想要打印日志,那么我干脆就往外抛,让调用方法的人自己去考虑,自己去处理


    所以简单来说,如果方法主要是给别人用的最好用throws把异常往外抛,反之就是可加可不加


    结束


    很多时候你的不理解只是因为你还不够了解


    作者:不够优雅
    来源:juejin.cn/post/7204594495996100664
    收起阅读 »

    日志脱敏之后,无法根据信息快速定位怎么办?

    日志脱敏之殇 小明同学在一家金融公司上班,为了满足安全监管要求,最近天天忙着做日志脱敏。 无意间看到了一篇文章金融用户敏感数据如何优雅地实现脱敏? 感觉写的不错,用起来也很方便。 不过日志脱敏之后,新的问题就诞生了:日志脱敏之后,很多问题无法定位。 比如身-份...
    继续阅读 »

    日志脱敏之殇


    小明同学在一家金融公司上班,为了满足安全监管要求,最近天天忙着做日志脱敏。


    无意间看到了一篇文章金融用户敏感数据如何优雅地实现脱敏? 感觉写的不错,用起来也很方便。


    不过日志脱敏之后,新的问题就诞生了:日志脱敏之后,很多问题无法定位。


    比如身-份-证号日志中看到的是 3****************8,业务方给一个身-份-证号也没法查日志。这可怎么办?


    在这里插入图片描述


    安全与数据唯一性


    类似于数据库中敏感信息的存储,一般都会有一个哈希值,用来定位数据信息,同时保障安全。


    那么日志中是否也可以使用类似的方式呢?


    说干就干,小明在开源项目 sensitive 基础上,添加了对应的哈希实现。


    使用入门


    开源地址



    github.com/houbb/sensi…



    使用方式


    1)maven 引入


    <dependency>
    <groupId>com.github.houbb</groupId>
    <artifactId>sensitive-core</artifactId>
    <version>1.1.0</version>
    </dependency>

    2)引导类指定


    SensitiveBs.newInstance()
    .hash(Hashes.md5())

    将哈希策略指定为 md5


    3)功能测试


    final SensitiveBs sensitiveBs = SensitiveBs.newInstance()
    .hash(Hashes.md5());

    User sensitiveUser = sensitiveBs.desCopy(user);
    String sensitiveJson = sensitiveBs.desJson(user);

    Assert.assertEquals(sensitiveStr, sensitiveUser.toString());
    Assert.assertEquals(originalStr, user.toString());
    Assert.assertEquals(expectJson, sensitiveJson);

    可以把如下的对象


    User{username='脱敏君', idCard='123456190001011234', password='1234567', email='12345@qq.com', phone='18888888888'}

    直接脱敏为:


    User{username='脱**|00871641C1724BB717DD01E7E5F7D98A', idCard='123456**********34|1421E4C0F5BF57D3CC557CFC3D667C4E', password='null', email='12******.com|6EAA6A25C8D832B63429C1BEF149109C', phone='1888****888|5425DE6EC14A0722EC09A6C2E72AAE18'}

    这样就可以通过明文,获取对应的哈希值,然后搜索日志了。


    新的问题


    不过小明还是觉得不是很满意,因为有很多系统是已经存在的。


    如果全部用注解的方式实现,就会很麻烦,也很难推动。


    应该怎么实现呢?


    小伙伴们有什么好的思路?欢迎评论区留言


    作者:老马啸西风
    来源:juejin.cn/post/7239647672460705829
    收起阅读 »

    尊嘟假嘟?三行代码提升接口性能600倍

    一、背景   业务在群里反馈编辑结算单时有些账单明细查不出来,但是新建结算单可以,我第一反应是去测试环境试试有没有该问题,结果发现没任何问题!!!   然后我登录生产环境编辑业务反馈有问题的结算单,发现查询接口直接504网关超时了,此时心里已经猜到是代码性能问...
    继续阅读 »

    一、背景


      业务在群里反馈编辑结算单时有些账单明细查不出来,但是新建结算单可以,我第一反应是去测试环境试试有没有该问题,结果发现没任何问题!!!
      然后我登录生产环境编辑业务反馈有问题的结算单,发现查询接口直接504网关超时了,此时心里已经猜到是代码性能问题导致的,接来下就把重点放到排查接口超时的问题上了。


    二、问题排查


    遇到生产问题先查日志是基本操作,登录阿里云的日志平台,可以查到接口耗时竟然高达469245毫秒


    这个结算单关联的账单数量也就800多条,所以可以肯定这个接口存在性能问题。


    image


    但是日志除了接口耗时,并没有其他报错信息或异常信息,看不出哪里导致了接口慢。


    接口慢一般是由如下几个原因导致:



    1. 依赖的外部系统慢,比如同步调用外部系统的接口耗时比较久

    2. 处理的数据过多导致

    3. sql性能有问题,存在慢sql

    4. 有大循环存在循环处理的逻辑,如循环读取exel并处理

    5. 网络问题或者依赖的中间件比较慢

    6. 如果使用了锁,也可能由于长时间获取不到锁导致接口超时


    当然也可以使用arthas的trace命令分析哪一块比较耗时。


    由于安装arthas有点麻烦,就先猜测可能慢sql导致的,然后就登录阿里云RDS查看了慢sql监控日志。
    image
    好家伙一看吓一跳,sql耗时竟然高达66秒,而且执行次数还挺多!


    我赶紧把sql语句放到数据库用explain命令看下执行计划,分析这条sql为啥这么慢。


    EXPLAIN SELECT DISTINCT(bill_code) FROM `t_bill_detail_2023_4` WHERE  
    (settlement_order_code IS NULL OR settlement_order_code = 'JS23122600000001');

    分析结果如下:


    image


    如果不知道explain结果每个字段的含义,可以看看这篇文章《长达1.7万字的explain关键字指南!》。


    可以看到扫描行数达到了250多万行,ref已经是最高效的const,但是看最后的Extra列
    Using temporary 表明这个sql用到了临时表,顿时心里清楚什么原因了。


    因为sql有个去重关键字DISTINCT,所以mysql在需要建临时表来完成查询结果集的去重操作,如果结果集数据量比较小没有超过buffer,就可以直接在内存中去重,这种效率也是比较高的。


    但是如果结果集数据量很大,buffer存不下,那就需要借助磁盘完成去重了,我们都知道操作磁盘相比内存是非常慢的,时间差几个数量级


    虽然这个表里的settlement_order_code字段是有索引的,但是线上也有很多settlement_order_code为null的数据,这就导致查出来的结果集非常大,然后又用到临时表,所以sql耗时才这么久!


    同时,这里也解释了为什么测试环境没有发现这个问题,因为测试环境的数据不多,直接在内存就完成去重了。


    三、问题解决


    知道了问题原因就很好解决了,首先根据SQL和接口地址很快就找到出现问题的代码是下图红框圈出来的地方


    image


    可以看到代码前面有个判断,只有当isThreeOrderQuery=true时才会执行这个查询,判断方法代码如下


    image


    然后因为这是个编辑场景,前端会把当前结算单号(usedSettlementOrderCode字段)传给后端,所以这个方法就返回了true。


    同理,拼接出来的sql就带了条件(settlement_order_code IS NULL OR settlement_order_code = 'JS23122600000001')。
    image


    解决起来也很简单,把isThreeOrderQuery()方法圈出来的代码去掉就行了,这样就不会执行那个查询,同时也不会影响原有的代码逻辑,因为后面会根据筛选条件再查一次t_bill_detail表。


    改代码发布后,再编辑结算单,优化后的效果如下图:


    image


    只改了三行代码,接口耗时就立马从469245ms缩短到700ms,性能提升了600多倍


    四、总结


    感觉压测环境还是有必要的,有些问题数据量小了或者请求并发不够都没法暴露出来,同时以后写代码可以提前把sql在数据库explain下看看性能如何,毕竟能跑就行不是我们的追求😏。


    作者:2YSP
    来源:juejin.cn/post/7322156759443144713
    收起阅读 »

    Service 层异常抛到 Controller 层处理还是直接处理?

    0 前言 一般初学者学习编码和[错误处理]时,先知道[编程语言]有一种处理错误的形式或约定(如Java就抛异常),然后就开始用这些工具。但却忽视这问题本质:处理错误是为了写正确程序。可是 1 啥叫“正确”? 由解决的问题决定的。问题不同,解决方案不同。 如一个...
    继续阅读 »

    0 前言


    一般初学者学习编码和[错误处理]时,先知道[编程语言]有一种处理错误的形式或约定(如Java就抛异常),然后就开始用这些工具。但却忽视这问题本质:处理错误是为了写正确程序。可是


    1 啥叫“正确”?


    由解决的问题决定的。问题不同,解决方案不同。


    如一个web接口接受用户请求,参数age,也许业务要求字段是0~150之间整数。如输入字符串或负数就肯定不接受。一般在后端某地做输入合法性检查,不过就抛异常。


    但归根到底这问题“正确”解决方法总是要以某种形式提示用户。而提示用户是某种前端工作,就要看界面是app,H5+AJAX还是类似于[jsp]的服务器产生界面。不管啥,你要根据需求去”设计一个修复错误“的流程。如一个常见的流程要后端抛异常,然后一路到某个集中处理错误的代码,将其转换为某个HTTP的错误(业务错误码)提供给前端,前端再映射做”提示“。如用户输入非法请求,从逻辑上后端都没法自己修复,这是个“正确”的策略。


    2 报500了嘞!


    如用户上传一个头像,后端将图片发给[云存储],结果云存储报500,咋办?你可能想重试,因为也许仅是[网络抖动],重试就能正常执行。但若重试多次无效,若设计了某种热备方案,可能改为发到另一个服务器。“重试”和“使用备份的依赖”都是“立刻处理“。


    但若重试无效,所有的[备份服务]也无效,也许就能像上面那样把错误抛给前端,提示用户“服务器开小差”。从这方案易看出,你想把错误抛到哪里是因为那个catch的地方是处理问题最方便的地方。一个问题的解决方案可能要几个不同的错误处理组合起来才能办到。


    3 NPE了!


    你的程序抛个NPE。这一般就是程序员的bug:



    • 要不就是程序员想表达一个东西”没有“,结果在后续处理中忘判断是否为null

    • 要不就是在写代码时觉得100%不可能为null的地方出现了一个null


    不管哪种,这错误用户总会看到一个很含糊的报错信息,这远远不够。“正确”办法是程序员自己能尽快发现它,并尽快修复。要做到这点,需要[监控系统]不断爬log,把问题报警出来。而非等用户找客服投诉。


    4 OOM了!


    比如你的[后端程序]突然OOM挂了。挂的程序没法恢复自己。要做到“正确”,须在服务之外的容器考虑这问题。


    如你的服务跑在[k8s],他们会监控你程序状态,然后重启新的服务实例弥补挂掉的服务,还得调整流量,把去往宕机服务的流量切换到新实例。这的恢复因为跨系统所以不能仅用异常实现,但道理一样。


    但光靠重启就“正确”了?若服务是完全无状态,问题不大。但若有状态,部分用户数据可能被执行一半的请求搞乱。因此重启要留意先“恢复数据到合法状态”。这又回到你要知道咋样才是“正确”的做法。只依靠简单的语法功能不能无脑解决这事。


    5 提升维度



    • 一个工作线程的“外部容器“是管理工作线程的“master”

    • 一个网络请求的“外部容器”是一个Web Server

    • 一个用户进程的“外部容器”是[操作系统]

    • Erlang把这种supervisor-worker的机制融入到语言的设计


    Web程序很大程度能把异常抛给顶层,是因为:



    • 请求来自前端,对因为用户请求有误(数据合法性、权限、用户上下文状态)造成的问题,最终基本只能告诉用户。因此抛异常到一个集中处理错误的地方,把异常转换为某个业务错误码的方法,合理

    • 后端服务一般无状态。这也是软件系统设计的一般原则。无状态才意味着可随时随地安心重启。用户数据不会因为因为下一条而会出问题

    • 后端对数据的修改依赖DB的事务。因此一个改一半的、没提交的事务不会造成副作用。


    但这3条件并非总成立。总能遇到:



    • 一些处理逻辑并非无状态

    • 也并非所有的数据修改都能用一个事务保护


    尤其要注意对[微服务]的调用,对内存状态的修改是没有事务保护的,一不留神就会搞乱用户数据。比如下面代码段


    6 难以排查的代码段


     try {
    int res1 = doStep1();
    this.status1 += res1;
    int res2 = doStep2();
    this.status2 += res2;
    // 抛个异常
    int res3 = doStep3();
    this.status3 = status1 + status2 + res3;
    } catch ( ...) {
    // ...
    }

    先假设status1、status2、status3之间需维护某种不变的约束(invariant)。然后执行这段代码时,如在doStep3抛异常,下面对status3的赋值就不会执行。这时如不能将status1、status2的修改rollback,就会造成数据违反约束的问题。


    而程序员很难发现这个数据被改坏了。坏数据还可能导致其他依赖这数据的代码逻辑出错(如原本应该给积分的,却没给)。而这种错误一般很难排查,从大量数据里找到不正确的那一小段何其困难。


    7 更难搞定的代码段


    // controller
    void controllerMethod(/* 参数 */) {
    try {
    return svc.doWorkAndGetResult(/* 参数 */);
    } catch (Exception e) {
    return ErrorJsonObject.of(e);
    }
    }

    // svc
    void doWorkAndGetResult(/* some params*/) {
    int res1 = otherSvc1.doStep1(/* some params */);
    this.status1 += res1;
    int res2 = otherSvc2.doStep2(/* some params */);
    this.status2 += res2;
    int res3 = otherSvc3.doStep3(/* some params */);
    this.status3 = status1 + status2 + res3;
    return SomeResult.of(this.status1, this.status2, this.status3);
    }

    难搞在于你写的时候可能以为doStep1~3这种东西即使抛异常也能被Controller里的catch。


    在svc这层是不用处理任何异常,因此不写[try……catch]天经地义。但实际上doStep1、doStep2、doStep3任何一个抛异常都会造成svc的数据状态不一致。甚至你一开始都可以通过文档或其他沟通确定doStep1、doStep2、doStep3一开始都是必然可成功,不会抛错的,因此你写的代码一开始是对的。


    但你可能无法控制他们的实现(如他们是另外一个团队开发的[jar]提供的),而他们的实现可能会改成抛错。你的代码可能在完全不自知情况下从“不会出问题”变成“可能出问题”…… 更可怕的类似代码不能正确工作:


    void doWorkAndGetResult(/* some params*/) {
    try {
    int res1 = otherSvc1.doStep1(/* some params */);
    this.status1 += res1;
    int res2 = otherSvc2.doStep2(/* some params */);
    this.status2 += res2;
    int res3 = otherSvc3.doStep3(/* some params */);
    this.status3 = status1 + status2 + res3;
    return SomeResult.of(this.status1, this.status2, this.status3);
    } catch (Exception e) {
    // do rollback
    }
    }

    你以为这样就会处理好数据rollback,甚至觉得这种代码优雅。但实际上doStep1~3每一个地方抛错,rollback的代码都不一样。


    得这么写


    void doWorkAndGetResult(/* some params*/) {
    int res1, res2, res3;
    try {
    res1 = otherSvc1.doStep1(/* some params */);
    this.status1 += res1;
    } catch (Exception e) {
    throw e;
    }

    try {
    res2 = otherSvc2.doStep2(/* some params */);
    this.status2 += res2;
    } catch (Exception e) {
    // rollback status1
    this.status1 -= res1;
    throw e;
    }

    try {
    res3 = otherSvc3.doStep3(/* some params */);
    this.status3 = status1 + status2 + res3;
    } catch (Exception e) {
    // rollback status1 & status2
    this.status1 -= res1;
    this.status2 -= res2;
    throw e;
    }
    }

    这才是得到正确结果的代码,在任何地方出错都能维护数据一致性。优雅吗?


    看起来很丑。比go的if err != nil还丑。但要在正确性和优雅性取舍,肯定毫不犹豫选前者。作为程序员不能直接认为抛异常可解决任何问题,须学会写出有正确逻辑的程序,哪怕很难且看起来丑。


    为达成高正确性,你不能总将自己大部分注意力放在“一切都OK的流程“,而把错误看作是可随便应付了事的工作或简单的相信exception可自动搞定一切。


    8 总结


    对错误处理要有敬畏之心:



    • Java因为Checked Exception设计问题不得不避免使用

    • 而Uncaughted Exception实在弱鸡,不能给程序员提供更好帮助


    因此,程序员在每次抛错或者处理错误的时候都要三省吾身:



    • 这个错误的处理是正确吗?

    • 会让用户看到啥?

    • 会不会搞乱数据?


    不要以为自己抛个异常就完事了。在[编译器]不能帮上太多忙时,好好写UT来保护代码可怜的正确性。


    请多写正确的代码


    作者:JavaEdge在掘金
    来源:juejin.cn/post/7280050832949968954
    收起阅读 »

    线程数突增!领导说再这么写就gc掉我

    线程数突增!领导说再这么写就gc掉我 前言 大家好,我是魔性的茶叶,今天给大家分享一个线上问题引出的一次思考,过程比较长,但是挺有意思。 今天上班把需求写完,出于学习(摸鱼)的心理上skywalking看看,突然发现我们的一个应用,应用内线程数超过900条,接...
    继续阅读 »

    线程数突增!领导说再这么写就gc掉我


    前言


    大家好,我是魔性的茶叶,今天给大家分享一个线上问题引出的一次思考,过程比较长,但是挺有意思。


    今天上班把需求写完,出于学习(摸鱼)的心理上skywalking看看,突然发现我们的一个应用,应用内线程数超过900条,接近1000条,但是cpu并没有高涨,内存也不算高峰。但是敏锐的我还是立刻意识到这个应用有不妥,因为线程数太多了,不符合我们一个正常健康的应用数量。熟练的打出cpu dump观察,首先看线程组名的概览。


    image-20230112200957387


    从线程分组看,pool名开头线程占616条,而且waiting状态也是616条,这个点就非常可疑了,我断定就是这个pool开头线程池导致的问题。我们先排查为何这个线程池中会有600+的线程处于waiting状态并且无法释放,记接下来我们找几条线程的堆栈观察具体堆栈:


    image-20230112201456234


    这个堆栈看上去很合理,线程在线程池中不断的循环获取任务,因为获取不到任务所以进入了waiting状态,等待着有任务后被唤醒。


    看上去不只一个线程池,并且这些线程池的名字居然是一样的,我大胆的猜测一下,是不断的创建同样的线程池,但是线程池无法被回收导致的线程数,所以接下来我们要分析两个问题,首先这个线程池在代码里是哪个线程池,第二这个线程池是怎么被创建的?为啥释放不了?


    我在idea搜索new ThreadPoolExecutor()得到的结果是这样的:


    image-20230112202915219


    于是我陷入懵逼的状态,难道还有其他骚操作?


    正在这时,一位不知名的郑网友发来一张截图:


    image-20230112203527173


    好家伙!竟然是用new FixedTreadPool()整出来的。难怪我完全搜不到,因为用的new FixedTreadPool(),所以线程池中的线程名是默认的pool(又多了一个不使用Executors来创建线程池的理由)。


    然后我迫不及die的打开代码,试图找到罪魁祸首,结果发现作者居然是我自己。这是另一个惊喜,惊吓的惊。


    冷静下来后我梳理一遍代码,这个接口是我两年前写的,主要是功能是统计用户的钱包每个月的流水,因为担心统计比较慢,所以使用了线程池,做了批量的处理,没想到居然导致了线程数过高,虽然没有导致事故,但是确实是潜在的隐患,现在没出事不代表以后不会出事。


    去掉多余业务逻辑,我简单的还原一个代码给大家看,还原现场:


    private static void threadDontGcDemo(){
          ExecutorService executorService = Executors.newFixedThreadPool(10);
          executorService.submit(() -> {
               System.out.println("111");
           });
       }

    那么为啥线程池里面的线程和线程池都没释放呢


    难道是因为没有调用shutdown?我大概能理解我两年前当时为啥不调用shutdown,是因为当初我觉得接口跑完,方法走到结束,理论上栈帧出栈,局部变量应该都销毁了,按理说executorService这个变量应该直接GG了,那么按理说我是不用调用shutdown方法的。


    我简单的跑了个demo,循环的去new线程池,不调用shutdown方法,看看线程池能不能被回收


    image-20230113142322106


    打开java visual vm查看实时线程:


    image-20230113142304644


    可以看到线程数和线程池都一直在增加,但是一直没有被回收,确实符合发生的问题状况,那么假如我在方法结束前调用shutdown方法呢,会不会回收线程池和线程呢?


    简单写个demo结合jvisualvm验证下:


    image-20230113142902514


    image-20230113142915722


    结果是线程和线程池都被回收了。也就是说,执行了shutdown的线程池最后会回收线程池和线程对象


    我们知道,一个对象能不能回收,是看它到gc root之间有没有可达路径,线程池不能回收说明到达线程池的gc root还是有可达路径的。这里讲个冷知识,这里的线程池的gc root是线程,具体的gc路径是thread->workers->线程池。线程对象是线程池的gc root,假如线程对象能被gc,那么线程池对象肯定也能被gc掉(因为线程池对象已经没有到gc root的可达路径了)。


    那么现在问题就转为线程对象是在什么时候gc


    郑网友给了一个粗浅但是合理的解释,线程对象肯定不是在运行中的时候被回收的,因为jvm肯定不可能去回收一条在运行中的线程,至少runnalbe状态的线程jvm不可能去回收。


    在stackoverflow上我找到了更准确的答案:stackoverflow.com/questions/2…


    image-20230113152802164


    A running thread is considered a so called garbage collection root and is one of those things keeping stuff from being garbage collected。


    这句话的意思是,一条正在运行的线程是gc root,注意,是正在运行,这个正在运行我先透露下,即使是waiting状态,也算正在运行。这个回答的整体的意思是,运行的线程是gc root,但是非运行的线程不是gc root(可以被回收)。


    现在比较清楚了,线程池和线程被回收的关键就在于线程能不能被回收,那么回到原来的起点,为何调用线程池的shutdown方法能够导致线程和线程池被回收呢?难道是shutdown方法把线程变成了非运行状态吗


    talk is cheap,show me the code


    我们直接看看线程池的shutdown方法的源码


    public void shutdown() {
           final ReentrantLock mainLock = this.mainLock;
           mainLock.lock();
           try {
               checkShutdownAccess();
               advanceRunState(SHUTDOWN);
               interruptIdleWorkers();
               onShutdown(); // hook for ScheduledThreadPoolExecutor
          } finally {
               mainLock.unlock();
          }
           tryTerminate();
    }

    private void interruptIdleWorkers() {
           interruptIdleWorkers(false);
    }

    private void interruptIdleWorkers(boolean onlyOne) {
           final ReentrantLock mainLock = this.mainLock;
           mainLock.lock();
           try {
               for (Worker w : workers) {
                   Thread t = w.thread;
                   if (!t.isInterrupted() && w.tryLock()) {
                       try {
                           t.interrupt();
                      } catch (SecurityException ignore) {
                      } finally {
                           w.unlock();
                      }
                  }
                   if (onlyOne)
                       break;
              }
          } finally {
               mainLock.unlock();
          }
    }

    我们从interruptIdleWorkers方法入手,这方法看上去最可疑,看到interruptIdleWorkers方法,这个方法里面主要就做了一件事,遍历当前线程池中的线程,并且调用线程的interrupt()方法,通知线程中断,也就是说shutdown方法只是去遍历所有线程池中的线程,然后通知线程中断。所以我们需要了解线程池里的线程是怎么处理中断的通知的。


    我们点开worker对象,这个worker对象是线程池中实际运行的线程,所以我们直接看worker的run方法,中断通知肯定是在里面被处理了


    //WOrker的run方法里面直接调用的是这个方法
    final void runWorker(Worker w) {
           Thread wt = Thread.currentThread();
           Runnable task = w.firstTask;
           w.firstTask = null;
           w.unlock(); // allow interrupts
           boolean completedAbruptly = true;
           try {
               while (task != null || (task = getTask()) != null) {
                   w.lock();
                   // If pool is stopping, ensure thread is interrupted;
                   // if not, ensure thread is not interrupted. This
                   // requires a recheck in second case to deal with
                   // shutdownNow race while clearing interrupt
                   if ((runStateAtLeast(ctl.get(), STOP) ||
                        (Thread.interrupted() &&
                         runStateAtLeast(ctl.get(), STOP))) &&
                       !wt.isInterrupted())
                       wt.interrupt();
                   try {
                       beforeExecute(wt, task);
                       Throwable thrown = null;
                       try {
                           task.run();
                      } catch (RuntimeException x) {
                           thrown = x; throw x;
                      } catch (Error x) {
                           thrown = x; throw x;
                      } catch (Throwable x) {
                           thrown = x; throw new Error(x);
                      } finally {
                           afterExecute(task, thrown);
                      }
                  } finally {
                       task = null;
                       w.completedTasks++;
                       w.unlock();
                  }
              }
               completedAbruptly = false;
          } finally {
               processWorkerExit(w, completedAbruptly);
          }
    }



    这个runwoker属于是线程池的核心方法了,相当的有意思,线程池能不断运作的原理就是这里,我们一点点看。


    首先最外层用一个while循环套住,然后不断的调用gettask()方法不断从队列中取任务,假如拿不到任务或者任务执行发生异常(抛出异常了)那就属于异常情况,直接将completedAbruptly 设置为true,并且进入异常的processWorkerExit流程。


    我们看看gettask()方法,了解下啥时候可能会抛出异常:


    private Runnable getTask() {
           boolean timedOut = false; // Did the last poll() time out?

           for (;;) {
               int c = ctl.get();
               int rs = runStateOf(c);

               // Check if queue empty only if necessary.
               if (rs >= SHUTDOWN && (rs >= STOP || workQueue.isEmpty())) {
                   decrementWorkerCount();
                   return null;
              }

               int wc = workerCountOf(c);

               // Are workers subject to culling?
               boolean timed = allowCoreThreadTimeOut || wc > corePoolSize;

               if ((wc > maximumPoolSize || (timed && timedOut))
                   && (wc > 1 || workQueue.isEmpty())) {
                   if (compareAndDecrementWorkerCount(c))
                       return null;
                   continue;
              }

               try {
                   Runnable r = timed ?
                       workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
                       workQueue.take();
                   if (r != null)
                       return r;
                   timedOut = true;
              } catch (InterruptedException retry) {
                   timedOut = false;
              }
          }
      }

    这样很清楚了,抛去前面的大部分代码不看,这句代码解释了gettask的作用:


    Runnable r = timed ?
      workQueue.poll(keepAliveTime, TimeUnit.NANOSECONDS) :
      workQueue.take()

    gettask就是从工作队列中取任务,但是前面还有个timed,这个timed的语义是这样的:如果allowCoreThreadTimeOut参数为true(一般为false)或者当前工作线程数超过核心线程数,那么使用队列的poll方法取任务,反之使用take方法。这两个方法不是重点,重点是poll方法和take方法都会让当前线程进入time_waiting或者waiting状态。而当线程处于在等待状态的时候,我们调用线程的interrupt方法,毫无疑问会使线程当场抛出异常


    也就是说线程池的shutdownnow方法调用interruptIdleWorkers去对线程对象interrupt是为了让处于waiting或者是time_waiting的线程抛出异常


    那么线程池是在哪里处理这个异常的呢?我们看runwoker中的调用的processWorkerExit方法,说实话这个方法看着就像处理抛出异常的方法:


    private void processWorkerExit(Worker w, boolean completedAbruptly) {
           if (completedAbruptly) // If abrupt, then workerCount wasn't adjusted
               decrementWorkerCount();

           final ReentrantLock mainLock = this.mainLock;
           mainLock.lock();
           try {
               completedTaskCount += w.completedTasks;
               workers.remove(w);
          } finally {
               mainLock.unlock();
          }

           tryTerminate();

           int c = ctl.get();
           if (runStateLessThan(c, STOP)) {
               if (!completedAbruptly) {
                   int min = allowCoreThreadTimeOut ? 0 : corePoolSize;
                   if (min == 0 && ! workQueue.isEmpty())
                       min = 1;
                   if (workerCountOf(c) >= min)
                       return; // replacement not needed
              }
               addWorker(null, false);
          }
    }

    我们可以看到,在这个方法里有一个很明显的 workers.remove(w)方法,也就是在这里,这个w的变量,被移出了workers这个集合,导致worker对象不能到达gc root,于是workder对象顺理成章的变成了一个垃圾对象,被回收掉了。然后等到worker中所有的worker都被移出works后,并且当前请求线程也完成后,线程池对象也成为了一个孤儿对象,没办法到达gc root,于是线程池对象也被gc掉了。


    写了挺长的篇幅,我小结一下:



    1. 线程池调用shutdownnow方法是为了调用worker对象的interrupt方法,来打断那些沉睡中的线程(waiting或者time_waiting状态),使其抛出异常

    2. 线程池会把抛出异常的worker对象从workers集合中移除引用,此时被移除的worker对象因为没有到达gc root的路径已经可以被gc掉了

    3. 等到workers对象空了,并且当前tomcat线程也结束,此时线程池对象也可以被gc掉,整个线程池对象成功释放


    最后总结:


    如果只是在局部方法中使用线程池,线程池对象不是bean的情况时,记得要合理的使用shutdown或者shutdownnow方法来释放线程和线程池对象,如果不使用,会造成线程池和线程对象的堆积。


    作者:魔性的茶叶
    来源:juejin.cn/post/7197424371991855159
    收起阅读 »

    PageHelper引发的“幽灵数据”,怎么回事?

    前言 最近测试反馈一个问题,某个查询全量信息的接口,有时候返回全量数据,符合预期,但是偶尔又只返回1条数据,简直就是“见鬼”了,究竟是为什么出现这样的“幽灵数据”呢? 大胆猜测 首先我们看了下这对代码的业务逻辑,非常的简单,总共没有几行代码,也没有分页逻辑,代...
    继续阅读 »

    前言


    最近测试反馈一个问题,某个查询全量信息的接口,有时候返回全量数据,符合预期,但是偶尔又只返回1条数据,简直就是“见鬼”了,究竟是为什么出现这样的“幽灵数据”呢?


    大胆猜测


    首先我们看了下这对代码的业务逻辑,非常的简单,总共没有几行代码,也没有分页逻辑,代码如下:


    public  List<SdSubscription> findAll() {
    return sdSubscriptionMapper.selectAll();
    }

    那么究竟是咋回事呢?讲道理不可能出现这种情况的啊,不要慌,我们加点日志,将日志级别调整为DEBUG,让日志飞一段时间。


    public  List<SdSubscription> findAll() {
    log.info("find the sub start .....");
    List<SdSubscription> subs = sdSubscriptionMapper.selectAll();
    log.info("find the sub end .....");
    return subs;
    }

    果不其然,日志中出现了奇奇怪怪的分页参数,如下图所示:



    果然是PageHelper这个开源框架搞的鬼,我想大家都用过吧,分页非常方便,那么究竟为什么别人都没问题,单单就我会出现问题呢?


    PageHelper工作原理


    为了回答上面的疑问,我们先看看PageHelper框架的工作原理吧。


    PageHelper 是一个开源的 MyBatis 分页插件,它可以帮助开发者在查询数据时,快速的实现分页功能。


    PageHelper 的工作原理可以简单概括为以下几个步骤:



    1. 在需要进行分页的查询方法前,调用 PageHelper 的静态方法 startPage(),设置当前页码和每页显示的记录数。它会将分页信息放到线程的ThreadLocal中,那么在线程的任何地方都可以访问了。

    2. 当查询方法执行时,PageHelper 会自动拦截查询语句,如果发现线程的ThreadLocal中有分页信息,那么就会在其前后添加分页语句,例如 MySQL 中的 LIMIT 语句。

    3. 查询结果将被包装在 Page 对象中返回,该对象包含分页信息和查询结果列表。

    4. 在查询方法执行完毕后,会在finally中清除线程ThreadLocal中的分页信息,避免分页设置对其他查询方法的影响。


    PageHelper 的实现原理主要依赖于拦截器技术和反射机制,通过拦截查询语句并动态生成分页语句,实现了简单、高效、通用的分页功能。具体源码在下图的类中,非常容易看懂。



    明白了PageHelper的工作原理后,反复检查代码,都没有调用过startPagedebug查看ThreadLocal中也没有分页信息啊,懵逼中。那我看看别人写的添加分页参数的代码吧,不看不知道,一看吓一跳。



    原来有位“可爱”的同事竟然在查询后,加了一个分页,就是把分页信息放到线程的ThreadLocal中。


    那大家是不是有疑问,丁是丁,矛是矛,你的线程关我何事?这就要说到我们的tomcat了。


    Tomcat请求流程


    其实这就涉及到我们的tomcat相关知识了,我们一个浏览器发一个接口请求,经过我们的tomcat的,究竟是一个什么样的流程呢?



    1. 客户端发送HTTP请求到Tomcat服务器。

    2. TomcatHTTP连接器(Connector)接收到请求,将连接请求交给线程池Executor处理,解析它,然后将请求转发给对应的Web应用程序。

    3. Tomcat的Web应用程序容器(Container)接收到请求,根据请求的URL找到对应的Servlet


    关于tomcat中使用线程池提交浏览器的连接请求的源码如下:



    从而得知,你的连接请求是从线程池从拿的,而拿到的这个线程恰好是一个“脏线程”,在ThreadLocal中放了分页信息,导致你这边出现问题。


    总结


    后来追问了同事具体原因,才发现是粗心导致的。有些bug总是出现的莫名其妙,就像生活一样。所以关键的是我们在使用一些开源框架的时候一定要掌握底层实现的原理、核心的机制,这样才能够在解决一些问题的时候有据可循。



    欢迎关注个人公众号【JAVA旭阳】交流学习!



    作者:JAVA旭阳
    来源:juejin.cn/post/7223590232730370108
    收起阅读 »

    该死,这次一定要弄懂什么是时间复杂度和空间复杂度!

    开始首先,相信大家在看一些技术文章或者刷算法题的时候,总是能看到要求某某某程序(算法)的时间复杂度为O(n)或者O(1)等字样,就像这样: Q:那么到底这个O(n)、O(1)是什么意思呢?A:时间复杂度和空间复杂度其实是对算法执行期间的性能进行衡量的...
    继续阅读 »

    开始

    首先,相信大家在看一些技术文章或者刷算法题的时候,总是能看到要求某某某程序(算法)的时间复杂度为O(n)或者O(1)等字样,就像这样:

    image.png Q:那么到底这个O(n)、O(1)是什么意思呢?

    A:时间复杂度空间复杂度其实是对算法执行期间的性能进行衡量的依据。

    Talk is cheap, show me the code!

    下面从代码入手,来直观的理解一下这两个概念:

    时间复杂度

    先来看看copilot如何解释的

    image.png

    • 举个🌰
    function fn (arr) {
    let length = arr.length
    for (let i = 0; i < length; i++) {
    console.log(arr[i])
    }
    }

    首先来分析一下这段代码,这是一个函数,接收一个数组,然后对这个数组进行了一个遍历

    1. 第一段代码,在函数执行的时候,这段代码只会被执行1次,这里记为 1 次
    let length = arr.length
    1. 循环体中的代码,循环多少次就会执行多少次,这里记为 n 次
    console.log(arr[i])
    1. 循环条件部分,首先是 let i = 0,只会执行一次,记为 1 次
    2. 然后是i < length这个判断,想要退出循环,这里最后肯定要比循环次数多判断一次,所以记为 n + 1 次
    3. 最后是 i++,会执行 n 次

    我们把总的执行次数记为T(n)

    T(n) = 1 + n + 1 (n + 1) + n = 3n + 3
    • 再来一个🌰
    // arr 是一个二维数组
    function fn2(arr) {
    let lenOne = arr.length
    for(let i = 0; i < lenOne; i++) {
    let lenTwo = arr[i].length
    for(let j = 0; j < lenTwo; j++) {
    console.log(arr[i][j])
    }
    }
    }

    来分析一下这段代码,这是一个针对二维数组进行遍历的操作,我们再来分析一下这段代码的执行次数

    1. 第一行赋值代码,只会执行1次
    let lenOne = arr.length
    1. 第一层循环,let i = 0 1次,i < lenOne n + 1 次,i++ n 次,let len_two = arr[i].length n 次
    2. 第二层循环,let j = 0 n 次,j < lenTwo n * (n + 1) 次,j++ n * n 次
    3. console n*n 次
    T(n) = 1 + n + 1 + n + n + n + n * (n + 1) + n * n + n * n = 3n^2 + 5n + 3

    代码的执行次数,可以反映出代码的执行时间。但是如果每次我们都逐行去计算 T(n),事情会变得非常麻烦。算法的时间复杂度,它反映的不是算法的逻辑代码到底被执行了多少次,而是随着输入规模的增大,算法对应的执行总次数的一个变化趋势。我们可以尝试对 T(n) 做如下处理:

    • 若 T(n) 是常数,那么无脑简化为1
    • 若 T(n) 是多项式,比如 3n^2 + 5n + 3,我们只保留次数最高那一项,并且将其常数系数无脑改为1。

    那么上面两个算法的时间复杂度可以简化为:

    T(n) = 3n + 3
    O(n) = n

    T(n) = 3n^2 + 5n + 3
    O(n) = n^2

    实际推算时间复杂度时不用这么麻烦,像上面的两个函数,第一个是规模为n的数组的遍历,循环会执行n次,所以对应的时间幅度是O(n),第二个函数是 n*n的二维数组的遍历,对应的时间复杂度就是O(n^2) 依次类推,规模为n*m的二维数组的遍历,时间复杂度就是O(n*m)

    常见的时间复杂度按照从小到大的顺序排列,有以下几种:

    常数时间对数时间线性时间线性对数时间二次时间三次时间指数时间
    O(1)O(logn)O(n)O(nlogn)O(n^2)O(n^3)O(2^n)

    空间复杂度

    先看看copilot的解释:

    image.png

    • 来一个🌰看看吧:
    function fn (arr) {
    let length = arr.length
    for (let i = 0; i < length; i++) {
    console.log(arr[i])
    }
    }

    在函数fn中,我们创建了变量 length arr i,函数 fn 对内存的占用量是固定的,无论,arr的length如何,所以这个函数对应的空间复杂度就是 O(1)

    • 再来一个🌰:
    function fn2(n) {
    let arr = []
    for(let i = 0; i < n; i++) {
    arr[i] = i
    }
    }

    在这个函数中,我们创建了一个数组 arr,并在循环中向 arr 中添加了 n 个元素。因此,arr 的大小与输入 n 成正比。所以,我们说这个函数的空间复杂度是 O(n)。

    • 再再来一个🌰:
    function createMatrix(n) {
    let matrix = [];
    for (let i = 0; i < n; i++) {
    matrix[i] = [];
    for (let j = 0; j < n; j++) {
    matrix[i][j] = 0;
    }
    }
    return matrix;
    }

    在这个函数中,我们创建了一个二维数组 matrix,并在两层循环中向 matrix 中添加了 n*n 个元素。因此,matrix 的大小与输入 n 的平方成正比。所以,我们说这个函数的空间复杂度是 O(n^2)。

    • 再再再来一个🌰:
    // 二分查找算法
    function binarySearch(arr, target, low, high) {
    if (low > high) {
    return -1;
    }
    let mid = Math.floor((low + high) / 2);
    if (arr[mid] === target) {
    return mid;
    } else if (arr[mid] > target) {
    return binarySearch(arr, target, low, mid - 1);
    } else {
    return binarySearch(arr, target, mid + 1, high);
    }
    }

    在二分查找中,我们每次都将问题规模减半,因此需要的额外空间与输入数据的对数成正比,我们开始时有一个大小为 n 的数组。然后,我们在每一步都将数组划分为两半,并只在其中一半中继续查找。因此,每一步都将问题的规模减半

    所以,最多要划分多少次才能找到目标数据呢?答案是log2n次,但是在计算机科学中,当我们说 log n 时,底数通常默认为 2,因为许多算法(如二分查找)都涉及到将问题规模减半的操作。

    2^x = n

    x = log2n

    常见的时间复杂度按照从小到大的顺序排列,有以下几种:

    常数空间线性空间平方空间对数空间
    O(1)O(n)O(n^2)O(logn)

    你学废了吗?


    作者:爱吃零食的猫
    来源:juejin.cn/post/7320288222529536038

    收起阅读 »

    为什么mysql最好不要只用limit做分页查询?

    在项目中遇到的真实问题,以及我的解决方案,部分数据做了脱敏处理。 问题 最近在做项目时需要写sql做单表查询,每次查出来的数据有几百万甚至上千万条,公司用的数据库是MySQL5.7,做了分库分表,部分数据库设置了查询超时时间,比如查询超过15s直接报超时错误,...
    继续阅读 »

    在项目中遇到的真实问题,以及我的解决方案,部分数据做了脱敏处理。


    问题


    最近在做项目时需要写sql做单表查询,每次查出来的数据有几百万甚至上千万条,公司用的数据库是MySQL5.7,做了分库分表,部分数据库设置了查询超时时间,比如查询超过15s直接报超时错误,如下图:


    image.png


    可以通过show variables like 'max_statement_time';命令查看数据库超时时间(单位:毫秒):


    image.png


    方案1


    尝试使用索引加速sql,从下图可以看到该sql已经走了主键索引,但还是需要扫描150万行,无法从这方面进行优化。


    image.png


    方案2


    尝试使用limit语句进行分页查询,语句为:


    SELECT * FROM table WHERE user_id = 123456789 limit 0, 300000;

    像这样每次查30万条肯定就不会超时了,但这会引出另一个问题--查询耗时与起始位置成正比,如下图:


    image.png


    第二条语句实际上查了60w条记录,不过把前30w条丢弃了,只返回后30w条,所以耗时会递增,最终仍会超时。


    方案3


    使用指定主键范围的分页查询,主要思想是将条件语句改为如下形式(其中id为自增主键):


    WHERE user_id = 123456789 AND id > 0 LIMIT 300000;
    WHERE user_id = 123456789 AND id > (上次查询结果中最后一条记录的id值) LIMIT 300000;

    也可以将上述语句简化成如下形式(注意:带了子查询会变慢):


    WHERE user_id = 123456789 AND id >= (SELECT id FROM table LIMIT 300000, 1) limit 300000;

    每次查询只需要修改子查询limit语句的起始位置即可,但我发现表中并没有自增主键id这个字段,表内主键是fs_id,而且是无序的。


    这个方案还是不行,组内高工都感觉无解了。


    方案4


    既然fs_id是无序的,那么就给它排序吧,加了个ORDER BY fs_id,最终解决方案如下:


    WHERE user_id = 123456789 AND fs_id > 0 ORDER BY fs_id LIMIT 300000;
    WHERE user_id = 123456789 AND fs_id > (上次查询结果中最后一条记录的id值) ORDER BY fs_id LIMIT 300000;

    效果如下图:


    image.png


    查询时间非常稳定,每条查询的fs_id都大于上次查询结果中最后一条记录的fs_id值。正常查30w条需要3.88s,排序后查30w条需要6.48s,确实慢了许多,但总算能把问题解决了。目前代码还在线上跑着哈哈,如果有更好的解决方案可以在评论区讨论哟。


    作者:我要出去乱说
    来源:juejin.cn/post/7209612932366270519
    收起阅读 »

    都用HTTPS了,还能被查出浏览记录?

    最近,群里一个刚入职的小伙因为用公司电脑访问奇怪的网站,被约谈了。他很困惑 —— 访问的都是HTTPS的网站,公司咋知道他访问了啥? 实际上,由于网络通信有很多层,即使加密通信,仍有很多途径暴露你的访问地址,比如: DNS查询:通常DNS查询是不会加密的,...
    继续阅读 »

    最近,群里一个刚入职的小伙因为用公司电脑访问奇怪的网站,被约谈了。他很困惑 —— 访问的都是HTTPS的网站,公司咋知道他访问了啥?



    实际上,由于网络通信有很多层,即使加密通信,仍有很多途径暴露你的访问地址,比如:



    • DNS查询:通常DNS查询是不会加密的,所以,能看到你DNS查询的观察者(比如运营商)是可以推断出访问的网站

    • IP地址:如果一个网站的IP地址是独一无二的,那么只需看到目标 IP地址,就能推断出用户正在访问哪个网站。当然,这种方式对于多网站共享同一个IP地址(比如CDN)的情况不好使

    • 流量分析:当访问一些网站的特定页面,可能导致特定大小和顺序的数据包,这种模式可能被用来识别访问的网站

    • cookies或其他存储:如果你的浏览器有某个网站的cookies,显然这代表你曾访问过该网站,其他存储信息(比如localStorage)同理


    除此之外,还有很多方式可以直接、间接知道你的网站访问情况。


    本文将聚焦在HTTPS协议本身,聊聊只考虑HTTPS协议的情况下,你的隐私是如何泄露的。


    HTTPS简介


    我们每天访问的网站大部分是基于HTTPS协议的,简单来说,HTTPS = HTTP + TLS,其中:



    • HTTP是一种应用层协议,用于在互联网上传输超文本(比如网页内容)。由于HTTP是明文传递,所以并不安全

    • TLS是一种安全协议。TLS在传输层对数据进行加密,确保任何敏感信息在两端(比如客户端和服务器)之间安全传输,不被第三方窃取或篡改


    所以理论上,结合了HTTPTLS特性的HTTPS,在数据传输过程是被加密的。但是,TLS建立连接的过程却不一定是加密的。


    TLS的握手机制


    当我们通过TLS传递加密的HTTP信息之前,需要先建立TLS连接,比如:



    • 当用户首次访问一个HTTPS网站,浏览器开始查询网站服务器时,会发生TLS连接

    • 当页面请求API时,会发生TLS连接


    建立连接的过程被称为TLS握手,根据TLS版本不同,握手的步骤会有所区别。



    但总体来说,TLS握手是为了达到三个目的:



    1. 协商协议和加密套件:通信的两端确认接下来使用的TLS版本及加密套件

    2. 验证省份:为了防止“中间人”攻击,握手过程中,服务器会向客户端发送其证书,包含服务器公钥和证书授权中心(即CA)签名的身份信息。客户端可以使用这些信息验证服务器的身份

    3. 生成会话密钥:生成用于加密接下来数据传输的密钥


    TLS握手机制的缺点


    虽然TLS握手机制会建立安全的通信,但在握手初期,数据却是明文发送的,这就造成隐私泄漏的风险。


    在握手初期,客户端、服务端会依次发送、接收对方的打招呼信息。首先,客户端会向服务端打招呼(发送client hello信息),该消息包含:



    • 客户端支持的TLS版本

    • 支持的加密套件

    • 一串称为客户端随机数client random)的随机字节

    • SNI等一些服务器信息


    服务端接收到上述消息后,会向客户端打招呼(发送server hello消息),再回传一些信息。


    其中,SNIServer Name Indication,服务器名称指示)就包含了用户访问的网站域名。


    那么,握手过程为什么要包含SNI呢?


    这是因为,当多个网站托管在一台服务器上并共享一个IP地址,且每个网站都有自己的SSL证书时,那就没法通过IP地址判断客户端是想和哪个网站建立TLS连接,此时就需要域名信息辅助判断。


    打个比方,快递员送货上门时,如果快递单只有收货的小区地址(IP地址),没有具体的门牌号(域名),那就没法将快递送到正确的客户手上(与正确的网站建立TLS连接)。


    所以,SNI作为TLS的扩展,会在TLS握手时附带上域名信息。由于打招呼的过程是明文发送的,所以在建立HTTPS连接的过程中,中间人就能知道你访问的域名信息。


    企业内部防火墙的访问控制和安全策略,就是通过分析SNI信息完成的。



    虽然防火墙可能已经有授信的证书,但可以先分析SNI,根据域名情况再判断要不要进行深度检查,而不是对所有流量都进行深度检查



    那么,这种情况下该如何保护个人隐私呢?


    Encrypted ClientHello


    Encrypted ClientHelloECH)是TLS1.3的一个扩展,用于加密Client Hello消息中的SNI等信息。


    当用户访问一个启用ECH的服务器时,网管无法通过观察SNI来窥探域名信息。只有目标服务器才能解密ECH中的SNI,从而保护了用户的隐私。



    当然,对于授信的防火墙还是不行,但可以增加检查的成本



    开启ECH需要同时满足:



    • 服务器支持TLSECH扩展

    • 客户端支持ECH


    比如,cloudflare SNI测试页支持ECH扩展,当你的浏览器不支持ECH时,访问该网站sni会返回plaintext



    对于chrome,在chrome://flags/#encrypted-client-hello中,配置ECH支持:



    再访问上述网站,sni如果返回encrypted则代表支持ECH


    总结


    虽然HTTPS连接本身是加密的,但在建立HTTPS的过程中(TLS握手),是有数据明文传输的,其中SNI中包含了服务器的域名信息。


    虽然SNI信息的本意是解决同一IP下部署多个网站,每个网站对应不同的SSL证书,但也会泄漏访问的网站地址


    ECH通过对TLS握手过程中的敏感信息(主要是SNI)进行加密,为用户提供了更强的隐私保护。


    作者:魔术师卡颂
    来源:juejin.cn/post/7264753569834958908
    收起阅读 »

    请给系统加个【消息中心】功能,因为真的很简单

    我相信,打开一个带有社交类型的网站,你或多或少都可以看到如下的界面: 1)消息提示 2)消息列表 这样 这样 那,这就是我们今天要聊的【消息中心】。 1、设计 老规矩先来搞清楚消息中心的需求,再来代码实现。 我们知道在社交类项目中,有很多评论、点赞等数据...
    继续阅读 »

    我相信,打开一个带有社交类型的网站,你或多或少都可以看到如下的界面:


    1)消息提示


    Snipaste_2023-08-27_13-41-36.jpg


    2)消息列表


    这样


    Snipaste_2023-08-27_13-42-25.jpg


    这样


    Snipaste_2023-08-27_16-41-30.jpg


    那,这就是我们今天要聊的【消息中心】。


    1、设计


    老规矩先来搞清楚消息中心的需求,再来代码实现。


    我们知道在社交类项目中,有很多评论、点赞等数据的产生,而如果这些数据的产生不能让用户感知到,那你们想想这会带来什么影响?



    用户A:太鸡肋了,发布的内容被人评论点赞了,我居然看不到,下次不用了...


    用户B:还好没用这个系统...



    所以,看到这些结果我们是不是能够意识到一个健全的社交功能,是不是少不了这种通知用户的机制啊!而这种机制我就把他定义为【消息中心】功能。


    再来拆分一下这四个字:消息中心



    1. 消息

    2. 中心


    消息:这个可以是由我们自己定义,如:把帖子被用户评论当作一条消息,把评论被用户点赞也可以当作一条消息,甚至系统发布的通知也是一条消息。


    中心:这个就是字面意思,将上面所提到的所有消息,归拢到一个地方进行展示。


    上面我们也提到消息基本就是这两种:



    • 用户对用户:用户消息

    • 平台对用户:系统消息


    针对用户消息,就类似这样,用户 A 给用户 B 的一条评论进行了点赞,那这个点赞动作就会产生一条消息,并且通知到用户 B 的一个存储消息的地方,这里通常就指用户的收件箱。这个收件箱就是专门用来存储用户发给用户的消息,而这个点对点的模式是不是就是推送模式啊!(A 推送消息给 B)


    接着针对系统消息,就类似这样,平台管理人员发布了一条通知,告诉大家平台有啥 XXX 活动。那这个活动通知肯定是要让平台的所有用户都知道把,所以这个通知就要存在一个发件箱中。这个发件箱就是专门存储平台的通知,所有用户都来这个发件箱中读取消息就行,而这个一对多的模式是不是就是拉取模式啊!(所有用户都来拉取平台消息)


    这样一来,我们根据不同的消息场景就抽出了一个基本的消息推拉模型,模型图如下:



    Snipaste_2023-08-27_14-27-25.jpg



    Snipaste_2023-08-27_14-59-50.jpg


    针对这两种模式,不知道大家有没有看出区别,好像乍一看没啥区别,都是发消息,读消息,对吧!


    没错,确实都是一个发,一个读,但是两者的读写频率确实有着巨大的差异。先来看推模型,一个普通用户发表了一条帖子,然后获得了寥寥无几的评论和赞,这好似也没啥特别之处,对吧!那如果这个普通用户发表的帖子成为了热门帖子呢,也即该贴子获得了上万的评论和赞。那,你们想想是不是发消息的频率非常高,而该普通用户肯定是不可能一下子读取这么多消息的,所以是不是一个写多读少的场景。再来看看拉模型,如果你的平台用户人数寥寥无几,那倒没啥特别之处,但如果用户人数几万甚至几十万。那,每个用户都过来拉取系统消息是不是就是一个读频率非常高,而发消息频率非常低(系统消息肯定不会发的很快),所以这是不是一个读多写少的场景。


    1.1 推:写多读少


    针对这个模式,我们肯定是要将写这个动作交给性能更高的中间件来处理,而不是 MySQL,所以此时我们的 RocketMQ 就出来了。


    当系统中产生了评论、点赞类的高频消息,那就无脑的丢给 MQ 吧,让其在消息中间件中呆会,等待消费者慢慢的将消息进行消费并发到各个用户的收件箱中,就类似下面这张图的流程:


    Snipaste_2023-08-27_15-45-46.jpg


    2.2 拉:读多写少


    那对于这个模式,所实话,我觉得不用引入啥就可以实现,因为对于读多的话无非就是一个查,MySQL 肯定是能搞定的,即使你的用户几万、几十万都是 ok 的。


    但咱们是不是可以这样想一下,一个系统的官方通知肯定是不多的,或者说几天或者几个星期一次,且一旦发送就不可更改。那是不是可以考虑缓存,让用户读取官方通知的时候走缓存,如果缓存没有再走 MySQL 这样应该是可以提高查询效率,提高响应速度。


    具体流程如下图:


    Snipaste_2023-08-27_15-57-21.jpg


    2.3 表结构设计


    基本的业务流程已经分析的差不多了,现在可以把表字段抽一下了,先根据上面分析的,看看我们需要那些表:



    1. 用户收件箱表

    2. 系统发件箱表


    看似好像就这两张表,但是应该还有第三张表:



    1. 用户读取系统消息记录表



    我们看到页面是不是每次有一条新的消息都会有一个小标点记录新消息数量,而第三张表就是为了这个作用而设计的。


    具体原理如下:



    1. 首先运营人员发布的消息都是存储在第二张表中,这肯定是没错的

    2. 那用户每次过来拉取系统消息时,将最近拉取的一条消息写入到第三种表中

    3. 这样等用户下次再来拉取的时候,就可以根据第三张表的读取记录,来确定他有几条系统消息未查看了


    可能有人会发出疑问:那用户的收件箱为啥不出一个用户读取记录表呢!


    这个很简单,因为收件箱中的数据已经表示这个用户需要都这些个消息了,只是不知道那些是已读的那些是未读的,我们只需要再收件箱表中加一个字段,这个字段的作用就是记录最新一次读取的消息 ID 就行,等下次要读消息时,找到上传读取读取消息的记录ID,往后读新消息即可。



    好,现在来看看具体的表字段:


    1)用户收件箱表(sb_user_inbox)



    • id

    • 消息数据唯一 id:MQ唯一消息凭证

    • 消息类型:评论消息或者点赞消息

    • 帖子id:业务id

    • 业务数据id:业务id

    • 内容:消息内容

    • 业务数据类型:业务数据类型(商品评论、帖子、帖子一级评论、帖子二级评论)

    • 发起方的用户ID:用户 A 对用户 B 进行点赞,那这就是用户 A 的ID

    • 接收方的用户ID:用户 B 的 ID

    • 用户最新读取位置ID:用户最近一次读取记录的 ID


    SQL


    CREATE TABLE `sb_user_inbox` (
    `id` bigint(20) NOT NULL,
    `uuid` varchar(128) COLLATE utf8mb4_german2_ci NOT NULL COMMENT '消息数据唯一id',
    `message_type` tinyint(1) NOT NULL COMMENT '消息类型',
    `post_id` bigint(20) DEFAULT NULL COMMENT '帖子id',
    `item_id` bigint(20) NOT NULL COMMENT '业务数据id',
    `content` varchar(1000) COLLATE utf8mb4_german2_ci DEFAULT NULL COMMENT '内容',
    `service_message_type` tinyint(1) NOT NULL COMMENT '业务数据类型',
    `from_user_id` bigint(20) NOT NULL COMMENT '发起方的用户ID',
    `to_user_id` bigint(20) NOT NULL COMMENT '接收方的用户ID',
    `read_position_id` bigint(20) DEFAULT '0' COMMENT '用户最新读取位置ID',
    `create_time` datetime DEFAULT NULL,
    PRIMARY KEY (`id`),
    UNIQUE KEY `un01` (`uuid`),
    UNIQUE KEY `un02` (`item_id`,`service_message_type`,`to_user_id`),
    KEY `key` (`to_user_id`)
    ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_german2_ci

    可以看到,我加了很多业务相关的字段,这个主要是为了方便查询数据和展示数据。


    2)系统发件箱表(sb_sys_outbox)



    • id

    • 内容


    SQL


    CREATE TABLE `sb_sys_outbox` (
    `id` bigint(20) NOT NULL AUTO_INCREMENT,
    `content` varchar(2000) COLLATE utf8mb4_german2_ci NOT NULL COMMENT '内容',
    `create_time` datetime DEFAULT NULL,
    PRIMARY KEY (`id`)
    ) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_german2_ci

    这个表就非常简单了,没啥业务字段冗余。


    3)用户读取系统消息记录表(sb_user_read_sys_outbox)



    • id

    • 系统收件箱数据读取id

    • 读取的用户id


    SQL


    CREATE TABLE `sb_user_read_sys_outbox` (
    `id` bigint(20) NOT NULL AUTO_INCREMENT,
    `sys_outbox_id` bigint(20) NOT NULL COMMENT '系统收件箱数据读取id',
    `user_id` bigint(20) NOT NULL COMMENT '读取的用户id',
    PRIMARY KEY (`id`),
    UNIQUE KEY `un` (`user_id`),
    KEY `key` (`user_id`)
    ) ENGINE=InnoDB AUTO_INCREMENT=17 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_german2_ci

    ok,这是消息中心所有分析阶段了,下面就开始实操。


    2、实现


    先来引入引入一下 RocketMQ 的依赖




    org.apache.rocketmq
    rocketmq-spring-boot-starter
    2.2.1


    RocketMQ 的双主双从同步刷新集群搭建教程:blog.csdn.net/qq_40399646…


    MQ 配置:


    Snipaste_2023-08-27_16-26-09.jpg


    2.1 生产者


    先来实现生产者如何发送消息。


    1)消息体对象:LikeAndCommentMessageDTO


    位置:cn.j3code.config.dto.mq


    @Data
    public class LikeAndCommentMessageDTO {

    /**
    * 该消息的唯一id
    * 业务方可以不设置,如果为空,代码会自动填充
    */

    private String uuid;

    /**
    * 消息类型
    */

    private UserCenterMessageTypeEnum messageType;

    /**
    * 冗余一个帖子id进来
    */

    private Long postId;

    /**
    * 业务数据id
    */

    private Long itemId;

    /**
    * 如果是评论消息,这个内容就是评论的内容
    */

    private String content;

    /**
    * 业务数据类型
    */

    private UserCenterServiceMessageTypeEnum serviceMessageType;

    /**
    * 发起方的用户ID
    */

    private Long fromUserId;

    /**
    * 接收方的用户ID
    */

    private Long toUserId;


    /*
    例子:
    用户 A 发表了一个帖子,B 对这个帖子进行了点赞,那这个实体如下:
    messageType = UserCenterMessageTypeEnum.LIKE
    itemId = 帖子ID(对评论进行点赞,就是评论id,对评论进行回复,就是刚刚评论的id)
    serviceMessageType = UserCenterServiceMessageTypeEnum.POST(这个就是说明 itemId 的 ID 是归于那个业务的,方便后续查询业务数据)
    fromUserId = 用户B的ID
    toUserId = 用户 A 的ID
    */

    }

    2)发送消息代码


    位置:cn.j3code.community.mq.producer


    @Slf4j
    @Component
    @AllArgsConstructor
    public class LikeAndCommentMessageProducer {

    private final RocketMQTemplate rocketMQTemplate;

    /**
    * 单个消息发送
    *
    *
    @param dto
    */

    public void send(LikeAndCommentMessageDTO dto) {
    if (Objects.isNull(dto.getUuid())) {
    dto.setUuid(IdUtil.simpleUUID());
    }
    checkMessageDTO(dto);
    Message message = MessageBuilder
    .withPayload(dto)
    .build();
    rocketMQTemplate.send(RocketMQConstants.USER_MESSAGE_CENTER_TOPIC, message);
    }

    /**
    * 批量消息发送
    *
    *
    @param dtos
    */

    public void send(List dtos) {
    /**
    * 将 dtos 集合分割成 1MB 大小的集合
    * MQ 批量推送的消息大小最大 1MB 左右
    */

    ListSizeSplitUtil.split(1 * 1024 * 1024L, dtos).forEach(items -> {
    List> messageList = new ArrayList<>(items.size());
    items.forEach(dto -> {
    if (Objects.isNull(dto.getUuid())) {
    dto.setUuid(IdUtil.simpleUUID());
    }
    checkMessageDTO(dto);
    Message message = MessageBuilder
    .withPayload(dto)
    .build();
    messageList.add(message);
    });
    rocketMQTemplate.syncSend(RocketMQConstants.USER_MESSAGE_CENTER_TOPIC, messageList);
    });
    }

    private void checkMessageDTO(LikeAndCommentMessageDTO dto) {
    AssertUtil.isTrue(Objects.isNull(dto.getMessageType()), "消息类型不为空!");
    AssertUtil.isTrue(Objects.isNull(dto.getItemId()), "业务数据ID不为空!");
    AssertUtil.isTrue(Objects.isNull(dto.getServiceMessageType()), "业务数据类型不为空!");
    AssertUtil.isTrue(Objects.isNull(dto.getFromUserId()), "发起方用户ID不为空!");
    AssertUtil.isTrue(Objects.isNull(dto.getToUserId()), "接收方用户ID不为空!");
    }


    /**
    * 发送点赞消息
    *
    *
    @param messageType 消息类型
    *
    @param serviceMessageType 业务类型
    *
    @param itemToUserIdMap 业务ID对应的用户id
    *
    @param saveLikeList 点赞数据
    */

    public void sendLikeMQMessage(
    UserCenterMessageTypeEnum messageType,
    UserCenterServiceMessageTypeEnum serviceMessageType,
    Map itemToUserIdMap, List saveLikeList)
    {
    if (CollectionUtils.isEmpty(saveLikeList)) {
    return;
    }
    List dtos = new ArrayList<>();
    for (Like like : saveLikeList) {
    LikeAndCommentMessageDTO messageDTO = new LikeAndCommentMessageDTO();
    messageDTO.setItemId(like.getItemId());
    messageDTO.setMessageType(messageType);
    messageDTO.setServiceMessageType(serviceMessageType);
    messageDTO.setFromUserId(like.getUserId());
    messageDTO.setToUserId(itemToUserIdMap.get(like.getItemId()));
    dtos.add(messageDTO);
    }
    try {
    send(dtos);
    } catch (Exception e) {
    //错误处理
    log.error("发送MQ消息失败!", e);
    }
    }
    }

    注意:这里我用了 MQ 批量发送消息的一个功能,但是他有一个限制就是每次只能发送 1MB 大小的数据。所以我需要做一个功能工具类将业务方丢过来的批量数据进行分割。


    工具类:ListSizeSplitUtil


    位置:cn.j3code.config.util


    public class ListSizeSplitUtil {

    private static Long maxByteSize;

    /**
    * 根据传进来的 byte 大小限制,将 list 分割成对应大小的 list 集合数据
    *
    *
    @param byteSize 每个 list 数据最大大小
    *
    @param list 待分割集合
    *
    @param
    *
    @return
    */

    public static List> split(Long byteSize, List list) {
    if (Objects.isNull(list) || list.size() == 0) {
    return new ArrayList<>();
    }

    if (byteSize <= 100) {
    throw new RuntimeException("参数 byteSize 值不小于 100 bytes!");
    }
    ListSizeSplitUtil.maxByteSize = byteSize;


    if (isSurpass(List.of(list.get(0)))) {
    throw new RuntimeException("List 中,单个对象都大于 byteSize 的值,分割失败");
    }

    List> result = new ArrayList<>();

    List itemList = new ArrayList<>();
    for (int i = 0; i < list.size(); i++) {
    itemList.add(list.get(i));

    if (isSurpass(itemList)) {
    i = i - 1;
    itemList.remove(itemList.size() - 1);
    result.add(new ArrayList<>(itemList));
    itemList = new ArrayList<>();
    }
    }
    result.add(new ArrayList<>(itemList));
    return result;
    }


    private static Boolean isSurpass(List obj) {
    // 字节(byte)
    long objSize = RamUsageEstimator.sizeOfAll(obj.toArray());
    return objSize >= ListSizeSplitUtil.maxByteSize;
    }
    }

    至此呢,生产者的逻辑就算是完成了,每次有消息的时候就调用这个方法即可。


    2.2 消费者


    位置:cn.j3code.user.mq.consumer


    @Slf4j
    @Component
    @AllArgsConstructor
    @RocketMQMessageListener(topic = RocketMQConstants.USER_MESSAGE_CENTER_TOPIC,
    consumerGr0up = RocketMQConstants.GR0UP,
    messageModel = MessageModel.CLUSTERING,
    consumeMode = ConsumeMode.CONCURRENTLY
    )

    public class LikeAndCommentMessageConsumer implements RocketMQListener {

    private final UserInboxService userInboxService;

    @Override
    public void onMessage(LikeAndCommentMessageDTO message) {
    userInboxService.saveMessage(message);
    }
    }

    saveMessage 方法的逻辑就是将消息保存到 MySQL 中,至此消息的产生和存储就算完成了,下面来看看用户如何查看吧!


    2.3 用户消息查看


    对于用户查看普通的消息就是访问一下 MySQL,并且更新一下最新读取的字段值即可,我贴一下关键代码就行了,代码如下:


    public IPage page(UserMessagePageRequest request) {
    // 获取消息
    IPage page = getBaseMapper().page(new Page(request.getCurrent(), request.getSize()), request);

    if (CollectionUtils.isEmpty(page.getRecords())) {
    return page;
    }
    // 记录一下消息读取位置,默认进来就把全部消息读完了,类似掘金
    if (request.getCurrent() == 1) {
    if (Objects.isNull(page.getRecords().get(0).getReadPositionId()) ||
    page.getRecords().get(0).getReadPositionId() == 0) {
    UserInbox userInbox = new UserInbox();
    userInbox.setId(page.getRecords().get(0).getId());
    userInbox.setReadPositionId(userInbox.getId());
    updateById(userInbox);
    }
    }
    return page;
    }

    2.4 系统消息查看


    对于系统消息的查看也是,只贴出关键代码,查询和更新读取记录逻辑,代码如下:


    @Override
    public IPage lookSysPage(SysOutboxPageRequest request) {
    Page page = lambdaQuery()
    .orderByDesc(SysOutbox::getId)
    .page(new Page<>(request.getCurrent(), request.getSize()));
    IPage outboxVOIPage = page.convert(userInboxConverter::converter);
    if (CollectionUtils.isEmpty(outboxVOIPage.getRecords())) {
    return outboxVOIPage;
    }
    // 记录一下消息读取位置,默认进来就把全部消息读完了,类似掘金
    if (request.getCurrent() == 1) {
    userReadSysOutboxService.updateReadLog(page.getRecords().get(0).getId(), SecurityUtil.getUserId());
    }
    return outboxVOIPage;
    }

    这里,可能有人会发现,没有按照上面分析的那用从缓存中读,是的。这里的实现我没有用到 Redis,这里我偷了一下懒,如果有拿到我代码的同学可以试着优化一下这个逻辑。


    作者:J3code
    来源:juejin.cn/post/7274922643453853735
    收起阅读 »

    18张图,详解SpringBoot解析yml全流程

    前几天的时候,项目里有一个需求,需要一个开关控制代码中是否执行一段逻辑,于是理所当然的在yml文件中配置了一个属性作为开关,再配合nacos就可以随时改变这个值达到我们的目的,yml文件中是这样写的: switch: turnOn: on 程序中的代码也...
    继续阅读 »



    前几天的时候,项目里有一个需求,需要一个开关控制代码中是否执行一段逻辑,于是理所当然的在yml文件中配置了一个属性作为开关,再配合nacos就可以随时改变这个值达到我们的目的,yml文件中是这样写的:


    switch:
    turnOn: on

    程序中的代码也很简单,大致的逻辑就是下面这样,如果取到的开关字段是on的话,那么就执行if判断中的代码,否则就不执行:


    @Value("${switch.turnOn}")
    private String on;

    @GetMapping("testn")
    public void test(){
    if ("on".equals(on)){
    //TODO
    }
    }

    但是当代码实际跑起来,有意思的地方来了,我们发现判断中的代码一直不会被执行,直到debug一下,才发现这里的取到的值居然不是on而是true



    看到这,是不是感觉有点意思,首先盲猜是在解析yml的过程中把on作为一个特殊的值进行了处理,于是我干脆再多测试了几个例子,把yml中的属性扩展到下面这些:


    switch:
    turnOn: on
    turnOff: off
    turnOn2: 'on'
    turnOff2: 'off'

    再执行一下代码,看一下映射后的值:



    可以看到,yml中没有带引号的onoff被转换成了truefalse,带引号的则保持了原来的值不发生改变。


    到这里,让我忍不住有点好奇,为什么会发生这种现象呢?于是强忍着困意翻了翻源码,硬磕了一下SpringBoot加载yml配置文件的过程,终于让我看出了点门道,下面我们一点一点细说!


    因为配置文件的加载会涉及到一些SpringBoot启动的相关知识,所以如果对SpringBoot启动不是很熟悉的同学,可以先提前先看一下Hydra在古早时期写过一篇Spring Boot零配置启动原理预热一下。下面的介绍中,只会摘出一些对加载和解析配置文件比较重要的步骤进行分析,对其他无关部分进行了省略。


    加载监听器


    当我们启动一个SpringBoot程序,在执行SpringApplication.run()的时候,首先在初始化SpringApplication的过程中,加载了11个实现了ApplicationListener接口的拦截器。



    这11个自动加载的ApplicationListener,是在spring.factories中定义并通过SPI扩展被加载的:



    这里列出的10个是在spring-boot中加载的,还有剩余的1个是在spring-boot-autoconfigure中加载的。其中最关键的就是ConfigFileApplicationListener,它和后面要讲到的配置文件的加载相关。


    执行run方法


    在实例化完成SpringApplication后,会接着往下执行它的run方法。



    可以看到,这里通过getRunListeners方法获取的SpringApplicationRunListeners中,EventPublishingRunListener绑定了我们前面加载的11个监听器。但是在执行starting方法时,根据类型进行了过滤,最终实际只执行了4个监听器的onApplicationEvent方法,并没有我们希望看到的ConfigFileApplicationListener,让我们接着往下看。



    run方法执行到prepareEnvironment时,会创建一个ApplicationEnvironmentPreparedEvent类型的事件,并广播出去。这时所有的监听器中,有7个会监听到这个事件,之后会分别调用它们的onApplicationEvent方法,其中就有了我们心心念念的ConfigFileApplicationListener,接下来让我们看看它的onApplicationEvent方法中做了什么。



    在方法的调用过程中,会加载系统自己的4个后置处理器以及ConfigFileApplicationListener自身,一共5个后置处理器,并执行他们的postProcessEnvironment方法,其他4个对我们不重要可以略过,最终比较关键的步骤是创建Loader实例并调用它的load方法。


    加载配置文件


    这里的LoaderConfigFileApplicationListener的一个内部类,看一下Loader对象实例化的过程:



    在实例化Loader对象的过程中,再次通过SPI扩展的方式加载了两个属性文件加载器,其中的YamlPropertySourceLoader就和后面的yml文件的加载、解析密切关联,而另一个PropertiesPropertySourceLoader则负责properties文件的加载。创建完Loader实例后,接下来会调用它的load方法。



    load方法中,会通过嵌套循环方式遍历默认配置文件存放路径,再加上默认的配置文件名称、以及不同配置文件加载器对应解析的后缀名,最终找到我们的yml配置文件。接下来,开始执行loadForFileExtension方法。



    loadForFileExtension方法中,首先将classpath:/application.yml加载为Resource文件,接下来准备正式开始,调用了之前创建好的YamlPropertySourceLoader对象的load方法。


    封装Node


    load方法中,开始准备进行配置文件的解析与数据封装:



    load方法中调用了OriginTrackedYmlLoader对象的load方法,从字面意思上我们也可以理解,它的用途是原始追踪yml的加载器。中间一连串的方法调用可以忽略,直接看最后也是最重要的是一步,调用OriginTrackingConstructor对象的getData接口,来解析yml并封装成对象。



    在解析yml的过程中实际使用了Composer构建器来生成节点,在它的getNode方法中,通过解析器事件来创建节点。通常来说,它会将yml中的一组数据封装成一个MappingNode节点,它的内部实际上是一个NodeTuple组成的ListNodeTupleMap的结构类似,由一对对应的keyNodevalueNode构成,结构如下:



    好了,让我们再回到上面的那张方法调用流程图,它是根据文章开头的yml文件中实际内容内容绘制的,如果内容不同调用流程会发生改变,大家只需要明白这个原理,下面我们具体分析。


    首先,创建一个MappingNode节点,并将switch封装成keyNode,然后再创建一个MappingNode,作为外层MappingNodevalueNode,同时存储它下面的4组属性,这也是为什么上面会出现4次循环的原因。如果有点困惑也没关系,看一下下面的这张图,就能一目了然了解它的结构。



    在上图中,又引入了一种新的ScalarNode节点,它的用途也比较简单,简单String类型的字符串用它来封装成节点就可以了。到这里,yml中的数据被解析完成并完成了初步的封装,可能眼尖的小伙伴要问了,上面这张图中为什么在ScalarNode中,除了value还有一个tag属性,这个属性是干什么的呢?


    在介绍它的作用前,先说一下它是怎么被确定的。这一块的逻辑比较复杂,大家可以翻一下ScannerImplfetchMoreTokens方法的源码,这个方法会根据yml中每一个keyvalue是以什么开头,来决定以什么方式进行解析,其中就包括了{['%?等特殊符号的情况。以解析不带任何特殊字符的字符串为例,简要的流程如下,省略了一些不重要部分:



    在这张图的中间步骤中,创建了两个比较重要的对象ScalarTokenScalarEvent,其中都有一个为trueplain属性,可以理解为这个属性是否需要解释,是后面获取Resolver的关键属性之一。


    上图中的yamlImplicitResolvers其实是一个提前缓存好的HashMap,已经提前存储好了一些Char类型字符与ResolverTuple的对应关系:



    当解析到属性on时,取出首字母o对应的ResolverTuple,其中的tag就是tag:yaml.org.2002:bool。当然了,这里也不是简单的取出就完事了,后续还会对属性进行正则表达式的匹配,看与regexp中的值是否能对的上,检查无误时才会返回这个tag


    到这里,我们就解释清楚了ScalarNodetag属性究竟是怎么获取到的了,之后方法调用层层返回,返回到OriginTrackingConstructor父类BaseConstructorgetData方法中。接下来,继续执行constructDocument方法,完成对yml文档的解析。


    调用构造器


    constructDocument中,有两步比较重要,第一步是推断当前节点应该使用哪种类型的构造器,第二步是使用获得的构造器来重新对Node节点中的value进行赋值,简易流程如下,省去了循环遍历的部分:



    推断构造器种类的过程也很简单,在父类BaseConstructor中,缓存了一个HashMap,存放了节点的tag类型到对应构造器的映射关系。在getConstructor方法中,就使用之前节点中存入的tag属性来获得具体要使用的构造器:



    tagbool类型时,会找到SafeConstruct中的内部类 ConstructYamlBool作为构造器,并调用它的construct方法实例化一个对象,来作为ScalarNode节点的value的值:



    construct方法中,取到的val就是之前的on,至于下面的这个BOOL_VALUES,也是提前初始化好的一个HashMap,里面提前存放了一些对应的映射关系,key是下面列出的这些关键字,value则是Boolean类型的truefalse



    到这里,yml中的属性解析流程就基本完成了,我们也明白了为什么yml中的on会被转化为true的原理了。至于最后,Boolean类型的truefalse是如何被转化为的字符串,就是@Value注解去实现的了。


    思考


    那么,下一个问题来了,既然yml文件解析中会做这样的特殊处理,那么如果换成properties配置文件怎么样呢?


    sw.turnOn=on
    sw.turnOff=off

    执行一下程序,看一下结果:



    可以看到,使用properties配置文件能够正常读取结果,看来是在解析的过程中没有做特殊处理,至于解析的过程,有兴趣的小伙伴可以自己去阅读一下源码。


    那么,今天就写到这里,我们下期见。


    作者:码农参上
    来源:juejin.cn/post/7054818269621911559
    收起阅读 »

    Java 中for循环和foreach循环哪个更快?

    本文旨在探究Java中的for循环和foreach循环的性能差异,并帮助读者更好地选择适合自身需求的循环方式 前言 在Java编程中,循环结构是程序员常用的控制流程,而for循环和foreach循环是其中比较常见的两种形式。关于它们哪一个更快的讨论一直存在。...
    继续阅读 »

    本文旨在探究Java中的for循环和foreach循环的性能差异,并帮助读者更好地选择适合自身需求的循环方式



    前言


    在Java编程中,循环结构是程序员常用的控制流程,而for循环和foreach循环是其中比较常见的两种形式。关于它们哪一个更快的讨论一直存在。本文旨在探究Java中的for循环和foreach循环的性能差异,并帮助读者更好地选择适合自身需求的循环方式。通过详细比较它们的遍历效率、数据结构适用性和编译器优化等因素,我们将为大家揭示它们的差异和适用场景,以便您能够做出更明智的编程决策。



    for循环与foreach循环的比较


    小编认为for和foreach 之间唯一的实际区别是,对于可索引对象,我们无权访问索引。


    for(int i = 0; i < mylist.length; i++) {
    if(i < 5) {
    //do something
    } else {
    //do other stuff
    }
    }

    但是,我们可以使用 foreach 创建一个单独的索引 int 变量。例如:


    int index = -1;
    for(int myint : mylist) {
    index++;
    if(index < 5) {
    //do something
    } else {
    //do other stuff
    }
    }

    现在写一个简单的类,其中有 foreachTest() 方法,该方法使用 forEach 迭代列表。


    import java.util.List;

    public class ForEachTest {
    List intList;

    public void foreachTest(){
    for(Integer i : intList){

    }
    }
    }

    编译这个类时,编译器会在内部将这段代码转换为迭代器实现。小编通过执行 javap -verbose IterateListTest 反编译代码。


    public void foreachTest();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
    stack=1, locals=3, args_size=1
    0: aload_0
    1: getfield #19 // Field intList:Ljava/util/List;
    4: invokeinterface #21, 1 // InterfaceMethod java/util/List.iterator:()Ljava/util/Iterator;
    9: astore_2
    10: goto 23
    13: aload_2
    14: invokeinterface #27, 1 // InterfaceMethod java/util/Iterator.next:()Ljava/lang/Object;
    19: checkcast #33 // class java/lang/Integer
    22: astore_1
    23: aload_2
    24: invokeinterface #35, 1 // InterfaceMethod java/util/Iterator.hasNext:()Z
    29: ifne 13
    32: return
    LineNumberTable:
    line 9: 0
    line 12: 32
    LocalVariableTable:
    Start Length Slot Name Signature
    0 33 0 this Lcom/greekykhs/springboot/ForEachTest;
    StackMapTable: number_of_entries = 2
    frame_type = 255 /* full_frame */
    offset_delta = 13
    locals = [ class com/greekykhs/springboot/ForEachTest, top, class java/util/Iterator ]
    stack = []
    frame_type = 9 /* same */

    从上面的字节码我们可以看到:


    a). getfield命令用于获取变量整数。


    b).调用List.iterator获取迭代器实例


    c).调用iterator.hasNext,如果返回true,则调用iterator.next方法。


    下边来做一下性能测试。在 IterateListTest 的主要方法中,创建了一个列表并使用 for 和 forEach 循环对其进行迭代。


    import java.util.ArrayList;
    import java.util.List;

    public class IterateListTest {
    public static void main(String[] args) {
    List mylist = new ArrayList<>();
    for (int i = 0; i < 1000000; i++) {
    mylist.add(i);
    }

    long forLoopStartTime = System.currentTimeMillis();
    for (int i = 0; i < mylist.size(); i++) {mylist.get(i);}

    long forLoopTraversalCost =System.currentTimeMillis()-forLoopStartTime;
    System.out.println("for loop traversal cost for ArrayList= "+ forLoopTraversalCost);

    long forEachStartTime = System.currentTimeMillis();
    for (Integer integer : mylist) {}

    long forEachTraversalCost =System.currentTimeMillis()-forEachStartTime;
    System.out.println("foreach traversal cost for ArrayList= "+ forEachTraversalCost);
    }
    }

    结果如下:


    总结


    观察结果显示,for循环的性能优于for-each循环。然后再使用LinkedList比较它们的性能差异。对于 LinkedList 来说,for-each循环展现出更好的性能。ArrayList内部使用连续存储的数组,因此数据的检索时间复杂度为 O(1),通过索引可以直接访问数据。而 LinkedList 使用双向链表结构,当我们使用 for 循环进行遍历时,每次都需要从链表头节点开始,导致时间复杂度达到了 O(n*n),因此在这种情况下,for-each 循环更适合操作 LinkedList。


    作者:葡萄城技术团队
    来源:juejin.cn/post/7280050832950624314
    收起阅读 »

    到了2038年时间戳溢出了怎么办?

    计算机中的时间 看完这篇文章相信你会对计算机中的时间有更系统全面的认识。 我经常自嘲,自己写的程序运行不超过3年,因为大部分项目方就早早跑路了。大多数项目上线后,你跟这个项目就再无瓜葛,关于时间你只需要保证时区正确就不会有太大问题,哈哈。 但是今天我想认真对待...
    继续阅读 »

    计算机中的时间


    看完这篇文章相信你会对计算机中的时间有更系统全面的认识。


    我经常自嘲,自己写的程序运行不超过3年,因为大部分项目方就早早跑路了。大多数项目上线后,你跟这个项目就再无瓜葛,关于时间你只需要保证时区正确就不会有太大问题,哈哈。 但是今天我想认真对待时间这个问题,作为一个库作者或基础软件作者,就需要考虑下游项目万一因为你处理时间不当而造成困扰,影响范围就比较广了。


    计算机中与时间有关的关键词:


    时间类型
    时间戳(timestamp
    定时器(例如jssetInterval())
    时间计算
    时间段
    超时(setTimeout())
    时间片
    GMT
    UTC
    Unix时间戳
    ISO8601
    CST
    EST

    看到这些你可能会疑惑,为何一个时间竟然如此复杂!!


    如果下面的问题你都能答上来,那这篇文章对你的帮助微乎其微,不如做些更有意义的事情。



    • 常用的时间格式,他们都遵循哪些标准?

    • 什么是GMT?

    • 什么是UTC?

    • GMT UTC 和ISO8601有什么区别?

    • RFC5322是什么?

    • RFC5322 采用的是GMT还是UTC?

    • ISO8601 使用的是UTC还是GMT?

    • 在ISO8601中 Z可以使用+00:00表示吗?

    • UTC什么时候校准?

    • CST是东八区吗?

    • Z是ISO 8601规定的吗,为什么是Z?

    • 时区划分是哪个标准定义的?

    • 为什么是1970年1月1日呢?

    • 到了2038年时间戳溢出了怎么办?

    • 计算机中时间的本质是一个long类型吗?

    • WEB前后端用哪个格式传输好?

    • '2024-01-01T24:00:00' 等于 '2024-01-02T00:00:00' ??



    正文开始


    1. 两种时间标准


    UTC和GMT都是时间标准,定义事件的精度。它们只表示 零时区 的时间,本地时间则需要与 时区 或偏移 结合后表示。这两个标准之间差距通常不会超过一秒。


    UTC(协调世界时)


    UTC,即协调世界时(Coordinated Universal Time),是一种基于原子钟的时间标准。它的校准是根据地球自转的变化而进行的,插入或删除闰秒的实际需求在短期内是难以预测的,因此这个决定通常是在需要校准的时候发布。 闰秒通常由国际电信联盟(ITU) 和国际度量衡局(BIPM) 等组织进行发布。由国际原子时(International Atomic Time,TAI) 通过闰秒 的调整来保持与地球自转的同步。


    GMT(格林尼治标准时间)


    以英国伦敦附近的格林尼治天文台(0度经线,本初子午线)的时间为基准。使用地球自转的平均速度来测量时间,是一种相对于太阳的平均时刻。尽管 GMT 仍然被广泛使用,但现代科学和国际标准更倾向于使用UTC。


    2. 两种显示标准


    上面我们讨论的时间标准主要保证的是时间的精度,时间显示标准指的是时间的字符串表示格式。我们熟知的有 RFC 5322 和 ISO 8601。


    RFC 5322 电子邮件消息格式的规范


    RFC 5322 的最新版本是在2008年10月在IETF发布的,你阅读时可能有了更新的版本。



    RFC 5322 是一份由 Internet Engineering Task Force (IETF) 制定的标准,定义了 Internet 上的电子邮件消息的格式规范。该标准于2008年发布,是对之前的 RFC 2822 的更新和扩展。虽然 RFC 5322 主要关注电子邮件消息的格式,但其中的某些规范,比如日期时间格式,也被其他领域采纳,例如在 HTTP 协议中用作日期头部(Date Header)的表示。



    格式通常如下:


    Thu, 14 Dec 2023 05:36:56 GMT

    时区部分为了可读可以如下表示:


    Thu, 14 Dec 2023 05:36:56 CST
    Thu, 14 Dec 2023 05:36:56 +0800
    Thu, 14 Dec 2023 05:36:56 +0000
    Thu, 14 Dec 2023 05:36:56 Z

    但并不是所有程序都兼容这种时区格式,通常程序会忽略时区,在写程序时要做好测试。标准没有定义毫秒数如何显示。


    需要注意的是,有时候我们会见到这种格式Tue Jan 19 2038 11:14:07 GMT+0800 (中国标准时间),这是js日期对象转字符串的格式,它与标准无关,千万不要混淆了。


    ISO 8601


    ISO 8601 最新版本是 ISO 8601:2019,发布日期为2019年11月15日,你阅读时可能有了更新的版本。


    下面列举一些格式示例:


    2004-05-03T17:30:08+08:00
    2004-05-03T17:30:08+00:00
    2004-05-03T17:30:08Z
    2004-05-03T17:30:08.000+08:00

    标准并没有定义小数位数,保险起见秒后面一般是3位小数用来表示毫秒数。 字母 "Z" 是 "zero"(零)的缩写,因此它被用来表示零时区,也可以使用+00:00,但Z更直观且简洁。



    1. 本标准提供两种方法来表示时间:一种是只有数字的基础格式;第二种是添加了分隔符的扩展格式,更易读。扩展格式使用连字符“-”来分隔日期,使用冒号“:”来分隔时间。比如2009年1月6日在扩展格式中可以写成"2009-01-06",在基本格式中可以简单地写成"20090106"而不会产生歧义。 若要表示前1年之前或9999年之后的年份,标准也允许有共识的双方扩展表达方式。双方应事先规定增加的位数,并且年份前必须有正号“+”或负号“-”而不使用“。依据标准,若年份带符号,则前1年为"+0000",前2年为"-0001",依此类推。

    2. 午夜,一日的开始:完全表示为000000或00:00:00;仅有小时和分表示为0000或00:00

    3. 午夜,一日的终止:完全表示为240000或24:00:00;仅有小时和分表示为2400或24:00

    4. 如果时间在零时区,并恰好与UTC相同,那么在时间最后加一个大写字母Z。Z是相对协调世界时时间0偏移的代号。 如下午2点30分5秒表示为14:30:05Z或143005Z;只表示小时和分,为1430Z或14:30Z;只表示小时,则为14Z或14Z。

    5. 其它时区用实际时间加时差表示,当时的UTC+8时间表示为22:30:05+08:00或223005+0800,也可以简化成223005+08。


    日期与时间合并表示时,要在时间前面加一大写字母T,如要表示东八区时间2004年5月3日下午5点30分8秒,可以写成2004-05-03T17:30:08+08:00或20040503T173008+08。


    在编写API时推荐使用ISO 8601标准接收参数或响应结果,并且做好时区测试,因为不同编程语言中实现可能有差异。


    时区划分和偏移



    全球被分为24个时区,每个时区对应一个小时的时间差。 时区划分由IANA维护和管理,其时区数据库被称为 TZ Database(或 Olson Database)。这个数据库包含了全球各个时区的信息,包括时区的名称、标识符、以及历史性的时区变更数据,例如夏令时的开始和结束时间等。在许多操作系统(如Linux、Unix、macOS等)和编程语言(如Java、Python等)中得到广泛应用。


    TZ Database具体见我整理的表格,是从Postgresql中导出的一份Excel,关注公众号"程序饲养员",回复"tz"



    时区标识符采用"洲名/城市名"的命名规范,例如:"America/New_York"或"Asia/Shanghai"。这种命名方式旨在更准确地反映时区的地理位置。时区的具体规定和管理可能因国家、地区、或国际组织而异。


    有一些时区是按照半小时或15分钟的间隔进行偏移的,以适应地理和政治需求。在某些地区,特别是位于边界上的地区,也可能采用不同的时区规则。


    EST,CST、GMT(另外一个含义是格林尼治标准时间)这些都是时区的缩写。


    这种简写存在重复,如CST 可能有多种不同的含义,China Standard Time(中国标准时间),它对应于 UTC+8,即东八区。Central Standard Time(中部标准时间) 在美国中部标准时间的缩写中也有用。中部标准时间对应于 UTC-6,即西六区。因此在某些软件配置时不要使用简称,一定要使用全称,如”Asia/Shanghai“。


    采用东八区的国家和地区有哪些



    • 中国: 中国标准时间(China Standard Time,CST)是东八区的时区,对应于UTC+8。

    • 中国香港: 中国香港也采用东八区的时区,对应于UTC+8。

    • 中国澳门: 澳门也在东八区,使用UTC+8。

    • 中国台湾: 台湾同样在东八区,使用UTC+8。

    • 新加坡: 新加坡位于东八区,使用UTC+8。

    • 马来西亚: 马来西亚的半岛部分和东马来西亚位于东八区,使用UTC+8。

    • 菲律宾: 菲律宾采用东八区的时区,对应于UTC+8。


    计算机系统中的时间 —— Unix时间戳


    Unix时间戳(Unix timestamp)定义为从1970年01月01日00时00分00秒(UTC)起至现在经过的总秒数(秒是毫秒、微妙、纳秒的总称)。


    这个时间点通常被称为 "Epoch" 或 "Unix Epoch"。时间戳是一个整数,表示从 Epoch 开始经过的秒数。


    一些关键概念:



    1. 起始时间点: Unix 时间戳的起始时间是 1970 年 1 月 1 日 00:00:00 UTC。在这一刻,Unix 时间戳为 0。

    2. 增量单位: Unix 时间戳以秒为单位递增。每过一秒,时间戳的值增加 1。

    3. 正负值: 时间戳可以是正值或负值。正值表示从 Epoch 开始经过的秒数,而负值表示 Epoch 之前的秒数。

    4. 精度: 通常情况下,Unix 时间戳以整数形式表示秒数。有时也会使用浮点数表示秒的小数部分,以提供更精细的时间分辨率。精确到秒是10位;有些编程语言精确到毫秒是13位,被称为毫秒时间戳。


    为什么是1970年1月1日?


    这个选择主要是出于历史和技术的考虑。


    Unix 操作系统的设计者之一,肯·汤普森(Ken Thompson)和丹尼斯·里奇(Dennis Ritchie)在开发 Unix 操作系统时,需要选择一个固定的起始点来表示时间。1970-01-01 00:00:00 UTC 被选为起始时间。这个设计的简洁性和通用性使得 Unix 时间戳成为计算机系统中广泛使用的标准方式来表示和处理时间。


    时间戳为什么只能表示到2038年01月19日03时14分07秒?


    在许多系统中,结构体time_t 被定义为 long,具体实现取决于编译器和操作系统的架构。例如,在32位系统上,time_t 可能是32位的 long,而在64位系统上,它可能是64位的 long。 32位有符号long类型,实际表示整数只有31位,最大能表示十进制2147483647(01111111 11111111 11111111 11111111)。


    > new Date(2147483647000)
    < Tue Jan 19 2038 11:14:07 GMT+0800 (中国标准时间)

    实际上到2038年01月19日03时14分07秒,便会到达最大时间,过了这个时间点,所有32位操作系统时间便会变为10000000 00000000 00000000 00000000。因具体实现不同,有可能会是1901年12月13日20时45分52秒,这样便会出现时间回归的现象,很多软件便会运行异常了。


    至于时间回归的现象相信随着64为操作系统的产生逐渐得到解决,因为用64位操作系统可以表示到292,277,026,596年12月4日15时30分08秒。


    另外,考虑时区因素,北京时间的时间戳的起始时间是1970-01-01T08:00:00+08:00。


    好了,关于计算机中的时间就说完了,有疑问评论区相见 或 关注 程序饲养员 公号。



    作者:程序饲养员
    来源:juejin.cn/post/7312640704404111387
    收起阅读 »