解决使用MyBatis处理含有$的变量时报IllegalArgumentException Illegal group reference

现象

在一个GET请求中,输入参数包含$符号,然后后台报错。

复现

1
2
3
4
5
6
7
8
9
curl 'http://localhost:9999/xxxx/drivers?page=1&size=20&realname=$' \
-H 'Accept-Encoding: gzip, deflate' \
-H 'Accept-Language: zh,en;q=0.9,ja;q=0.8,zh-TW;q=0.7,fr;q=0.6,zh-CN;q=0.5' \
-H 'User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.75 Safari/537.36' \
-H 'Accept: application/json, text/plain, */*' \
-H 'userId: 453' \
-H 'Connection: keep-alive' \
-H 'token: 1e6966ec2fafdbbd53ef53124cc3e5ae' \
--compressed

报错截图

在这里插入图片描述

原因

这个现象与mybatis无关,因为mybatis对$符号的操作主要集中在GenericTokenParser.java中,而这些操作都是以\${出现,因此可以认为mybatis不处理单独的$符号。如下:
在这里插入图片描述
在interceptor中,有一段打印将要执行的sql语句相关信息,根据调试结果,锁定了这个方法出问题所在,如下:

1
2
Object obj = boundSql.getAdditionalParameter(propertyName);
sql = sql.replaceFirst("\\?", getParameterValue(obj));

因此问题转化成了下面的问题:

1
2
3
4
5
6
// 有问题的语句
String sql = "er WHERE realname like ? AND deleted = ?";
sql = sql.replaceFirst("\\?", "\\%$%");
System.out.println(sql);
// 修改
sql = sql.replaceFirst("\\?", Matcher.quoteReplacement("\\%$%"));

$在replaceFirst(String regex, String replacement)中的使用

网上关于这个方法的使用,普遍停留在regex,对replacement的解释少之又少,基本上没有。因为$符号在replacement中导致操作报错,因此,这个$肯定是有某种特殊的意义的。打开源代码,一直跟踪到抛出错误的地方。代码如下:

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
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
/**
* Processes replacement string to replace group references with
* groups.
*/
private StringBuilder appendExpandedReplacement(String replacement, StringBuilder result) {
int cursor = 0;
while (cursor < replacement.length()) {
char nextChar = replacement.charAt(cursor);
// 可以看出,转义符号使用起来也需要小心。如果在最后面,又会报错。
if (nextChar == '\\') {
cursor++;
if (cursor == replacement.length())
throw new IllegalArgumentException(
"character to be escaped is missing");
// 对转义符的操作比较简单,只是先跳过转义符,然后将下一个字符拷贝
nextChar = replacement.charAt(cursor);
result.append(nextChar);
cursor++;
// 如果是$,那么后面要么是数字,要么是{
} else if (nextChar == '$') {
// Skip past $
cursor++;
// Throw IAE if this "$" is the last character in replacement
if (cursor == replacement.length())
throw new IllegalArgumentException(
"Illegal group reference: group index is missing");
nextChar = replacement.charAt(cursor);
int refNum = -1;
if (nextChar == '{') {
cursor++;
StringBuilder gsb = new StringBuilder();
// 先读出{}中的内容
while (cursor < replacement.length()) {
nextChar = replacement.charAt(cursor);
if (ASCII.isLower(nextChar) ||
ASCII.isUpper(nextChar) ||
ASCII.isDigit(nextChar)) {
gsb.append(nextChar);
cursor++;
} else {
break;
}
}
if (gsb.length() == 0)
throw new IllegalArgumentException(
"named capturing group has 0 length name");
if (nextChar != '}')
throw new IllegalArgumentException(
"named capturing group is missing trailing '}'");
String gname = gsb.toString();
if (ASCII.isDigit(gname.charAt(0)))
throw new IllegalArgumentException(
"capturing group name {" + gname +
"} starts with digit character");
//{}中包含的内容,是分组的名字,分组存在,直接拿来分组的值,不存在则报错
if (!parentPattern.namedGroups().containsKey(gname))
throw new IllegalArgumentException(
"No group with name {" + gname + "}");
refNum = parentPattern.namedGroups().get(gname);
cursor++;
} else {
// The first number is always a group
refNum = nextChar - '0';
// 只接受0-9的数字,也就是分组的序号
if ((refNum < 0) || (refNum > 9))
throw new IllegalArgumentException(
"Illegal group reference");
cursor++;
// Capture the largest legal group string
boolean done = false;
while (!done) {
if (cursor >= replacement.length()) {
break;
}
int nextDigit = replacement.charAt(cursor) - '0';
if ((nextDigit < 0) || (nextDigit > 9)) { // not a number
break;
}
int newRefNum = (refNum * 10) + nextDigit;
if (groupCount() < newRefNum) {
done = true;
} else {
refNum = newRefNum;
cursor++;
}
}
}
// Append group
if (start(refNum) != -1 && end(refNum) != -1)
result.append(text, start(refNum), end(refNum));
} else {
result.append(nextChar);
cursor++;
}
}
return result;
}

可以看出,转义符号使用起来也需要小心。如果在最后面,又会报错,对转义符的操作比较简单,只是先跳过转义符,然后将下一个字符拷贝;如果是$,那么后面要么是数字,要么是{。对$符号的处理,如果后面是{,则先读出{}中的内容,也就是分组的名字,然后查询分组是否存在,存在则直接拿来分组的值,不存在则报错,如果后面是数字,就去拿第n个分组的值。

过程如上,但是网上基本上没有上述内容的示例,根据上面的简要分析,做出了下面简要的demo:

1
2
3
4
5
String Str = new String("Welcome to Tutoririalspoint.combb");
System.out.print("Return Value :" );
System.out.println(Str.replaceFirst("Tuto(.*)als(?<hi>.*)(bb)", "$1AMROOD}$2--$3--${hi}--xx"));
// 输出:
// Return Value :Welcome to ririAMROOD}point.com--bb--point.com--xx

给分组命名确实以前没尝试过,参考:https://stackoverflow.com/questions/415580/regex-named-groups-in-java。

解决办法

替换后的方法如下所示:

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
private String showSql(Configuration configuration, BoundSql boundSql) {
Object parameterObject = boundSql.getParameterObject();
List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
String sql = boundSql.getSql().replaceAll("[\\s]+", " ");
if (parameterMappings.size() > 0 && parameterObject != null) {
TypeHandlerRegistry typeHandlerRegistry = configuration.getTypeHandlerRegistry();
if (typeHandlerRegistry.hasTypeHandler(parameterObject.getClass())) {
sql = sql.replaceFirst("\\?", Matcher.quoteReplacement(getParameterValue(parameterObject)));

} else {
MetaObject metaObject = configuration.newMetaObject(parameterObject);
for (ParameterMapping parameterMapping : parameterMappings) {
String propertyName = parameterMapping.getProperty();
if (metaObject.hasGetter(propertyName)) {
Object obj = metaObject.getValue(propertyName);
sql = sql.replaceFirst("\\?", Matcher.quoteReplacement(getParameterValue(obj)));
} else if (boundSql.hasAdditionalParameter(propertyName)) {
Object obj = boundSql.getAdditionalParameter(propertyName);
sql = sql.replaceFirst("\\?", Matcher.quoteReplacement(getParameterValue(obj)));
}
}
}
}
return sql;
}

参考:
https://github.com/abel533/Mapper/issues/30

MyBatis分页插件PageHelper封装以及遇到的bug

PageHelper链接:https://github.com/pagehelper/Mybatis-PageHelper
项目中使用到了一个注解,叫做PageAble,这是一个对PageHelper的封装注解。这个注解有一个非常显著的问题就是,不能在这个方法里面执行两次SQL查询(原因将在后续中慢慢分析)。使用方法如下:

1
2
3
4
@PageAble
public Object method(int page, int size) {
。。。
}

注解的内容比较简单,就是定义了两个参数,分别为这两个参数设置了默认的名字、以及默认值。

1
2
3
4
5
6
7
8
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface PageAble {
String pageSizeName() default "size";
String pageNumName() default "page";
int pageSize() default 20;
int pageNum() default 1;
}

然后得到的返回值是一个叫做ResultPageView的类,是对分页情况的一个封装,其中的内容如下:

1
2
3
4
5
6
7
public class ResultPageView<T> {
private Long total = 0l;
private Integer current = 1;
private Integer pageCount = 0;
private List<T> list;
// 省略一些构造方法、getter/setter方法
}

所以,最重要的问题当然是@PageAble注解的方法是怎样执行的。显然这里是利用了Spring AOP,在这个方法的前后,加上了自定义的处理方法,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
private static final String PAGE_ABLE = "@annotation(com.xxxxxo0o0.baseservice.annotation.PageAble)";
@Around(PAGE_ABLE)
public Object doAroundAdvice(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
logger.info("execute method : " + proceedingJoinPoint.getSignature().getName());
try {
// 准备开始分页
prepare(proceedingJoinPoint);
// 执行被注解的方法
Object obj = proceedingJoinPoint.proceed();
// 装饰被注解方法返回的值
Object result = after(obj);
return result;
} catch (Throwable throwable) {
logger.error("aspect execute error : ", throwable);
throw throwable;
} finally {
// 先忽略这个finally里面的内容
//PageHelper.clearPage();
}
}

在被注解方法执行前的准备活动中,执行了什么操作?代码如下:

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
private void prepare(ProceedingJoinPoint point) throws Exception {
Signature signature = point.getSignature();
MethodSignature methodSignature = (MethodSignature) signature;
Method targetMethod = methodSignature.getMethod();
PageAble pageAble = targetMethod.getAnnotation(PageAble.class);
// 获取参数名称
String numName = pageAble.pageNumName();
String sizeName = pageAble.pageSizeName();
// 先获取page和size的默认值
int pageNo = pageAble.pageNum();
int pageSize = pageAble.pageSize();
// 获取参数值列表
Object[] paramValues = point.getArgs();
// 获取参数名列表
String[] paramNames = methodSignature.getParameterNames();
int length = paramNames.length;
// 对参数列表进行遍历
for (int i = 0; i < length; i++) {
// 如果参数名 == 注解中写入的页数参数名
if (paramNames[i].equals(numName)) {
// 从参数值列表中取出值,赋值给页数
pageNo = (Integer) paramValues[i];
// 如果参数名 == 注解中写入的每页数量的参数名
} else if (paramNames[i].equals(sizeName)) {
// // 从参数值列表中取出值,赋值为每页尺寸
pageSize = (Integer) paramValues[i];
}
}
// 调用PageHelper的分页
PageHelper.startPage(pageNo, pageSize);
}

先忽略其中的细节,看看被注解方法后面执行的方法做了什么事情,代码如下:

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
private Object after(Object obj) {
assert obj instanceof List;
PageInfo<?> pageInfo = new PageInfo((List<?>) obj);
// 从某个地方获取的分页的信息。(其实是ThreadLocal,先忽略)
Page<Object> localPage = PageHelper.getLocalPage();
// 获取分页参数
long total = localPage.getTotal();
int pageNum = localPage.getPageNum();
int pages = localPage.getPages();
List<?> list = (List<?>) obj;
try {
List<Map> mapList = new ArrayList<>();
for (Object o : list) {
// 将一个对象按照原来的字段名转成map
HashMap<String, Object> map = MapUtil.convertObj2Map(o);
if (o instanceof BaseModel) {
BaseModel baseModel = (BaseModel) o;
map.put("id", baseModel.getId());
}
ReflectionUtils
.doWithFields(o.getClass(), new InnerFieldCallback(map, o), new InnerFieldFilter());
mapList.add(map);
}
list = mapList;
} catch (Exception e) {
logger.error("convert obj to map occurred error ", e);
}
pageInfo = new PageInfo((list));
ResultPageView<?> resultPageView;
resultPageView = new ResultPageView<>(total, pageNum, pages, pageInfo.getList());
// 清除分页信息
PageHelper.clearPage();
return resultPageView;
}

PageHelper.start()做了什么

一路往父类翻到start()的实现代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
/**
* 开始分页
*
* @param pageNum 页码
* @param pageSize 每页显示数量
* @param count 是否进行count查询
* @param reasonable 分页合理化,null时用默认配置
* @param pageSizeZero true且pageSize=0时返回全部结果,false时分页,null时用默认配置
*/
public static <E> Page<E> startPage(int pageNum, int pageSize, boolean count, Boolean reasonable, Boolean pageSizeZero) {
Page<E> page = new Page<E>(pageNum, pageSize, count);
page.setReasonable(reasonable);
page.setPageSizeZero(pageSizeZero);
//当已经执行过orderBy的时候
Page<E> oldPage = getLocalPage();
if (oldPage != null && oldPage.isOrderByOnly()) {
page.setOrderBy(oldPage.getOrderBy());
}
setLocalPage(page);
return page;
}

setLocalPage()与之前的after()中的getLocalPage()是一对get/set方法,他们的目的是从当前线程中获取/设置分页信息。其实现如下(关于ThreadLocal的具体实现,可以去参考其他博客):
在这里插入图片描述
既然startPage()只是在线程中塞了一个关于分页的信息,那么真正读取这个分页信息的动作一定是在处理SQL语句的地方,也就是Interceptor。PageHelper的官方使用文档链接:
https://github.com/pagehelper/Mybatis-PageHelper/blob/master/wikis/zh/HowToUse.md
其中也有一块,是对不安全分页的说明:

PageHelper 方法使用了静态的 ThreadLocal 参数,分页参数和线程是绑定的。只要你可以保证在 PageHelper 方法调用后紧跟 MyBatis 查询方法,这就是安全的。因为 PageHelperfinally 代码段中自动清除了 ThreadLocal 存储的对象。如果代码在进入 Executor 前发生异常,就会导致线程不可用,这属于人为的 Bug(例如接口方法和 XML 中的不匹配,导致找不到 MappedStatement 时),这种情况由于线程不可用,也不会导致 ThreadLocal 参数被错误的使用。但是如果你写出下面这样的代码,就是不安全的用法:

1
2
3
4
5
6
7
PageHelper.startPage(1, 10);
List<Country> list;
if(param1 != null){
list = countryMapper.selectIf(param1);
} else {
list = new ArrayList<Country>();
}

这种情况下由于 param1 存在 null 的情况,就会导致 PageHelper 生产了一个分页参数,但是没有被消费,这个参数就会一直保留在这个线程上。当这个线程再次被使用时,就可能导致不该分页的方法去消费这个分页参数,这就产生了莫名其妙的分页。

因此打开项目中的MyPageInterceptor,它的功能就是充当Mybatis的拦截器,还有一部分自定义的功能,比如说输出sql执行时间、打印sql语句。这个类与PageHelper的拦截器关键的代码基本一致,可以说是copy吧,其中关键的一个地方是intercept()方法中,有一个进行判断,是否需要分页的语句。
在这里插入图片描述
在查询完毕后,finally方法回执行一次清除动作:
在这里插入图片描述
这个dialect是一个本地的类,继承自PageHelper这个类,覆盖了其中的afterAll()方法,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class MyPageHelper extends PageHelper {

@Override
public void afterAll() {
// 获取分页信息
Page<Object> localPage = getLocalPage();
// 调用父类方法,即清除分页信息
super.afterAll();
// 又将分页信息塞回线程中。
// 为什么要这样做?为了让在切面中,加入分页的详细信息。
setLocalPage(localPage);
}
}

这里的代码执行完成后,不论查询的结果是成功还是失败,分页信息都会存在当前线程中(如果直接调用父类的方法,不自定义这个方法,就能保证执行完一次查询,分页信息不会保存在当前线程中)。问题就出在这里。因为interceptor处理过后,当前线程中还存在分页的信息,并且这个分页的信息需要以来切面的处理方法来完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Override
public boolean skip(MappedStatement ms, Object parameterObject, RowBounds rowBounds) {
if(ms.getId().endsWith(MSUtils.COUNT)){
throw new RuntimeException("在系统中发现了多个分页插件,请检查系统配置!");
}
Page page = pageParams.getPage(parameterObject, rowBounds);
if (page == null) {
return true;
} else {
//设置默认的 count 列
if(StringUtil.isEmpty(page.getCountColumn())){
page.setCountColumn(pageParams.getCountColumn());
}
autoDialect.initDelegateDialect(ms);
return false;
}
}

其中获取Page的代码是这样的,说到底还是从当前线程中去取:
在这里插入图片描述

bug描述与分析

执行一个分页查询,让查询故意报错,多执行几次,然后再进行一次普通查询,得到ClassCastException异常。
因此,问题已经出来了。
在这里插入图片描述

解决

因此加上finally后,无论是否报错,那么分页信息都将会被在线程中清除。问题就解决了,所以把涂掉的finally加上清除分页信息的处理,即可解决此问题。

后记

批量发送请求的脚本,用来引发bug用:
main.py

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
import requests
import json

host1 = 'localhost:9999'
host2 = 'xxxxxxxx'
host = host1
MAX = 100

def vehicle():
parse_response(send_get_req('http://'+host+'/nemt/driver/get'))
parse_response(send_get_req('http://'+host+'/nemt/vehicles?page=1&size=20'))
parse_response(send_get_req('http://'+host+'/nemt/vehicleType/all'))
pass


def app_version():
parse_response(send_get_req('http://'+host+'/nemt/driver-apps'))


def parse_response(resp):
s = json.loads(resp[0].content)
if s['code'] == 500:
# print()
print("x " + s['message'] + ' --> ' + resp[1])
else:
print("o")
pass


def send_get_req(url):
# print('url --> '+url)
return requests.get(url), url


def main():
i = 0
while i <= MAX:
app_version()
vehicle()
i = i + 1


if __name__ == '__main__':
main()

crack.sh

1
2
3
4
5
6
7
8
9
10
11
#!/usr/bin/env bash
#if [$1 -eq ""]; then
# max=1
#else
# max=$1
#fi
for i in $(seq 1 $1):
do
curl 'http://localhost:9999/nemt/orders?page=1&size=20' -H 'Accept-Encoding: gzip, deflate' -H 'Accept-Language: zh,en;q=0.9,ja;q=0.8,zh-TW;q=0.7,fr;q=0.6,zh-CN;q=0.5' -H 'User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.77 Safari/537.36' -H 'Accept: application/json, text/plain, */*' -H 'userId: 453' -H 'Connection: keep-alive' -H 'token: 48143d9154e7c42face53855826f5ffa' --compressed
echo ''
done

解决web端登录时等待过久并偶尔抛出事务相关异常

问题描述

在这里插入图片描述

分析

登录函数中有一个事务,如下:
在这里插入图片描述
事务里面有一个有可能操作比较耗时的过程:
在这里插入图片描述
在新增登录日志的时候,获取用户的ip。
在这里插入图片描述
具体干了啥不重要,重要的是发了一个http请求,并且是串行的,所以这个请求比较耗时的可能是很大的,并且具备不确定性因素。
在这里插入图片描述

反思

TransactionRollbackException的文档注释为:

This exception indicates that the transaction associated with processing of the request has been rolled back, or marked to roll back. Thus the requested operation either could not be performed or was not performed because further computation on behalf of the transaction would be fruitless

可能的过程为:线程1进入事务、然后进行了一次update操作,获得了一个排他锁,然后被卡在了获取ip的那个地方,即此事务持有着排他锁,然后还长时间不结束(50s+),然后线程2也进入了事务,此时在进行update的时候,需要等待线程1释放排它锁,在50秒过后,仍未获取到锁,此时获取锁时间超过了预设,抛出上述异常。

解决

规避潜在的耗时操作。但是由于此服务没人维护,因此通过本地编译,然后拉包替换相应class文件,再上传到服务器的方式进行修改。

复现

查看获取锁的超时阀值:

1
SHOW VARIABLES LIKE '%timeout%';

输出如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
+-----------------------------+----------+
| Variable_name | Value |
+-----------------------------+----------+
| connect_timeout | 10 |
| delayed_insert_timeout | 300 |
| have_statement_timeout | YES |
| innodb_flush_log_at_timeout | 1 |
| innodb_lock_wait_timeout | 50 |
| innodb_rollback_on_timeout | OFF |
| interactive_timeout | 28800 |
| lock_wait_timeout | 31536000 |
| net_read_timeout | 30 |
| net_write_timeout | 60 |
| rpl_stop_slave_timeout | 31536000 |
| slave_net_timeout | 60 |
| wait_timeout | 28800 |
+-----------------------------+----------+
13 rows in set (0.01 sec)

代码如下:

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
// --------------------------Controller------------
/**
* 测试web端登录锁表
*/
@GetMapping("/hotline/test")
public void testSomeThing() throws InterruptedException {
testService.doSomething();
}
// ----------------------------Service----------------
@Service
public class TestService {

@Autowired
private BizConfMapper confMapper;

@Transactional(rollbackFor = Exception.class)
public void doSomething() throws InterruptedException {
// 共享锁
BizConf conf = confMapper.selectByPrimaryKey(0);
// 排它锁
confMapper.updateByPrimaryKey(conf);
// 等待让下个线程超时,最起码要大于50
Thread.sleep(60000);
}
}

日志输出与项目中出现的错误信息基本一致,如下:
在这里插入图片描述
@Transactional注解中加入timeout后,报错不一样,但是阔以理解为spring框架为我们抛出了异常。如下:
在这里插入图片描述

后记

其中对我理解这种现象有很大帮助的资料为这一张图,它让我明白了锁与事务之间的关系。
在这里插入图片描述
参考:
https://segmentfault.com/a/1190000014133576

一种基于redis的分布式锁的实现(当前项目中在使用)

主要思想是利用redis执行命令时的单线程特性。

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
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
/**
* 分布式锁
*/
@Component
public class DistributedLock {

public static final Logger logger = LoggerFactory.getLogger(DistributedLock.class);
/**
* 加锁默认超时时间
*/
private static final long DEFAULT_TIMEOUT_SECOND = 5;
/**
* 获取所等待的时间
*/
private static final long DEFAULT_WAITE_TIMEOUT_SECOND = 5;
/**
* 加锁循环等待时间
*/
private static final long LOOP_WAIT_TIME_MILLISECOND = 30;
@Autowired
@Qualifier("redisCacheService")
private BaseCacheService cacheService;

/**
* 有超时等待的加锁
*
* @param timeoutSecond 如果为null,使用默认超时时间
* @param waiteTimeoutSecond 若果为null,使用默认超时时间
* @return 加锁的值(超时时间:-1表示获取失败,超时)
*/
public long lock(String key, Long timeoutSecond, Long waiteTimeoutSecond) {

logger.info("Thread:" + Thread.currentThread().getName() + " start lock");
long beganTime = System.currentTimeMillis() / 1000;
//如果参数错误
if (timeoutSecond != null && timeoutSecond <= 0) {
timeoutSecond = DEFAULT_TIMEOUT_SECOND;
}
timeoutSecond = timeoutSecond == null ? DEFAULT_TIMEOUT_SECOND : timeoutSecond;
if (waiteTimeoutSecond != null && waiteTimeoutSecond <= 0) {
waiteTimeoutSecond = DEFAULT_WAITE_TIMEOUT_SECOND;
}
waiteTimeoutSecond =
waiteTimeoutSecond == null ? DEFAULT_WAITE_TIMEOUT_SECOND : waiteTimeoutSecond;
while (true) {
//等待超时判断
long endTime = System.currentTimeMillis() / 1000;
if ((endTime - beganTime) >= waiteTimeoutSecond) {
return -1l;
}
//超时时间点
long timeoutTimeMilli = cacheService.getCurrentTimeMilliForCache() + timeoutSecond * 1000;

//如果设置成功
if (cacheService.setIfAbsent(key, timeoutTimeMilli)) {
logger.info("Thread:" + Thread.currentThread().getName() + " lock success");
return timeoutTimeMilli;
}

//如果已经超时
Long value = cacheService.getVal(key, Long.class);
if (value != null && value.longValue() < cacheService.getCurrentTimeMilliForCache()) {

//设置新的超时时间
Long oldValue = cacheService.getAndSet(key, timeoutTimeMilli);

//多个线程同时getset,只有第一个才可以获取到锁
if (value.equals(oldValue)) {
logger.info("Thread:" + Thread.currentThread().getName() + " lock success");
return timeoutTimeMilli;
}
}

//延迟一定毫秒,防止请求太频繁
try {
Thread.sleep(LOOP_WAIT_TIME_MILLISECOND);
} catch (InterruptedException e) {
logger.error("DistributedLock lock sleep error", e);
}
}
}

/**
* 无超时等待的加锁
*
* @param timeoutSecond 如果为null,使用默认超时时间
* @return 加锁的值(超时时间)
*/
public long lock(String key, Long timeoutSecond) {

logger.info("Thread:" + Thread.currentThread().getName() + " start lock");

//如果参数错误
if (timeoutSecond != null && timeoutSecond <= 0) {
timeoutSecond = DEFAULT_TIMEOUT_SECOND;
}
timeoutSecond = timeoutSecond == null ? DEFAULT_TIMEOUT_SECOND : timeoutSecond;
while (true) {
//超时时间点
long timeoutTimeMilli = cacheService.getCurrentTimeMilliForCache() + timeoutSecond * 1000;

//如果设置成功
// 若在redis中、没有相应的key值,那么可以认为,当前线程即或得该锁。
if (cacheService.setIfAbsent(key, timeoutTimeMilli)) {
logger.info("Thread:" + Thread.currentThread().getName() + " lock success");
return timeoutTimeMilli;
}

//如果已经超时
// 此时该key在redis已存在,获取该key的值
Long value = cacheService.getVal(key, Long.class);
if (value != null && value.longValue() < cacheService.getCurrentTimeMilliForCache()) {

//设置新的超时时间
// 如果此时获取到的oldValue,与前面获取的value相同,则说明该线程可以获得锁
// 获得锁后,set进新值。
Long oldValue = cacheService.getAndSet(key, timeoutTimeMilli);

//多个线程同时getset,只有第一个才可以获取到锁
if (value.equals(oldValue)) {
logger.info("Thread:" + Thread.currentThread().getName() + " lock success");
return timeoutTimeMilli;
}
}

//延迟一定毫秒,防止请求太频繁
try {
Thread.sleep(LOOP_WAIT_TIME_MILLISECOND);
} catch (InterruptedException e) {
logger.error("DistributedLock lock sleep error", e);
}
}
}

/**
* 释放锁
*/
public void unLock(String key, long lockValue) {

logger.info("Thread:" + Thread.currentThread().getName() + " start unlock");
Long value = cacheService.getVal(key, Long.class);
if (value != null && value.equals(lockValue)) {//如果是本线程加锁
cacheService.deleteVal(key);
logger.info("Thread:" + Thread.currentThread().getName() + " unlock success");
}
}
}

nginx中的root与alias的差别

格式

nginx指定文件路径有两种方式root和alias,指令的使用方法和作用域:
[root]
语法:root path
默认值:root html
配置段:http、server、location、if
[alias]
语法:alias path
配置段:location

root与alias主要区别

在于nginx如何解释location后面的uri,这会使两者分别以不同的方式将请求映射到服务器文件上。
root的处理结果是:root路径 + location路径
alias的处理结果是:使用alias路径替换location路径
alias是一个目录别名的定义,root则是最上层目录的定义。
还有一个重要的区别是alias后面必须要用“/”结束,否则会找不到文件的,而root则可有可无。

例:

1
2
3
4
5
6
7
8
9
10
# 如果一个请求的URI是/t/a.html时,web服务器将会返回服务器上的/www/root/html/t/a.html的文件。
location ^~ /t/ {
root /www/root/html/;
}

# 如果一个请求的URI是/t/a.html时,web服务器将会返回服务器上的/www/root/html/new_t/a.html的文件。
# 注意这里是new_t,因为alias会把location后面配置的路径丢弃掉,把当前匹配到的目录指向到指定的目录。
location ^~ /t/ {
alias /www/root/html/new_t/;
}

注意

  1. 使用alias时,目录名后面一定要加”/“。
  2. alias在使用正则匹配时,必须捕捉要匹配的内容并在指定的内容处使用。
  3. alias只能位于location块中。(root可以不放在location中)

搬运工:
文章为: nginx.cn原创,转载请注明本文地址: http://www.nginx.cn/4658.html

Java进程周期性自动退出的原因排查

一个java -jar服务在被CI启动后,过一段时间,进程就被消失了,不见了。日志没有关于出错的相关信息。对日志中记录的最后一条请求,进行压力测试,但该进程却没有自己消失。个人觉得这个问题很有意思,但是我也明白,找到这其中的原因可能需要很长的时间。

Update(2019-3-15 )

最近公司的其他项目上,又遇到了一个进程老是无缘无故就挂的现象,按照之前的那种场景来排查,并没有发现有那种CI的出现。顿时又陷入了困境之中。不过我还是按部就班的做了三件事:

  • 用root权限启动改服务
  • 做好对jvm的监控
  • 用strace对进程做好监控
    顺便了解了一下strace的含义,发现其中的字段确实是有很大的意义。
    监控命令如下:
    1
    nohup strace -T -tt -e trace=all -p \`pgrep -f algorithm-work-1.0.0.jar\ ` > trace.\`pgrep -f algorithm-work-1.0.0.jar\`.log &\
    监控日志如下:
    1
    2
    3
    4
    5
    6
    7
    8
    webapp@ecs-f1c4-0003:/opt/webapp/logs$ cat trace.26077.log 
    strace: Process 26077 attached
    11:22:43.117920 futex(0x7f631a5fb9d0, FUTEX_WAIT, 26078, NULL) = ? ERESTARTSYS (To be restarted if SA_RESTART is set) <1238.653416>
    11:43:21.776222 --- SIGTERM {si_signo=SIGTERM, si_code=SI_USER, si_pid=26158, si_uid=0} ---
    11:43:21.777278 futex(0x7f63199c0540, FUTEX_WAKE_PRIVATE, 1) = 1 <0.004217>
    11:43:21.786583 rt_sigreturn({mask=[]}) = 202 <0.005049>
    11:43:21.796220 futex(0x7f631a5fb9d0, FUTEX_WAIT, 26078, NULL <unfinished ...>
    11:43:23.605244 +++ exited with 143 +++
    从上面看来,uid=0说明操作者是root,但是kill -9不一定有,不过至少是可以看出来是什么时候被干掉的。

转机
发现是被别人用来挖矿了==
在这里插入图片描述
详细的情况与下面链接的描述一模一样。本来还有一个进程叫做crond64,在top中看到占用的CPU非常高,各个核的都占到了90%+。执行的挖矿脚本的目录在~/.ttp下,打包好了,准备研究研究。
https://askubuntu.com/questions/1115770/crond64-tsm-virus-in-ubuntu

压力测试

首先想到的是:是不是某一个接口出现了问题,所以根据日志中所记录的最后一条请求,对其进行压力测试。脚本如下:

1
2
3
4
5
6
7
#!/bin/bash
# 获取工单详情
for i in {1..100000}
do
curl --header "token:71e8e4dd40dd65f645ceb214397f578e" --url "192.168.31.117:9997/workorder/mine/orders?id=79" &
echo ""
done

结果被认为遭到了ddos攻击,囧!
在这里插入图片描述
该服务进程扛过了这些请求,没有死亡。

排查CI

因为整个项目通过gitlab管理,而gitlab中有一项叫做CI,可以通过ci脚本来执行一些脚本达到发布、部署最新的服务到相应服务器上。这里面可能会存在问题,比如说,另外一个ci脚本在执行的时候,会把该服务的进程kill掉,只是会有这种可能,因为CI脚本大部分是通过copy的,但是可能性不高,因为所有的CI脚本都能够顺利执行,所以kill掉的肯定是自身服务的进程,不然CI脚本对应的服务可能起不来,但目前所有的CI脚本都能顺利执行完。但是还是去排查一下CI脚本,没毛病

是谁kill掉了该进程?

这个进程消失的原因,可以想到的情况为:jvm崩溃、被操作系统的oom_killer杀掉、被某个脚本杀掉?

是否为操作系统所终结?

由于outofmemory被kill掉的进程,会在/var/log下的某个文件中留下最终的遗迹。但是在整个/var/log下、都没有搜索kill的痕迹,如下:
在这里插入图片描述
在这里插入图片描述
如果没有/var/log/messages这个文件,可以通过设置,将这个log文件开启。

  1. root身份打开 /etc/rsyslog.d/50-default.conf

  2. 把注释#去掉

    1
    2
    3
    4
    #*.=info;*.=notice;*.=warn;\
    # auth,authpriv.none;\
    # cron,daemon.none;\
    # mail,news.none -/var/log/messages
  3. 重启后ok
    sudo restart rsyslog

并没有发现这个oom_killer的痕迹

JVM自己崩溃?

在该服务的启动参数中加入了对崩溃日志的指定:
java -jar -Xms512m -Xmx512m -XX:MaxPermSize=126m -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/opt/webapp/xxx-server/ -XX:ErrorFile=/opt/webapp/xxx-server/hs_err_pid_%p.log xxx-server.jar
但是在该进程被终结后,并没有发现相应目录下的日志文件。所以这种情况下,同样也没有对其进行内存分析的先决条件、jdk自带的一系列工具并没有发挥出作用的余地。

到底是谁杀掉了这个进程?

使用nohup strace -T -tt -e trace=all -p 21715 > trace.log &监控该pid的情况,如果是被kill -9,会出现一个log,大致如下:
在这里插入图片描述

结果

通过strace命令的跟踪、最终发现trace.log的内容如下所示:
在这里插入图片描述
出现这个的原因基本上是两个、一个是人为的kill -9、或者就是被系统kill -9。系统杀掉该进程的原因、被逐一排除,结果只剩下人为的因素。关于人为的因素、首先查看命令,并没有相关的kill记录。然后发现开发环境与测试环境的该进程基本上同时挂掉、并且都点了这两个环境的某个CI,然后这两个环境上的该进程都挂掉了,因此基本断定是CI操作杀掉了该进程。
被杀掉的进程名字为:java -jar workorder-server.jar,通过CI发布&启动的进程名字为:java -jar order-server.jar。然后在点击order相关的CI时会执行如下操作:

1
2
3
4
5
script:
- mvn clean package -pl order-server -am -Dmaven.test.skip=true
- scp order-server/target/order-server.jar ${user_dev}@${nemt_host_dev}:/opt/webapp/order-server/.
- ssh ${user_dev}@${nemt_host_dev} 'kill -9 `pgrep -f order-server\.jar` ; echo 1'
- ssh ${user_dev}@${nemt_host_dev} '. /etc/profile ; cd /opt/webapp/order-server/ ; nohup java -jar order-server.jar >> /dev/null 2>&1 &'

问题就在kill -9上面,pgrep查出来的进程号有两个,所以执行order相关的CI时,顺带也把workorder干掉了。
在这里插入图片描述

解决办法

修改shell语句,让pgrep order-server时,只显示出order-server的进程号即可。

CentOS服务器的简单配置

修改软件源

这里使用阿里云的源,阿里云的镜像地址为:https://opsx.alibaba.com/mirror
1、备份
mv /etc/yum.repos.d/CentOS-Base.repo /etc/yum.repos.d/CentOS-Base.repo.backup

2、下载新的CentOS-Base.repo/etc/yum.repos.d/
CentOS 5
wget -O /etc/yum.repos.d/CentOS-Base.repo http://mirrors.aliyun.com/repo/Centos-5.repo
或者
curl -o /etc/yum.repos.d/CentOS-Base.repo http://mirrors.aliyun.com/repo/Centos-5.repo
CentOS 6
wget -O /etc/yum.repos.d/CentOS-Base.repo http://mirrors.aliyun.com/repo/Centos-6.repo
或者
curl -o /etc/yum.repos.d/CentOS-Base.repo http://mirrors.aliyun.com/repo/Centos-6.repo
CentOS 7
wget -O /etc/yum.repos.d/CentOS-Base.repo http://mirrors.aliyun.com/repo/Centos-7.repo
或者
curl -o /etc/yum.repos.d/CentOS-Base.repo http://mirrors.aliyun.com/repo/Centos-7.repo

3、之后运行yum makecache生成缓存

安装以及配置JDK

1、卸载预装的OpenJDK

1
2
rpm -qa | grep jdk
sudo yum remove ****(软件包名,上条命令的输出结果)

2、从Oracle官网下载JDK

下载地址

3、下载完成后,进行安装

1
2
3
4
# 安装
sudo rpm -ivh jdk-8u181-linux-x64.rpm
# 验证安装是否成功
java -version

安装&配置Tomcat

安装操作如下

1
2
3
4
5
6
7
wget http://mirrors.hust.edu.cn/apache/tomcat/tomcat-8/v8.5.33/bin/apache-tomcat-8.5.33.tar.gz
tar -xzvf apache-tomcat-8.5.33.tar.gz
sudo vim /etc/profile
# 添加CATALINA_HOME到最后一行
# export CATALINA_HOME=/home/asahi/devtools/apache-tomcat-8.5.33
# 保存-退出
source /etc/profile

配置UTF-8字符集

因为默认不使用UTF-8,所以我们在web中使用中文时,会出现乱码。
所要修改文件的位置:conf/server.xml
文件需要修改的地方:
这里写图片描述

通过HTTP包来再次理解重定向与转发

之所以去看它们之间数据包的差异并不是好奇。我理解它们之间的差异,但是我不知道该如何证明它们之间的差异。想了很久,我觉得去抓包,看它们之间数据包之间的差异,就是一个有力的证据。

预设

总共有三个servlet,即/ddgg_ssm/loginre//ddgg_ssm/loginzf/ddgg_ssm/user/,它们的作用依次为重定向到/ddgg_ssm/user/、转发到/ddgg_ssm/user/、显示一串字符。简要代码如下:
重定向

1
2
3
4
5
6
7
8
9
10
11
12
13
@Controller
@RequestMapping("/loginre")
public class LoginController {
@RequestMapping("/*")
@ResponseBody
public void printMsg(HttpServletRequest request, HttpServletResponse response) {
try {
response.sendRedirect("/ddgg_ssm/user/");
} catch (IOException e) {
e.printStackTrace();
}
}
}

转发

1
2
3
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
request.getRequestDispatcher("/user/").forward(request, response);
}

先进行的是重定向,再接着的是转发。

数据包抓取结果以及简要的分析

重定向与转发的数据包如下:
重定向与转发的HTTP数据包分析

首先是第一个数据包,请求loginre

1
2
3
4
5
6
7
8
9
10
GET /ddgg_ssm/loginre/ HTTP/1.1
Host: 172.10.1.39:8080
Connection: keep-alive
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Linux; U; Android 7.0; zh-cn; Redmi Note 4X Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/61.0.3163.128 Mobile Safari/537.36 XiaoMi/MiuiBrowser/9.7.2
x-miorigin: b
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,en-US;q=0.8
Cookie: JSESSIONID=3AFB73B954FEA21EA30E9F1D5ECF63A4

得到服务器的302重定向响应

1
2
3
4
HTTP/1.1 302 
Location: /ddgg_ssm/user/
Content-Length: 0
Date: Tue, 05 Jun 2018 09:04:57 GMT

浏览器得到重定向的响应后,再请求Location中的Servlet

1
2
3
4
5
6
7
8
9
10
GET /ddgg_ssm/user/ HTTP/1.1
Host: 172.10.1.39:8080
Connection: keep-alive
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Linux; U; Android 7.0; zh-cn; Redmi Note 4X Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/61.0.3163.128 Mobile Safari/537.36 XiaoMi/MiuiBrowser/9.7.2
x-miorigin: b
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,en-US;q=0.8
Cookie: JSESSIONID=3AFB73B954FEA21EA30E9F1D5ECF63A4

最后再得到服务器的响应:

1
2
3
4
5
6
HTTP/1.1 200 
Content-Type: text/html;charset=ISO-8859-1
Content-Length: 12
Date: Tue, 05 Jun 2018 09:04:57 GMT

Hello World!

此时浏览器的地址栏已经变成了/ddgg_ssm/user/


而后续的转发,只有两个数据包,一个请求与一个响应。

1
2
3
4
5
6
7
8
9
10
GET /ddgg_ssm/loginzf HTTP/1.1
Host: 172.10.1.39:8080
Connection: keep-alive
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Linux; U; Android 7.0; zh-cn; Redmi Note 4X Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/61.0.3163.128 Mobile Safari/537.36 XiaoMi/MiuiBrowser/9.7.2
x-miorigin: b
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,en-US;q=0.8
Cookie: JSESSIONID=3AFB73B954FEA21EA30E9F1D5ECF63A4

得到的响应是/ddgg_ssm/user/所写入的内容Hello world!

1
2
3
4
5
6
HTTP/1.1 200 
Content-Type: text/html;charset=ISO-8859-1
Content-Length: 12
Date: Tue, 05 Jun 2018 09:05:28 GMT

Hello World!

再贴一个结论吧

  • 转发在服务器端完成的;重定向在客户端完成
  • 转发的是同一次请求;重定向是两次不同请求
  • 转发地址栏没有变化;重定向地址栏有变化

JDBC的使用笔记

下午花了三四个小时,在慕课网上看了看关于JDBC的两个系列的视频,跟着做了做。因为之前也看过,所以一直都记得有这么一个视频存在,但是唯一不足的是没写上一些笔记。写个简易的学习笔记,加深一下对其中一些知识的理解,以后翻起来就不用去看视频了,看这个就好。还记录下自己在学习过程中所遇到的一些疑问以及自己所
阅读更多