2024年9月

一、查询参数编码问题

我们在日常开发中,有时候会遇到拼接参数特别多的情况,那么就会导致一行代码特别长。那么为了美观呢,有的同学会进行换行处理,如下代码:


可以看到我红色框出来的地方就是经过了手动的回车导致产生的回车换行符。这么做乍一看也挺正常是吧,但其实对于JavaScript来说,这是会被保留的。
我们知道,当使用uni.request或其他HTTP客户端发送请求时,浏览器或客户端会对URL后面的查询参数进行编码,也就是问号后面那些东西,于是我们可以自己将编码后的东西打印出来看看,如下:

可以看到我们编码后多了很多
%0A%09
的东西,而这个其实就是我们的回车换行符,大家可以去
在线编解码网站
看看解码后的东西。

我把这种情况分别运行在浏览器端、Android端以及ios端,如下:

H5

iPhone

Android

可以看到,即使是这种带入了回车换行符的情况下,在H5以及Android端都是可以正常发送请求的,而ios就没那么幸运了,ios处理比较严格,也可以说是反人类,它不会去处理这种东西,一起给到后端,导致参数错误。

那么解决办法呢有很多种,比如:

通过设置打开Hbuilder X的自动换行,这样的话就不会改变代码并且也不影响代码阅读;
另外也可以通过 ‘+’ 号手动拼接各个参数的写法,比较麻烦;
再不济也可以手动将换行符换成空格也可以。

我这边选择打开自动换行,如下:

在编辑器中看的效果就会根据你的视口宽度自动换行,如下:

二、日期使用问题

在日期的使用上,如果传入的字符串非标准格式(主要就是得用‘-’分割),iso情况下会出现错误。如下代码:

const date = '2024/8'
let year = new Date(date).getFullYear(),
	month = new Date(date).getMonth() + 1
console.log('获取到的年份:', year)
console.log('获取到的月份:', month)

打印结果:

Android

H5

IOS

可以看到当在ios情况下时,获取到的年月都是NaN,那么对后续用到这两个变量的地方都会是意想之外的结果。
所以在使用时要确保传入的值是标准日期格式,如果确保不了就做下格式化处理,如下:

const getMonthStr = (date) => {
	let year = date.getFullYear();
	let month = (date.getMonth() + 1).toString().padStart(2, '0');//这里做下补0操作,避免个位数月份的情况
	return `${year}-${month}`;
};

前言

最近在给
opentelemetry-java-instrumentation
提交了一个
PR
,是关于给 gRPC 新增四个 metrics:

  • rpc.client.request.size
    : 客户端请求包大小
  • rpc.client.response.size
    :客户端收到的响应包大小
  • rpc.server.request.size
    :服务端收到的请求包大小
  • rpc.server.response.size
    :服务端响应的请求包大小

这个 PR 的主要目的就是能够在指标监控中拿到
RPC
请求的包大小,而这里的关键就是如何才能拿到这些包的大小。

首先支持的是
gRPC
(目前在云原生领域使用的最多),其余的 RPC 理论上也是可以支持的:

在实现的过程中我也比较好奇
OpenTelemetry
框架是如何给
gRPC
请求创建
span
调用链的,如下图所示:
image.png
image.png

这是一个 gRPC 远程调用,java-demo 是 gRPC 的客户端,k8s-combat 是 gRPC 的服务端

在开始之前我们可以根据
OpenTelemetry
的运行原理大概猜测下它的实现过程。

首先我们应用可以创建这些链路信息的前提是:使用了
OpenTelemetry
提供的
javaagent
,这个 agent 的原理是在运行时使用了
byte-buddy
增强了我们应用的字节码,在这些字节码中代理业务逻辑,从而可以在不影响业务的前提下增强我们的代码(只要就是创建 span、metrics 等数据)

Spring 的一些代理逻辑也是这样实现的

gRPC 增强原理

而在工程实现上,我们最好是不能对业务代码进行增强,而是要找到这些框架提供的扩展接口。


gRPC
来说,我们可以使用它所提供的
io.grpc.ClientInterceptor

io.grpc.ServerInterceptor
接口来增强代码。

打开
io.opentelemetry.instrumentation.grpc.v1_6.TracingClientInterceptor
类我们可以看到它就是实现了
io.grpc.ClientInterceptor

而其中最关键的就是要实现
io.grpc.ClientInterceptor#interceptCall
函数:

@Override  
public <REQUEST, RESPONSE> ClientCall<REQUEST, RESPONSE> interceptCall(  
    MethodDescriptor<REQUEST, RESPONSE> method, CallOptions callOptions, Channel next) {  
  GrpcRequest request = new GrpcRequest(method, null, null, next.authority());  
  Context parentContext = Context.current();  
  if (!instrumenter.shouldStart(parentContext, request)) {  
    return next.newCall(method, callOptions);  
  }  
  Context context = instrumenter.start(parentContext, request);  
  ClientCall<REQUEST, RESPONSE> result;  
  try (Scope ignored = context.makeCurrent()) {  
    try {  
      // call other interceptors  
      result = next.newCall(method, callOptions);  
    } catch (Throwable e) {  
      instrumenter.end(context, request, Status.UNKNOWN, e);  
      throw e;  
    }  }  
  return new TracingClientCall<>(result, parentContext, context, request);  
}

这个接口是
gRPC
提供的拦截器接口,对于
gRPC
客户端来说就是在发起真正的网络调用前后会执行的方法。

所以在这个接口中我们就可以实现创建 span 获取包大小等逻辑。

使用 byte-buddy 增强代码

不过有一个问题是我们实现的
io.grpc.ClientInterceptor
类需要加入到拦截器中才可以使用:

var managedChannel = ManagedChannelBuilder.forAddress(host, port) .intercept(new TracingClientInterceptor()) // 加入拦截器
.usePlaintext()
.build();

但在
javaagent
中是没法给业务代码中加上这样的代码的。

此时就需要
byte-buddy
登场了,它可以动态修改字节码从而实现类似于修改源码的效果。


io.opentelemetry.javaagent.instrumentation.grpc.v1_6.GrpcClientBuilderBuildInstr umentation
类里可以看到
OpenTelemetry
是如何使用
byte-buddy
的。

  @Override
  public ElementMatcher<TypeDescription> typeMatcher() {
    return extendsClass(named("io.grpc.ManagedChannelBuilder"))
        .and(declaresField(named("interceptors")));
  }

  @Override
  public void transform(TypeTransformer transformer) {
    transformer.applyAdviceToMethod(
        isMethod().and(named("build")),
        GrpcClientBuilderBuildInstrumentation.class.getName() + "$AddInterceptorAdvice");
  }

  @SuppressWarnings("unused")
  public static class AddInterceptorAdvice {

    @Advice.OnMethodEnter(suppress = Throwable.class)
    public static void addInterceptor(
        @Advice.This ManagedChannelBuilder<?> builder,
        @Advice.FieldValue("interceptors") List<ClientInterceptor> interceptors) {
      VirtualField<ManagedChannelBuilder<?>, Boolean> instrumented =
          VirtualField.find(ManagedChannelBuilder.class, Boolean.class);
      if (!Boolean.TRUE.equals(instrumented.get(builder))) {
        interceptors.add(0, GrpcSingletons.CLIENT_INTERCEPTOR);
        instrumented.set(builder, true);
      }
    }
  }

从这里的源码可以看出,使用了
byte-buddy
拦截了
io.grpc.ManagedChannelBuilder#intercept(java.util.List<io.grpc.ClientInterceptor>)
函数。

io.opentelemetry.javaagent.extension.matcher.AgentElementMatchers#extendsClass/ isMethod 等函数都是 byte-buddy 库提供的函数。

而这个函数正好就是我们需要在业务代码里加入拦截器的地方。

interceptors.add(0, GrpcSingletons.CLIENT_INTERCEPTOR);
GrpcSingletons.CLIENT_INTERCEPTOR = new TracingClientInterceptor(clientInstrumenter, propagators);

通过这行代码可以手动将
OpenTelemetry
里的
TracingClientInterceptor
加入到拦截器列表中,并且作为第一个拦截器。

而这里的:

extendsClass(named("io.grpc.ManagedChannelBuilder"))
        .and(declaresField(named("interceptors")))

通过函数的名称也可以看出是为了找到 继承了
io.grpc.ManagedChannelBuilder
类中存在成员变量
interceptors
的类。

transformer.applyAdviceToMethod(  
    isMethod().and(named("build")),  
    GrpcClientBuilderBuildInstrumentation.class.getName() + "$AddInterceptorAdvice");

然后在调用
build
函数后就会进入自定义的
AddInterceptorAdvice
类,从而就可以拦截到添加拦截器的逻辑,然后把自定义的拦截器加入其中。

获取 span 的 attribute

我们在 gRPC 的链路中还可以看到这个请求的具体属性,比如:

  • gRPC 服务提供的 IP 端口。
  • 请求的响应码
  • 请求的 service 和 method
  • 线程等信息。

这些信息在问题排查过程中都是至关重要的。

可以看到这里新的
attribute
主要是分为了三类:

  • net.*
    是网络相关的属性
  • rpc.*
    是和 grpc 相关的属性
  • thread.*
    是线程相关的属性

所以理论上我们在设计 API 时最好可以将这些不同分组的属性解耦开,如果是 MQ 相关的可能还有一些 topic 等数据,所以各个属性之间是互不影响的。

带着这个思路我们来看看 gRPC 这里是如何实现的。

clientInstrumenterBuilder
	.setSpanStatusExtractor(GrpcSpanStatusExtractor.CLIENT)
	.addAttributesExtractors(additionalExtractors)
        .addAttributesExtractor(RpcClientAttributesExtractor.create(rpcAttributesGetter))
        .addAttributesExtractor(ServerAttributesExtractor.create(netClientAttributesGetter))
        .addAttributesExtractor(NetworkAttributesExtractor.create(netClientAttributesGetter))

OpenTelemetry
会提供一个
io.opentelemetry.instrumentation.api.instrumenter.InstrumenterBuilder#addAttributesExtractor
构建器函数,用于存放自定义的属性解析器。

从这里的源码可以看出分别传入了网络相关、RPC 相关的解析器;正好也就对应了图中的那些属性,也满足了我们刚才提到的解耦特性。

而每一个自定义属性解析器都需要实现接口
io.opentelemetry.instrumentation.api.instrumenter.AttributesExtractor

public interface AttributesExtractor<REQUEST, RESPONSE> {
}

这里我们以
GrpcRpcAttributesGetter
为例。

enum GrpcRpcAttributesGetter implements RpcAttributesGetter<GrpcRequest> {
  INSTANCE;

  @Override
  public String getSystem(GrpcRequest request) {
    return "grpc";
  }

  @Override
  @Nullable
  public String getService(GrpcRequest request) {
    String fullMethodName = request.getMethod().getFullMethodName();
    int slashIndex = fullMethodName.lastIndexOf('/');
    if (slashIndex == -1) {
      return null;
    }
    return fullMethodName.substring(0, slashIndex);
  }

可以看到 system 是写死的
grpc
,也就是对于到页面上的
rpc.system
属性。

而这里的
getService
函数则是拿来获取
rpc.service
属性的,可以看到它是通过
gRPC
的method
信息来获取
service
的。


public interface RpcAttributesGetter<REQUEST> {  
  
  @Nullable  
  String getService(REQUEST request);
}

而这里
REQUEST
其实是一个泛型,在 gRPC 里是
GrpcRequest
,在其他 RPC 里这是对应的 RPC 的数据。

这个
GrpcRequest
是在我们自定义的拦截器中创建并传递的。

而我这里需要的请求包大小也是在拦截中获取到数据然后写入进 GrpcRequest。

static <T> Long getBodySize(T message) {  
  if (message instanceof MessageLite) {  
    return (long) ((MessageLite) message).getSerializedSize();  
  } else {  
    // Message is not a protobuf message  
    return null;  
  }}

这样就可以实现不同的 RPC 中获取自己的
attribute
,同时每一组
attribute
也都是隔离的,互相解耦。

自定义 metrics

每个插件自定义 Metrics 的逻辑也是类似的,需要由框架层面提供 API 接口:

public InstrumenterBuilder<REQUEST, RESPONSE> addOperationMetrics(OperationMetrics factory) {  
  operationMetrics.add(requireNonNull(factory, "operationMetrics"));  
  return this;  
}
// 客户端的 metrics
.addOperationMetrics(RpcClientMetrics.get());

// 服务端的 metrics
.addOperationMetrics(RpcServerMetrics.get());

之后也会在框架层面回调这些自定义的
OperationMetrics
:

    if (operationListeners.length != 0) {
      // operation listeners run after span start, so that they have access to the current span
      // for capturing exemplars
      long startNanos = getNanos(startTime);
      for (int i = 0; i < operationListeners.length; i++) {
        context = operationListeners[i].onStart(context, attributes, startNanos);
      }
    }

	if (operationListeners.length != 0) {  
	  long endNanos = getNanos(endTime);  
	  for (int i = operationListeners.length - 1; i >= 0; i--) {  
	    operationListeners[i].onEnd(context, attributes, endNanos);  
	  }
	}

这其中最关键的就是两个函数 onStart 和 onEnd,分别会在当前这个 span 的开始和结束时进行回调。

所以通常的做法是在
onStart
函数中初始化数据,然后在
onEnd
结束时统计结果,最终可以拿到 metrics 所需要的数据。

以这个
rpc.client.duration
客户端的请求耗时指标为例:

@Override  
public Context onStart(Context context, Attributes startAttributes, long startNanos) {  
  return context.with(  
      RPC_CLIENT_REQUEST_METRICS_STATE,  
      new AutoValue_RpcClientMetrics_State(startAttributes, startNanos));  
}

@Override  
public void onEnd(Context context, Attributes endAttributes, long endNanos) {  
  State state = context.get(RPC_CLIENT_REQUEST_METRICS_STATE);
	Attributes attributes = state.startAttributes().toBuilder().putAll(endAttributes).build();  
	clientDurationHistogram.record(  
	    (endNanos - state.startTimeNanos()) / NANOS_PER_MS, attributes, context);
}

在开始时记录下当前的时间,结束时获取当前时间和结束时间的差值正好就是这个 span 的执行时间,也就是 rpc client 的处理时间。


OpenTelemetry
中绝大多数的请求时间都是这么记录的。

Golang 增强

而在
Golang
中因为没有
byte-buddy
这种魔法库的存在,不可以直接修改源码,所以通常的做法还是得硬编码才行。

还是以
gRPC
为例,我们在创建 gRPC server 时就得指定一个
OpenTelemetry
提供的函数。

s := grpc.NewServer(  
    grpc.StatsHandler(otelgrpc.NewServerHandler()),  
)

在这个 SDK 中也会实现刚才在 Java 里类似的逻辑,限于篇幅具体逻辑就不细讲了。

总结

以上就是
gRPC

OpenTelemetry
中的具体实现,主要就是在找到需要增强框架是否有提供扩展的接口,如果有就直接使用该接口进行埋点。

如果没有那就需要查看源码,找到核心逻辑,再使用
byte-buddy
进行埋点。

比如 Pulsar 并没有在客户端提供一些扩展接口,只能找到它的核心函数进行埋点。

而在具体埋点过程中
OpenTelemetry
提供了许多解耦的 API,方便我们实现埋点所需要的业务逻辑,也会在后续的文章继续分析
OpenTelemetry
的一些设计原理和核心 API 的使用。

这部分 API 的设计我觉得是
OpenTelemetry
中最值得学习的地方。

参考链接:

前言

之前的python-pytorch的系列文章还没有写完,只是写到卷积神经网络。因为我报名成功了系统架构师的考试,所以决定先备考,等考完再继续写。

虽然架构师证书不能证明技术水平,但在现实生活中的某些情况下是有意义的,比如我要是去学校做培训老师的话,有这个证就会课时费高一点。考试虽然无聊,但有些考题还是蛮有意思的。

思考

看了几套架构师的考题,发现个有趣的现象,就是综合知识的考题都会加入当年流行的概念,比如2020年就有问微内核的考题,这是因为19年华为发布了鸿蒙系统。

这让我想起来两种架构师的区别,一种是能从1-100搭建框架的,一种是10-100搭建框架。什么是10-100呢,就是找一个开源项目或者付费开源项目。

区别一:10-100的架构师的特点是,当开发向他发问一些细节问题,他会让开发去自己调查,如果推脱不掉,他就只能自己调查,然后把意见给开发。而1-100的架构师,会直接给出答案。

区别二:10-100的架构师就会特别关注这种实事,比如鸿蒙发布的系统这种事;然后通过加入10概念+10组件,让100分的系统进化到200分。而1-100的架构师会深入研究组件,然后优化或者自研组件,然后将重组后混合的5个概念和3个组件,以最优的性能的方式,将其加入到系统,然后将系统从100分提升到200分。

两种模式的架构师,其实都很累,但10-100分的架构师是更被重视,而且其所在团队的人数数量通常是,1-100架构师的团队人数数量的5-10倍。所以通常10-100的架构师会被老板认为能力更强,毕竟带的团队更大,概念和组件更多。

回到架构师考试,这个考题,从本质上就是从java的10-100架构师的角度出发的。

后来,我又回头看了软件设计师的考题,因为我已经从1-100的net架构师转java开发了,所以我看这考题就有一种很深的思考,那是一种这考题就是为了java开发出的感觉。

比如,23种设计模式,这个就是在java里玩的很转,这是因为java语言的不完善,他是一个高级语言和低级语言的结合,但在其他语言,23种设计模式就是常规的写代码操作,完全没必要学习,因为只要你会写代码,你写的每行代码都可以解释为23种设计模式中的一种或几种。

而如果你是java开发,只要你工作几年,就会对23种设计有深刻理解,完全不用背,因为总用。但其他语言开发,就得背,而且背的时候还不理解,因为它违背了你认知,所以你不可能背明白。

再比如微服务设计,只有java搞无限制的http请求,例如一个用户创建接口里要创建用户和部门关系,而创建用户部门关系又要验证用户是否存在,那么我们就有token,创建用户,创建用户部门关系,验证用户存在,4个http请求,如果业务复杂,10+的http请求也是可能的。

这在其他语言是不可理解的,因为其他语言玩微服务不是这样的。但因为java的环境如此,所以会有很多相关问题,而这些问题被拿到考题中,这就跟其他语言的开发者的认知相背了,所以这是其他语言开发根本不可能靠背和理解能认知的。

考题

当然还是有一些考题很有意思,下面是09年的考题,虽然是以前的,可能这题型不会考了,但还是挺有意思,可以学习一下。

当然这题的答案我认为是有问题的。

image
这道题,关键点是ZP=Z,在已知转移矩阵p的情况下,已知x是当前的销售数量,例如x=[10,5],那么如果要预测下一次该品牌的销售概率x'的话,可以使用公式x'=x⋅P。

[10,5]⋅ [ 0.8 0.4    =10*0.8+5*0.2  10*0.4+5*0.6  =  [9,7]
        0.2 0.6 ] 

即A,B品牌下次卖 [9,7]。
因为ZP=Z,所以,选项中的最终占有率就是Z,所以我们挨个计算就行。
答案是D。
计算如下:

[2/3 1/3]⋅ [ 0.8 0.2    =2/3*0.8+1/3*0.2  2/3*0.2+1/3*0.6  =2/3*4/5+1/3*1/5  2/3*1/5+1/3*3/5 =9/15+1/15 2/15+3/15  =2/3 1/3
            0.2 0.6 ] 

所以d令zp=z成立,所以选D。


注:此文章为原创,任何形式的转载都请联系作者获得授权并注明出处!



若您觉得这篇文章还不错,请点击下方的【推荐】,非常感谢!

https://www.cnblogs.com/kiba/p/18388762

一、主从复制

1、主从关系

都说的 Redis 具有高可靠性,这里有两层含义:一是数据尽量少丢失,二是服务尽量少中断。AOF 和 RDB 保证了前者,而对于后者,Redis 的做法就是将一份数据同时保存在多个实例上。为了保证数据一致性,Redis 提供了主从库模式,并采用读写分离的方式,如图

2、主从复制-全量

当启动多个 Redis 实例的时候,它们相互之间就可以通过 replicaof(Redis 5.0 之前使用 slaveof)命令形成主库和从库的关系。例如,让实例 1(ip:172.16.19.3)和实例 2(ip:172.16.19.5)成为主从关系的命令:
replicaof 172.16.19.3 6379
,当关系建立后,第一次同步分数据为三个阶段:


(1)从库给主库发送 psync 命令,表示要进行数据同步,包含主库的 runID(redis 实例启动生成的随机 ID) 和复制进度 offset 两个参数,初次复制runID 为 ?offset 为 -1,主库会用 FULLRESYNC(初次为全量复制)响应命令带上两个参数返回给从库,从库收到响应后会记录 runID、offset 两个参数。
(2)主库执行 bgsave 命令,生成 RDB 文件并发给从库,从库会先清空当前数据库,然后加载 RDB 文件。这个过程中主库不会被阻塞,为了保证主从库的数据一致性,主库会在内存中用专门的 replication buffer,记录 RDB 文件生成后收到的所有写操作。
(3)主库完成 RDB 文件发送后,就会把此时 replication buffer 中的修改操作发给从库,从库再重新执行这些操作。

3、主从复制-级联

全量复制中,对于主库来说,需要完成两个耗时的操作:生成 RDB 文件和传输 RDB 文件,如果从库数量很多,主线程忙于 fork 子进程生成 RDB 文件会阻塞主线程处理正常请求,从而导致主库响应应用程序的请求速度变慢。此外,传输 RDB 文件也会占用主库的网络带宽,同样会给主库的资源使用带来压力。

我们可以通过“主-从-从”模式将主库生成 RDB 和传输 RDB 的压力,以级联的方式分散到从库上,从而降低主库的压力。简单来说,我们在部署主从集群的时候,可以手动选择一个从库(比如选择内存资源配置较高的从库),用于级联其他的从库,相当于选择一个从库当做其他从库的主库,执行
replicaof 所选从库IP 6379
,建立关系。

主从复制完成后,它们之间就会一直维护一个网络连接,主库会通过这个连接将后续陆续收到的命令操作再同步给从库,这个过程也称为基于长连接的命令传播,可以避免频繁建立连接的开销。

4、网络问题

网络中断后,主从库会采用增量复制的方式把主从库网络断连期间主库收到的命令同步给从库,期间命令会写入 replication buffer 以及 repl_backlog_buffer 缓冲区。这是一个环形缓冲区,主库会记录自己写到的位置,从库则会记录自己已经读到的位置。

起初,两个位置是相同的,但随着主库不断接收新的写操作,缓冲区中的写位置会逐步偏离起始位置,通常用偏移量来衡量这个偏移距离的大小,偏移越多,master_repl_offset 越大。

当主从库的连接恢复,从库首先会给主库发送 psync 命令把自己当前的 slave_repl_offset 发给主库,主库根据 master_repl_offset 和 slave_repl_offset 之间的差距,形成命令发送给从库进行数据同步。

需要强调的是,因为 repl_backlog_buffer 是一个环形缓冲区,所以在缓冲区写满后,主库会继续写入,此时,就会覆盖掉之前写入的操作。如果从库的读取速度比较慢,就有可能导致从库还未读取的操作被主库新写的操作覆盖了,这会导致主从库间的数据不一致。

我们可以调整 repl_backlog_size 这个参数来设置缓冲空间大小。计算公式是:缓冲空间大小 = 主库写入命令速度 * 操作大小 - 主从库间网络传输命令速度 * 操作大小。在实际应用中,我们可以扩大一定倍数应对突发的请求压力。

如果并发请求量非常大,除了适当增加 repl_backlog_size 值,就需要考虑使用切片集群来分担单个主库的请求压力了。

二、切片集群

在使用 RDB 持久化时,Redis 会 fork 子进程来完成,fork 操作的用时和 Redis 的数据量是正相关的,而 fork 在执行时会阻塞主线程。数据量越大,fork 操作造成的主线程阻塞的时间越长,会导致 Redis 响应变慢。我们使用 INFO 命令查看 Redis 的 latest_fork_usec 指标值(表示最近一次 fork 的耗时)。所以当数据量持续增长时,通过扩大内存的方式不太适用,应该采用切片集群。

切片集群,也叫分片集群,就是指启动多个 Redis 实例组成一个集群,然后按照一定的规则,把收到的数据划分成多份,每一份用一个实例来保存。例如把 25GB 的数据平均分成 5 份(当然,也可以不做均分),使用 5 个实例来保存,每个实例只需要保存 5GB 数据。实例在为 5GB 数据生成 RDB 时,数据量就小了很多,fork 子进程一般不会给主线程带来较长时间的阻塞。

加大内存、切片集群,两种方法对应的就是 纵向扩展(scale up)和横向扩展(scale out):
(1)纵向扩展:升级单个 Redis 实例的资源配置,包括增加内存容量、增加磁盘容量、使用更高配置的 CPU。
(2)横向扩展:横向增加当前 Redis 实例的个数。
纵向扩展的好处是,实施起来简单、直接,但是受限于数据量增加带来的阻塞问题和硬件成本问题。与纵向扩展相比,横向扩展是一个扩展性更好的方案,要想保存更多的数据,只用增加 Redis 的实例个数就行了,相对的管理起来会复杂一点。

我们就需要解决两大问题:
(1)数据切片后,在多个实例之间如何分布?
(2)客户端怎么确定想要访问的数据在哪个实例上?

Redis 切片集群通常是通过 Redis Cluster 来实现的,用哈希槽(Hash Slot,接下来我会直接称之为 Slot),来处理数据和实例之间的映射关系,一个切片集群共有 16384 个哈希槽,这些哈希槽类似于数据分区,每个键值对都会根据它的 key,被映射到一个哈希槽中。先根据键值对的 key 按照 CRC16 算法 计算一个 16 bit 的值,然后再用这个 16bit 值对 16384 取模,得到 0~16383 范围内的模数,每个模数代表一个相应编号的哈希槽。

使用 cluster create 命令创建集群时,Redis 会自动把这些槽平均分布在集群实例上,每个实例上的槽个数为 16384/N 个。也可以使用 cluster meet 命令手动建立实例间的连接,形成集群,再使用 cluster addslots 命令,指定每个实例上的哈希槽个数。举个栗子,假设3个实例,5个哈希槽,根据实例内存情况,按下图配置:

redis-cli -h 172.16.19.3 –p 6379 cluster addslots 0,1
redis-cli -h 172.16.19.4 –p 6379 cluster addslots 2,3
redis-cli -h 172.16.19.5 –p 6379 cluster addslots 4

key1 和 key2 计算完 CRC16 值后,对哈希槽总个数 5 取模,再根据各自的模数结果,就可以被映射到对应的实例 1 和实例 3 上了,这个过程就完成了数据分布的问题。(注意:手动分配哈希槽时,需要把 16384 个槽都分配完,否则 Redis 集群无法正常工作)

集群创建后,Redis 实例会把自己的哈希槽信息发给和它相连接的其它实例,来完成哈希槽分配信息的扩散。客户端和集群实例建立连接后,实例就会把哈希槽的分配信息发给客户端,客户端收到哈希槽信息后,会把哈希槽信息缓存在本地。当客户端请求键值对时,会先计算键所对应的哈希槽,然后就可以给相应的实例发送请求了。

但是,在集群中,实例和哈希槽的对应关系并不是一成不变的,最常见的变化有两个:
(1)在集群中,实例有新增或删除,Redis 需要重新分配哈希槽
(2)为了负载均衡,Redis 需要把哈希槽在所有实例上重新分布一遍

实例之间可以通过相互传递消息,获得最新的哈希槽分配信息,但是,客户端是无法主动感知这些变化的。这就会导致,它缓存的分配信息和最新的分配信息就不一致。Redis Cluster 方案提供了一种重定向机制,所谓的“重定向”,就是指,客户端给一个实例发送数据读写操作时,这个实例上并没有相应的数据的话,会返回 MOVED 命令响应结果,这个结果中就包含了新实例的访问地址,客户端重定向到新实例,同时更新本地对应关系的缓存。

GET hello:key
(error) MOVED 13320 172.16.19.5:6379

在实际应用时,如果正好赶上实例数据正在迁移,访问的 Slot 2 中的数据只有一部分迁移到了实例 3,还有部分数据没有迁移。这种情况下,客户端就会收到一条 ASK 报错信息:

GET hello:key
(error) ASK 13320 172.16.19.5:6379

这表明客户端请求的键值对所在的哈希槽 13320,在 172.16.19.5 这个实例上,但是这个哈希槽正在迁移,客户端需要先给 172.16.19.5 这个实例发送一个 ASKING 命令,让这个实例允许执行客户端接下来发送的命令。然后,客户端再向这个实例发送 GET 命令,以读取数据。举个例子如图:

Slot 2 正在从实例 2 往实例 3 迁移,key1 和 key2 已经迁移过去,key3 和 key4 还在实例 2。客户端向实例 2 请求 key2 后,就会收到实例 2 返回的 ASK 命令。然后给实例 3 发送 ASKING 命令,才能读取 key2 的数据。注意,和 MOVED 命令不同,ASK 命令并不会更新客户端缓存的哈希槽分配信息,如果客户端再次请求 Slot 2 中的数据,它还是会给实例 2 发送请求,重复上述步骤。


前言

大家好,今天推荐一个文档管理系统Dorisoy.Pan。

Dorisoy.Pan 是一个基于 .NET 8 和 WebAPI 构建的文档管理系统,它集成了 Autofac、MediatR、JWT、EF Core、MySQL 8.0 和 SQL Server 等技术,以实现一个简单、高性能、稳定且安全的解决方案。

这个系统支持多种客户端,包括网站、Android、iOS 和桌面应用,覆盖了文档管理的全流程,如计划、总结、开发、模板、测试、验收、设计、需求、收藏、分享、回收站和总空间等30多种核心功能。

项目介绍

Dorisoy.Pan 是一款基于.NET 8.0的免费、跨平台的文档管理系统。支持 MS SQL 2012 及以上和 MySQL 8.0 及以上数据库,可在 Windows、Linux 或 Mac 上运行。

系统采用全异步方法和令牌认证,遵循最佳安全实践,提供高性能、稳定且安全的文档管理体验。

源代码完全开放且可定制,采用了模块化和清晰的架构设计,使开发和定制特定功能变得简单快捷。

Dorisoy.Pan 利用最新的.NET 生态技术栈,为用户提供了一个既稳定又安全的文档管理解决方案。

项目技术

项目使用的技术栈分为:

  • 后端采用 .NET 8、EF Core、NLog、AutoMapper、FluentValidation、Newtonsoft.Json 和 MediatR;
  • 前端则使用 Node.js 和 Angular。
  • 支持的数据库包括 MS SQL 2012 及更高版本和 MySQL 8.0 及更高版本。

项目结构

项目使用

1、后端启动步骤

  • 使用 Visual Studio 2019 或更新版本打开解决方案文件 Dorisoy.Pan.sln。
  • 在解决方案资源管理器中右键点击并选择"还原 NuGet 包"。
  • 更新 Dorisoy.Pan.API 项目中的 appsettings.json 文件内的数据库连接字符串。
  • 通过 Visual Studio 菜单中的"工具">"NuGet 包管理器">"包管理器控制台",打开包管理器控制台。
  • 在包管理器控制台中,设置默认项目为 Dorisoy.Pan.Domain。 在控制台中运行 Update-Database 命令以创建数据库并填充初始数据。 将 Dorisoy.Pan.API 设置为启动项目。 按 F5 键启动项目。

2、前端启动步骤

如果尚未安装 Node.js,请访问 https://nodejs.org,下载并全局安装 Node.js(确保版本号至少为 4.0,同时 NPM 版本至少为 3),并全局安装 TypeScript。

  • 全局安装 Angular CLI:npm install -g @angular/cli 使用 Visual Code 打开项目目录 \UI。 在集成终端中运行 npm install 以初始化并安装依赖项。
  • 运行 npm run start 启动 Angular 开发服务器。
  • 当 Angular 开发服务器在 localhost:4200 上监听时,在浏览器中打开 http://localhost:4200/。
  • 为了在本地构建并运行生产版本,请执行 ng build --prod。这将生成一个包含压缩后的 HTML、CSS 和 JS 文件的应用程序生产版本,并放置在 dist 文件夹中,该文件夹可用于部署到生产服务器。

项目展示

  • 演示地址:http://pan.dorisoy.com/
  • 默认账号:admin@gmail.com 密码:admin@123

1、Desktop 客户端示例

2、Web 客户端示例

项目地址

GitHub:
https://github.com/dorisoy/Dorisoy.Pan

总结

对于刚开始接触 .NET 8 和 WebAPI 的小伙伴来说,Dorisoy.Pan 提供了一个全面的学习资源,帮助我们快速掌握跨平台开发的技能。项目代码行数为 42,310 行,是一个适合深入学习和实践的保姆级项目。

需要的小伙伴们赶快学习起来吧,希望能够帮助大家提升技术。

最后

如果你觉得这篇文章对你有帮助,不妨点个赞支持一下!你的支持是我继续分享知识的动力。如果有任何疑问或需要进一步的帮助,欢迎随时留言。

也可以加入微信公众号
[DotNet技术匠]
社区,与其他热爱技术的同行一起交流心得,共同成长!
优秀是一种习惯,欢迎大家留言学习!