做一个后台管理系统的时候,用到了admin-lte框架。不过其实大部分后台管理系统都是这样的设计,即,左边是可以缩进的菜单栏,右边是内容,用户点击菜单的时候,菜单栏和上边的用户信息栏并不会重新加载,也就是不会跳转到新的页面,紧紧是更新了右边的内容模块。

跟大多数人一样,我使用的是jqueryload()方法,如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<li><a href="javascript:loadMenu('<%=request.getContextPath()%>/commons/redirect.do?path=management/user_list')">用户列表</a></li>

<li><a href="javascript:loadMenu('<%=request.getContextPath()%>/commons/redirect.do?path=management/department_list')"></i>归属部门</a></li>
...
<script>
function loadMenu(path, parameter) {
$('#main-content').load(path, parameter);
}

$(function () {
var defaultPath = '
<%=request.getContextPath()%>/commons/redirect.do?path=task/task_list';
loadMenu(defaultPath);
});
</script>

上述的代码中,定义了一个loadMenu方法,按钮点击的时候调用此方法,将新的内容页面加载到main-content这个<div>块中,实现了菜单栏不刷新、内容变化的功能。当第一次进入此页面时,自动调用loadMenu方法,加载默认页面。

然而这种实现方式有一种缺陷:当用户点击前进按钮、后退按钮、刷新按钮时,由于实际上用户只访问了一个页面,其他的“功能页面”都是更新此页面中的main-content的内容,所以,前进、后退、刷新时,可能会退出当前网站、页面,这并不是我们在访问普通网页时期望的前进、后退、刷新行为。

下面提出一种解决方案,实测有效:

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
...
<script>

$(function () {
$.refresh()
})

function loadMenu(path, parameter) {
$.pushState(path, parameter)
$.loadPage(path, parameter)
}

$.extend(
{
loadPage: function (url, data) {
console.log('loading url: ' + url)
$('#main-content').load(url, data);
},
pushState: function (url, data) {
console.log("pushing state: " + url)
history.pushState({urlStr: url, data: data}, "页面标题", "?_url=" + url);
},

popState: function () {
window.addEventListener("popstate", function () {
var currentState = history.state
if (currentState != null) {
var url = ".." + currentState.urlStr
console.log('poping state: ' + url)
$.loadPage(url, currentState.data)
}
})
},

refresh: function () {
var currentState = history.state;
if (currentState != null) {
var loadUrl = ".." + currentState.urlStr
console.log('refreshing state: ' + loadUrl)
$.loadPage(loadUrl, currentState.date)
}else{
var defaultPath = '<%=request.getContextPath()%>/commons/redirect.do?path=task/task_list';
loadMenu(defaultPath)
}
}
}
)

$.popState()
</script>

如上述代码,我们拓展了popState/refresh方法,当用户点击前进(后退)、刷新按钮时会分别触发这两个方法, 然后在方法中获取到用户(这里指我…)期望的路径,通过load方法将这些内容加载。

那么这些“记录”是如何保存和获取的呢?当用户点击菜单(按钮),调用loadMenu方法时,我们先调用pushState方法,将他要访问的页面添加到历史记录history.pushState,然后在前进、后退、刷新时获取到对应的历史记录history.state,通过load方法加载这些内容饥渴。并且参数也可以通过对应data字段保存和获取。

实测有效,不过还需要考虑,加载页面后菜单栏的active问题。可以通过传递的参数来判断,修改className,这里不赘述。

应用上线后常常会面对这样一种困境,即,如果把日志级别开得太高,那么当系统出现问题时不好查,如果把日志级别定得太低,那么硬盘很可能很快就被撑爆了。
这时候我们常常选择先将日志级别定高点,当出现问题时,再调低。大部分时候人们习惯的做法是修改logback.xml配置文件,然后重启应用。
这听起来当然有问题,我应用跑得好好的,用户用着好好的,为什么要重启呢,谁来应对重启时客户的怒火呢?

logback的开发者想得周到啦,他们为用户提供了一种动态修改日志级别的能力,而不需要你重启应用。下面的内容仅作为一次简单尝试:

  1. 修改logback.xml文件

    1
    2
    3
    4
    5
    6
    7
    8
    9
    ...
    <jmxConfigurator/>

    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
    <encoder>
    <pattern>%d{yyyy-MM-dd HH:mm:ss} %level [%thread] [%class:%line] - %m%n</pattern>
    </encoder>
    </appender>
    ...

    实际上我们只是新增了一行<jmxConfigurator/>

  2. 启动应用,观察日志

    1
    2
    3
    4
    5
      2018-04-02 19:23:50 INFO [metrics-logger-reporter-thread-1] [com.codahale.metrics.Slf4jReporter:214] - ....
    2018-04-02 19:23:50 INFO [metrics-logger-reporter-thread-1] [com.codahale.metrics.Slf4jReporter:183] - ...
    2018-04-02 19:24:00 INFO [qbScheduler-4] [com.gzcb.creditcard.fdp.utils.ReturnFileTasksScheduler:34] - 定时扫描返回文件开始执行...
    2018-04-02 19:24:00 INFO [qbScheduler-4] [com.gzcb.creditcard.fdp.utils.ReturnFileTasksScheduler:37] - 当前Web节点不是master,跳过...
    ...
  3. 启动jconsole, 连接应用,选择MBean

  4. 找到ch.qos.logback.classic.default目录下的类ch.qos.logback.classic.jmx.JMXConfigurator

  5. 选择操作,选择setLoggerLevel,输入参数,第一个是loggerName,第二个是loggerLevel,点击按钮
    jconsole

  6. 观察日志

    1
    2
    3
    4
    5
    6
    7
    2018-04-02 19:30:27 DEBUG [DefaultQuartzScheduler_QuartzSchedulerThread] [org.quartz.core.QuartzSchedulerThread:276] - ...
    2018-04-02 19:30:50 INFO [metrics-logger-reporter-thread-1] [com.codahale.metrics.Slf4jReporter:214] - ...
    2018-04-02 19:30:50 INFO [metrics-logger-reporter-thread-1] [com.codahale.metrics.Slf4jReporter:183] - ...
    2018-04-02 19:30:56 DEBUG [DefaultQuartzScheduler_QuartzSchedulerThread] [org.quartz.core.QuartzSchedulerThread:276] - ...
    2018-04-02 19:31:00 INFO [qbScheduler-4] [com.gzcb.creditcard.fdp.utils.ReturnFileTasksScheduler:34] - ...
    2018-04-02 19:31:00 INFO [qbScheduler-4] [com.gzcb.creditcard.fdp.utils.ReturnFileTasksScheduler:37] - ...
    2018-04-02 19:31:22 DEBUG [DefaultQuartzScheduler_QuartzSchedulerThread] [org.quartz.core.QuartzSchedulerThread:276] - ...

    我们发现本来全是info的日志,突然变成有debug级别输出了。如果再次修改loggerLevel为info,debug日志又消失了。

    基础乞丐版应用就是这么简单,请自行尝试。如果想获取更多内容,自行访问https://logback.qos.ch/manual/jmxConfig.html

背景

现在有两个或以上的web节点,启动时,会查询数据库,根据数据库中配置的数据(cron表达式等)创建定时任务。由于期望一个任务定时只有一个节点执行,所以需要多个web节点间做master竞选,这个已经实现了,身为master的web节点会将定时任务发送给实际执行的worker节点执行,非master节点的web节点会跳过此定时任务。

然而,由于系统有个功能,用户可以在web页面对定时任务进行增删改查。当用户访问某一个web节点增删改时,该节点可以通过quartz添加、修改、删除定时任务,但是,由于没有消息组件,其他的web节点无法得知定时任务的修改,还是按照它启动时查询数据库得知的信息在运转,这肯定是有问题的。

解决思路

思考了几种解决方式:

  1. 只在master节点上做定时任务的增删改
    直接pass掉这个方案,首先,这样做,非master的节点没有意义,负载均衡没有意义,如果master挂掉同样会遇到定时任务丢失的问题
  2. web节点定时查询数据库做更新
    这并不是一种好的解决方式,频率低了任务会不及时,频率高了会频繁查询更新,并不友好。就好像websocket之前使用http做轮询,除非逼不得已才这样做
  3. 引入消息队列比如kafka
    为了这一个问题,需要重新搭建一套kafka集群,似乎成本太大(还要申请机器、环境、端口巴拉巴拉)。如果已经有一套公用的消息集群和接口..这其实是个好办法
  4. 用zookeeper作信息同步
    既然前面已经用了zookeeper做主从竞选,那么也继续用它作为一个定时任务信息的同步器,多个web节点都对它保持监听,如果发生变化,按规则更新自己本地的定时任务

具体方案

总的来说,每个web节点在启动时,查询所有的定时任务,将每个定时任务写到zookeeper上,并保持监听,如果有修改,则根据情况更新本地定时任务

  1. web节点启动时,将所有定时任务通过quartz添加,同时,对于每一个定时任务,向zookeeper的/jobs节点下创建新的临时节点,节点为/jobs/{taskId}, data为任务的具体信息
  2. web节点创建/jobs/{taskId}节点后,对该节点保持数据监听,同时对/jobs节点做子节点变化监听
  3. 另一个web节点启动时,将所有定时任务通过quartz添加,同时,对于每一个定时任务,向zookeeper中的/jobs节点下创建新的临时节点,若节点/jobs/{taskId}已存在,则保持数据监听,同时,对/jobs节点做子节点变化监听
  4. 某个web节点更新了某个定时任务的信息,首先通过quartz更新本地定时任务,然后判断zookeeper中该任务对应的临时节点是否存在
    4.1. 若存在,则更新该节点的数据;这时候,其他的web节点会收到该/jobs/{taskId}节点的数据变化消息,拿到变化后的数据,判断与本地缓存的是否一致,不一致则更新定时任务
    4.2. 不存在,则新增节点/jobs/{taskId}, 数据为该任务信息,并添加监听;这时候,其他的web节点会收到/jobs节点的子节点变化信息,重新获取/jobs下所有子节点信息,并与本地缓存的对比,判断是否一致,若不一致,则更新定时任务
  5. 某个web节点新增了定时任务,首先通过quartz新增本地定时任务,然后向zookeeper中新增节点/jobs/{taskId},并添加监听;这时候,其他web节点会收到/jobs节点的子节点变化信息,依次获取,添加监听,判断本地是否缓存了此任务,若没有,则新增,若有,则判断是否有变化,变化则更新
  6. 某个web节点删除了某个定时任务,由于业务上的删除并不是实际删除,只是修改该定时任务状态,所以,该web节点并不是去删除/jobs/{taskId}节点,而是去修改它的data信息。其他web节点监听到此节点变化,需要判断修改后状态,再判断是否需要本地删除此定时任务
  7. 某个节点恢复了某个定时任务,由于不是物理删除,等同于更新
  8. 如果某个web节点挂了,它创建的临时节点会被删除,其他web节点会收到通知,然后去获取/jobs节点下所有子节点。由于这时候,仅仅是zookeeper上节点被删除,但实际上,这个定时任务并没有被修改,所以,我认为不需要对删除的临时节点做处理,其他临时节点也不用去尝试创建新的临时节点,因为节点的删除并没有影响定时任务的执行,仅仅是添加和修改会影响

上面是大致的流程,感觉有些细节可能还会有问题,写代码的时候再考虑和测试

看代码


产生原因

中午在生产数据库执行了升级sql,导致数据表结构变更,产生了大量的binlog信息,后台服务(order-service)使用kafka consumer消费binlog消息,由于数据量大,处理缓慢,导致后续的生产订单更新无法通过binlog更新到redis, 前端组件查询不到最新的订单信息等。

解决方法

  1. 使用Kafka的Consumer消费这些处理缓慢的消息:

    1
    2
    3
    4
    5
    //进入kafka容器bin目录,执行
    kafka-console-consumer.sh --bootstrap-server localhost:9092 --topic Binlog --zookeeper *.*.*.*:2181 --consumer.config consumer.config

    //consumer.config内容为groupId
    group.id=...

    消费完后,重启服务,重新加载缓存,后面使用正常

  2. 重置Topic偏移量到最新(未实操)

    1
    2
    3
    4
    5
    6
    bin/kafka-run-class.sh kafka.tools.UpdateOffsetsInZK latest consumer.properties topic

    //其中,consumer.properties为consumer配置信息
    zookeeper.connect=*.*.*.*:2181
    zookeeper.connection.timeout.ms=6000
    group.id=...

后续关注点

  • 生产上线sql,如果影响数据量大,需要在晚上下班后执行;

Mysql从库恢复失败

推断是因为一句修改表结构的语句在从库执行失败(或者未同步?)导致mysql从库同步异常,使用命令show slave status;可以看到

1
2
Slave_IO_Running: Yes
Slave_SQL_Running: No

然后通过Relay_Master_Log_FileExec_Master_Log_Pos定位到具体的sql语句,发现是条更新语句,更新某个表的19个字段,然而从库该表只有18个字段。

  1. 找到历史更新记录,然后手动在从库添加该字段
  2. 暂停同步stop slave;
  3. 跳过当前执行的同步sql set global sql_slave_skip_counter =1;
  4. 继续同步 start slave;

查看同步状态,发现开始正常地执行历史sql了。然而,过了大约半个小时,我再次查看状态时,发现另一个sql又执行报错,重复几次后发现又有不同的sql执行报错,而且由于前面跳过了一些重要数据没有同步,导致后面大片失败,这时候只好选择重建从库。

重建过程

  1. 1
    mysqldump -u*** -p*** -h db-master --default-character-set=utf8 --master-data=2 --single-transaction --databases DB1 DB2 DB3  > product_data_backup_20170515.sql

    这里将需要同步的数据库DB1/DB2/DB3导出到文件。注意–master-data=2 –single-transaction的配合使用,前一个参数会在开始导出时锁全表,记录当前的binlog文件和位置,然后释放锁,然后在同一个事务中导出数据,以保证一致性。

  2. 从库stop slave;

  3. 从库先drop需要同步的数据库,然后source product_data_backup_20170515.sql导入数据;
  4. grep 'CHANGE MASTER TO MASTER_LOG_FILE' product_data_backup_20170515.sql拿到导出时的MASTER_LOG_FILE和MASTER_LOG_POS,例:-- CHANGE MASTER TO MASTER_LOG_FILE='mysql-bin.000068', MASTER_LOG_POS=70371793;
  5. 1
    CHANGE MASTER TO MASTER_HOST='10.*.*.*',MASTER_USER='slaveUser',MASTER_PASSWORD='***',MASTER_LOG_FILE='mysql-bin.000068', MASTER_LOG_POS=70371793;
  6. start slave;

  7. show slave status;发现从库正在同步最新的数据,一会儿之后就已经同步到最新的POS,恢复正常

其他问题

  1. 不要修改从库的表结构以及数据,避免同步冲突失败;
  2. 不要滥用set global sql_slave_skip_counter,这会跳过某些同步sql,可能导致数据不一致;
  3. 附件类数据尽量不要直接存储在数据库中,备份和恢复时会特别慢

dapeng容器目前的模式,每个请求都需要以调用远程服务的方式请求服务,即使它请求的服务运行在本地同一个容器内。打个比方,客户端调用A服务,A服务内需要调用B服务,这时候,对于A服务来说,它是B服务的客户端,它请求B服务的方式与标准的前端没有任何区别,都需要经过各种过滤链,走网络传输,然后调用到本地(或其他节点)的服务。

这样做的优点是,对于所有请求,模式是统一的,每一个请求都是完整的服务调用。
缺点在于:

  1. 如果一个节点有很多服务,一个请求链的互相调用过程中,即使每次都是调用本节点,也依然有大量的网络传输过程开销;
  2. 对一个完整的服务调用来说,每个请求处理需要占用一个新的线程,如果一个请求中调用了十次本节点的服务,那么就至少占用了本节点10个线程,并且大多数都处于等待状态;
  3. 在分布式服务的调用过程中,如果后面某个服务调用异常,前面成功的服务调用无法自动回滚,但如果都是在同一个线程同一个事务,后面的异常可以使前面的数据库操作回滚。

因此,提出了这么一种需求:“如果本节点存在即将调用的服务,那么,不经过网络传输,直接用当前线程调用本地服务”,以此来解决上述的3个问题。

阅读全文