注册
环信即时通讯云

环信即时通讯云

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

环信开发文档

Demo体验

Demo体验

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

RTE开发者社区

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

技术讨论区

技术交流、答疑
资源下载

资源下载

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

iOS Library

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

Android Library

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

不可不知的Redis秘籍:事务命令全攻略!

在数据处理的世界里,事务(Transaction)是一个不可或缺的概念。它们确保了在一系列操作中,要么所有的操作都成功执行,要么都不执行。这就像是一个“全有或全无”的规则,保证了数据的一致性和完整性。今天,我们就来聊聊Redis事务的使用,看看如何通过它来提升...
继续阅读 »

在数据处理的世界里,事务(Transaction)是一个不可或缺的概念。它们确保了在一系列操作中,要么所有的操作都成功执行,要么都不执行。这就像是一个“全有或全无”的规则,保证了数据的一致性和完整性。

今天,我们就来聊聊Redis事务的使用,看看如何通过它来提升我们的数据操作效率和安全性。

一、Redis事务的概念

Redis 事务的本质是一组命令的集合。事务支持一次执行多个命令,一个事务中所有命令都会被序列化。在事务执行过程,会按照顺序串行化执行队列中的命令,其他客户端提交的命令请求不会插入到事务执行命令序列中。

Description

总结来说: redis事务就是一次性、顺序性、排他性的执行一个队列中的一系列命令。

Redis事务没有隔离级别的概念

批量操作在发送 EXEC 命令前被放入队列缓存,并不会被实际执行,也就不存在事务内的查询要看到事务里的更新,事务外查询不能看到。

Redis不保证原子性

Redis中,单条命令是原子性执行的,但事务不保证原子性,且没有回滚。事务中任意命令执行失败,其余的命令仍会被执行。

redis事务的执行阶段

  • 开始事务(multi)。
  • 命令入队。
  • 执行事务(exec)

Description

二、Redis事务优缺点

对于Redis事务的概念我们已经有了基本的了解,下面我们再来看看它都有哪些优缺点。

优点:

  • 一次性按顺序执行多个Redis命令,不受其他客户端命令请求影响;

  • 事务中的命令要么都执行(命令间执行失败互相不影响),要么都不执行(比如中间有命令语法错误);

缺点:

  • 事务执行时,不能保证原子性;

  • 命令入队每次都需要和服务器进行交互,增加带宽;

注意:

  • 当事务中命令语法使用错误时,最终会导致事务执行不成功,即事务内所有命令都不执行;

  • 当事务中命令知识逻辑错误,就比如给字符串做加减乘除操作时,只能在执行过程中发现错误,这种事务执行中失败的命令不影响其他命令的执行。

三、Redis事务相关命令

Redis事务可以通过一系列命令来执行多个操作,并确保这些操作可以原子性地执行。以下是Redis事务的相关命令及其作用:

MULTI: 开启一个事务。在调用此命令后,Redis 会将后续的命令逐个放入队列中,直到接收到 EXEC 命令为止。

EXEC: 执行事务中的所有操作命令。一旦调用 EXEC 命令,Redis 会原子性地执行队列中的所有命令。

DISCARD: 取消事务,放弃执行事务块中的所有命令。如果不想继续执行事务中的操作,可以使用 DISCARD 命令来清除当前事务队列。

WATCH: 监视一个或多个键,如果在事务执行之前这些键被其他命令所改动,那么事务将会被打断。

UNWATCH: 取消所有由 WATCH 命令监视的键。如果不想继续监视某些键,可以使用 UNWATCH 命令来取消监视。

需要注意的是,在事务执行过程中,其他客户端提交的命令请求不会插入到事务执行命令序列中,这保证了事务的隔离性。同时,Redis 事务提供了批量操作缓存的功能,即在发送 EXEC 命令前,所有操作都会被放入队列缓存。

在这里给大家分享一下【云端源想】学习平台,无论你是初学者还是有经验的开发者,这里都有你需要的一切。包含课程视频、知识库、微实战、云实验室、一对一咨询等等,现在功能全部是免费的,点击这里,立即开始你的学习之旅!

四、Redis事务的使用

使用Redis事务的步骤如下:

  • 使用MULTI命令开启一个事务。

  • 在事务中执行需要的命令,如SET、GET等。

  • 使用EXEC命令提交事务,将事务中的命令一次性发送给Redis服务器执行。

  • 如果需要取消事务,可以使用DISCARD命令。

Description

下面通过一些示例来讲解一下这些命令的使用方法:

1、正常执行

192.168.xxx.21:6379> multi
OK
192.168.xxx.21:6379> set aa AA
QUEUED
192.168.xxx.21:6379> set bb BB
QUEUED
192.168.xxx.21:6379> set cc CC
QUEUED
192.168.xxx.21:6379> set dd DD
QUEUED
192.168.xxx.21:6379> exec
1) OK
2) OK
3) OK
4) OK
192.168.xxx.21:6379> get aa
"AA"

首先,通过执行multi命令开始一个事务块。然后,依次执行了四个set命令,将键"aa"、“bb”、“cc"和"dd"分别设置为对应的值"AA”、“BB”、“CC"和"DD”。

每个set命令执行后返回的结果为"QUEUED",表示该命令已被加入到事务队列中等待执行。

接下来,通过执行exec命令来提交事务,一次性执行事务队列中的所有命令。执行结果为每个命令的返回值,即"OK"。最后,通过执行get aa命令获取键"aa"的值,返回结果为"AA"。

2、取消事务

192.168.xxx.21:6379> multi
OK
192.168.xxx.21:6379> set aa 11
QUEUED
192.168.xxx.21:6379> set ee EE
QUEUED
192.168.xxx.21:6379> discard
OK
192.168.xxx.21:6379> get aa
"AA"
192.168.xxx.21:6379> get ee
(nil)
192.168.xxx.21:6379>

示例代码中,首先,通过执行multi命令开始一个事务块。然后,依次执行了两个set命令,将键"aa"设置为值"11",将键"ee"设置为值"EE"。每个set命令执行后返回的结果为"QUEUED",表示该命令已被加入到事务队列中等待执行。

接下来,通过执行discard命令来取消事务,放弃执行事务块内的所有命令。执行结果为"OK"。

最后,通过执行get aa命令获取键"aa"的值,返回结果为"AA"。而执行get ee命令获取键"ee"的值时,由于之前已经取消了事务,所以返回结果为"(nil)",表示该键不存在。

3、事务队列中存在命令错误

如果在事务队列中存在命令性错误(类似于java编译性错误),则执行EXEC命令时,所有命令都不会执行

192.168.xxx.21:6379> multi
OK
192.168.xxx.21:6379> set aa 22
QUEUED
192.168.xxx.21:6379> set bb 33
QUEUED
192.168.xxx.21:6379> setq cc 44
(error) ERR unknown command 'setq'
192.168.xxx.21:6379> set ff FF
QUEUED
192.168.xxx.21:6379> exec
(error) EXECABORT Transaction discarded because of previous errors.
192.168.xxx.21:6379> get ff
(nil)
192.168.xxx.21:6379> get bb
"BB"
192.168.xxx.21:6379>

首先,通过执行multi命令开始一个事务块。然后,依次执行了三个set命令,将键"aa"设置为值"22",将键"bb"设置为值"33",将键"cc"设置为值"44"。每个set命令执行后返回的结果为"QUEUED",表示该命令已被加入到事务队列中等待执行。

然而,在执行第三个set命令时,出现了错误。因为Redis中并没有名为"setq"的命令,所以返回结果为"(error) ERR unknown command ‘setq’"。

接下来,通过执行exec命令来提交事务,一次性执行事务队列中的所有命令。由于之前已经出现了错误,导致事务被中断,所以执行结果为"(error) EXECABORT Transaction discarded because of previous errors."。

最后,通过执行get ff命令获取键"ff"的值时,由于事务被中断,所以返回结果为"(nil)“,表示该键不存在。而执行get bb命令获取键"bb"的值时,由于事务被中断,所以返回结果为"BB”。

4、事务队列中存在语法错误

如果在事务队列中存在语法性错误(类似于java的1/0的运行时异常),则执行EXEC命令时,其他正确命令会被执行,错误命令抛出异常。

192.168.xxx.21:6379> multi
OK
192.168.xxx.21:6379> incr aa
QUEUED
192.168.xxx.21:6379> set ff FF
QUEUED
192.168.xxx.21:6379> set bb 22
QUEUED
192.168.xxx.21:6379> exec
1) (error) ERR value is not an integer or out of range
2) OK
3) OK
192.168.xxx.21:6379> get bb
"22"
192.168.xxx.21:6379> get ff
"FF"
192.168.xxx.21:6379>

错误原因:字符串不能累加1

5、watch监控

watch 命令可以监控一个或多个键,一旦有其中一个键被修改(被删除),后面的事务就不会执行了。监控一直持续到 EXEC 命令(事务中的命令是在exec之后才执行的,所以在multi命令后可以修改watch监控的键值)

假设我们通过watch命令在事务执行之前监控了多个Keys,倘若在watch之后有任何Key的值发生了变化,exec命令执行的事务都将被放弃,同时返回Null multi-bulk应答以通知调用者事务执行失败。

(1)、执行watch,不执行multi、exec

192.168.xxx.21:6379> get aa
"AA"
192.168.xxx.21:6379> watch aa
OK
192.168.xxx.21:6379> set aa 11
OK
192.168.xxx.21:6379> get aa
"11"
192.168.xxx.21:6379>

(2)、执行 watch 命令,通知执行 MULTI、exec

192.168.xxx.21:6379> set aa Aa
OK
192.168.xxx.21:6379> get aa
"Aa"
192.168.xxx.21:6379> multi
OK
192.168.xxx.21:6379> set aa 11
QUEUED
192.168.xxx.21:6379> exec
(nil)
192.168.xxx.21:6379> get aa
"Aa"
192.168.xxx.21:6379>

(3)、exec 执行之后,会自动执行 UNWatch 命令,撤销监听操作

192.168.xxx.21:6379> set aa Aa
OK
192.168.xxx.21:6379> get aa
"Aa"
192.168.xxx.21:6379> multi
OK
192.168.xxx.21:6379> set aa 11
QUEUED
192.168.xxx.21:6379> exec
(nil)
192.168.xxx.21:6379> get aa
"Aa"
192.168.xxx.21:6379> set aa 11
OK
192.168.xxx.21:6379> get aa
"11"
192.168.xxx.21:6379>

(4) 、unwatch撤销监听

192.168.xxx.21:6379> get bb
"BBB"
192.168.xxx.21:6379> watch bb
OK
192.168.xxx.21:6379> multi
OK
192.168.xxx.21:6379> unwatch
QUEUED
192.168.xxx.21:6379> set bb 222
QUEUED
192.168.xxx.21:6379> exec
1) OK
2) OK
192.168.xxx.21:6379> get bb
"222"
192.168.xxx.21:6379>

以上就是Redis事务的概念及相关命令的使用,Redis事务是一个非常强大的工具,它可以帮助我们在处理数据的时候保持数据的一致性和完整性。通过使用Redis事务,可以让我们的数据操作更高效、更安全。

希望这篇文章能够帮助你更好地理解和使用Redis事务!

收起阅读 »

应用容器化后为什么性能下降这么多?

1. 背景 随着越来越多的公司拥抱云原生,从原先的单体应用演变为微服务,应用的部署方式也从虚机变为容器化,容器编排组件k8s也成为大多数公司的标配。然而在容器化以后,我们发现应用的性能比原先在虚拟机上表现更差,这是为什么呢?。 2. 压测结果 2.1 容器化之...
继续阅读 »

1. 背景


随着越来越多的公司拥抱云原生,从原先的单体应用演变为微服务,应用的部署方式也从虚机变为容器化,容器编排组件k8s也成为大多数公司的标配。然而在容器化以后,我们发现应用的性能比原先在虚拟机上表现更差,这是为什么呢?。


2. 压测结果


2.1 容器化之前的表现


应用部署在虚拟机下,我们使用wrk工具进行压测,压测结果如下:


image.png


从压测结果看,平均RT1.68msqps716/s\color{red}{平均RT为1.68ms,qps为716/s},我们再来看下机器的资源使用情况,cpu基本已经被打满。
image.png


2.2 容器化后的表现


使用wrk工具进行压测,结果如下:
image.png


从压测结果看,平均RT2.11msqps554/s\color{red}{平均RT为2.11ms,qps为554/s},我们再来看下机器的资源使用情况,cpu基本已经被打满。
image.png


2.3 性能对比结果


性能对比虚拟机容器
RT1.68ms2.11ms
QPS716/s554/s


总体性能下降:RT(25%)、QPS(29%)



3. 原因分析


3.1 架构差异


由于应用在容器化后整体架构的不同、访问路径的不同,将可能导致应用容器化后性能的下降,于是我们先来分析下两者架构的区别。我们使用k8s作为容器编排基础设施,网络插件使用calico的ipip模式,整体架构如下所示。


x3.png


这里需要说明,虽然使用calico的ipip模式,由于pod的访问为service的nodePort模式,所以不会走tunl0网卡,而是从eth0经过iptables后,通过路由到calico的calixxx接口,最后到pod。


3.2性能分析


在上面压测结果的图中,我们容器化后,cpu的软中断si使用率明显高于原先虚拟机的si使用率,所以我们使用perf继续分析下热点函数。


image.png
为了进一步验证是否是软中断的影响,我们使用perf进一步统计软中断的次数。


image.png



我们发现容器化后比原先软中断多了14%,到这里,我们能基本得出结论,应用容器化以后,需要更多的软中断的网络通信导致了性能的下降。



3.3 软中断原因


由于容器化后,容器和宿主机在不同的网络namespace,数据需要在容器的namespace和host namespace之间相互通信,使得不同namespace的两个虚拟设备相互通信的一对设备为veth pair,可以使用ip link命令创建,对应上面架构图中红色框内的两个设备,也就是calico创建的calixxx和容器内的eth0。我们再来看下veth设备发送数据的过程


static netdev_tx_t veth_xmit(struct sk_buff *skb, struct net_device *dev)
{
...
if (likely(veth_forward_skb(rcv, skb, rq, rcv_xdp)
...
}

static int veth_forward_skb(struct net_device *dev, struct sk_buff *skb,
struct veth_rq *rq, bool xdp)
{
return __dev_forward_skb(dev, skb) ?: xdp ?
veth_xdp_rx(rq, skb) :
netif_rx(skb);//中断处理
}


/* Called with irq disabled */
static inline void ____napi_schedule(struct softnet_data *sd,
struct napi_struct *napi)
{
list_add_tail(&napi->poll_list, &sd->poll_list);
//发起软中断
__raise_softirq_irqoff(NET_RX_SOFTIRQ);
}

通过虚拟的veth发送数据和真实的物理接口没有区别,都需要完整的走一遍内核协议栈,从代码分析调用链路为veth_xmit -> veth_forward_skb -> netif_rx -> __raise_softirq_irqoff,veth的数据发送接收最后会使用软中断的方式,这也刚好解释了容器化以后为什么会有更多的软中断,也找到了性能下降的原因。


4. 优化策略


原来我们使用calico的ipip模式,它是一种overlay的网络方案,容器和宿主机之间通过veth pair进行通信存在性能损耗,虽然calico可以通过BGP,在三层通过路由的方式实现underlay的网络通信,但还是不能避免veth pari带来的性能损耗,针对性能敏感的应用,那么有没有其他underly的网络方案来保障网络性能呢?那就是macvlan/ipvlan模式,我们以ipvlan为例稍微展开讲讲。


4.1 ipvlan L2 模式


IPvlan和传统Linux网桥隔离的技术方案有些区别,它直接使用linux以太网的接口或子接口相关联,这样使得整个发送路径变短,并且没有软中断的影响,从而性能更优。如下图所示:


ipvlan l2 mode


上图是ipvlan L2模式的通信模型,可以看出container直接使用host eth0发送数据,可以有效减小发送路径,提升发送性能。


4.2 ipvlan L3 模式


ipvlan L3模式,宿主机充当路由器的角色,实现容器跨网段的访问,如下图所示:


ipvlan L3 mode


4.3 Cilium


除了使用macvlan/ipvlan提升网络性能外,我们还可以使用Cilium来提升性能,Cilium为云原生提供了网络、可观测性、网络安全等解决方案,同时它是一个高性能的网络CNI插件,高性能的原因是优化了数据发送的路径,减少了iptables开销,如下图所示:


cilium netwok


虽然calico也支持ebpf,但是通过benchmark的对比,Cilium性能更好,高性能名副其实,接下来我们来看看官网公布的一些benchmark的数据,我们只取其中一部分来分析,如下图:


xxxx2
xxxx3


无论从QPS和CPU使用率上Cilium都拥有更强的性能。


5. 总结


容器化带来了敏捷、效率、资源利用率的提升、环境的一致性等等优点的同时,也使得整体的系统复杂度提升一个等级,特别是网络问题,容器化使得整个数据发送路径变长,排查难度增大。不过现在很多网络插件也提供了很多可观测性的能力,帮助我们定位问题。


我们还是需要从实际业务场景出发,针对容器化后性能、安全、问题排查难度增大等问题,通过优化架构,增强基础设施建设才能让我们在云原生的路上越走越远。


最后,感谢大家观看,也希望和我讨论云原生过程中遇到的问题。


5. 参考资料


docs.docker.com/network/dri…


cilium.io/blog/2021/0…


作者:云之舞者
来源:juejin.cn/post/7268663683881828413
收起阅读 »

记一种不错的缓存设计思路

之前与同事讨论接口性能问题时听他介绍了一种缓存设计思路,觉得不错,做个记录供以后参考。 场景 假设有个以下格式的接口: GET /api?keys={key1,key2,key3,...}&types={1,2,3,...} 其中 keys 是业务...
继续阅读 »

之前与同事讨论接口性能问题时听他介绍了一种缓存设计思路,觉得不错,做个记录供以后参考。


场景


假设有个以下格式的接口:


GET /api?keys={key1,key2,key3,...}&types={1,2,3,...}


其中 keys 是业务主键列表,types 是想要取到的信息的类型。


请求该接口需要返回业务主键列表对应的业务对象列表,对象里需要包含指定类型的信息。


业务主键可能的取值较多,千万量级,type 取值范围为 1-10,可以任意组合,每种 type 对应到数据库是 1-N 张表,示意:


redis-cache-design.drawio.png


现在设想这个接口遇到了性能瓶颈,打算添加 Redis 缓存来改善响应速度,应该如何设计?


设计思路


方案一:


最简单粗暴的方法是直接使用请求的所有参数作为缓存 key,请求的返回内容为 value。


方案二:


如果稍做一下思考,可能就会想到文首我提到的觉得不错的思路了:



  1. 使用 业务主键:表名 作为缓存 key,表名里对应的该业务主键的记录作为 value;

  2. 查询时,先根据查询参数 keys,以及 types 对应的表,得到所有 key1:tb_1_1key1:tb_1_2 这样的组合,使用 Redis 的 mget 命令,批量取到所有缓存中存在的信息,剩下没有命中的,批量到数据库里查询到结果,并放入缓存;

  3. 在某个表的数据有更新时,只需刷新 涉及业务主键:该表名 的缓存,或令其失效即可。


小结


在以上两种方案之间做评估和选择,考虑几个方面:



  • 缓存命中率;

  • 缓存数量、占用空间大小;

  • 刷新缓存是否方便;


稍作思考和计算,就会发现此场景下方案二的优势。


另外,就是需要根据实际业务场景,如业务对象复杂度、读写次数比等,来评估合适的缓存数据的粒度和层次,是对应到某一级组合后的业务对象(缓存值对应存储 + 部分逻辑),还是最基本的数据库表/字段(存储的归存储,逻辑的归逻辑)。


作者:mzlogin
来源:juejin.cn/post/7271597656118394899
收起阅读 »

HTTP请求头中的Authorization

当使用HTTP请求中的Authorization头时,表示传入的是认证信息。具体的认证类型由凭证前缀指明。以下是Authorization头中常见的几种认证机制: 基本认证(Basic Authentication): Authorization: Basi...
继续阅读 »

当使用HTTP请求中的Authorization头时,表示传入的是认证信息。具体的认证类型由凭证前缀指明。以下是Authorization头中常见的几种认证机制:



  1. 基本认证(Basic Authentication):


    Authorization: Basic base64(用户名:密码)

    这是最常见的一种,涉及将用户名和密码以base64格式编码并与请求一起发送。需要注意,Basic 后面有空格, 未使用HTTPS时基本认证不够安全
    实际使用例子,比如:


    curl -u "admin:P@88w0rd" -H "Accept: application/json" http://localhost:8090/api/v1alpha1/users

    curl -u “username:password” 就相当于在请求的请求头中添加keyAuthorizationvalueadmin:P@88w0rd,这是一种认证方式。
    对账号密码进行base64编码之后


    echo -n "admin:P@88w0rd" | base64

    得到:YWRtaW46UEA4OHcwcmQ=
    ,上方的curl也可以写成:


    curl -H "Authorization: Basic YWRtaW46UEA4OHcwcmQ=" -H "Accept:  application/json" http://localhost:8090/api/v1alpha1/users


  2. Bearer令牌(Bearer Token):


    Authorization: Bearer <令牌>

    这通常与OAuth 2.0一起使用。<令牌>通常是通过单独的认证过程获取的长寿命访问令牌。


  3. 摘要认证(Digest Authentication):


    Authorization: Digest username="用户名", realm="领域", nonce="随机数", uri="URI", response="响应", opaque="opaque", qop=auth, nc=00000001, cnonce="cnonce"

    摘要认证比基本认证更安全,涉及挑战-响应机制来验证客户端。挑战-响应机制(Challenge-Response Mechanism,在这种机制中,服务器通过向客户端发送一个随机的挑战(challenge),并期望客户端使用其凭据(通常是密码)生成一个对应的响应(response)来证明其身份,服务端收到响应后验证身份)


  4. API密钥(API Key):


    Authorization: ApiKey <API密钥>

    API密钥通常用于API请求中的身份验证。密钥包含在Authorization头中。


  5. Bearer令牌(JWT):


    Authorization: Bearer eyJhbGciOiJIUzI1NiIsIn...

    JSON Web Tokens(JWT)通常在现代身份验证系统中使用。令牌包含在Bearer方案中。


  6. 自定义方案(Custom Schemes):
    一些应用程序或服务可能定义了自己的自定义认证方案。例如:


    Authorization: CustomScheme 自定义数据



以上的使用的scheme,如BasicBearer,Digest,ApiKey是约定俗成的,大家都这样使用,具体认证类型取决于服务器的要求和实现的协议,针对自己的业务也可以自定义scheme。也可以参考正在与之交互的服务或API的文档,以确定Authorization头的正确格式。


作者:星夜晚晚
来源:juejin.cn/post/7329573746464718857
收起阅读 »

双token和无感刷新token(简单写法,一文说明白,不墨迹)

为什么有这篇小作文?最近要给自己的项目加上token自动续期,但是在网上搜的写法五花八门,有的光前端部分就写了几百行代码,我看着费劲,摸了半天也没有实现,所以决定自己造轮子项目构成后端部分:使用golang的gin框架起的服务前端部分:vue+elementu...
继续阅读 »

为什么有这篇小作文?

最近要给自己的项目加上token自动续期,但是在网上搜的写法五花八门,有的光前端部分就写了几百行代码,我看着费劲,摸了半天也没有实现,所以决定自己造轮子

项目构成

  • 后端部分:使用golang的gin框架起的服务
  • 前端部分:vue+elementui

先说后端部分,后端逻辑相对前端简单点,关键两步

  1. 登陆接口生成双token
"github.com/dgrijalva/jwt-go"
func (this UserController) DoLogin(ctx *gin.Context) {
username := ctx.Request.FormValue("username")
passWord := ctx.Request.FormValue("password")
passMd5 := middlewares.CreateMD5(passWord)
expireTime := time.Now().Add(10 * time.Second).Unix() //token过期时间10秒,主要是测试方便
refreshTime := time.Now().Add(20 * time.Second).Unix() //刷新的时间限制,超过20秒重新登录
user := modules.User{}
err := modules.DB.Model(&modules.User{}).Where("username = ? AND password = ?", username, passMd5).Find(&user).Error
if err != nil {
ctx.JSON(400, gin.H{
"success": false,
"message": "用户名或密码错误",
})
} else {
println("expireTime", string(rune(expireTime)))
myClaims := MyClaims{
user.Id,
jwt.StandardClaims{
ExpiresAt: expireTime,
},
}
myClaimsRefrrsh := MyClaims{
user.Id,
jwt.StandardClaims{
ExpiresAt: refreshTime,
},
}
jwtKey := []byte("lyf123456")
tokenObj := jwt.NewWithClaims(jwt.SigningMethodHS256, myClaims)
tokenStr, err := tokenObj.SignedString(jwtKey)
tokenFresh := jwt.NewWithClaims(jwt.SigningMethodHS256, myClaimsRefrrsh)
tokenStrRefresh, err2 := tokenFresh.SignedString(jwtKey)
if err != nil && err2 != nil {
ctx.JSON(200, gin.H{
"message": "生成token失败",
"success": false,
})
} else {
ctx.JSON(200, gin.H{
"message": "登录成功",
"success": true,
"token": tokenStr,//数据请求的token
"refreshToken": tokenStrRefresh,//刷新token用的
})
}
}
}
  1. 刷新token的方法
func (this UserController) RefrshToken(ctx *gin.Context) {
tokenData := ctx.Request.Header.Get("Authorization") //这里是个关键点,刷新token时也要带上token,不过这里是前端传的refreshToken
if tokenData == "" {
ctx.JSON(401, gin.H{
"message": "token为空",
"success": false,
})
ctx.Abort()
return
}
tokenStr := strings.Split(tokenData, " ")[1]
_, claims, err := middlewares.ParseToken(tokenStr)
expireTime := time.Now().Add(10 * time.Second).Unix()
refreshTime := time.Now().Add(20 * time.Second).Unix()
if err != nil {
ctx.JSON(400, gin.H{
"success": false,
"message": "token传入错误",
})
} else {
myClaims := MyClaims{
claims.Uid,
jwt.StandardClaims{
ExpiresAt: expireTime,
},
}
myClaimsRefrrsh := MyClaims{
claims.Uid,
jwt.StandardClaims{
ExpiresAt: refreshTime,
},
}
jwtKey := []byte("lyf123456")
tokenObj := jwt.NewWithClaims(jwt.SigningMethodHS256, myClaims)
tokenStr, err := tokenObj.SignedString(jwtKey)
tokenFresh := jwt.NewWithClaims(jwt.SigningMethodHS256, myClaimsRefrrsh)
tokenStrRefresh, err2 := tokenFresh.SignedString(jwtKey)
if err != nil && err2 != nil {
ctx.JSON(400, gin.H{
"message": "生成token失败",
"success": false,
})
} else {
ctx.JSON(200, gin.H{
"message": "刷新token成功",
"success": true,
"token": tokenStr,
"refreshToken": tokenStrRefresh,
})
}
}
}
  1. 路由中间件里验证token
package middlewares

import (
"strings"

"github.com/dgrijalva/jwt-go"
"github.com/gin-gonic/gin"
)

type MyClaims struct {
Uid int
jwt.StandardClaims
}

func AuthMiddleWare(c *gin.Context) {
tokenData := c.Request.Header.Get("Authorization")
if tokenData == "" {
c.JSON(401, gin.H{
"message": "token为空",
"success": false,
})
c.Abort()
return
}
tokenStr := strings.Split(tokenData, " ")[1]
token, _, err := ParseToken(tokenStr)
if err != nil || !token.Valid {
// 这里我感觉觉是个关键点,我看别人写的,过期了返回401,但是前端的axios的响应拦截器里捕获不到,所以我用201状态码,
c.JSON(201, gin.H{
"message": "token已过期",
"success": false,
})
c.Abort()
return
} else {
c.Next()
}
}

func ParseToken(tokenStr string) (*jwt.Token, *MyClaims, error) {
jwtKey := []byte("lyf123456")
// 解析token
myClaims := &MyClaims{}
token, err := jwt.ParseWithClaims(tokenStr, myClaims, func(token *jwt.Token) (interface{}, error) {
return jwtKey, nil
})
return token, myClaims, err
}

总结一下:后端部分三步,1.登陆时生成双token,2,路由中间件里验证token,过期时返回201状态码(201是我私人定的,并不是行业标准)。3,刷新token的方法里也和登陆接口一样返回双token

前端部分

前端部分在axios封装时候加拦截器判断token是否过期,我这里跟别人写的最大的不同点是:我创建了两个axios对象,一个正常数据请求用(server),另一个专门刷新token用(serverRefreshToken),这样写的好处是省去了易错的判断逻辑

import axios from 'axios'
import { ElMessage } from 'element-plus'
import router from '../router'
//数据请求用
const server=axios.create({
baseURL:'/shopApi',
timeout:5000
})
// 刷新token专用
const serverRefreshToken=axios.create({
baseURL:'/shopApi',
timeout:5000
})
//获取新token的方法
async function getNewToken(){
let res=await serverRefreshToken.request({
url:`/admin/refresh`,
method:"post",
})
if(res.status==200){
sessionStorage.setItem("token",res.data.token)
sessionStorage.setItem("refreshToken",res.data.refreshToken)
return true
}else{
ElMessage.error(res.data.message)
router.push('/login')
return false
}
}
//这里是正常获取数据用的请求拦截器,主要作用是给所有请求的请求头里加上token
server.interceptors.request.use(config=>{
let token=""
token=sessionStorage.getItem("token")
if(token){
config.headers.Authorization="Bearer "+token
}
return config
},error=>{
Promise.reject(error)
})
//这里是正常获取数据用的响应拦截器,正常数据请求都是200状态码,当拦截到201状态码时,代表token过期了,
server.interceptors.response.use(async(res)=>{
if(res.status==201){
//获取新token
let bl=await getNewToken()
if(bl){
//获取成功新token之后,把刚才token过期拦截到的请求重新发一遍,获取到数据之后把res覆盖掉
//这里是个关键点,下边这行代码里的第二个res是token过期后被拦截的那个请求,config里是该请求的详细信息,重新请求后返回的是第一个res,把失败的res覆盖掉,这里有点绕,文字不好表达,
res=await server.request(res.config)
}
}
return res
},error=>{
if(error.response.status==500||error.response.status==401||error.response.status==400){
router.push('/login')
ElMessage.error(error.response.data.message)
Promise.reject(error)
}

})
//这里是刷新token专用的axios对象,他的作用是给请求加上刷新token专用的refreshToken
serverRefreshToken.interceptors.request.use(config=>{
let token=""
token=sessionStorage.getItem("refreshToken")
if(token){
config.headers.Authorization="Bearer "+token
}
return config
},error=>{
Promise.reject(error)
})
export default server

总结一下,前端部分:1,正常数据请求和刷新token用的请求分开了,各司其职。省去复杂的判断。2,获取新的token和refreshToken后更新原来旧的token和refreshToken。(完结)


作者:锋行天下
来源:juejin.cn/post/7337876697427148811

为什么有这篇小作文?

最近要给自己的项目加上token自动续期,但是在网上搜的写法五花八门,有的光前端部分就写了几百行代码,我看着费劲,摸了半天也没有实现,所以决定自己造轮子

项目构成

  • 后端部分:使用golang的gin框架起的服务
  • 前端部分:vue+elementui

先说后端部分,后端逻辑相对前端简单点,关键两步

  1. 登陆接口生成双token
"github.com/dgrijalva/jwt-go"
func (this UserController) DoLogin(ctx *gin.Context) {
username := ctx.Request.FormValue("username")
passWord := ctx.Request.FormValue("password")
passMd5 := middlewares.CreateMD5(passWord)
expireTime := time.Now().Add(10 * time.Second).Unix() //token过期时间10秒,主要是测试方便
refreshTime := time.Now().Add(20 * time.Second).Unix() //刷新的时间限制,超过20秒重新登录
user := modules.User{}
err := modules.DB.Model(&modules.User{}).Where("username = ? AND password = ?", username, passMd5).Find(&user).Error
if err != nil {
ctx.JSON(400, gin.H{
"success": false,
"message": "用户名或密码错误",
})
} else {
println("expireTime", string(rune(expireTime)))
myClaims := MyClaims{
user.Id,
jwt.StandardClaims{
ExpiresAt: expireTime,
},
}
myClaimsRefrrsh := MyClaims{
user.Id,
jwt.StandardClaims{
ExpiresAt: refreshTime,
},
}
jwtKey := []byte("lyf123456")
tokenObj := jwt.NewWithClaims(jwt.SigningMethodHS256, myClaims)
tokenStr, err := tokenObj.SignedString(jwtKey)
tokenFresh := jwt.NewWithClaims(jwt.SigningMethodHS256, myClaimsRefrrsh)
tokenStrRefresh, err2 := tokenFresh.SignedString(jwtKey)
if err != nil && err2 != nil {
ctx.JSON(200, gin.H{
"message": "生成token失败",
"success": false,
})
} else {
ctx.JSON(200, gin.H{
"message": "登录成功",
"success": true,
"token": tokenStr,//数据请求的token
"refreshToken": tokenStrRefresh,//刷新token用的
})
}
}
}
  1. 刷新token的方法
func (this UserController) RefrshToken(ctx *gin.Context) {
tokenData := ctx.Request.Header.Get("Authorization") //这里是个关键点,刷新token时也要带上token,不过这里是前端传的refreshToken
if tokenData == "" {
ctx.JSON(401, gin.H{
"message": "token为空",
"success": false,
})
ctx.Abort()
return
}
tokenStr := strings.Split(tokenData, " ")[1]
_, claims, err := middlewares.ParseToken(tokenStr)
expireTime := time.Now().Add(10 * time.Second).Unix()
refreshTime := time.Now().Add(20 * time.Second).Unix()
if err != nil {
ctx.JSON(400, gin.H{
"success": false,
"message": "token传入错误",
})
} else {
myClaims := MyClaims{
claims.Uid,
jwt.StandardClaims{
ExpiresAt: expireTime,
},
}
myClaimsRefrrsh := MyClaims{
claims.Uid,
jwt.StandardClaims{
ExpiresAt: refreshTime,
},
}
jwtKey := []byte("lyf123456")
tokenObj := jwt.NewWithClaims(jwt.SigningMethodHS256, myClaims)
tokenStr, err := tokenObj.SignedString(jwtKey)
tokenFresh := jwt.NewWithClaims(jwt.SigningMethodHS256, myClaimsRefrrsh)
tokenStrRefresh, err2 := tokenFresh.SignedString(jwtKey)
if err != nil && err2 != nil {
ctx.JSON(400, gin.H{
"message": "生成token失败",
"success": false,
})
} else {
ctx.JSON(200, gin.H{
"message": "刷新token成功",
"success": true,
"token": tokenStr,
"refreshToken": tokenStrRefresh,
})
}
}
}
  1. 路由中间件里验证token
package middlewares

import (
"strings"

"github.com/dgrijalva/jwt-go"
"github.com/gin-gonic/gin"
)

type MyClaims struct {
Uid int
jwt.StandardClaims
}

func AuthMiddleWare(c *gin.Context) {
tokenData := c.Request.Header.Get("Authorization")
if tokenData == "" {
c.JSON(401, gin.H{
"message": "token为空",
"success": false,
})
c.Abort()
return
}
tokenStr := strings.Split(tokenData, " ")[1]
token, _, err := ParseToken(tokenStr)
if err != nil || !token.Valid {
// 这里我感觉觉是个关键点,我看别人写的,过期了返回401,但是前端的axios的响应拦截器里捕获不到,所以我用201状态码,
c.JSON(201, gin.H{
"message": "token已过期",
"success": false,
})
c.Abort()
return
} else {
c.Next()
}
}

func ParseToken(tokenStr string) (*jwt.Token, *MyClaims, error) {
jwtKey := []byte("lyf123456")
// 解析token
myClaims := &MyClaims{}
token, err := jwt.ParseWithClaims(tokenStr, myClaims, func(token *jwt.Token) (interface{}, error) {
return jwtKey, nil
})
return token, myClaims, err
}

总结一下:后端部分三步,1.登陆时生成双token,2,路由中间件里验证token,过期时返回201状态码(201是我私人定的,并不是行业标准)。3,刷新token的方法里也和登陆接口一样返回双token

前端部分

前端部分在axios封装时候加拦截器判断token是否过期,我这里跟别人写的最大的不同点是:我创建了两个axios对象,一个正常数据请求用(server),另一个专门刷新token用(serverRefreshToken),这样写的好处是省去了易错的判断逻辑

import axios from 'axios'
import { ElMessage } from 'element-plus'
import router from '../router'
//数据请求用
const server=axios.create({
baseURL:'/shopApi',
timeout:5000
})
// 刷新token专用
const serverRefreshToken=axios.create({
baseURL:'/shopApi',
timeout:5000
})
//获取新token的方法
async function getNewToken(){
let res=await serverRefreshToken.request({
url:`/admin/refresh`,
method:"post",
})
if(res.status==200){
sessionStorage.setItem("token",res.data.token)
sessionStorage.setItem("refreshToken",res.data.refreshToken)
return true
}else{
ElMessage.error(res.data.message)
router.push('/login')
return false
}
}
//这里是正常获取数据用的请求拦截器,主要作用是给所有请求的请求头里加上token
server.interceptors.request.use(config=>{
let token=""
token=sessionStorage.getItem("token")
if(token){
config.headers.Authorization="Bearer "+token
}
return config
},error=>{
Promise.reject(error)
})
//这里是正常获取数据用的响应拦截器,正常数据请求都是200状态码,当拦截到201状态码时,代表token过期了,
server.interceptors.response.use(async(res)=>{
if(res.status==201){
//获取新token
let bl=await getNewToken()
if(bl){
//获取成功新token之后,把刚才token过期拦截到的请求重新发一遍,获取到数据之后把res覆盖掉
//这里是个关键点,下边这行代码里的第二个res是token过期后被拦截的那个请求,config里是该请求的详细信息,重新请求后返回的是第一个res,把失败的res覆盖掉,这里有点绕,文字不好表达,
res=await server.request(res.config)
}
}
return res
},error=>{
if(error.response.status==500||error.response.status==401||error.response.status==400){
router.push('/login')
ElMessage.error(error.response.data.message)
Promise.reject(error)
}

})
//这里是刷新token专用的axios对象,他的作用是给请求加上刷新token专用的refreshToken
serverRefreshToken.interceptors.request.use(config=>{
let token=""
token=sessionStorage.getItem("refreshToken")
if(token){
config.headers.Authorization="Bearer "+token
}
return config
},error=>{
Promise.reject(error)
})
export default server

总结一下,前端部分:1,正常数据请求和刷新token用的请求分开了,各司其职。省去复杂的判断。2,获取新的token和refreshToken后更新原来旧的token和refreshToken。(完结)


作者:锋行天下
来源:juejin.cn/post/7337876697427148811
收起阅读 »

听说你会架构设计?来,弄一个公交&地铁乘车系统

1. 引言 1.1 上班通勤的日常 “叮铃铃”,“叮铃铃”,早上七八点,你还在温暖的被窝里和闹钟“斗智斗勇”。 突然,你意识到已经快迟到了,于是像个闪电侠一样冲进卫生间,速洗漱,急穿衣,左手抄起手机,右手拿起面包,边穿衣边啃早餐。 这个时候,通勤的老难题又摆...
继续阅读 »

1. 引言


1.1 上班通勤的日常


“叮铃铃”,“叮铃铃”,早上七八点,你还在温暖的被窝里和闹钟“斗智斗勇”。



突然,你意识到已经快迟到了,于是像个闪电侠一样冲进卫生间,速洗漱,急穿衣,左手抄起手机,右手拿起面包,边穿衣边啃早餐。


这个时候,通勤的老难题又摆在了你面前:要不要吃完这口面包、刷牙和洗脸,还是先冲出门赶车?


好不容易做出了一个艰难的决定——放下面包,快步冲出门。你拿出手机,点开了熟悉的地铁乘车 App 或公交地铁乘车码小程序。


然后,一张二维码在屏幕上亮了起来,这可是你每天通勤的“敲门砖”。





你快步走到地铁站,将手机二维码扫描在闸机上,"嗖"的一声,闸机打开,你轻松通过,不再需要排队买票,不再被早高峰的拥挤闹心。


你走进地铁车厢,挤到了一个角落,拿出手机,开始计划一天的工作。


1.2 公交&地铁乘车系统


正如上文所说,人们只需要一台手机,一个二维码就可以完成上班通勤的所有事项。


那这个便捷的公交或地铁乘车系统是如何设计的呢?它背后的技术和架构是怎样支撑着你我每天的通勤生活呢?


今天让我们一起揭开这个现代都市打工人通勤小能手的面纱,深入探讨乘车系统的设计与实现


在这个文章中,小❤将带你走进乘车系统的世界,一探究竟,看看它是如何在短短几年内从科幻电影中走出来,成为我们日常生活不可或缺的一部分。


2. 需求设计


2.1 功能需求




  • 用户注册和登录: 用户可以通过手机应用或小程序注册账号,并使用账号登录系统。

  • 路线查询: 用户可以查询地铁的线路和站点信息,包括发车时间、车票价格等。

  • 获取乘车二维码: 系统根据用户的信息生成乘车二维码。

  • 获取地铁实时位置: 用户可以查询地铁的实时位置,并查看地铁离当前站台还有多久到达。

  • 乘车扫描和自动支付: 用户在入站和出站时通过扫描二维码来完成乘车,系统根据乘车里程自动计算费用并进行支付。

  • 交易记录查询: 用户可以查询自己的交易历史记录,包括乘车时间、金额、线路等信息。


2.2 乘车系统的非功能需求


乘车系统的用户量非常大,据《中国主要城市通勤检测报告-2023》数据显示,一线城市每天乘公交&地铁上班的的人数普遍超过千万,平均通勤时间在 45-60 分钟,并集中在早高峰和晚高峰时段。


所以,设计一个热点数据分布非均匀、人群分布非均匀的乘车系统时,需要考虑如下几点:



  • 用户分布不均匀,一线城市的乘车系统用户,超出普通城市几个数量级。

  • 时间分布不均匀,乘车系统的设计初衷是方便上下班通勤,所以早晚高峰的用户数会高出其它时间段几个数量级。

  • 高并发: 考虑到公交车/地铁系统可能同时有大量的用户在高峰时段使用,系统需要具备高并发处理能力。

  • 高性能: 为了提供快速的查询和支付服务,系统需要具备高性能,响应时间应尽可能短。

  • 可扩展性: 随着用户数量的增加,系统应该容易扩展,以满足未来的需求。

  • 可用性: 系统需要保证24/7的可用性,随时提供服务。

  • 安全和隐私保护: 系统需要确保用户数据的安全和隐私,包括支付信息和个人信息的保护。


3. 概要设计


3.1 核心组件




  • 前端应用: 开发手机 App 和小程序,提供用户注册、登录、查询等功能。

  • 后端服务: 设计后端服务,包括用户管理、路线查询、二维码管理、订单处理、支付系统等。

  • 数据库: 使用关系型数据库 MySQL 集群存储用户信息、路线信息、交易记录等数据。

  • 推送系统: 将乘车后的支付结果,通过在线和离线两种方式推送给用户手机上。

  • 负载均衡和消息队列: 考虑使用负载均衡和消息队列技术来提高系统性能。


3.2 乘车流程


1)用户手机与后台系统的交互


交互时序图如下:



1. 用户注册和登录: 用户首先需要在手机应用上注册并登录系统,提供个人信息,包括用户名、手机号码、支付方式等。


2. 查询乘车信息: 用户可以使用手机应用查询公交车/地铁的路线和票价信息,用户可以根据自己的出行需求选择合适的线路。


3. 生成乘车二维码: 用户登录后,系统会生成一个用于乘车的二维码,这个二维码可以在用户手机上随时查看。这个二维码是城市公交系统的通用乘车二维码,同时该码关联到用户的账户和付款方式,用户可以随时使用它乘坐任何一辆公交车或地铁。


2)用户手机与公交车的交互


交互 UML 状态图如下:




  1. 用户进站扫码: 当用户进入地铁站时,他们将手机上的乘车码扫描在进站设备上。这个设备将扫描到的乘车码发送给后台系统。

  2. 进站数据处理: 后台系统接收到进站信息后,会验证乘车码的有效性,检查用户是否有进站记录,并记录下进站的时间和地点。

  3. 用户出站扫码: 用户在乘车结束后,将手机上的乘车码扫描在出站设备上。

  4. 出站数据处理: 后台系统接收到出站信息后,会验证乘车码的有效性,检查用户是否有对应的进站记录,并记录下出站的时间和地点。


3)后台系统的处理



  1. 乘车费用计算: 基于用户的进站和出站地点以及乘车规则,后台系统计算乘车费用。这个费用可以根据不同的城市和运营商有所不同。

  2. 费用记录和扣款: 系统记录下乘车费用,并从用户的付款方式(例如,支付宝或微信钱包)中扣除费用。

  3. 乘车记录存储: 所有的乘车记录,包括进站、出站、费用等信息,被存储在乘车记录表中,以便用户查看和服务提供商进行结算。

  4. 通知用户: 如果有需要,系统可以向用户发送通知,告知他们的乘车费用已被扣除。

  5. 数据库交互: 在整个过程中,系统需要与数据库交互来存储和检索用户信息、乘车记录、费用信息等数据。


3. 详细设计


3.1 数据库设计



  • 用户信息表(User) ,包括用户ID、手机号、密码、支付方式、创建时间等。

  • 二维码表 (QRCode) ,包括二维码ID、用户ID、城市ID、生成时间、有效期及二维码数据等。

  • 车辆&地铁车次表 (Vehicle) ,包括车辆ID、车牌或地铁列车号、车型(公交、地铁)、扫描设备序列号等。

  • 乘车记录表 (TripRecord) ,包括记录ID、用户ID、车辆ID、上下车时间、起止站点等。

  • 支付记录表 (PaymentRecord) ,包括支付ID、乘车记录ID、交易时间、交易金额、支付方式、支付状态等。


以上是一些在公交车&地铁乘车系统中需要设计的数据库表及其字段的基本信息,后续可根据具体需求和系统规模,还可以进一步优化表结构和字段设计,以满足性能和扩展性要求。


详细设计除了要设计出表结构以外,我们还针对两个核心问题进行讨论:



  • 最短路线查询



  • 乘车二维码管理


3.2 最短路线查询


根据交通部门给的公交&地铁路线,我们可以绘制如下站点图:



假设图中的站点有 A-F,涉及到的交通工具有地铁 1 号线和 2 路公交,用户的起点和终点分别为 A、F 点。我们可以使用 Dijkstra 算法来求两点之间的最短路径,具体步骤为:


步骤已遍历集合未遍历集合
1选入A,此时最短路径 A->A = 0,再以 A 为中间点,开始寻找下一个邻近节点{B、C、D、E、F},其中与 A 相邻的节点有 B 和 C,AB=6,AC=3。接下来,选取较短的路径节点 C 开始遍历
2选取C,A->C=3,此时已遍历集合为{A、C},以 A 和 C 为中间点,开始寻找下一个邻近节点{B、D、E、F},其中与 A、C 相邻的节点有 B 和 D,AB=6,ACD=3+4=7。接下来,选取较短的路径节点 B 开始遍历
3选取B,A->B=6,此时已遍历集合为{A、C、B},A 相邻的节点已经遍历结束,开始寻找和 B、C 相近的节点{D、E、F},其中与 B、C 相邻的节点有 D,节点 D 在之前已经有了一个距离记录(7),现在新的可选路径是 ABD=6+5=11。显然第一个路径更短,于是将 D 的最近距离 7 加入到集合中
4选取D,A->D=7,此时已遍历集合为{A、C、B、D},寻找 D 相邻的节点{E、F},其中 DE=2,DF=3,选取最近路径的节点 E 加入集合
5选取 E,A->E=7+2=9,此时已遍历集合为{A、C、B、D、E},继续寻找 D 和 E 相近的节点{F},其中 DF=3,DEF=2+5=7,于是F的最近距离为7+3=10.
6选取F,A->F=10,此时遍历集合为{A、C、B、D、E、F}所有节点已遍历结束,从 A 点出发,它们的最近距离分别为{A=0,C=3,B=6,D=7,E=9,F=10}

在用户查询路线之前,交通部门会把公交 & 地铁的站点经纬度信息输入到路线管理系统,并根据二维的空间经纬度编码存储对应的站点信息。


我们设定西经为负,南纬为负,所以地球上的经度范围就是[-180, 180],纬度范围就是[-90,90]。如果以本初子午线、赤道为界,地球可以分成 4 个部分。



根据这个原理,我们可以先将二维的空间经纬度编码成一个字符串,来唯一标识用户或站点的位置信息。再通过 Redis 的 GeoHash 算法,来获取用户出发点附近的所有站点信息。


GeoHash 算法的原理是将一个位置的经纬度换算成地址编码字符串,表示在某个矩形区域,通过这个算法可以快速找到同一个区域的所有站点


一旦获得了起始地点的经纬度,系统就可以根据附近的站点信息,调用路线管理系统来查找最佳的公交或地铁路线。


一旦用户选择了一条路线,导航引擎启动并提供实时导航指引。导航引擎可能会使用地图数据和 GPS 定位来指导用户前往起止站点。


3.3 乘车二维码管理


乘车码是通过 QR 码(Quick Response Code)技术生成的,它比传统的 Bar Code 条形码能存更多的信息,也能表示更多的数据类型,如图所示:



二维码的生成非常简单,拿 Go 语言来举例,只需引入一个三方库:


import "github.com/skip2/go-qrcode"

func main() {
    qr,err:=qrcode.New("https://mp.weixin.qq.com",qrcode.Medium)
if err != nil {
    log.Fatal(err)
else {
    qr.BackgroundColor = color.RGBA{50,205,50,255//定义背景色
    qr.ForegroundColor = color.White //定义前景色
    qr.WriteFile(256,"./wechatgzh_qrcode.png"//转成图片保存
    }
}

以下是该功能用户和系统之间的交互、二维码信息存储、以及高并发请求处理的详细说明:



  1. 用户与系统交互: 用户首先在手机 App 上登录,系统会验证用户的身份和付款方式。一旦验证成功,系统根据用户的身份信息和付款方式,动态生成一个 QR 码,这个 QR 码包含了用户的标识信息和相关的乘车参数。

  2. 二维码信息存储: 生成的二维码信息需要在后台进行存储和关联。通常,这些信息会存储在一个专门的数据库表中,该表包含以下字段:



    • 二维码ID:主键ID,唯一标识一个二维码。

    • 用户ID:与乘车码关联的用户唯一标识。

    • 二维码数据:QR码的内容,包括用户信息和乘车参数。

    • 生成时间:二维码生成的时间戳,用于后续的验证和管理。

    • 有效期限:二维码的有效期,通常会设置一个时间限制,以保证安全性。



  3. 高并发请求处理: 在高并发情况下,大量的用户会同时生成和扫描二维码,因此需要一些策略来处理这些请求:



    • 负载均衡: 后台系统可以采用负载均衡技术,将请求分散到多个服务器上,以分担服务器的负载。

    • 缓存优化: 二维码的生成是相对耗时的操作,可以采用 Redis 来缓存已生成的二维码,避免重复生成。

    • 限制频率: 为了防止滥用,可以限制每个用户生成二维码的频率,例如,每分钟只允许生成 5  次,这可以通过限流的方式来实现。




总之,通过 QR 码技术生成乘车码,后台系统需要具备高并发处理的能力,包括负载均衡、缓存和频率限制等策略,以确保用户能够快速获得有效的乘车二维码。


同时,二维码信息需要被安全地存储和管理,比如:加密存储以保护用户的隐私和付款信息。



不清楚如何限流的,可以看我之前的这篇文章:若我问到高可用,阁下又该如何应对呢?



4. 乘车系统的发展


4.1 其它设计


除此之外,公交车或地铁的定位和到站时间计算可能还涉及定位设备、GPS 系统、NoSQL 数据库、用户 TCP 连接管理系统等核心组件,并通过实时数据采集、位置处理、到站时间计算和信息推送等流程来为用户提供准确的乘车信息。


同时,自动支付也是为了方便用户的重要功能,可以通过与第三方支付平台的集成来实现。


4.2 未来发展


公交车/地铁乘车系统的未来发展可以包括以下方向:



  • 智能化乘车: 引入智能设备,如人脸自动识别乘客、人脸扣款等。

  • 大数据分析: 利用大数据技术分析乘车数据,提供更好的服务。


在设计和发展过程中,也要不断考虑用户体验、性能和安全,确保系统能够满足不断增长的需求。


由于篇幅有限,文章就到此结束了。


希望读者们能对公交&地铁乘车系统的设计有更深入的了解,并和小❤一起期待未来更多的交通创新解决方案叭~


作者:xin猿意码
来源:juejin.cn/post/7287495466514055202
收起阅读 »

前端要懂的Docker部分

前言 最近学习部署的时候,发现前端部署可以通过多种方式来进行部署,web服务器,Docker,静态部署:Github page,网站托管平台:Vercel,还有一些自动化部署的东西目前还没有学到... Docker开始 简介 Docker是一个应用打包,分发,...
继续阅读 »

前言


最近学习部署的时候,发现前端部署可以通过多种方式来进行部署,web服务器,Docker,静态部署:Github page,网站托管平台:Vercel,还有一些自动化部署的东西目前还没有学到...


Docker开始


简介


Docker是一个应用打包,分发,部署的工具,它就相当于一个容器,将你想要的一些依赖、第三方库、环境啥的和代码进行一块打包为镜像,并上传到镜像仓库里面,这样别人可以直接从镜像仓库里面直接拉去这个代码来进行运行,可以适应不同的电脑环境,打造了一个完全封闭的环境。



  • 打包:将软件运行所需要的依赖、第三方库、软件打包到一起,变成一个安装包

  • 分发:将你打包好的安装包上传到一个镜像 仓库,其他人可以很方便的获取安装

  • 部署:拿着安装包就可以一个命令运行起来你的应用,自动模拟出一摸一样的运行环境


为什么使用Docker



Docker 的出现主要是为了解决以下问题:“在我的机器上运行正常,但为什么到你的机器上就运行不正常了?”。



平常开发的时候,项目在本地跑的好好的,但是其他人想要在他的电脑上去跑你的应用程序,但是却跑不起来,他得配置数据库,Web 服务器,插件啥的,非常不方便。Docker的出现解决了这些问题。


基本术语


镜像:理解为软件安装包,可以方便的进行传播和安装,它包含了运行应用程序所需的所有元素,包括代码、运行时环境、库、环境变量和配置文件。



  • 镜像可以通过Dockerfile来进行创建

  • Dockerfile就相当于一个脚本,编写一些命令,他会识别这些命令并且执行


容器:软件安装后的状态,每个软件运行的环境都是独立的、隔离的,称之为容器。


仓库:仓库是存放 Docker 镜像的地方。仓库允许你分享你的镜像,你可以将你的镜像推送(push)到仓库,也可以从仓库拉取(pull)其他人分享的镜像。



  • Docker 提供了一个公共的仓库 Docker Hub,你可以在上面上传你的镜像,或者寻找你需要的镜像。


常见命令



  • docker run: 用于从 Docker 镜像启动一个容器。例如,docker run -p 8080:80 -d my-app 将从名为 "my-app" 的 Docker 镜像启动一个新的容器,并将容器的 8080 端口映射到主机的 80 端口。

  • docker build: 用于从 Dockerfile 构建 Docker 镜像。例如,docker build -t my-app . 将使用当前目录中的 Dockerfile 构建一个名为 "my-app" 的 Docker 镜像。

  • docker pull: 用于从 Docker Hub 或其他 Docker 注册服务器下载 Docker 镜像。例如,docker pull nginx 将从 Docker Hub 下载官方的 Nginx 镜像。

  • docker push: 用于将 Docker 镜像推送到 Docker Hub 或其他 Docker 注册服务器。例如,docker push my-app 将名为 "my-app" 的 Docker 镜像推送到你的 Docker Hub 账户。

  • docker ps: 用于列出正在运行的 Docker 容器。添加 -a 选项(docker ps -a)可以列出所有容器,包括已停止的。

  • docker stop: 用于停止正在运行的 Docker 容器。例如,docker stop my-container 将停止名为 "my-container" 的 Docker 容器。

  • docker rm: 用于删除 Docker 容器。例如,docker rm my-container 将删除名为 "my-container" 的 Docker 容器。

  • docker rmi: 用于删除 Docker 镜像。例如,docker rmi my-app 将删除名为 "my-app" 的 Docker 镜像。

  • docker logs: 用于查看 Docker 容器的日志。例如,docker logs my-container 将显示名为 "my-container" 的 Docker 容器的日志。

  • docker exec: 用于在正在运行的 Docker 容器中执行命令。例如,docker exec -it my-container bash 将在名为 "my-container" 的 Docker 容器中启动一个 bash shell。
    docker常见命令



ps:不会使用就:docker load --help



编写Dockerfile


FROM nginx:latest
# 定义作者
MAINTAINER Merikle

#删除目录下的default.conf文件
#RUN rm /etc/nginx/conf.d/default.conf
#设置时区
RUN cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && echo 'Asia/Shanghai' >/etc/timezone

#将本地nginx.conf配置覆盖nginx配置
COPY nginx.conf /etc/nginx/nginx.conf
# 将dist文件中的内容复制到 /usr/share/nginx/html/ 这个目录下面
COPY dist/ /usr/share/nginx/html/
#声名端口
EXPOSE 8748

RUN echo 'web project build success!!'

FROM:指定基础镜像


RUN:一般指安装的过程


COPY:拷贝本地文件到镜像的指定目录


ENV:环境变量


EXPOSE:指定容器运行时监听到的端口,是给镜像的使用者看的


ENTRYPOINT:镜像中应用的启动命令,容器运行时调用


编写完Dockerfile生成镜像:


docker build -t test:v1 .


运行镜像:


docker run -p 8080:8080 --name test-hello test:v1


意思:跑在8080端口将test:v1命名为text-hello


之后你可以发布到上面说的仓库里面




  • 命令行登录账号: docker login -u username

  • 新建一个tag,名字必须跟你注册账号一样 docker tag test:v1 username/test:v1

  • 推上去 docker push username/test:v1

  • 部署试下 docker run -dp 8080:8080 username/test:v1


实践一下Docker部署前端项目


核心思想:


将前端打包的dist放到nginx里面,然后添加一个nginx.conf文件。


docker的话就是多了一个Dockerfile文件来构建镜像而已。


nginx.conf文件的编写


#nginx.conf文件编写
#user nobody;
worker_processes 1;

#error_log logs/error.log;
#error_log logs/error.log notice;
#error_log logs/error.log info;

#pid logs/nginx.pid;


events {
worker_connections 1024;
}


http {
include mime.types;
default_type application/octet-stream;

#log_format main '$remote_addr - $remote_user [$time_local] "$request" '
# '$status $body_bytes_sent "$http_referer" '
# '"$http_user_agent" "$http_x_forwarded_for"';

#access_log logs/access.log main;

sendfile on;
#tcp_nopush on;

#keepalive_timeout 0;
keepalive_timeout 65;

#gzip on;

server {
#监听的端口
listen 8748;
#请填写绑定证书的域名或者IP
server_name 121.199.29.3;

gzip on;
gzip_min_length 1k;
gzip_comp_level 9;
gzip_types text/plain application/javascript application/x-javascript text/css application/xml text/javascript application/x-httpd-php image/jpeg image/gif image/png;
gzip_vary on;
gzip_disable "MSIE [1-6]\.";

#charset koi8-r;

#access_log logs/host.access.log main;

location / {
root /usr/share/nginx/html;
index index.html index.htm;
try_files $uri $uri/ /index.html;
}

#error_page 404 /404.html;

# redirect server error pages to the static page /50x.html
#
error_page 500 502 503 504 /50x.html;
location = /50x.html {
root html;
}

# proxy the PHP scripts to Apache listening on 127.0.0.1:80
#
#location ~ \.php$ {
# proxy_pass http://127.0.0.1;
#}

# pass the PHP scripts to FastCGI server listening on 127.0.0.1:9000
#
#location ~ \.php$ {
# root html;
# fastcgi_pass 127.0.0.1:9000;
# fastcgi_index index.php;
# fastcgi_param SCRIPT_FILENAME /scripts$fastcgi_script_name;
# include fastcgi_params;
#}

# deny access to .htaccess files, if Apache's document root
# concurs with nginx's one
#
#location ~ /\.ht {
# deny all;
#}
}


# another virtual host using mix of IP-, name-, and port-based configuration
#
#server {
# listen 8000;
# listen somename:8080;
# server_name somename alias another.alias;

# location / {
# root html;
# index index.html index.htm;
# }
#}


# HTTPS server
#
#server {
# listen 443 ssl;
# server_name localhost;

# ssl_certificate cert.pem;
# ssl_certificate_key cert.key;

# ssl_session_cache shared:SSL:1m;
# ssl_session_timeout 5m;

# ssl_ciphers HIGH:!aNULL:!MD5;
# ssl_prefer_server_ciphers on;

# location / {
# root html;
# index index.html index.htm;
# }
#}

}


Dockerfile文件的编写


# Dockerfile文件

FROM nginx:latest
# 定义作者
MAINTAINER Merikle

#删除目录下的default.conf文件
#RUN rm /etc/nginx/conf.d/default.conf
#设置时区
RUN cp /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && echo 'Asia/Shanghai' >/etc/timezone

#将本地nginx.conf配置覆盖nginx配置
COPY nginx.conf /etc/nginx/nginx.conf
# 将dist文件中的内容复制到 /usr/share/nginx/html/ 这个目录下面
COPY dist/ /usr/share/nginx/html/
#声名端口
EXPOSE 8748

RUN echo 'web project build success!!'


然后将这些压缩成zip(或者本地打包为镜像,然后上传到镜像仓库里面,在服务器里面拉取镜像就可以了),然后传到服务器中,进行解压,构建镜像并且运行镜像来进行前端部署。


部署node项目


Dockerfile的编写


FROM node:11
MAINTAINER Merikle

#
复制代码
ADD . /mongo-server

#
设置容器启动后的默认运行目录
WORKDIR /mongo-server

#
运行命令,安装依赖
# RUN 命令可以有多个,但是可以用 && 连接多个命令来减少层级。
# 例如 RUN npm install && cd /app && mkdir logs
RUN npm install --registry=https://registry.npm.taobao.org

#
CMD 指令只能一个,是容器启动后执行的命令,算是程序的入口。
# 如果还需要运行其他命令可以用 && 连接,也可以写成一个shell脚本去执行。
# 例如 CMD cd /app && ./start.sh
CMD node app.js

然后构建镜像


发布镜像到hub上面


在服务器上面进行拉取镜像并且运行镜像。


部署mongodb


这个还没有部署,之后再说啦。


作者:Charlotten
来源:juejin.cn/post/7332290436345905187
收起阅读 »

SQL中为什么不要使用1=1

最近看几个老项目的SQL条件中使用了1=1,想想自己也曾经这样写过,略有感触,特别拿出来说道说道。 编写SQL语句就像炒菜,每一种调料的使用都会影响菜品的最终味道,每一个SQL条件的加入也会影响查询的执行效率。那么 1=1 存在什么样的问题呢?为什么又会使用呢...
继续阅读 »

最近看几个老项目的SQL条件中使用了1=1,想想自己也曾经这样写过,略有感触,特别拿出来说道说道。


编写SQL语句就像炒菜,每一种调料的使用都会影响菜品的最终味道,每一个SQL条件的加入也会影响查询的执行效率。那么 1=1 存在什么样的问题呢?为什么又会使用呢?


为什么会使用 1=1?


在动态构建SQL查询时,开发者可能会不确定最终需要哪些条件。这时候,他们就会使用“1=1”作为一个始终为真的条件,让接下来的所有条件都可以方便地用“AND”连接起来,就像是搭积木的时候先放一个基座,其他的积木块都可以在这个基座上叠加。


就像下边这样:


SELECT * FROM table WHERE 1=1
<if test="username != null">
AND username = #{username}
</if>
<if test="age > 0">
AND age = #{age}
</if>

这样就不用在增加每个条件之前先判断是否需要添加“AND”。


1=1 带来的问题


性能问题


我们先来了解一下数据库查询优化器的工作原理。查询优化器就像是一个聪明的图书管理员,它知道如何最快地找到你需要的书籍。当你告诉它所需书籍的特征时,它会根据这些信息选择最快的检索路径。比如你要查询作者是“谭浩强”的书籍,它就选择先通过作者索引找到书籍索引,再通过书籍索引找到对应的书籍,而不是费力的把所有的书籍遍历一遍。


但是,如果我们告诉它一些无关紧要的信息,比如“我要一本书,它是一本书”,这并不会帮助管理员更快地找到书,反而可能会让他觉得困惑。一个带有“1=1”的查询可能会让数据库去检查每一条记录是否满足这个始终为真的条件,这就像是图书管理员不得不检查每一本书来确认它们都是书一样,显然是一种浪费。


不过这实际上可能也不会产生问题,因为现代数据库的查询优化器已经非常智能,它们通常能够识别出像 1=1 这样的恒真条件,并在执行查询计划时优化掉它们。在许多情况下,即使查询中包含了1=1,数据库的性能也不会受到太大影响,优化器会在实际执行查询时将其忽略。


代码质量


不过,我们仍然需要避免在查询中包含 1=1,有以下几点考虑:



  1. 代码清晰性:即使数据库可以优化掉这样的条件,但对于阅读SQL代码的人来说,1=1可能会造成困惑。代码的可读性和清晰性非常重要,特别是在团队协作的环境中。

  2. 习惯养成:即使在当前的数据库系统中1=1不会带来性能问题,习惯了写不必要的代码可能会在其他情况下引入实际的性能问题。比如,更复杂的无用条件可能不会那么容易被优化掉。

  3. 优化器的限制:虽然现代优化器很强大,但它们并不是万能的。在某些复杂的查询场景中,即使是简单的 1=1 也可能对优化器的决策造成不必要的影响,比如索引的使用。

  4. 跨数据库兼容性:不同的数据库管理系统(DBMS)可能有不同的优化器能力。一个系统可能轻松优化掉1=1,而另一个系统则可能不那么高效。编写不依赖于特定优化器行为的SQL语句是一个好习惯。


编写尽可能高效、清晰和准确的SQL语句,不仅有助于保持代码的质量,也让代码具有更好的可维护性和可扩展性。


替代 1=1 的更佳做法


现在开发者普遍使用ORM框架来操作数据库了,还在完全手写拼SQL的同学可能需要反思下了,这里给两个不同ORM框架下替代1=1的方法。


假设我们有一个用户信息表 user,并希望根据传入的参数动态地过滤用户。


首先是Mybatis


<!-- MyBatis映射文件片段 -->
<select id="selectUsersByConditions" parameterType="map" resultType="com.example.User">
SELECT * FROM user
<where>
<!-- 使用if标签动态添加条件 -->
<if test="username != null and username != ''">
AND username = #{username}
</if>
<if test="age > 0">
AND age = #{age}
</if>
<!-- 更多条件... -->
</where>
</select>

在 MyBatis 中,避免使用 WHERE 1=1 的典型方法是利用动态SQL标签(如 )来构建条件查询。 标签会自动处理首条条件前的 AND 或 OR。当没有满足条件的 或其他条件标签时, 标签内部的所有内容将被忽略,从而不会生成多余的 AND 或 WHERE 子句。


再看看 Entity Framework 的方法:


var query = context.User.AsQueryable();
if (!string.IsNullOrEmpty(username))
{
query = query.Where(b => b.UserName.Contains(username));
}
if (age>0)
{
query = query.Where(b => b.Age = age);
}
var users = query.ToList();

这是一种函数式编程的写法,最终生成SQL时,框架会决定是否在条件前增加AND,而不需要人为的增加 1=1。


总结


“1=1”在SQL语句中可能看起来无害,但实际上它是一种不良的编程习惯,可能会导致性能下降。就像在做饭时不会无缘无故地多加调料一样,我们在编写SQL语句时也应该避免添加无意义的条件。


每一行代码都应该有它存在的理由,不要让你的数据库像一个困惑的图书管理员,浪费时间在不必要的事情上。


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

对代码封装的一点思考

之前看过一篇文章讲代码封装的过程,下面从简述文章的内容开始梳理下自己对代码封装的一点思考. --- 以下是之前文章内容简述 --- 在业务迭代的初期,代码中有一段业务逻辑A,由于A逻辑在项目中使用比较广泛,于是对A逻辑进行了代码逻辑的封装,包括参数的预设等 ...
继续阅读 »

之前看过一篇文章讲代码封装的过程,下面从简述文章的内容开始梳理下自己对代码封装的一点思考.


--- 以下是之前文章内容简述 ---


在业务迭代的初期,代码中有一段业务逻辑A,由于A逻辑在项目中使用比较广泛,于是对A逻辑进行了代码逻辑的封装,包括参数的预设等

初始化封装

随着业务的发展,又出现了B模块,B模块中几乎可以直接使用A逻辑,但是需要对A逻辑进行一些小小的改动,比如入参的调整、逻辑分支的增加

再次封装

这种通过修改较小范围代码就能实现功能的方式在实际开发中是很常规的操作,它并没有不好,只是当这种修改变的次数多的时候,最开始封装的逻辑A就变成了‘四不像’,它似乎可以服务于A模块,但是确有些冗余,出现了‘我好像看不懂这段代码’,‘这段代码还是别改了’,‘你改吧 我是改不了’的场景。在多次封装之后,逻辑A容易出现以下的问题



  • 入参不可控

  • 逻辑分支复杂,不清晰,修改范围不确定,容易对其他模块产生影响

  • 复用度逐渐下降,解决方法



    1. 将就不再继续新增逻辑,变成'老大难'问题,总有爆发的一天

    2. 重写相关逻辑,功能回测范围大,影响范围不可控,容易出现漏测等




--- 以下是正文 ---


提前封装或者过度封装对实际业务的编写都有很大影响,最实际的感受就是这段代码不好写,这个逻辑看不懂。封装的代码逻辑是结果,封装的出发点是因,从出发点去看而不是面向结果的封装似乎更能编写出可维护性的代码。以下是自己对封装的一点思考。


降低复杂度


复杂度

通过对业务中分散的相似逻辑提取,就能一定程度上降低项目的复杂度和重复度的问题。在修改相应的业务逻辑时候就相对集中和可控。新增逻辑的修改也能做到应用范围的同步。


解耦逻辑


在设计模式中需要设计的模块尽量遵循单一职责,但是在实际编写代码的过程中很容易出现模块功能的耦合,比如常见的:



  • 视图层逻辑与数据层逻辑耦合

  • 非相关逻辑的入侵 比如视图的逻辑需要感知请求状态的改变做相应的逻辑处理


通过将相关的逻辑都聚合到模块封装的内部,就能较低其他模块对不相关逻辑的感知,也就做到了解耦。在下面的例子中,通过将A逻辑的关联逻辑都封装进A模块中,降低了功能模块对封装逻辑的感知。内部封装

相关例子:



  • ahooks中的useRequest中,就通过对请求状态和数据的操作就行封装,将数据相关操作做到了内聚,减轻了视图层的负担

  • Antd pro components的table


分层


分层封装

在例子中,通过将逻辑A中的相关逻辑提取到上层,分层的方式实现了模块功能的内聚与解耦。在前端的MVC中就通过将相关操作封装成Controller,来完成逻辑的解耦


复用(一致性,可控性)


从降低复杂度、逻辑结构、分层设计最终的目标是达到封装代码的复用。通过复用能控制代码的一致性,实现修改代码行为的可控。



-- 写在最后 --


代码的优化或者设计是应该在理解业务的基础上,从相对大的视角出发来看的。在实际做的时候容易出现缺少全局视角的设计缺失和不完善,也容易出现过度设计,需要仔细去把握这个度量。


作者:前端小板凳
来源:juejin.cn/post/7337354931479085096
收起阅读 »

分类树,我从2s优化到0.1s

前言 分类树查询功能,在各个业务系统中可以说随处可见,特别是在电商系统中。 但就是这样一个简单的分类树查询功能,我们却优化了5次。 到底是怎么回事呢? 背景 我们的网站使用了SpringBoot推荐的模板引擎:Thymeleaf,进行动态渲染。 它是一个XM...
继续阅读 »

前言


分类树查询功能,在各个业务系统中可以说随处可见,特别是在电商系统中。



但就是这样一个简单的分类树查询功能,我们却优化了5次。


到底是怎么回事呢?


背景


我们的网站使用了SpringBoot推荐的模板引擎:Thymeleaf,进行动态渲染。


它是一个XML/XHTML/HTML5模板引擎,可用于Web与非Web环境中的应用开发。


它提供了一个用于整合SpringMVC的可选模块,在应用开发中,我们可以使用Thymeleaf来完全代替JSP或其他模板引擎,如Velocity\FreeMarker等。


前端开发写好Thymeleaf的模板文件,调用后端接口获取数据,进行动态绑定,就能把想要的内容展示给用户。


由于当时这个是从0-1的新项目,为了开快速开发功能,我们第一版接口,直接从数据库中查询分类数据,组装成分类树,然后返回给前端。


通过这种方式,简化了数据流程,快速把整个页面功能调通了。


第1次优化


我们将该接口部署到dev环境,刚开始没啥问题。


随着开发人员添加的分类越来越多,很快就暴露出性能瓶颈。


我们不得不做优化了。


我们第一个想到的是:加Redis缓存


流程图如下:

于是暂时这样优化了一下:



  1. 用户访问接口获取分类树时,先从Redis中查询数据。

  2. 如果Redis中有数据,则直接数据。

  3. 如果Redis中没有数据,则再从数据库中查询数据,拼接成分类树返回。

  4. 将从数据库中查到的分类树的数据,保存到Redis中,设置过期时间5分钟。

  5. 将分类树返回给用户。


我们在Redis中定义一个了key,value是一个分类树的json格式转换成了字符串,使用简单的key/value形式保存数据。


经过这样优化之后,dev环境的联调和自测顺利完成了。


第2次优化


我们将这个功能部署到st环境了。


刚开始测试同学没有发现什么问题,但随着后面不断地深入测试,隔一段时间就出现一次首页访问很慢的情况。


于是,我们马上进行了第2次优化。


我们决定使用Job定期异步更新分类树到Redis中,在系统上线之前,会先生成一份数据。


当然为了保险起见,防止Redis在哪条突然挂了,之前分类树同步写入Redis的逻辑还是保留。


于是,流程图改成了这样:

增加了一个job每隔5分钟执行一次,从数据库中查询分类数据,封装成分类树,更新到Redis缓存中。


其他的流程保持不变。


此外,Redis的过期时间之前设置的5分钟,现在要改成永久。


通过这次优化之后,st环境就没有再出现过分类树查询的性能问题了。


第3次优化


测试了一段时间之后,整个网站的功能快要上线了。


为了保险起见,我们需要对网站首页做一次压力测试。


果然测出问题了,网站首页最大的qps是100多,最后发现是每次都从Redis获取分类树导致的网站首页的性能瓶颈。


我们需要做第3次优化。


该怎么优化呢?


答:加内存缓存。


如果加了内存缓存,就需要考虑数据一致性问题。


内存缓存是保存在服务器节点上的,不同的服务器节点更新的频率可能有点差异,这样可能会导致数据的不一致性。


但分类本身是更新频率比较低的数据,对于用户来说不太敏感,即使在短时间内,用户看到的分类树有些差异,也不会对用户造成太大的影响。


因此,分类树这种业务场景,是可以使用内存缓存的。


于是,我们使用了Spring推荐的caffine作为内存缓存。


改造后的流程图如下:



  1. 用户访问接口时改成先从本地缓存分类数查询数据。

  2. 如果本地缓存有,则直接返回。

  3. 如果本地缓存没有,则从Redis中查询数据。

  4. 如果Redis中有数据,则将数据更新到本地缓存中,然后返回数据。

  5. 如果Redis中也没有数据(说明Redis挂了),则从数据库中查询数据,更新到Redis中(万一Redis恢复了呢),然后更新到本地缓存中,返回返回数据。



需要注意的是,需要改本地缓存设置一个过期时间,这里设置的5分钟,不然的话,没办法获取新的数据。



这样优化之后,再次做网站首页的压力测试,qps提升到了500多,满足上线要求。
最近我建了新的技术交流群,打算将它打造成高质量的活跃群,欢迎小伙伴们加入。


我以往的技术群里技术氛围非常不错,大佬很多。


image.png


加微信:su_san_java,备注:加群,即可加入该群。


第4次优化


之后,这个功能顺利上线了。


使用了很长一段时间没有出现问题。


两年后的某一天,有用户反馈说,网站首页有点慢。


我们排查了一下原因发现,分类树的数据太多了,一次性返回了上万个分类。


原来在系统上线的这两年多的时间内,运营同学在系统后台增加了很多分类。


我们需要做第4次优化。


这时要如何优化呢?


限制分类树的数量?


答:也不太现实,目前这个业务场景就是有这么多分类,不能让用户选择不到他想要的分类吧?


这时我们想到最快的办法是开启nginxGZip功能。


让数据在传输之前,先压缩一下,然后进行传输,在用户浏览器中,自动解压,将真实的分类树数据展示给用户。


之前调用接口返回的分类树有1MB的大小,优化之后,接口返回的分类树的大小是100Kb,一下子缩小了10倍。


这样简单的优化之后,性能提升了一些。


第5次优化


经过上面优化之后,用户很长一段时间都没有反馈性能问题。


但有一天公司同事在排查Redis中大key的时候,揪出了分类树。之前的分类树使用key/value的结构保存数据的。


我们不得不做第5次优化。


为了优化在Redis中存储数据的大小,我们首先需要对数据进行瘦身。


只保存需要用到的字段。


例如:


@AllArgsConstructor
@Data
public class Category {

private Long id;
private String name;
private Long parentId;
private Date inDate;
private Long inUserId;
private String inUserName;
private List children;
}

像这个分类对象中inDate、inUserId和inUserName字段是可以不用保存的。


修改自动名称。


例如:


@AllArgsConstructor
@Data
public class Category {
/**
* 分类编号
*/

@JsonProperty("i")
private Long id;

/**
* 分类层级
*/

@JsonProperty("l")
private Integer level;

/**
* 分类名称
*/

@JsonProperty("n")
private String name;

/**
* 父分类编号
*/

@JsonProperty("p")
private Long parentId;

/**
* 子分类列表
*/

@JsonProperty("c")
private List children;
}

由于在一万多条数据中,每条数据的字段名称是固定的,他们的重复率太高了。


由此,可以在json序列化时,改成一个简短的名称,以便于返回更少的数据大小。


这还不够,需要对存储的数据做压缩。


之前在Redis中保存的key/value,其中的value是json格式的字符串。


其实RedisTemplate支持,value保存byte数组


先将json字符串数据用GZip工具类压缩成byte数组,然后保存到Redis中。


再获取数据时,将byte数组转换成json字符串,然后再转换成分类树。


这样优化之后,保存到Redis中的分类树的数据大小,一下子减少了10倍,Redis的大key问题被解决了。




作者:苏三说技术
来源:juejin.cn/post/7233012756315963452
收起阅读 »

听说你会架构设计?来,弄一个红包系统

大家好,我是小❤,一个漂泊江湖多年的 985 非科班程序员,曾混迹于国企、互联网大厂和创业公司的后台开发攻城狮。 1. 引言 当我那天拿着手机,正在和朋友们的微信群里畅聊着八卦新闻和即将到来的周末计划时,忽然一条带着喜意的消息扑面而来,消息正中间赫然写着八个大...
继续阅读 »

大家好,我是小❤,一个漂泊江湖多年的 985 非科班程序员,曾混迹于国企、互联网大厂和创业公司的后台开发攻城狮。


1. 引言


当我那天拿着手机,正在和朋友们的微信群里畅聊着八卦新闻和即将到来的周末计划时,忽然一条带着喜意的消息扑面而来,消息正中间赫然写着八个大字:恭喜发财,大吉大利



抢红包!!相信大部分人对此都不陌生,自 2015 年春节以来,微信就新增了各类型抢红包功能,吸引了数以亿万级的用户参与体验,今天,我们就来聊一聊这个奇妙有趣的红包系统。


2. 概要设计


2.1 系统特点



抢红包系统从功能拆分,可以分为包红包、发红包、抢红包和拆红包 4 个功能。


对于系统特性来说,抢红包系统和秒杀系统类似。



每次发红包都是一次商品秒杀流程,包括商品准备,商品上架,查库存、减库存,以及秒杀开始,最终的用户转账就是红包到账的过程。


2.2 难点


相比秒杀活动,微信发红包系统的用户量更大,设计更加复杂,需要重视的点更多,主要包括以下几点。


1、高并发


海量并发请求,秒杀只有一次活动,但红包可能同一时刻有几十万个秒杀活动。


比如 2017 鸡年除夕,微信红包抢红包用户数高达 3.42 亿,收发峰值 76 万/秒,发红包 37.77 亿 个。


2、安全性要求


红包业务涉及资金交易,所以一定不能出现超卖、少卖的情况。



  • 超卖:发了 10 块钱,结果抢到了 11 块钱,多的钱只能系统补上,如此为爱发电应用估计早就下架了;

  • 少卖:发了 10 块钱,只抢了 9 块,多的钱得原封不动地退还用户,否则第二天就接到法院传单了。


3、严格事务


参与用户越多,并发 DB 请求越大,数据越容易出现事务问题,所以系统得做好事务一致性


这也是一般秒杀活动的难点所在,而且抢红包系统涉及金钱交易,所以事务级别要求更高,不能出现脏数据


3. 概要设计


3.1 功能说明


抢红包功能允许用户在群聊中发送任意个数和金额的红包,群成员可以抢到随机金额的红包,但要保证每个用户的红包金额不小于 0.01 元



抢红包的详细交互流程如下:



  1. 用户接收到抢红包通知,点击通知打开群聊页面;

  2. 用户点击抢红包,后台服务验证用户资格,确保用户尚未领取过此红包;

  3. 若用户资格验证通过,后台服务分配红包金额并存储领取记录;

  4. 用户在微信群中看到领取金额,红包状态更新为“已领取”;

  5. 异步调用支付接口,将红包金额更新到钱包里。


3.2 数据库设计


红包表 redpack 的字段如下:



  • id: 主键,红包ID

  • userId: 发红包的用户ID

  • totalAmount: 总金额

  • surplusAmount: 剩余金额

  • total: 红包总数

  • surplusTotal: 剩余红包总数


该表用来记录用户发了多少红包,以及需要维护的剩余金额。


红包记录表 redpack_record 如下:



  • id: 主键,记录ID

  • redpackId: 红包ID,外键

  • userId: 用户ID

  • amount: 抢到的金额


记录表用来存放用户具体抢到的红包信息,也是红包表的副表。


3.3 发红包



  1. 用户设置红包的总金额和个数后,在红包表中增加一条数据,开始发红包;

  2. 为了保证实时性和抢红包的效率,在 Redis 中增加一条记录,存储红包 ID 和总人数 n``;

  3. 抢红包消息推送给所有群成员。


3.4 抢红包


从 2015 年起,微信红包的抢红包和拆红包就分离了,用户点击抢红包后需要进行两次操作。


这也是为什么明明有时候抢到了红包,点开后却发现该红包已经被领取完了



抢红包的交互步骤如下:



  1. 抢红包:抢操作在 Redis 缓存层完成,通过原子递减的操作来更新红包个数,个数递减为 0 后就说明抢光了。

  2. 拆红包:拆红包时,首先会实时计算金额,一般是通过二倍均值法实现(即 0.01 到剩余平均值的 2 倍之间)。

  3. 红包记录:用户获取红包金额后,通过数据库的事务操作累加已经领取的个数和金额,并更新红包表和记录表。

  4. 转账:为了提升效率,最终的转账为异步操作,这也是为什么在春节期间,红包领取后不能立即在余额中看到的原因。


上述流程,在一般的秒杀活动中随处可见,但是,红包系统真的有这么简单吗?


当用户量过大时,高并发下的事务一致性怎么保证,数据分流如何处理,红包的数额分配又是怎么做的,接下来我们一一探讨。


4. 详细设计


由于是秒杀类设计,以及 money 分发,所以我们重点关注抢红包时的高并发解决方案和红包分配算法。


4.1 高并发解决方案


首先,抢红包系统的用户量很大,如果几千万甚至亿万用户同时在线发抢红包,请求直接打到数据库,必然会导致后端服务过载甚至崩溃。


而在这种业务量下,简单地对数据库进行扩容不仅会让成本消耗剧增,另一方面由于存在磁盘的性能瓶颈,所以大概率解决不了问题。


所以,我们将解决方案集中在 减轻系统压力、提升响应速度 上,接下来会从缓存、加锁、异步分治等方案来探讨可行性。


1、缓存


和大多数秒杀系统设计相似,由于抢红包时并发很高,如果直接操作 DB 里的数据表,可能触发 DB 锁的逻辑,导致响应不及时。



所以,我们可以在 DB 落盘之前加一层缓存,先限制住流量,再处理红包订单的数据更新。


这样做的优点是用缓存操作替代了磁盘操作,提升了并发性能,这在一般的小型秒杀活动中非常有效!


但是,随着微信使用发&抢红包的用户量增多,系统压力增大,各种连锁反应产生后,数据一致性的问题逐渐暴露出来:



  • 假设库存减少的内存操作成功,但是 DB 持久化失败了,会出现红包少发的问题;

  • 如果库存操作失败,DB 持久化成功,又可能会出现红包超发的问题。


而且在几十万的并发下,直接对业务加锁也是不现实的,即便是乐观锁。


2、加锁


在关系型 DB 里,有两种并发控制方法:分为乐观锁(又叫乐观并发控制,Optimistic Concurrency Control,缩写 “OCC”)和悲观锁(又叫悲观并发,Pessimistic Concurrency Control,缩写“PCC”)。



悲观锁在操作数据时比较悲观,认为别的事务可能会同时修改数据,所以每次操作数据时会先把数据锁住,直到操作完成


乐观锁正好相反,这种策略主打一个“信任”的思想,认为事务之间的数据竞争很小,所以在操作数据时不会加锁,直到所有操作都完成到提交时才去检查是否有事务更新(通常是通过版本号来判断),如果没有则提交,否则进行回滚。


在高并发场景下,由于数据操作的请求很多,所以乐观锁的吞吐量更大一些。但是从业务来看,可能会带来一些额外的问题:



  1. 抢红包时大量用户涌入,但只有一个可以成功,其它的都会失败并给用户报错,导致用户体验极差;

  2. 抢红包时,如果第一时间有很多用户涌入,都失败回滚了。过一段时间并发减小后,反而让手慢的用户抢到了红包

  3. 大量无效的更新请求和事务回滚,可能给 DB 造成额外的压力,拖慢处理性能。


总的来说,乐观锁适用于数据竞争小,冲突较少的业务场景,而悲观锁也不适用于高并发场景的数据更新。


因此对于抢红包系统来说,加锁是非常不适合的。


3、异步分治


综上所述,抢红包时不仅要解决高并发问题、还得保障并发的顺序性,所以我们考虑从队列的角度来设计。


我们知道,每次包红包、发红包、抢红包时,也有先后依赖关系,因此我们可以将红包 ID 作为一个唯一 Key,将发一次红包看作一个单独的 set,各个 set 相互独立处理



这样,我们就把海量的抢红包系统分成一个个的小型秒杀系统,在调度处理中,通过对红包 ID 哈希取模,将一个个请求打到多台服务器上解耦处理。


然后,为了保证每个用户抢红包的先后顺序,我们把一个红包相关的操作串行起来,放到一个队列里面,依次消费。


从上述 set 分流我们可以看出,一台服务器可能会同时处理多个红包的操作,所以,为了保证消费者处理 DB 不被高并发打崩,我们还需要在消费队列时用缓存来限制并发消费数量


抢红包业务消费时由于不存储数据,只是用缓存来控制并发。所以我们可以选用大数据量下性能更好的 Memcached。


除此之外,在数据存储上,我们可以用红包 ID 进行哈希分表,用时间维度对 DB 进行冷热分离,以此来提升单 set 的处理性能。


综上所述,抢红包系统在解决高并发问题上采用了 set 分治、串行化队列、双维度分库分表 等方案,使得单组 DB 的并发性能得到了有效提升,在应对数亿级用户请求时取得了良好的效果。


4.2 红包分配算法


抢红包后,我们需要进行拆红包,接下来我们讨论一下红包系统的红包分配算法。


红包金额分配时,由于是随机分配,所以有两种实现方案:实时拆分和预先生成。


1、实时拆分


实时拆分,指的是在抢红包时实时计算每个红包的金额,以实现红包的拆分过程。


这个对系统性能和拆分算法要求较高,例如拆分过程要一直保证后续待拆分红包的金额不能为空,不容易做到拆分的红包金额服从正态分布规律。


2、预先生成


预先生成,指的是在红包开抢之前已经完成了红包的金额拆分,抢红包时只是依次取出拆分好的红包金额。


这种方式对拆分算法要求较低,可以拆分出随机性很好的红包金额,但通常需要结合队列使用。


3、二倍均值法


综合上述优缺点考虑,以及微信群聊中的人数不多(目前最高 500 人),所以我们采用实时拆分的方式,用二倍均值法来生成随机红包,只满足随机即可,不需要正态分布。



故可能出现很大的红包差额,但这更刺激不是吗🐶



使用二倍均值法生成的随机数,每次随机金额会在 0.01 ~ 剩余平均值*2 之间。


假设当前红包剩余金额为 10 元,剩余个数为 5,10/5 = 2,则当前用户可以抢到的红包金额为:0.01 ~ 4 元之间。


4、算法优化


用二倍均值法生成的随机红包虽然接近平均值,但是在实际场景下:微信红包金额的随机性和领取的顺序有关系,尤其是金额不高的情况下


于是,小❤耗费 “巨资” 在微信群发了多个红包,得出了这样一个结论:如果发出的 红包总额 = 红包数*0.01 + 0.01,比如:发了 4 个红包,总额为 0.05,则最后一个人领取的红包金额一定是 0.02



无一例外:



所以,红包金额算法大概率不是随机分配,而是在派发红包之前已经做了处理。比如在红包金额生成前,先生成一个不存在的红包,这个红包的总额为 0.01 * 红包总数


而在红包金额分配的时候,会对每个红包的随机值基础上加上 0.01,以此来保证每个红包的最小值不为 0。


所以,假设用户发了总额为 0.04 的个数为 3 的红包时,需要先提取 3*0.01 到 "第四个" 不存在的红包里面,于是第一个人抢到的红包随机值是 0 ~ (0.04-3*0.01)/3


由于担心红包超额,所以除数的商是向下取二位小数,0 ~ (0.04-3*0.01)/3 ==> (0 ~ 0) = 0,再加上之前提取的保底值 0.01,于是前两个抢到的红包金额都是 0.01。最后一个红包的金额为红包余额,即 0.02


算法逻辑用 Go 语言实现如下:


    import (
   "fmt"
   "math"
   "math/rand"
   "strconv"
)

type RedPack struct {
    SurplusAmount float64 // 剩余金额
     SurplusTotal int // 红包剩余个数
}

// 取两位小数
func remainTwoDecimal(num float64) float64 {
    numStr := strconv.FormatFloat(num, 'f'264)
    num, _ = strconv.ParseFloat(numStr, 64)
    return num
}

// 获取随机金额的红包
func getRandomRedPack(rp *RedPack) float64 {
    if rp.SurplusTotal <= 0 {
        // 该红包已经被抢完了
        return 0
    }

    if rp.SurplusTotal == 1 {
        return remainTwoDecimal(rp.SurplusAmount + 0.01)
    }

       // 向下取整
    avgAmount := math.Floor(100*(rp.SurplusAmount/float64(rp.SurplusTotal))) / float64(100)
    avgAmount = remainTwoDecimal(avgAmount)

       // 生成随机数种子
    rand.NewSource(time.Now().UnixNano())

    var max float64
    if avgAmount > 0 {
        max = 2*avgAmount - 0.01
    } else {
        max = 0
    }
    money := remainTwoDecimal(rand.Float64()*(max) + 0.01)

    rp.SurplusTotal -= 1
    rp.SurplusAmount = remainTwoDecimal(rp.SurplusAmount + 0.01 - money)

    return money
}

// 实现主函数
func main() {
    rp := &RedPack{
        SurplusAmount: 0.06,
        SurplusTotal:  5,
    }
    rp.SurplusAmount -= 0.01 * float64(rp.SurplusTotal)
    total := rp.SurplusTotal
    for i := 0; i < total; i++ {
        fmt.Println(getRandomRedPack(rp))
    }
}

打印结果:



0.01、0.01、0.01、0.01、0.02



喜大普奔,符合预期!


5. 总结


设计一个红包系统不仅要考虑海量用户的并发体验和数据一致性,还得保障用户资金的安全


这种技术难点,对于传统的 “秒杀系统” 有过之而无不及。


本文主要探讨了高并发场景下的设计方案和红包分配的算法,覆盖了目前红包系统常见的几大难题。




作者:xin猿意码
来源:juejin.cn/post/7312352501406908452
收起阅读 »

代码要同时推送到 gitee 和 github 该怎么办?教你两招!

前言 我们作为特色社会主义社会中的一份子,跟国外程序员不一样的地方在于,别人能轻松访问 github, 我们却要借梯子,或者用国产的 gitee。 作为有追求的程序员,作为成年人,当然是两个我都要! 那么如何优雅地把本地代码同时维护到 gitee 和 gith...
继续阅读 »

前言


我们作为特色社会主义社会中的一份子,跟国外程序员不一样的地方在于,别人能轻松访问 github, 我们却要借梯子,或者用国产的 gitee


作为有追求的程序员,作为成年人,当然是两个我都要!


那么如何优雅地把本地代码同时维护到 giteegithub上呢?这里带给大家2个方法。


push 2个 remote original


假如我们在 github 创建了一个仓库,那么本地 clone 下来后,主分支(main)是默认跟github 仓库的主分支(main) 关联的。这样直接在 VSCode 里面点 同步的圈圈 就会自动同步,如下图。


image.png


现在我又要推到 gitee,怎么办,很简单,新增一个 remote 源,并命名为 gitee,默认的 origin 已经跟 github 关联了。


git remote add gitee 

这样我们就有2个源,在 VSCodeGit Graph 中可以直接 push 到2个源中,2个都勾选上。


image.png


点击 Yes, push 就可以推上去了。爽歪歪


gitee 仓库镜像管理:gitee -> github


本来上面的方法用得好好的,但是我的梯子质量不好,时不时推不上 github ( gitee 倒是轻轻松松,没有失败过),然后我又得重新 push,有的时候要重试好多次才行,非常浪费时间!!


然后我就发现 gitee 原来是可以同步推到 github 的,根本就不用我们手动操作,点赞!


image.png


官方链接在这里 仓库镜像管理(Gitee<->Github 双向同步),我就不做搬运工了,给几个图:


image.png


image.png


其中还需要到 github 获取 token,方法如下:


image.png


官方链接在这里 如何申请 GitHub 私人令牌?


我设置成功如下图:


image.png



token生成的时候要设置过期时间,尽量设置长一点比如一年。



image.png


推到 gitee 的代码会自动同步到 github, 我们也可以手动点右侧的 更新 按钮,手动同步。


经我检测,是成功的,刚刚推不上去 github 的代码,通过 gitee 同步过去了,本地也能看到代码是同步的。


image.png



有一个需要注意的是,你只能推到你自己的github仓库,不能推到你的github组织的仓库。



以我的仓库为例:



因为选推送的目标仓库时压根就不能选组织,只能选个人!但是源仓库可以是个人的或者组织的。


总结


本文介绍了2种同步 gitee 和 github 仓库的方法,视情况选择:



  • 当目标仓库是个人时,第二种会比较方便,推上到 gitee 后,会自动同步到 github

  • 当目标仓库是组织时,不能用第二种,只能用第一种,自己手动 push 到2个仓库,需要你的梯子质量好,不然可能推不上 github


最后把我每天都看的美女分享给大家~养眼啊


pretty-girl.png


作者:菲鸽
来源:juejin.cn/post/7327353620232339506
收起阅读 »

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

嗨,大家好,我是飘渺。 最近,我和小伙伴一起接手了一个由外包团队开发的微服务项目,这个项目采用了当前流行的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
    收起阅读 »