问题现象#

在一个使用Spring R2dbc与Mysql8.x的项目中,当创建 一个REST资源,进行创建,返回的毫秒精度时间戳,和下一瞬间查询的时间戳不一致。sql及代码大概如下

1
2
3
4
5
6
CREATE TABLE person (
id INT PRIMARY KEY,
name VARCHAR(255),
created_time DATETIME(3),
updated_time DATETIME(3)
);

实体类定义

1
2
3
4
5
6
7
8
9
10
@Entity
class PersonEntity {
@Id
private Long id;
private String name;
@CreatedDate
private LocalDateTime createdTime;
@LastModifiedDate
private LocalDateTime updatedTime;
}

这里使用了@CreatedDate@LastModifiedDate注解,并在Application类上配置了@EnableR2dbcAuditing注解用于在Repo操作实体的时候,自动更新时间戳。

1
2
public interface PersonRepo extends ReactiveCrudRepository<PersonEntity, Long> {
}

创建代码类比如下,大概就是使用r2dbc操作数据,并将r2dbc返回的实体用于转换毫秒时间戳

1
2
3
4
5
6
7
8
9
10
11
12
13
return createPersonReq
.flatMap(req -> {
PersonPo personPo = new PersonPo();
personPo.setAge(18);
personPo.setName(req.getName());
return personRepo.save(personPo);
})
.map(person -> {
PersonResp personResp = new PersonResp();
personResp.setName(person.getName());
personResp.setCreatedTime(TimeUtil.format(person.getCreatedTime()));
return new ResponseEntity<>(personResp, null, HttpStatus.CREATED);
});

然而创建的时候返回的时间戳和查询的时间戳不一致,现象举例:
创建的时候返回:2024-05-08T08:11:47.333Z
查询的时候却返回:2024-05-08T08:11:47.334Z

走读代码,发现代码基本上万无一失,那么问题出在哪里呢?

通过仔细观察时间戳的区别,发现时间戳的变化都在最后一位,且相差为一,醒悟到这估计是由于内存中纳秒时间戳精度在转化为数据库毫秒时间戳的时候,部分库的行为是截断,部分库的行为是四舍五入,导致了这个问题。

最终通过写demo,docker抓包复现了这个问题,如下图所示,mysql server会将接收的时间戳进行四舍五入,而java常见的format工具类都是截断,导致了这一不一致。同时,这也体现了,r2dbc返回的entity可能并不是实际存入数据的内容,而是”原始”的entity。

r2dbc-weird-timestamp-change.jpeg

r2dbc与mysql的时间精度失调问题#

在这个问题里面,存在三个时间精度:

  • 内存中的时间精度
  • r2dbc发给mysql的时间精度,有趣的是,r2dbc发给mysql的时间精度,并不是sql中列定义的精度,而是mysql server所能支持的最高精度即微秒精度。
  • mysql实际存储的时间精度

r2dbc返回的entity可能并不是实际存入数据的内容,而是经过r2dbc处理之后,发送到数据库之前的entity。问题的关键就在r2dbc并不根据列定义的精度处理数据,而是根据mysql server支持的最高精度处理数据。

解决问题的方式有几种:

  • 将mysql列定义到微秒级别精度,优选方案
  • 在进入r2dbc之前,将时间戳截断到mysql列定义的精度
  • 在r2dbc返回的entity中,将时间戳截断到mysql支持的精度。这其实对开发者的心智负担较重,返回的entity并不是实际存储的,使用前要做进位,限制也比较大。

在进入r2dbc之前,将时间戳截断到数据库表定义的精度,也有两种方式

  • 不使用@CreatedDate@LastModifiedDate注解,而是在应用程序中手动设置时间戳
  • 继续使用@CreatedDate@LastModifiedDate注解,通过拦截器统一进位

通过拦截器的代码如下,定义基类,不然每个实体类都要书写拦截器。一般来说,一个项目里,时间戳的精度都应该统一,所以可以定义一个统一的拦截器。

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
31

import lombok.ToString;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.annotation.LastModifiedDate;

import java.time.LocalDateTime;

@ToString
public abstract class AuditableEntity {
@CreatedDate
protected LocalDateTime createdTime;

@LastModifiedDate
protected LocalDateTime updatedTime;

public LocalDateTime getCreatedTime() {
return createdTime;
}

public void setCreatedTime(LocalDateTime createdTime) {
this.createdTime = createdTime;
}

public LocalDateTime getUpdatedTime() {
return updatedTime;
}

public void setUpdatedTime(LocalDateTime updatedTime) {
this.updatedTime = updatedTime;
}
}
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
31
32
33
34
35
36
37
38
39
40
41
import org.reactivestreams.Publisher;
import org.springframework.data.r2dbc.mapping.OutboundRow;
import org.springframework.data.r2dbc.mapping.event.BeforeSaveCallback;
import org.springframework.data.relational.core.mapping.event.BeforeConvertCallback;
import org.springframework.data.relational.core.sql.SqlIdentifier;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;

import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;

@Component
public class AuditableEntityCallback implements BeforeSaveCallback<AuditableEntity>, BeforeConvertCallback<AuditableEntity> {

@Override
public Publisher<AuditableEntity> onBeforeSave(AuditableEntity entity, OutboundRow row, SqlIdentifier table) {
System.out.println("before save " + entity.getCreatedTime());
entity.setCreatedTime(roundToMilliseconds(entity.getCreatedTime()));
entity.setUpdatedTime(roundToMilliseconds(entity.getUpdatedTime()));
System.out.println("before save " + entity.getCreatedTime());
return Mono.just(entity);
}

@Override
public AuditableEntity onBeforeConvert(AuditableEntity entity) {
System.out.println("before convert " + entity.getCreatedTime());
entity.setCreatedTime(roundToMilliseconds(entity.getCreatedTime()));
entity.setUpdatedTime(roundToMilliseconds(entity.getUpdatedTime()));
System.out.println("before convert " + entity.getCreatedTime());
return entity;
}

private static LocalDateTime roundToMilliseconds(LocalDateTime dateTime) {
LocalDateTime localDateTime = dateTime.truncatedTo(ChronoUnit.MILLIS);
int dateTimeNano = dateTime.getNano() % 1000_000;
if (dateTimeNano >= 500_000) {
localDateTime = localDateTime.plusNanos(1_000_000);
}
return localDateTime;
}
}

jpa有没有问题呢?#

出于好奇,我也做了jpa的尝试,jpa也是一样的行为

jpa-weird-timestamp-change.jpeg

对于一个组件来说,日志打印常见的有三种选择:

  1. 不打印日志,只提供回调函数。将打印日志还是忽略的权利交给组件的使用者
  2. 可以由使用者设置一些参数,但组件自己管理整个日志的生命周期
  3. 适配生态内的日志框架,组件打印日志,但将输出的控制权控制反转给使用者

java生态slf4j已经成为事实上的标准,像Apache Ignite在最开始的时候也将日志作为自己的Spi定义,是向着2来发展的,但在Ignite3版本也去掉。Go生态由于去没有这样的标准,很多library只能选择2,导致引入了一个go library,它的日志会怎么出来,对于使用者来说是一个未知数。

java生态的基本原则如下:

  1. library,不会独立部署的组件,只引入slf4j-api,不引入具体的实现,可以在单元测试里面引入某个实现,用于测试打印日志。
  2. 简单的sdk不打印日志,复杂的sdk可以打印一些关键日志,但QPS级别的日志不要打印,不要替用户做选择。

如果在一个高度一致的团队内,可以无视上面两条

错误信息无模板变量#

假设我们的错误信息返回如下

1
2
HTTP/1.1 200 OK
{"error_code": "IEEE.754", "error_msg": "IEE 754 error"}

无模板变量的错误信息国际化,可以直接在前端对整体字符串根据错误码进行静态国际化。

1
2
3
4
5
6
7
8
9
10
11
// catch the error code first
const error_code = body.error_code

const error_msg_map = {
"IEEE.754": {
"en": "IEE 754 error",
"zh": "IEE 754 错误"
}
}

const error_msg = error_msg_map[error_code][lang]

错误信息包含模板变量#

假设我们的错误信息返回如下

1
2
HTTP/1.1 200 OK
{"error_code": "IEEE.754", "error_msg": "IEE 754 NbN error, do you mean Nan?"}

包含模板变量的错误信息国际化,可以在前端通过正则表达式提取,并代入到中文字符串模板中实现。如示例代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// catch the error code first
const error_code = body.code

const error_msg_capture_map = {
"IEEE.754": "/IEE 754 (\w+) error, do you mean (\w+)?/"
};

const error_msg_template_map = {
"IEEE.754": {
"en": "IEE 754 {{var1}} error, do you mean {{var2}}?",
"zh": "IEE 754 {{var1}} 错误,你是指 {{var2}} 吗?"
}
};

const matches = error_msg_capture_map[error_code].exec(body.error_msg);
const variables = matches.slice(1);

let error_msg = error_msg_template_map[error_code][lang];
variables.forEach((value, index) => {
error_msg = error_msg.replace(`{{var${index + 1}}}`, value);
});

从Spring的新版本开始,推荐使用构造函数的注入方式,通过构造函数注入有很多优点,诸如不变性等等。同时在构造函数上,也不需要添加@Autowire
注解就可以完成注入

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
// Before
public class ABC {
private final A a;

private final B b;

private final C c;

public ABC(@Autowire A a, @Autowire B b, @Autowire C c) {
this.a = a;
this.b = b;
this.c = c;
}
}

// After
public class ABC {
private final A a;

private final B b;

private final C c;

public ABC(A a, B b, C c) {
this.a = a;
this.b = b;
this.c = c;
}
}

但是,这种注入方式会导致变动代码的时候,需要同时修改field以及构造函数,在项目早期发展时期,这种变动显得有一些枯燥,再加上已经不需要@Autowire
注解。这时,我们可以用Lombok的@RequiredArgsConstructor来简化这个流程。

Lombok的@RequiredArgsConstructor会包含这些参数:

  • 所有未初始化的 final 字段
  • 被标记为 @NonNull 但在声明时未初始化的字段。

对于那些被标记为 @NonNull
的字段,还会生成一个显式的空检查(不过在Spring框架里这个没什么作用)。通过应用@RequiredArgsConstructor
,代码可以简化为如下模样,同时添加新的字段也不需要修改多行。

1
2
3
4
5
6
7
8
9

@RequiredArgsConstructor
public class ABC {
private final A a;

private final B b;

private final C c;
}

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

  • 由外部/客户发起的增删改查、列表查询,访问协议一般为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的失败回调。

0%