对于一个资源实体来说,在解决方案里,常见的操作场景有:

  • 由外部/客户发起的增删改查、列表查询,访问协议一般为HTTP协议。
  • 由系统内部组件发起的增删改查、列表查询,协议可能为HTTP协议,也可能是RPC协议如gRPC等。
  • 由资源实体的owner服务跟数据库进行实体读写。
  • 由资源实体的owner服务将变更广播到消息中间件里。

可以将实体命名如下:
naming

实体类详细说明:

  • CreateXxxReq 创建资源请求,包含除资源id之外的所有字段,有些变种里面可能会包含id字段。
  • UpdateXxxReq 更新资源请求,包含除资源id之外支持更新的所有字段。
  • XxxResp 资源响应,可用于Crate、Update接口的返回,包含所有字段。
  • ListXxxsResp 资源列表响应,包含资源列表。
  • List 资源列表响应,包含资源列表,每个资源包含部分字段,一般是id、name、createdTime、updatedTime等。

出于复杂性的考虑,可以将XxxNotify类跟InnerXxx进行简化合并,转化为:

naming-omit-notify

swagger/openapi里,operationId可使用如下

操作 operationId
创建资源 CreateXxx
删除资源 DeleteXxx
更新资源 UpdateXxx
查询单个资源 ShowXxx
查询资源列表 ListXxx
内部创建资源 CreateInnerXxx
内部删除资源 DeleteInnerXxx
内部更新资源 UpdateInnerXxx
内部查询单个资源 ShowInnerXxx
内部查询资源列表 ListInnerXxx

在现代应用编码中,从数据库里面find出来,进行一些业务逻辑操作,最后再save回去。即:

1
2
3
Person person = personRepo.findById(id);
person.setAge(18);
personRepo.save(person);

但是这样的业务操作,如果一个线程修改年龄,另一个线程修改昵称,最后save回去,可能会导致年龄/昵称某一个的修改被覆盖。

sequenceDiagram
    participant A as Thread A
    participant B as Thread B
    participant DB as Database

    A->>DB: find person by id
    Note over A: person.setAge(18)
    B->>DB: find person by id
    Note over B: person.setNickname("NewName")

    A->>DB: save person
    B->>DB: save person

    Note over DB: Potential Overwrite Issue

常见的解决方案有两种

执行前添加悲观锁#

通过分布式锁等方式,保证同一时间只有一个线程能够对数据进行修改。

乐观锁思路实现#

版本控制是另一种流行的处理并发问题的方法。它通过在每次更新记录时递增版本号来确保数据的一致性。

这在JPA中,可以通过在field上添加@Version注解来实现,但这也就要求①数据库中必须有version字段,②对于查找后更新类操作,必须使用JPA的save方法来进行更新。

当然也可以通过update_time来模拟乐观锁实现,这可能需要你在更新的时候添加update_time的条件,并且,update_time在极端场景下,理论正确性没那么严谨。

在软件开发中,分页没有统一的规范,实现方式也各不相同,有的会返回总页数,有的会返回总条数,有的可以任意翻页。本文对比一下几种常见的分页方式。

总体来说,分页的实现方案分为四种:

  • 后端全部返回,由前端分页
  • limit offset方案
  • cursor方案
  • cursor方案与offset结合

后端全部返回,由前端分页#

sequenceDiagram
    participant 前端
    participant 后端
    前端 ->> 后端: 请求资源集数据
    后端 -->> 前端: 返回全部数据
前端功能 支持情况
显示总页 🙂
任意页码跳转 🙂
跳转附近数页 🙂
大量数据集 😭完全不可用
实现难度 简单

limit offset方案#

sequenceDiagram
    participant 前端
    participant 后端
    前端 ->> 后端: 请求满足条件的资源总数
    后端 -->> 前端: 返回满足条件的资源总数
    前端 ->> 后端: 请求资源集数据、PageNo
    后端 -->> 前端: 部分数据
前端功能 支持情况
显示总页 🙂
任意页码跳转 🙂
跳转附近数页 🙂
大量数据集 😭海量数据集下性能差
实现难度 相对简单

cursor方案#

sequenceDiagram
    participant 前端
    participant 后端
    前端 ->> 后端: 请求满足条件的资源总数
    后端 -->> 前端: 返回满足条件的资源总数
    前端 ->> 后端: 请求资源集数据、cursor、limit
    后端 -->> 前端: 部分数据、prevCursor、nextCursor
前端功能 支持情况
显示总页 🙂
任意页码跳转 😭
跳转附近数页 🙂
大量数据集 🙂
实现难度 相对复杂

如果每一次翻页都返回总页数的话,对性能来讲也是不小的开销。

相对动态的数据来说,如果不一直翻到没有数据为止,也不好确定是否到了最后一页。为了解决这个问题,以及跳转附近数页的问题,可以演进为这样的方案。

假定前端最多显示最近6页,每页50条数据,那么前端可以直接尝试预读300条数据,根据返回的数据来做局部的分页。一言以蔽之:读取更多的数据来进行局部分页。

cursor_preload

这里可以再简化一下前端的实现,添加offset参数,这样子前端只需要判断当前页前后数据条数是否足够,附近页的跳转可以通过携带offset字段请求得到。

cursor方案与offset结合#

cursor_offset

Spring记录数据库操作时间的几种方式

Spring Jpa#

@EnableJpaAuditing注解开启Jpa的审计功能,然后在实体类上使用@CreatedDate和@LastModifiedDate注解即可

1
2
3
4
5
6
7
@Column(name = "create_time")
@CreatedDate
private LocalDateTime createTime;

@Column(name = "update_time")
@LastModifiedDate
private LocalDateTime updateTime;

Spring R2dbc#

Spring R2dbc可以使用@CreatedDate和@LastModifiedDate注解来实现。但是需要在Application上开启@EnableR2dbcAuditing

1
2
3
4
5
6
7
@Column("created_time")
@CreatedDate
private LocalDateTime createdTime;

@Column("updated_time")
@LastModifiedDate
private LocalDateTime updatedTime;

应用程序修改#

应用程序修改就比较简单,简单设置一下即可,以PersonPo类为例

1
2
3
PersonPo personPo = new PersonPo();
personPo.setCreateTime(LocalDateTime.now());
personPo.setUpdateTime(LocalDateTime.now());

Mysql场景下利用TIMESTAMP能力#

1
2
3
4
5
6
CREATE TABLE person (
id INT PRIMARY KEY,
// ... 其他字段 ...
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);

本文介绍常见的异步网络请求编码手法。尽管像golang这些的语言,支持协程,可以使得Programmer以同步的方式编写代码,大大降低编码者的心智负担。但网络编程中,批量又非常常见,这就导致即使在Golang中,也不得不进行协程的切换来满足批量的诉求,在Golang中往往对外以callback的方式暴露接口。

无论是callback、还是返回future、还是返回Mono/Flux,亦或是从channel中读取,这是不同的异步编程范式,编码的时候,可以从项目整体、团队编码风格、个人喜好来依次考虑。本文将以callback为主,但移植到其他异步编码范式,并不困难。

使用callback模式后,对外的方法签名类似:

go

1
func (c *Client) Get(ctx context.Context, req *Request, callback func(resp *Response, err error)) error

java

1
2
3
public interface Client {
void get(Request req, Callback callback);
}

网络编程中的批量#

对于网络请求来说,批量可以提高性能。 批量处理是指将多个请求或任务组合在一起,作为单一的工作单元进行处理。批量尽量对用户透明,用户只需要简单地对批量进行配置,而不需要关心批量的实现细节。

常见的批量相关配置

  • batch interval: 批量的时间间隔,比如每隔1s,批量一次
  • batch size: 批量的最大大小,比如每次最多批量100个请求

批量可以通过定时任务实现,也可以做一些优化,比如队列中无请求时,暂停定时任务,有请求时,启动定时任务。

编码细节#

整体流程大概如下图所示:

async-network-code

一定要先把请求放到队列/map中#

避免网络请求响应过快,导致callback还没注册上,就已经收到响应了。

队列中的消息一定要有超时机制#

避免由于丢包等原因,导致请求一直没有响应,而导致队列中的请求越来越多,最终内存溢出。

wait队列生命周期与底层网络client生命周期一致#

wait队列中请求一定是依附于client的,一旦client重建,队列也需要重建,并触发callback、future的失败回调。

Apache Ignite简介#

Apache Ignite是一个开源分布式的数据库、缓存和计算平台。它的核心是一个内存数据网格,它可以将内存作为分布式的持久化存储,以提供高性能和可扩展性。它还提供了一个分布式的键值存储、SQL数据库、流式数据处理和复杂的事件处理等功能。

Ignite的核心竞争力包括:

  • 兼容Mysql、Oracle语法
  • 性能强大,可以水平扩展
  • 缓存与数据库同源,可通过KV、SQL、JDBC、ODBC等方式访问

同时,为了便于开发,除了jdbc、odbc、restful方式外,Ignite还官方提供了Java、C++、.Net、Python、Node.js、PHP等语言的客户端,可以方便的与Ignite进行交互。

ignite-storage-access

Apache Ignite的问题#

频繁创建删除表,导致IGNITE_DISCOVERY_HISTORY_SIZE超过限制#

根据Ignite2的拓扑模型,集群的拓扑版本会在创建表/删除表的时候发生变化,该变化版本号递增,且仅会保留最近$IgniteDiscoveryHistorySize条记录,程序某处会写死读取版本为0的数据,读取不到时,ignite集群会重启。默认值为500。
社区issue: https://github.com/apache/ignite/issues/10894
笔者暂时没有时间来修复这个issue,可以通过将IGNITE_DISCOVERY_HISTORY_SIZE设置地比较大,来规避这个问题。

Ignite2客户端易用性问题#

Ignite2客户端超时默认值不合理#

Ignite2客户端的连接超时、执行sql超时默认都是0,没有精心研究过配置的用户在异常场景下,应用程序可能会hang住。从易用性的角度来说,网络通信的任何操作,默认都应该有超时时间。

Ignite2客户端不支持永远的重试#

Ignite通过预先计算出所有需要重连的时间点来实现重连,如果想配置成永远的重连,会因为时间点的计算导致内存溢出。从易用性的角度来说,应该支持永远的重连。

Ignite2客户端在某些异常下无法自愈#

当client执行sql的时候,碰到如下异常的时候,无法自愈。可以通过执行SQL对client进行定期检查并重建。

1
Caused by: org.apache.ignite.internal.client.thin.ClientServerError: Ignite failed to process request [47]: 50000: Can not perform the operation because the cluster is inactive. Note, that the cluster is considered inactive by default if Ignite Persistent Store is used to let all the nodes join the cluster. To activate the cluster call Ignite.cluster.state(ClusterState.ACTIVE)

Ignite2 SocketChannel泄露问题#

Ignite客户端在连接时,如果对应的Server端没有启动,会导致SocketChannel泄露,已由笔者提交代码修复:https://github.com/apache/ignite/pull/11016/files

通用 GitHub Actions#

commit lint#

1
2
3
4
5
6
7
8
9
10
11
12
name: commit lint
on:
pull_request:
branches:
- main

jobs:
commitlint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: wagoid/commitlint-github-action@v5

line lint#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
name: line lint
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
build:
name: line lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: linelint
uses: fernandrone/linelint@master

Go#

golangci-lint#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
name: go ci Lint
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
golangci:
name: lint
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v4
with:
go-version: '1.21'
- name: golangci-lint
uses: golangci/golangci-lint-action@v3
with:
version: latest
args: --timeout 3m0s

go mod check#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
name: go mod check

on:
push:
branches:
- main
pull_request:
branches:
- main

jobs:
go_mod_check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run Go Mod Check Action
uses: shoothzj/go-mod-check-action@main
with:
prohibitIndirectDepUpdate: 'true'

go unit tests#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
name: go unit test

on:
push:
branches:
- main
pull_request:
branches:
- main

jobs:
go_unit_test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v4
with:
go-version: '1.21'
- name: setup OpenGemini
uses: shoothzj/setup-opengemini-action@main
- name: Run coverage
run: go test ./... -coverpkg=./padmin/... -race -coverprofile=coverage.out -covermode=atomic

Java GitHub Actions#

maven checkstyle#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
name: java checkstyle
on:
push:
branches:
- main
pull_request:
branches:
- main

jobs:
java_checkstyle:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Maven Central Repository
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'temurin'
- name: checkstyle
run: mvn -B clean checkstyle:check

maven spotbugs#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
name: java spotbugs
on:
push:
branches:
- main
pull_request:
branches:
- main

jobs:
java_spotbugs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Maven Central Repository
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'temurin'
- name: spotbugs
run: mvn -B -DskipTests clean verify spotbugs:spotbugs

maven unit tests#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
name: java unit tests
on:
push:
branches:
- main
pull_request:
branches:
- main

jobs:
java_unit_tests:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Maven Central Repository
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'temurin'
- name: unit tests
run: mvn -B clean test

TypeScript GitHub Actions#

npm build test#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
name: npm build test
on:
push:
branches:
- main
pull_request:
branches:
- main

jobs:
npm_buid_test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: latest
- run: npm install
- run: npm run build
- name: setup pulsar
uses: shoothzj/setup-pulsar-action@main
- run: npm run test

prettier#

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
name: prettier
on:
push:
branches:
- main
pull_request:
branches:
- main

jobs:
prettier:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: latest
- run: npm install --save-dev prettier
- run: npm install --global prettier
- run: prettier --check '**/*.ts'

概述#

我们以xyz文件格式为例,来说明文件编解码的代码设计。xyz文件格式内容如下:

  • header部分:文件头,包含文件版本号、文件类型、文件大小等信息
  • body部分:文件主体

通用设计大概如下

classDiagram
    class XyzHeader {
        + byte[] content
    }
    class XyzBody {
        + byte[] content
    }
    class Xyz{
        + XyzHeader header
        + XyzBody body
    }
    class XyzReader {
        + Xyz read(fileName: string)
        + void process(String fileName, XyzProcessor processor)
        - XyzHeader readHeader()
        - XyzBody readBody()
    }
    class XyzProcessor {
        <>
        + void processHeader(XyzHeader header)
        + void processBody(XyzBody body)
    }
    class XyzReadCollectProcessor {
        Xyz getXyz()
    }
    Xyz --> XyzHeader: contains
    Xyz --> XyzBody: contains
    XyzReader --> Xyz: reads
    XyzReader --> XyzProcessor: processes
    XyzReadCollectProcessor --|> XyzProcessor: implements

Java#

使用java.io.RandomAccessFilejava.nio.channels.FileChannel来实现文件读取,使用io.netty.buffer.ByteBuf来读写文件。

核心代码举例:

XyzReader:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class XyzHeader {
private byte[] content;
}
public class XyzBody {
private byte[] content;
}
public class Xyz {
private XyzHeader header;
private XyzBody body;
}
public interface XyzProcessor {
void processHeader(XyzHeader header);
void processBody(XyzBody body);
}
public class XyzReadCollectProcessor implements XyzProcessor {
private final Xyz xyz = new Xyz();
public Xyz getXyz() {
return xyz;
}
}
public class XyzReader {
public Xyz read(String fileName) throws Exception {
}

private XyzHeader readHeader(FileChannel fileChannel) throws Exception {
}

private XyzBody readBody(FileChannel fileChannel) throws Exception {
}
}

Go#

Go标准库#

timeout#

1
2
3
client := http.Client{
Timeout: timeout,
}

connection timeout#

1
2
3
4
5
6
7
client := http.Client{
Transport: &http.Transport{
Dial: (&net.Dialer{
Timeout: timeout,
}).Dial,
},
}

Java#

标准库(jdk17+)#

timeout#

1
2
3
4
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create("http://example.com"))
.timeout(Duration.ofSeconds(10))
.build();

connectionTimeout#

1
2
3
HttpClient.Builder builder = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.version(HttpClient.Version.HTTP_1_1);

Reactor Netty#

timeout#

1
HttpClient client = HttpClient.create().responseTimeout(Duration.ofSeconds(10));

connectionTimeout#

1
HttpClient client = HttpClient.create().option(ChannelOption.CONNECT_TIMEOUT_MILLIS, 5000);

多语言SDK设计的常见问题#

日志打印的设计策略#

在SDK的关键节点,比如初始化完成、连接建立或者连接断开,都可以打印日志。如果是PerRequest的日志,一般默认不会打印INFO级别的日志。

SDK应该避免仅仅打印错误日志然后忽略异常;相反,它应该提供机制让调用者能够捕获并处理异常信息。这种做法有助于保持错误处理的透明性,并允许调用者根据需要采取适当的响应措施。正如David J. Wheeler所说”Put the control in the hands of those who know how to handle the information, not those who know how to manage the computers, because encapsulated details will eventually leak out.”把控制权放到那些知道如何处理信息的人手中,而不是放在那些知道如何管理计算机的人手中,因为封装的细节最终都会暴露。

是否需要使用显式的start/connect方法?#

像go这样的语言,一般来说不太在意特定的时间内,某个协程是否处于阻塞等待连接的状态。而在java这样的语言,特别是在采用响应式编程模型的场景下,通常需要通过异步操作来管理连接的建立。这可以通过显式的start/connect方法来或者是异步的工厂方法来实现。

0%