Mongo Decimal128 类型转换问题排查解决

Mongo Decimal128 类型转换问题排查解决

问题背景

java中对于精确小数,我们通常使用Bigdecimal进行存储,而mongo中是不存在Bigdecimal类型,对应的是Decimal128。
项目中使用mongo的地方,为了能够在插入mongo时将Bigdecimal转为Decimal128,查询时将Decimal128转回Bigdecimal,可以利用spring中的org.springframework.core.convert.converter.Converter。如下

1
2
3
4
5
6
7
8
@WritingConverter
public class BigDecimalToDecimal128Converter implements Converter<BigDecimal, Decimal128> {

@Override
public Decimal128 convert(BigDecimal bigDecimal) {
return new Decimal128(bigDecimal);
}
}
1
2
3
4
5
6
7
8
@ReadingConverter
public class Decimal128ToBigDecimalConverter implements Converter<Decimal128, BigDecimal> {

@Override
public BigDecimal convert(Decimal128 decimal128) {
return decimal128.bigDecimalValue();
}
}

在向容器中注入MongoTemplate时添加自定义的Converter后
我们就可以自由的对BigDecimal类型进行mongo存储和读取了。

真的没有问题了吗

当使用Map类型去接收Mongo查询结果时,上面的自定义Converter就会失效:

1
return mongoTemplate.find(query, JSONObject.class, TABLE_NAME + year);

![企业微信截图_16535653001637.png](https://object-storage.mihoyo.com:9000/plat-knowledge-management/prod/92d2afc9c6cac8dd3ca5391f046577f0_1653616202505.png?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Credential=plat-knowledge-management-admin%2F20221130%2F%2Fs3%2Faws4_request&X-Amz-Date=20221130T124433Z&X-Amz-Expires=21600&X-Amz-SignedHeaders=host&X-Amz-Signature=9df161b1d3a1811f4b65f2e064e1a2084aae5d5b340c2c82da580e165c5f2226 =100%x)
经过代码调试,定位到MappingMongoConverter负责类型转换,其核心代码为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private Object getPotentiallyConvertedSimpleRead(@Nullable Object value, @Nullable Class<?> target) {
// 使用Map<String,Object>接收结果,target=Object.class 而Decimal128 是 Object子类,因此不进行转换
if (value == null || target == null || ClassUtils.isAssignableValue(target, value)) {
return value;
}

if (conversions.hasCustomReadTarget(value.getClass(), target)) {
return conversionService.convert(value, target);
}

if (Enum.class.isAssignableFrom(target)) {
return Enum.valueOf((Class<Enum>) target, value.toString());
}

return conversionService.convert(value, target);
}

由此发现使用Map<String,Object>接收结果时,target=Object.class ,而Decimal128属于Object子类,因此自定义Converter不生效

一波三折的解决方案-增加自定义Converter

由于Decimal128在下游系统中不存在,因此下游接收到数字被转换为Map类型且很难再次转换,因此准备从源头解决问题,修改类型转换逻辑,尝试将Decimal128转换为BigDecimal输出至下游,因此增加新的自定义Converter

1
2
3
4
5
6
7
8
9
10
@ReadingConverter
public class Decimal128ToObjectConverter implements Converter<Decimal128, Object> {
public Decimal128ToObjectConverter() {
}

@Override
public Object convert(Decimal128 decimal128) {
return decimal128.bigDecimalValue();
}
}

结论是不行的,原因为子类判断优先自定义Converter转换:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private Object getPotentiallyConvertedSimpleRead(@Nullable Object value, @Nullable Class<?> target) {

if (value == null || target == null || ClassUtils.isAssignableValue(target, value)) {
return value;
}
// 优先判断是否为子类,所以即使存在自定义Converter,也不会走该逻辑
if (conversions.hasCustomReadTarget(value.getClass(), target)) {
return conversionService.convert(value, target);
}

if (Enum.class.isAssignableFrom(target)) {
return Enum.valueOf((Class<Enum>) target, value.toString());
}

return conversionService.convert(value, target);
}

第二折 自定义MappingMongoConverter

我们自定义转换逻辑,重写核心转换逻辑,使得自定义Converter优先转换,是否还有问题?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
 private Object getPotentiallyConvertedSimpleRead(@Nullable Object value, @Nullable Class<?> target) {

if (value == null || target == null) {
return value;
}

if (conversions.hasCustomReadTarget(value.getClass(), target)) {
return conversionService.convert(value, target);
}
// 将 子类判断移动至自定义Converter之后
if (ClassUtils.isAssignableValue(target, value)) {
return value;
}

if (Enum.class.isAssignableFrom(target)) {
return Enum.valueOf((Class<Enum>) target, value.toString());
}

return conversionService.convert(value, target);
}

可以看到,当target=Object.class时,已经可以成功将Decimal128转换为BigDecimal,但是target=null时还是无法转换

第三折 解决嵌套转换问题

当对象存在嵌套时,MappingMongoConverter默认使用Map类型进行类型转换,此时target=null,造成了嵌套对象中的Decimal128对象并没有走到自定义Converter转换逻辑,因此再次修改代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private Object getPotentiallyConvertedSimpleRead(@Nullable Object value, @Nullable Class<?> target) {

if (value == null) {
return value;
}
// target == null 时,尝试寻找Object.class类型转换器
if (conversions.hasCustomReadTarget(value.getClass(), ObjectUtils.defaultIfNull(target, Object.class))) {
return conversionService.convert(value, ObjectUtils.defaultIfNull(target, Object.class));
}
// 如果没有匹配到 converter 则返回原始值
if (target == null || ClassUtils.isAssignableValue(target, value)) {
return value;
}

if (Enum.class.isAssignableFrom(target)) {
return Enum.valueOf((Class<Enum>) target, value.toString());
}

return conversionService.convert(value, target);
}

至此使用Map接收Decimal128将被正确的转换至BigDecimal类型

总结与收获

此方案需要改动两处代码

  1. 增加Decimal128ToObjectConverter
  2. 自定义MappingMongoConverter

排查后收获:

  1. 除非上游改动代价太大,尽量在上游解决问题,不要抛给下游处理
  2. mongo-spring对于Map等动态类型转换支持不完善,尽量定义准确类型数据结构接收结果,重写MappingMongoConverter不是上策
  3. 固定位数小数也可转换为整数存储,来绕过数字精度和类型转换问题
排查诡异的夏令时问题

排查诡异的夏令时问题

现象描述与复现

线上环境签署合同时发现某一员工生日为1988-04-15,但合同上显示的是1988-04-14缺少一天
合同生日问题

初步怀疑是夏令时问题,所以一开始排查方向重点放在数据存储和读取时的时区转换,走了不少弯路

Tip: 夏令时是什么

1986年4月,中国中央有关部门发出“在全国范围内实行夏时制的通知”,具体做法是:每年从四月中旬第一个星期日的凌晨2时整(北京时间),将时钟拨快一小时,即将表针由2时拨至3时,夏令时开始;到九月中旬第一个星期日的凌晨2时整(北京夏令时),再将时钟拨回一小时,即将表针由2时拨至1时,夏令时结束。

1986年至1991年,中华人民共和国在全国范围实行了六年夏令时,每年从4月中旬的第一个星期日2时整(北京时间)到9月中旬第一个星期日的凌晨2时整(北京夏令时)。除1986年因是实行夏令时的第一年,从5月4日开始到9月14日结束外,其它年份均按规定的时段施行。夏令时实施期间,将时间调快一小时。1992年4月5日后不再实行。

问题排查

第一步: 日期的来源

日期从数据库读取而来,类型为DATE类型,在数据库中保存的日期是1988-4-15,所以写入日期功能应该没有问题的

第二步: 线下环境确认

测试环境发现改问题能复现,并且在页面显示上也能看出问题,代码如下所示:

1
entryBasicInfo.setBirthday(new DateTime(entryInfoDetail.getBirthday(), DateTimeZone.forID("Asia/Shanghai")).plusHours(8).toDate());

可以看到页面展示的地方有+8小时的逻辑,也就是说读取到数据库时间为数据库中读取到的时间+8小时,代码如下所示:

1
2
3
4
5
6
/**
* 出生日期
*/
@NotNull(message = "出生日期不能为空", groups = EntryInfoV1.Group_EntryInfo.class)
@JsonFormat(pattern="yyyy-MM-dd HH:mm:ss",timezone = "GMT+8")
private Date birthday;

可以看到json序列化指定为GMT+8时区,如果是夏令时时间会转为为不执行夏令时的时间(-1hour), 返回值如下:

1
2
3
4
5
{
entryBasicInfo: {
birthday: "1988-04-15 07:00:00"
}
}

结合页面的返回值和阅读页面展示生日的代码,可以推断从数据库读取到的时间为夏令时时间1988-04-15 0:00:00(CDT)

再详细解释下为啥:

1988-04-15 07:00:00(GMT+8) = 1988-04-15 00:00:00(CDT) + 8 hours

为啥要从页面接口看?

因为生成合同逻辑在测试环境不好调试,所以借助页面展示接口推测从数据库读取到的Date数据

第三步 调试验证判断

如何验证我们前面推断出来的结论呢?肯定是依靠调试啦!造好数据,打上断点:

2664c4efc0c6ede126b16614c5d05446.png

如我所料, 读取到的时间正是 1988-04-15 00:00:00(CDT) 下面继续追查,看在什么地方格式化为1988-04-14

第四步 本地与线上JDK版本不同引起的一段插曲

实际上在本人本地执行的结果如下,相同的输入下,本地的时间戳和远程调试的并不一样,很是困惑

a00ebef19599799f392dd238ef75b045.png

对比发现zoneOffsets信息不同

iShot2021-07-15 19.19.57.png

经过光闪闪提醒,jdk会更新时区和夏令时信息,于是打开jdk更新公告查询,地址:https://www.oracle.com/java/technologies/tzdata-versions.html

Timezone Tzdata Version Introduced in Main Changes in this Timezone Data Release
tzdata2018f 2018/10/18 11.0.2 8u201 7u211 Changes to past timestamps: China’s 1988 spring-forward transition was on April 17, not April 10. Its DST transitions in 1986/91 were at 02:00, not 00:00. Fix several issues for Macau before 1992.

8u202版本后,1988年夏令时开始于1988-04-17此前版本开始于1988-04-10,而1988-04-15正好落在此区间,因此本地与线上读取到的时间不同

再深究下不同JDK返回不同时间的原因:

找到mysql-connect-driver中解析DATE类型的代码:

1
2
3
4
5
6
7
8
9
10
11
12
>public <T> T decodeDate(byte[] bytes, int offset, int length, ValueFactory<T> vf) {
if (length == 0) {
return vf.createFromDate(0, 0, 0);
} else if (length != MysqlaConstants.BIN_LEN_DATE) {
throw new DataReadException(Messages.getString("ResultSet.InvalidLengthForType", new Object[] { length, "DATE" }));
}
int year = (bytes[offset] & 0xff) | ((bytes[offset + 1] & 0xff) << 8);
int month = bytes[offset + 2];
int day = bytes[offset + 3];
return vf.createFromDate(year, month, day);
}

可以发现DATE类型 不包含时区信息,因此构建出来的是日期是本地时间+默认时区,因此在8u202版本后返回为1988-04-15 00:00:00(GMT+8) ,在此版本前返回为1988-04-15 00:00:00(CDT) 这两个时间相差1小时

关于JDK中存放时区信息的位置:

位于TzdbZoneRulesProvider类型,在我本地的位置为:/Library/Java/JavaVirtualMachines/jdk1.8.0_241.jdk/Contents/Home/jre/lib/tzdb.dat

1
2
3
4
5
6
7
8
9
10
11
12
>public TzdbZoneRulesProvider() {
try {
String libDir = System.getProperty("java.home") + File.separator + "lib";
try (DataInputStream dis = new DataInputStream(
new BufferedInputStream(new FileInputStream(
new File(libDir, "tzdb.dat"))))) {
load(dis);
}
} catch (Exception ex) {
throw new ZoneRulesException("Unable to load TZDB time-zone rules", ex);
}
>}

在本地可以替换该文件为旧版本从而可以在本地复现该问题

第四步 定位问题代码

一路搜索代码,找到合同中设置生日的地方:

1
2
//  出生日期
template.setPbBirthDate(new DateTime(eu.getBirthday()).toString("yyyy-MM-dd"));

需要注意的是DateTime属于第三方库joda time,基于上一段“小插曲”,我们大胆猜测下joda time是使用自己的时区信息还是读取jdk的时区信息?

铛铛铛揭晓下答案:

iShot2021-07-15 20.27.23.png

joda time使用自己的时区信息,不难推测,如果joda time的时区信息较新,在8u202版本前的jdk运行就会发生诡异的问题:

iShot2021-07-15 20.34.58.png

至此,我们已经完全查明少一天的前因后果

  1. 由于jdk版本较旧,时区信息较老, DATE类型数据1988-04-15被解析为 1988-04-15 0:00:00(CDT)
  2. 由于joda time时区信息较新,1988-04-15 0:00:00(CDT) 被解析为1988-04-14 23:00:00(GMT+8)

总结和收获

  1. Mysql DATE 类型不包含时区信息,使用配置的时区解析为java.sql.Date
  2. 不同JDK版本的时区信息和夏令时信息可能会发生变化,信息存储在$JAVA_HOME/lib/tzdb.dat文件中
  3. 8u201 以前版本认为中国时区在1988年时于04-10开始夏令时,8u201 后认为于04-17开始夏令时, 1988-04-10~1988-04-17期间的日期会因jdk不同而返回不同数据
  4. joda time的时区信息使用自己独立的数据, 在和jdk Date类型转换时有转换错误的隐患
  5. 升级jdk8版本到8u201以上,同时使用新版本joda time (其实就是为了保证两者时区最新且一致) 能避免上述隐患
接口延迟问题排查整理

接口延迟问题排查整理

问题接口流程梳理

根据报错信息,可以看出调用PersonnelSpi.getEmployeeInfo超时,且调用时长超过5s

1
com.netflix.hystrix.exception.HystrixRuntimeException: PersonnelSpi.getEmployeeInfo timed-out and fallback disabled

先梳理出PersonnelSpi.getEmployeeInfo执行流程

image-20210311152931902.png

  1. 先从网关获取oauthToken
  2. 通过网关调用人事cr/employee/info接口

目前可以得出结论是这两步耗时大于5s

排除人事接口问题

通过报警的data_trace字段,可以在fast7层中查询Proxy的access日志:

image-20210311153152323.png

查询结果类似这样:

企业微信截图_29f580c0-057f-4382-8cff-270e8f3a81ea.png

可以看到Proxy的accesslog中 Proxy返回cr/employee/info耗时5ms

image-20210311153925229.png

排除oauth/token接口问题

通过时间+clientIP 筛选方式,找到fast7层日志中 oauth/token的日志

比如13:20:15 调用了cr/employee/info接口

image-20210311154317930.png

通过时间+clientIP+接口筛选,找到oauth/token的日志

image-20210311154458147.png

从中可以得出两个结论:

  1. o auth/token接口耗时6ms
  2. o auth/tocken 和 cr/employee/info接口访问延迟在1s以内

如图,绿色部分是有日志证明没有问题的部分

image-20210311155024925.png

检查DNS问题

从上图可以看到,出现问题的部分只可能出现在sm-ps-server解析域名过程中

查看jk上DNS监控发现:idc-aroute.ke.com解析存在5s超时情况,但是发生超时时间和报错时间不一致

image-20210311155449332.png

查看代码发现,jk的dns监控并不是调用接口解析域名耗时,而是定时任务去检查域名解析耗时,所以报错时DNS监控数据正常并不说明当时DNS解析没有超时

通过DNS监控可以得出结论: DNS解析超时5s的情况存在,并且会影响oauth client真实请求延迟5s,并导致接口超时

结论

  1. 优化oauthclient, 减少调用auth/token接口次数可以减少超时情况发生
  2. 解决在容器环境中DNS解析超时问题

更新

networkaddress.cache.ttl (default: -1)
Specified in java.security to indicate the caching policy for successful
name lookups from the name service. The value is specified as as integer
to indicate the number of seconds to cache the successful lookup.
A value of -1 indicates “cache forever”.

networkaddress.cache.negative.ttl (default: 10)
Specified in java.security to indicate the caching policy for un-successful
name lookups from the name service. The value is specified as as integer to
indicate the number of seconds to cache the failure for un-successful lookups.
A value of 0 indicates “never cache”. A value of -1 indicates “cache forever”.

若DNS解析失败,会放入Negative Cache,默认缓存10秒(可调整/禁用),后续DNS解析直接返回UnknownHostException。也就是说,极端情况下,DNS解析失败会导致10秒不可用。所以建议禁用Negative Cache。

数据库数据迁移方案

数据库数据迁移方案

更换数据库这个事儿,是一个非常大的技术挑战,因为我们需要保证整个迁移过程中,既不能长时间停服,也不能丢数据。

实际上,无论是新版本的程序,还是新的数据库,即使我们做了严格的验证测试,做了高可用方案,刚刚上线的系统,它的稳定性总是没有那么好的,需要一个磨合的过程,才能逐步达到一个稳定的状态,这是一个客观规律。这个过程中一旦出现故障,如果不能及时恢复,造成的损失往往是我们承担不起的。

所以我们在设计迁移方案的时候,一定要做到,每一步都是可逆的。要保证,每执行一个步骤后,一旦出现问题,能快速地回滚到上一个步骤。这是很多同学在设计这种升级类技术方案的时候,容易忽略的问题。

第一步-复制旧库数据

首先要做的就是,把旧库的数据复制到新库中。对于大规模数据可以使用自增字段(自增主键/创建时间)作为复制的区间,在业务低谷期分批复制数据到新库中。

第二步-同步数据

因为旧库还在服务线上业务,所以不断会有数据写入旧库,我们不仅要往新库复制数据,还要保证新旧两个库的数据是实时同步的。所以,我们需要用一个同步程序来实现新旧两个数据库实时同步。

我们可以使用 Binlog 实时同步数据。如果源库不是 MySQL 的话,就麻烦一点儿,但也可以参考复制状态机理论来实现。这一步不需要回滚,原因是,只增加了一个新库和一个同步程序,对系统的旧库和程序都没有任何改变。即使新上线的同步程序影响到了旧库,只要停掉同步程序就可以了。

第三步-双写

然后,我们需要改造一下业务,业务逻辑部分不需要变,DAO 层需要做如下改造:

  1. 支持双写新旧两个库,并且预留热切换开关,能通过开关控制三种写状态:只写旧库、只写新库和同步双写。
  2. 支持读新旧两个库,同样预留热切换开关,控制读旧库还是新库。

然后上线新版的业务服务,这个时候业务服务仍然是只读写旧库,不读写新库。让这个新版的服务需要稳定运行至少一到二周的时间,期间除了验证新版服务的稳定性以外,还要验证新旧两个库中的数据是否是一致的。这个过程中,如果新版服务有问题,可以立即下线新版服务,回滚到旧版本的服务。

稳定一段时间之后,就可以开启服务的双写开关了。开启双写开关的同时,需要停掉同步程序。这里面有一个问题需要注意一下,就是这个双写的业务逻辑,一定是先写旧库,再写新库,并且以写旧库的结果为准。旧库写成功,新库写失败,返回写成功,但这个时候要记录日志,后续我们会用到这个日志来验证新库是否还有问题。旧库写失败,直接返回失败,就不写新库了。这么做的原因是,不能让新库影响到现有业务的可用性和数据准确性。上面这个过程如果出现问题,可以关闭双写,回滚到只读写旧库的状态。

第四步-对比补偿

切换到双写之后,新库与旧库的数据可能会存在不一致的情况,原因有两个:一是停止同步程序和开启双写,这两个过程很难做到无缝衔接,二是双写的策略也不保证新旧库强一致,这时候我们需要上线一个对比和补偿的程序,这个程序对比旧库最近的数据变更,然后检查新库中的数据是否一致,如果不一致,还要进行补偿。

开启双写后,还需要至少稳定运行至少几周的时间,并且期间我们要不断地检查,确保不能有旧库写成功,新库写失败的情况出现。对比程序也没有发现新旧两个库的数据有不一致的情况,这个时候,我们就可以认为,新旧两个库的数据是一直保持同步的。

第五步-流量切换

接下来就可以用类似灰度发布的方式,把读请求一点儿一点儿地切到新库上。同样,期间如果出问题的话,可以再切回旧库。全部读请求都切换到新库上之后,这个时候其实读写请求就已经都切换到新库上了,实际的切换已经完成了,但还有后续的收尾步骤。

第六步-下线历史逻辑

再稳定一段时间之后,就可以停掉对比程序,把服务的写状态改为只写新库。到这里,旧库就可以下线了。注意,整个迁移过程中,只有这个步骤是不可逆的。但是,这步的主要操作就是摘掉已经不再使用的旧库,对于在用的新库并没有什么改变,实际出问题的可能性已经非常小了。

到这里,我们就完成了在线更换数据库的全部流程。双写版本的服务也就完成了它的历史使命,可以在下一次升级服务版本的时候,下线双写功能。

如何实现对比和补偿程序?

在上面的整个切换过程中,如何实现这个对比和补偿程序,是整个这个切换设计方案中的一个难点。这个对比和补偿程序的难度在于,我们要对比的是两个都在随时变换的数据库中的数据。这种情况下,我们没有类似复制状态机这样理论上严谨实际操作还很简单的方法,来实现对比和补偿。但还是可以根据业务数据的实际情况,来针对性地实现对比和补偿,经过一段时间,把新旧两个数据库的差异,逐渐收敛到一致。

像订单这类时效性强的数据,是比较好对比和补偿的。因为订单一旦完成之后,就几乎不会再变了,那我们的对比和补偿程序,就可以依据订单完成时间,每次只对比这个时间窗口内完成的订单。补偿的逻辑也很简单,发现不一致的情况后,直接用旧库的订单数据覆盖新库的订单数据就可以了。

这样,切换双写期间,少量不一致的订单数据,等到订单完成之后,会被补偿程序修正。后续只要不是双写的时候,新库频繁写入失败,就可以保证两个库的数据完全一致。

比较麻烦的是更一般的情况,比如像商品信息这类数据,随时都有可能会变化。如果说数据上有更新时间,那我们的对比程序可以利用这个更新时间,每次在旧库取一个更新时间窗口内的数据,去新库上找相同主键的数据进行对比,发现数据不一致,还要对比一下更新时间。如果新库数据的更新时间晚于旧库数据,那可能是对比期间数据发生了变化,这种情况暂时不要补偿,放到下个时间窗口去继续对比。另外,时间窗口的结束时间,不要选取当前时间,而是要比当前时间早一点儿,比如 1 分钟前,避免去对比正在写入的数据。如果数据连时间戳也没有,那只能去旧库读取 Binlog,获取数据变化,然后去新库对比和补偿。

有一点需要说明的是,上面这些方法,如果严格推敲,都不是百分之百严谨的,都不能保证在任何情况下,经过对比和补偿后,新库的数据和旧库就是完全一样的。但是,在大多数情况下,这些实践方法还是可以有效地收敛新旧两个库的数据差异,你可以酌情采用。

小结

设计在线切换数据库的技术方案,首先要保证安全性,确保每一个步骤一旦失败,都可以快速回滚。此外,还要确保迁移过程中不丢数据,这主要是依靠实时同步程序和对比补偿程序来实现。

我把这个复杂的切换过程的要点,按照顺序总结成下面这个列表:

  1. 上线同步程序,从旧库中复制数据到新库中,并实时保持同步;
  2. 上线双写服务,只读写旧库;
  3. 开启双写,同时停止同步程序;
  4. 开启对比和补偿程序,确保新旧数据库数据完全一样;
  5. 逐步切量读请求到新库上;
  6. 下线对比补偿程序,关闭双写,读写都切换到新库上;
  7. 下线旧库和服务的双写功能。
排查docker oomkillded问题

排查docker oomkillded问题

年前docker oomkilled 问题一直在困扰我们项目组,大致现象为java堆Xmx配置了6G,但运行一段时间后常驻内存RSS从5G逐渐增长到8G容器阈值,最后报出om killed 之后重启。因为我们业务对内存需求不是很迫切,所以占用8G内存明显不合理,所以之后有了一场漫长的排查问题之旅。

基础中的基础-JVM内存模型

开始逐步对堆外内存进行排查,首先了解一下JVM内存模型。根据JVM规范,JVM运行时数据区共分为虚拟机栈、堆、方法区、程序计数器、本地方法栈五个部分。
内存模型

  • PC 寄存器,也叫程序计数器。可以看成是当前线程所执行的字节码的行号指示器。不是重点。

  • 虚拟机栈,描述Java方法执行的内存区域,它是线程私有的,栈帧在整个JVM体系中的地位颇高,包括局部变量表、操作栈、动态连接、方法返回地址等。当申请不到空间时,会抛出 OutOfMemoryError。

  • 本地方法栈,和虚拟机栈实现的功能与抛出异常几乎相同。

  • 堆内存。堆内存是 JVM 所有线程共享的部分,在虚拟机启动的时候就已经创建。所有的对象和数组都在堆上进行分配。这部分空间可通过 GC 进行回收。当申请不到空间时会抛出 OutOfMemoryError。

  • Metaspace(元空间)在JDK 1.8开始,方法区实现采用Metaspace代替,这些元数据信息直接使用本地内存来分配。元空间与永久代之间最大的区别在于:元空间不属于JVM使用的内存,而是使用(进程中的)直接内存。当申请不到空间时会抛出 OutOfMemoryError。

直接内存

java 8下是指除了Xmx设置的java堆外,java进程使用的其他内存。主要包括:DirectByteBuffer分配的内存,JNI里分配的内存,线程栈分配占用的系统内存,jvm本身运行过程分配的内存,codeCache,metaspace元数据空间。

JVM监控分析

2.png

可以看到重启前堆内存、栈内存、元空间、直接内存占用空间都没有异常,多数问题通过监控就能定位大致方向,可惜这次监控大法没有生效,怀疑是JVM问题转向JVM原生内存使用方向排查。

使用NMT排查JVM原生内存使用

Native Memory Tracking(NMT)使用

NMT是Java7U40引入的HotSpot新特性,可用于监控JVM原生内存的使用,但比较可惜的是,目前的NMT不能监控到JVM之外或原生库分配的内存。java进程启动时指定开启NMT(有一定的性能损耗),输出级别可以设置为“summary”或“detail”级别。如:

1
2
-XX:NativeMemoryTracking=summary 或者 
-XX:NativeMemoryTracking=detail

开启后,通过jcmd可以访问收集到的数据。

1
jcmd <pid> VM.native_memory [summary | detail | baseline | summary.diff | detail.diff 

如:jcmd 1 VM.native_memory,输出如下:

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
42
43
44
45
46
47
Native Memory Tracking:

Total: reserved=12259645KB(保留内存), committed=11036265KB (提交内存)
堆内存使用情况,保留内存和提交内存和Xms、Xmx一致,都是8G。
- Java Heap (reserved=8388608KB, committed=8388608KB)
(mmap: reserved=8388608KB, committed=8388608KB)
用于存储类元数据信息使用到的原生内存,总共12045个类,整体实际使用了79M内存。
- Class (reserved=1119963KB, committed=79751KB)
(classes #12045)
(malloc=1755KB #29277)
(mmap: reserved=1118208KB, committed=77996KB)
总共2064个线程,提交内存是2.1G左右,一个线程1M,和设置Xss1m相符。
- Thread (reserved=2130294KB, committed=2130294KB)
(thread #2064)
(stack: reserved=2120764KB, committed=2120764KB)
(malloc=6824KB #10341)
(arena=2706KB #4127)
JIT的代码缓存,12045个类JIT编译后代码缓存整体使用79M内存。
- Code (reserved=263071KB, committed=79903KB)
(malloc=13471KB #15191)
(mmap: reserved=249600KB, committed=66432KB)
GC相关使用到的一些堆外内存,比如GC算法的处理锁会使用一些堆外空间。118M左右。
- GC (reserved=118432KB, committed=118432KB)
(malloc=93848KB #453)
(mmap: reserved=24584KB, committed=24584KB)
JAVA编译器自身操作使用到的一些堆外内存,很少。
- Compiler (reserved=975KB, committed=975KB)
(malloc=844KB #1074)
(arena=131KB #3)
Internal:memory used by the command line parser, JVMTI, properties等。
- Internal (reserved=117158KB, committed=117158KB)
(malloc=117126KB #44857)
(mmap: reserved=32KB, committed=32KB)
Symbol:保留字符串(Interned String)的引用与符号表引用放在这里,17M左右
- Symbol (reserved=17133KB, committed=17133KB)
(malloc=13354KB #145640)
(arena=3780KB #1)
NMT本身占用的堆外内存,4M左右
- Native Memory Tracking (reserved=4402KB, committed=4402KB)
(malloc=396KB #5287)
(tracking overhead=4006KB)
不知道啥,用的很少。
- Arena Chunk (reserved=272KB, committed=272KB)
(malloc=272KB)
其他未分类的堆外内存占用,100M左右。
- Unknown (reserved=99336KB, committed=99336KB)
(mmap: reserved=99336KB, committed=99336KB)
  • 保留内存(reserved):reserved memory 是指JVM 通过mmaped PROT_NONE 申请的虚拟地址空间,在页表中已经存在了记录(entries),保证了其他进程不会被占用,且保证了逻辑地址的连续性,能简化指针运算。
  • 提交内存(commited):committed memory 是JVM向操做系统实际分配的内存(malloc/mmap),mmaped PROT_READ | PROT_WRITE,仍然会page faults,但是跟 reserved 不同,完全内核处理像什么也没发生一样。

这里需要注意的是:由于malloc/mmap的lazy allocation and paging机制,即使是commited的内存,也不一定会真正分配物理内存。

malloc/mmap is lazy unless told otherwise. Pages are only backed by physical memory once they’re accessed.

Tips:由于内存是一直在缓慢增长,因此在使用NMT跟踪堆外内存时,一个比较好的办法是,先建立一个内存使用基线,一段时间后再用当时数据和基线进行差别比较,这样比较容易定位问题。

1
jcmd 1 VM.native_memory baseline

同时pmap看一下物理内存的分配,RSS占用了10G。

1
pmap -x 1 | sort -n -k3

3.jpg

运行一段时间后,做一下summary级别的diff,看下内存变化,同时再次pmap看下RSS增长情况。

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
42
43
44
45
46
47
48
jcmd 1 VM.native_memory summary.diff
Native Memory Tracking:

Total: reserved=13089769KB +112323KB, committed=11877285KB +117915KB

- Java Heap (reserved=8388608KB, committed=8388608KB)
(mmap: reserved=8388608KB, committed=8388608KB)

- Class (reserved=1126527KB +2161KB, committed=85771KB +2033KB)
(classes #12682 +154)
(malloc=2175KB +113KB #37289 +2205)
(mmap: reserved=1124352KB +2048KB, committed=83596KB +1920KB)

- Thread (reserved=2861485KB +94989KB, committed=2861485KB +94989KB)
(thread #2772 +92)
(stack: reserved=2848588KB +94576KB, committed=2848588KB +94576KB)
(malloc=9169KB +305KB #13881 +460)
(arena=3728KB +108 #5543 +184)

- Code (reserved=265858KB +1146KB, committed=94130KB +6866KB)
(malloc=16258KB +1146KB #18187 +1146)
(mmap: reserved=249600KB, committed=77872KB +5720KB)

- GC (reserved=118433KB +1KB, committed=118433KB +1KB)
(malloc=93849KB +1KB #487 +24)
(mmap: reserved=24584KB, committed=24584KB)

- Compiler (reserved=1956KB +253KB, committed=1956KB +253KB)
(malloc=1826KB +253KB #2098 +271)
(arena=131KB #3)

- Internal (reserved=203932KB +13143KB, committed=203932KB +13143KB)
(malloc=203900KB +13143KB #62342 +3942)
(mmap: reserved=32KB, committed=32KB)

- Symbol (reserved=17820KB +108KB, committed=17820KB +108KB)
(malloc=13977KB +76KB #152204 +257)
(arena=3844KB +32 #1)

- Native Memory Tracking (reserved=5519KB +517KB, committed=5519KB +517KB)
(malloc=797KB +325KB #9992 +3789)
(tracking overhead=4722KB +192KB)

- Arena Chunk (reserved=294KB +5KB, committed=294KB +5KB)
(malloc=294KB +5KB)

- Unknown (reserved=99336KB, committed=99336KB)
(mmap: reserved=99336KB, committed=99336KB

4.jpg

发现这段时间pmap看到的RSS增长了3G多,但NMT观察到的内存增长了不到120M,还有大概2G多常驻内存不知去向,因此也基本排除了由于JVM自身管理的堆外内存的嫌疑。

gdb分析内存块内容

上面提到使用pmap来查看进程的内存映射,pmap命令实际是读取了/proc/pid/maps和/porc/pid/smaps文件来输出。发现一个细节,pmap取出的内存映射发现很多64M大小的内存块。这种内存块逐渐变多且占用的RSS常驻内存也逐渐增长到reserved保留内存大小,内存增长的2G多基本上也是由于这些64M的内存块导致的,因此看一下这些内存块里具体内容。

pmap -x 1看一下实际内存分配情况:
5.jpg

找一块内存块进行dump:

1
gdb --batch --pid 1 -ex "dump memory a.dump 0x7fd488000000 0x7fd488000000+56124000"

简单分析一下内容,发现绝大部分是乱码的二进制内容,看不出什么问题。
strings a.dump | less
或者: hexdump -C a.dump | less
或者: view a.dump

没啥思路的时候,随便搜了一下发现貌似很多人碰到这种64M内存块的问题,了解到glibc的内存分配策略在高版本有较大调整:

从glibc 2.11(为应用系统在多核心CPU和多Sockets环境中高伸缩性提供了一个动态内存分配的特性增强)版本开始引入了per thread arena内存池,Native Heap区被打散为sub-pools ,这部分内存池叫做Arena内存池。也就是说,以前只有一个main arena,目前是一个main arena(还是位于Native Heap区) + 多个per thread arena,多个线程之间不再共用一个arena内存区域了,保证每个线程都有一个堆,这样避免内存分配时需要额外的锁来降低性能。main arena主要通过brk/sbrk系统调用去管理,per thread arena主要通过mmap系统调用去分配和管理。

一个32位的应用程序进程,最大可创建 2 CPU总核数个arena内存池(MALLOC_ARENA_MAX),每个arena内存池大小为1MB,一个64位的应用程序进程,最大可创建 8 CPU总核数个arena内存池(MALLOC_ARENA_MAX),每个arena内存池大小为64MB

ptmalloc2内存分配和释放

当某一线程需要调用 malloc()分配内存空间时, 该线程先查看线程私有变量中是否已经存在一个分配区,如果存在, 尝试对该分配区加锁,如果加锁成功,使用该分配区分配内存,如果失败, 该线程搜索循环链表试图获得一个没有加锁的分配区。如果所有的分配区都已经加锁,那么 malloc()会开辟一个新的分配区,把该分配区加入到全局分配区循环链表并加锁,然后使用该分配区进行分配内存操作。在释放操作中,线程同样试图获得待释放内存块所在分配区的锁,如果该分配区正在被别的线程使用,则需要等待直到其他线程释放该分配区的互斥锁之后才可以进行释放操作。用户 free 掉的内存并不是都会马上归还给系统,ptmalloc2 会统一管理 heap 和 mmap 映射区域中的空闲的chunk,当用户进行下一次分配请求时, ptmalloc2 会首先试图在空闲的chunk 中挑选一块给用户,这样就避免了频繁的系统调用,降低了内存分配的开销。

ptmalloc2的内存收缩机制

业务层调用free方法释放内存时,ptmalloc2先判断 top chunk 的大小是否大于 mmap 收缩阈值(默认为 128KB),如果是的话,对于主分配区,则会试图归还 top chunk 中的一部分给操作系统。但是最先分配的 128KB 空间是不会归还的,ptmalloc 会一直管理这部分内存,用于响应用户的分配 请求;如果为非主分配区,会进行 sub-heap 收缩,将 top chunk 的一部分返回给操 作系统,如果 top chunk 为整个 sub-heap,会把整个 sub-heap 还回给操作系统。做 完这一步之后,释放结束,从 free() 函数退出。可以看出,收缩堆的条件是当前 free 的 chunk 大小加上前后能合并 chunk 的大小大于 64k,并且要 top chunk 的大 小要达到 mmap 收缩阈值,才有可能收缩堆。

ptmalloc2的mmap分配阈值动态调整

M_MMAP_THRESHOLD 用于设置 mmap 分配阈值,默认值为 128KB,ptmalloc 默认开启 动态调整 mmap 分配阈值和 mmap 收缩阈值。当用户需要分配的内存大于 mmap 分配阈值,ptmalloc 的 malloc()函数其实相当于 mmap() 的简单封装,free 函数相当于 munmap()的简单封装。相当于直接通过系统调用分配内存, 回收的内存就直接返回给操作系统了。因为这些大块内存不能被 ptmalloc 缓存管理,不能重用,所以 ptmalloc 也只有在万不得已的情况下才使用该方式分配内存。

如何优化解决
三种方案:

第一种:控制分配区的总数上限。默认64位系统分配区数为:cpu核数*8,如当前环境40核系统分配区数为320个,每个64M上限的话最多可达20G,限制上限后,后续不够的申请会直接走mmap分配和munmap回收,不会进入ptmalloc2的buffer池。
所以第一种方案调整一下分配池上限个数到4:

1
export MALLOC_ARENA_MAX=4

第二种:之前降到ptmalloc2默认会动态调整mmap分配阈值,因此对于较大的内存请求也会进入ptmalloc2的内存buffer池里,这里可以去掉ptmalloc的动态调整功能。可以设置 M_TRIM_THRESHOLD,M_MMAP_THRESHOLD,M_TOP_PAD 和 M_MMAP_MAX 中的任意一个。这里可以固定分配阈值为128K,这样超过128K的内存分配请求都不会进入ptmalloc的buffer池而是直接走mmap分配和munmap回收(性能上会有损耗):

1
2
3
4
export MALLOC_MMAP_THRESHOLD_=131072
export MALLOC_TRIM_THRESHOLD_=131072
export MALLOC_TOP_PAD_=131072
export MALLOC_MMAP_MAX_=65536

第三种:使用tcmalloc来替代默认的ptmalloc2。google的tcmalloc提供更优的内存分配效率,性能更好,ThreadCache会阶段性的回收内存到CentralCache里。 解决了ptmalloc2中arena之间不能迁移导致内存浪费的问题。

总结收获

  • 定位问题,一定要了解问题的领域范围,在这次排查中,定位OOM问题领域顺序就是 jvm内存 -> jvm内部内存 -> 进程内存。

  • 操作系统知识不能丢,扎实的基础知识可以节省非常多百度的时间和推理问题的时间

  • 知识领域是相同的,比如这次的ptmalloc内存分配基本原理和metaspace内存分配、netty的内存分配原理非常相似

  • 当时排查问题时因为已经定位到是内存分配问题,所以没有留下问题排查中间过程的相关数据。最近偶然看到一篇博客的记录和我的经历极为相似,于是我参考博客和自己的排查经验整合了这篇排查问题记录。结果和过程都很重要,只有结果,没有过程容易招致他人的不理解,能被人理解也是一门学问~

[项目] 多角色权限展示数据的一种实现

[项目] 多角色权限展示数据的一种实现

多角色权限如果遇到不同角色能看到不同的列可以怎么做

  • 逐行读取

最简单的解决方法,实现简单。但是在微服务中调用接口次数太多,性能很差。

  • 批量读取

实现较复杂,但是性能好很多,下面主要介绍这种方法的思路

批量读取

以分页读取数据为例:

  1. 读取第一页数据,包含需要展示数据的id和所属权限(多个)

为什么需要所属权限这个字段呢? 因为决定能否看到这行是有你所拥有的所有权限决定的,而决定能否看到哪个列是由这行所拥有的权限决定的。

如何获取该行所拥有的权限呢,我的做法是分不同的权限查询结果通过union 组合起来

  1. 将第一页数据原始顺序保存, 然后按行拥有权限分组

记录原始顺序是因为后面分组后会打乱, 为什么要分组?分组后同样的查询才能聚合在一起,可以简化代码

  1. 根据权限分组多次查询所需要的字段,然后将查询结果合并

这里我使用的graphql来选择需要查询的字段

  1. 最后还原成原来的顺序

可以使用guava Ordering工具类方便生成Compartor

[]: https://blog.yamato.moe/2018/11/06/2018-11-06-biz/ “根据权限查询时避免角色切换的一种思路”
[]: https://blog.yamato.moe/2019/04/04/Mybatis%20ResultSetHandler_2019-04-04%20%E7%BB%AD/ “【片段】 Mybatis ResultSetHandler 实践-续”
[]: https://blog.yamato.moe/2019/01/09/Mybatis%20ResultSetHandler_2019-01-09/ “【片段】 Mybatis ResultSetHandler 实践”

[项目] 根据权限查询时避免角色切换遇到的坑

前情概要

1. 问题背景

使用多个角色查询列表时,会遇到两个维度的不同点:

  • 行维度:多个角色能够看到行的并集,sql需要多次查询取并集,之后还要去重分页排序
  • 列维度:如果不同角色可见列不同,计算出当前行能看到列的并集

举一个例子:

假设存在一个登录员工拥有两个角色:

  1. 长期激励负责人:能看到拥有长期激励的人(行维度),能看到基本信息和长期激励信息(列维度)
  2. 薪酬负责人:能看到低职级的人(行维度),能看到基本信息和薪酬信息(列维度)

那么,在列表中他能看见:

基本信息 薪酬信息 长期激励信息
低职级/无长期激励 x
低职级/长期激励
高职级/无长期激励 x x x
高职级/长期激励 x

2. 实际遇到的问题(困难重重)

基本思路已经在前期概要里介绍,本人已经实践了一段时间,挖了两个深坑正在解决中。

性能问题(已解决)

最开始的实现中数据是一条一条读取的,同时薪酬字段属于加密信息,使用了第三方微服务提供解密,读取字段多+解密字段多 导致了在百条分页的情况下接口在超时的边缘不断试探。。。

解决方案:

  • 合并查询sql,批量查询数据
  • 合并解密请求,批量调用解密微服务

因为之前为了方便我们解密使用了mybatis的TypeHandler做到字段隐式加解密,目前我们的做法是对于单条数据的加解密,还是保持原来的typeHandler做法,而对批量数据处理,重新写一套数据实体,同时使用mybatis的拦截器对查询的批量数据做批量解密的处理。具体做法可以参见我的另一片文章:【片段】 Mybatis ResultSetHandler 实践-续

批量查询带来的问题

批量查询返回的列表中列字段都是一致的,而我们的需求是不同的行能看见不同的列字段,把批量查询出来的列表直接返回是有问题的,这个问题因为疏忽导致了线上的一次故障。

所以目前的思路是先做一次数据批量预取,之后在对列字段做处理,隐藏掉不能看见的字段。

3. 总结

没有想到当时想解决权限查询时避免角色切换这个问题时会遇到这么多困难,想法是正确的,在实际执行时还是困难重重。值得欣慰的在最开始的时候思路和方向都是正确的,同时也把其中遇到的各种问题和心得记录了下来,经过层层积累,才到达现在的高度。

[]: https://blog.yamato.moe/2018/11/06/2018-11-06-biz/ “根据权限查询时避免角色切换的一种思路”
[]: https://blog.yamato.moe/2019/04/04/Mybatis%20ResultSetHandler_2019-04-04%20%E7%BB%AD/ “【片段】 Mybatis ResultSetHandler 实践-续”
[]: https://blog.yamato.moe/2019/01/09/Mybatis%20ResultSetHandler_2019-01-09/ “【片段】 Mybatis ResultSetHandler 实践”

根据权限查询时避免角色切换的一种思路

1. 问题背景

权限系统现状

UC权限系统基于角色访问控制技术RBAC(Role Based Access Control) 。具体来说,就是赋予用户某个角色,角色给与角色对应的权限能访问及操作不同范围的资源。

什么是数据权限

代表一个角色对应某个权限所能操作的数据范围,比如gitlab组管理员能看到组下的所有项目代码,我们可以这样配置:

  1. 创建组管理员
  2. 分配给组管理员查看项目代码的权限
  3. 查看项目代码权限设置约束条件,约束为自己组下的项目

实际产生遇到的问题

对绝大多数简单的系统来说一个用户对应一个系统只会有一个角色,一个角色只有一个数据权限范围(即使有多个,也可以合并成一个)。但是随着产品的功能迭代,用户的变更和系统设计的原因,总有一些特殊且重要的用户在同一个系统中拥有多个角色。在多角色和数据权限的组合下,一个用户可以拥有复数的数据权限范围。

考虑到实现复杂性,大多数系统选择使用角色切换的手段简化系统实现,同时对用户暴露出了他们不熟悉的角色这一概念,造成这些用户在系统使用中的各种不便。

本文重点讨论在避免角色切换的前提下,进行多角色数据范围查询的一种思路。

具体需要解决的需求

我们的数据报表后台,不同的角色拥有不同的数据查看范围(不同角色所能看到的员工数据字段也各不相同),例如:

  • 薪酬管理员:查看非高职级员工数据
  • 高级薪酬管理员: 查看高职级员工数据
  • 长期激励管理员:查看有长期激励员工数据
  • 等等

简单来说,拥有长期激励管理员和高级薪酬管理员的用户能否直接看到高职级员工数据和长期激励员工数据?至少在直觉上是可行的。

2.多角色数据范围查询

直觉的做法

单角色单数据范围可以使用一句sql查询出结果,那多角色多数据范围是不是使用多句sql查询出结果合并就可以了?

深入思考 多角色数据范围对行的影响

  1. 查询条件合并还是结果合并? —-结果合并
  2. 如何排序? —–外部排序,或先内部排序,limit,再外部排序
  3. 有重复数据怎么办? —-使用groupby去重
  4. 查询性能有影响吗?—-有

具体体现:

1
2
3
4
5
6
7
select * from (
(select id, sortvalue from table_1 where t_name = 'a' order by sortvalue desc limit 20) -- 先内部排序,limit
union all -- 结果合并
(select id, sortvalue from table_1 where t_name = 'b' order by sortvalue desc limit 20) -- 先内部排序,limit
order by sortvalue desc -- 外部排序
) a group by id -- 使用groupby去重
limit 10, 10

深入思考 多角色数据范围对列的影响

  • 薪酬管理员: 查看员工薪酬字段
  • 长期激励管理员:查看员工长期激励字段

如何解决?方法有很多!

综合思考,给出一种解决方案

1
2
3
graph LR
A(查询行及角色信息) --> B(根据角色查询对应列字段)
B --> C(结果)

步骤:

  1. 查询多角色数据范围下的数据,附带角色信息
1
2
3
4
5
6
7
select id, GROUP_CONCAT(a.role) as roles from (
(select id, 'role_a' as role from table_1 where sortvalue > 10 order by `sortvalue` desc limit 2)
union all
(select id, 'role_b' as role from table_1 where sortvalue > 20 order by `sortvalue` desc limit 2)
order by `sortvalue` desc
) a group by id
limit 0, 2

结果:

id roles
1 薪酬管理员
5 薪酬管理员,长期激励管理员
  1. 根据每一行不同的角色,查询出可见的字段,例如id=1的行只能查看ROLE_B对应字段,而id=5的行可以看到ROLE_A,ROLE_B对应的两个角色的字段

3.总结和延伸

多角色数据范围写操作?

遍历角色直到找到满足条件的权限即可。

收获

自己不行动,等于等着被别人安排哈哈

还有疑问?

自己想。还可以点这里

Your browser is out-of-date!

Update your browser to view this website correctly.&npsb;Update my browser now

×