限流可以认为服务降级的一种,限流就是限制系统的输入和输出流量已达到保护系统的目的。一般来说系统的吞吐量是可以被测算的,为了保证系统的稳定运行,一旦达到的需要限制的阈值,就需要限制流量并采取一些措施以完成限制流量的目的。比如:延迟处理,拒绝处理,或者部分拒绝处理等等。
集群限流保证下游服务,单机限流,保护自己的服务不在极端情况被打呲。常用的限流算法是令牌桶和漏桶,另外有时候我们还使用计数器来进行限流,主要用来限制总并发数,比如数据库连接池、线程池、秒杀的并发数。
计数器是最简单的一种限流算法,比如我们规定1分钟内请求数量不超过100个。那么可以在一开始的时候设置一个计数器,每个请求过来时计数器+1,如果计数器的值大于100,并且与第一个请求时间间隔在1分钟内,就限流。如果时间间隔大于1分钟,且计数器的值还在限流范围内,就重置计数器。
这个算法有临界值问题。比如一个用户在一分钟的59秒发送了了100个请求,然后又在第二分钟的第一秒发送了100个请求,由于计数器重置,这2秒内处理了200个请求。实际是超出了限流的阈值的,容易在瞬间压垮我们的应用。此外这种限流方式不平滑,在大流量情况下,在一分钟开始的第一秒就打满了100个请求,后续请求都会被拒绝掉,然后第二分钟又进来100个请求,就会呈现阶梯状流量。
出现这种问题的原因是精度不够引起的,可以通过滑动窗口算法解决这个问题。
在上图中,整个红色的矩形框表示一个时间窗口,在我们的例子中,一个时间窗口就是一分钟。然后我们将时间窗口进行划分,比如图中,我们就将滑动窗口划成了6格,所以每格代表的是10秒钟。每过10秒钟,我们的时间窗口就会往右滑动一格。每一个格子都有自己独立的计数器,比如当一个请求在0:35秒的时候到达,那么0:30~0:39对应的计数器就会加1。
那么滑动窗口怎么解决刚才的临界问题的呢?我们可以看上图,0:59到达的100个请求会落在灰色的格子中,而1:00到达的请求会落在橘黄色的格子中。当时间到达1:00时,我们的窗口会往右移动一格,那么此时时间窗口内的总请求数量一共是200个,超过了限定的100个,所以此时能够检测出来触发了限流。
再来回顾一下刚才的计数器算法,可以发现,计数器算法其实就是滑动窗口算法。只是它没有对时间窗口做进一步地划分,所以只有1格。
由此可见,当滑动窗口的格子划分的越多,那么滑动窗口的滚动就越平滑,限流的统计就会越精确。
计数器算法是最简单的算法,可以看成是滑动窗口的低精度实现。滑动窗口由于需要存储多份的计数器(每一个格子存一份),所以滑动窗口在实现上需要更多的存储空间。也就是说,如果滑动窗口的精度越高,需要的存储空间就越大。
令牌桶算法是一个存放固定容量令牌的桶,按照固定速率往桶里添加令牌。令牌桶算法的描述如下:
漏桶有两种实现,一种是 as a meter,另一种是 as a queue。
As a meter
第一种实现是和令牌桶等价的,只是表述角度不同。
漏桶作为计量工具(The Leaky Bucket Algorithm as a Meter)时,可以用于流量整形(Traffic Shaping)和流量控制(TrafficPolicing),漏桶算法的描述如下:
As a queue
第二种实现是用一个队列实现,当请求到来时如果队列没满则加入到队列中,否则拒绝掉新的请求。同时会以恒定的速率从队列中取出请求执行。
对于该种算法,固定的限定了请求的速度,不允许流量突发的情况。
比如初始时桶是空的,这时1ms内来了100个请求,那只有前10个会被接受,其他的会被拒绝掉。
不过,当桶的大小等于每个ticket流出的水大小时,第二种漏桶算法和第一种漏桶算法是等价的。也就是说,as a queue是as a meter的一种特殊实现。
Google开源工具包Guava提供了限流工具类RateLimiter,该类基于令牌桶算法实现流量限制,使用十分方便。
RateLimiter有两种限流模式,一种为稳定模式(SmoothBursty 令牌生成速度恒定),一种为渐进模式(SmoothWarmingUp:令牌生成速度缓慢提升直到维持在一个稳定值)。
这里有段注释:
/**
* Last, but not least: consider a RateLimiter with rate of 1 permit per second, currently
* completely unused, and an expensive acquire(100) request comes. It would be nonsensical
* to just wait for 100 seconds, and /then/ start the actual task. Why wait without doing
* anything? A much better approach is to /allow/ the request right away (as if it was an
* acquire(1) request instead), and postpone /subsequent/ requests as needed. In this version,
* we allow starting the task immediately, and postpone by 100 seconds future requests,
* thus we allow for work to get done in the meantime instead of waiting idly.
**/
以上,表明这个版本的RateLimiter会预消费后续的流量额度。比如调用acquire(100),而限制的qps是10,在未被限流情况下,RateLimiter会通过这个acquire(100),而不是被阻塞。之后的10秒内的acquire()才会被阻塞住。
SmoothBursty中几个属性的含义
/**
* The currently stored permits.
* 当前存储令牌数
*/
double storedPermits;
/**
* The maximum number of stored permits.
* 最大存储令牌数
*/
double maxPermits;
/**
* The interval between two unit requests, at our stable rate. E.g., a stable rate of 5 permits
* per second has a stable interval of 200ms.
* 添加令牌时间间隔
*/
double stableIntervalMicros;
/**
* The time when the next request (no matter its size) will be granted. After granting a request,
* this is pushed further in the future. Large requests push this further than small requests.
* 下一次请求可以获取令牌的起始时间
* 由于RateLimiter允许预消费,上次请求预消费令牌后
* 下次请求需要等待相应的时间到nextFreeTicketMicros时刻才可以获取令牌
*/
private long nextFreeTicketMicros = 0L; // could be either in the past or future
根据令牌桶算法,桶中的令牌是持续生成存放的,有请求时需要先从桶中拿到令牌才能开始执行,谁来持续生成令牌存放呢?
一种解法是,开启一个定时任务,由定时任务持续生成令牌。这样的问题在于会极大的消耗系统资源,如某接口需要分别对每个用户做访问频率限制,假设系统中存在6W用户,则至多需要开启6W个定时任务来维持每个桶中的令牌数,这样的开销是巨大的。
另一种是触发式添加令牌。在取令牌的时候,通过计算上一次添加令牌和当前的时间差,计算出这段时间应该添加的令牌数,然后往桶里添加,添加完令牌之后再执行取令牌逻辑。
SmoothBursty采用的是触发式添加令牌的方式,实现方法为resync(long nowMicros)
/**
* Updates {@code storedPermits} and {@code nextFreeTicketMicros} based on the current time.
*/
void resync(long nowMicros) {
// if nextFreeTicket is in the past, resync to now
if (nowMicros > nextFreeTicketMicros) {
double newPermits = (nowMicros - nextFreeTicketMicros) / coolDownIntervalMicros();
storedPermits = min(maxPermits, storedPermits + newPermits);
nextFreeTicketMicros = nowMicros;
}
}
该函数会在每次获取令牌之前调用,其实现思路为,若当前时间晚于nextFreeTicketMicros,则计算该段时间内可以生成多少令牌,将生成的令牌加入令牌桶中并更新数据。这样一来,只需要在获取令牌时计算一次即可。
final long reserveEarliestAvailable(int requiredPermits, long nowMicros) {
resync(nowMicros);
long returnValue = nextFreeTicketMicros; // 返回的是上次计算的nextFreeTicketMicros
double storedPermitsToSpend = min(requiredPermits, this.storedPermits); // 可以消费的令牌数
double freshPermits = requiredPermits - storedPermitsToSpend; // 还需要的令牌数
long waitMicros =
storedPermitsToWaitTime(this.storedPermits, storedPermitsToSpend)
+ (long) (freshPermits * stableIntervalMicros); // 根据freshPermits计算需要等待的时间
this.nextFreeTicketMicros = LongMath.saturatedAdd(nextFreeTicketMicros, waitMicros); // 本次计算的nextFreeTicketMicros不返回
this.storedPermits -= storedPermitsToSpend;
return returnValue;
}
该函数用于获取requiredPermits个令牌,并返回需要等待到的时间点。其中,storedPermitsToSpend为桶中可以消费的令牌数,freshPermits为还需要的(需要补充的)令牌数,根据该值计算需要等待的时间,追加并更新到nextFreeTicketMicros。
需要注意的是,该函数的返回是更新前的(上次请求计算的)nextFreeTicketMicros,而不是本次更新的nextFreeTicketMicros,也就是说,本次请求需要为上次请求的预消费行为埋单,这也是RateLimiter可以预消费(处理突发)的原理所在。若需要禁止预消费,则修改此处返回更新后的nextFreeTicketMicros值。
redis 4.0中提供了redis-cell模块(需安装),基于令牌桶算法实现。
官方wiki:https://github.com/brandur/redis-cell
命令:CL.THROTTLE
CL.THROTTLE user123 15 30 60 3
以上命令表示从一个初始值为15的令牌桶中取3个令牌,该令牌桶的速率限制为30次/60秒。
127.0.0.1:6379> CL.THROTTLE user123 15 30 60
1) (integer) 0
2) (integer) 16
3) (integer) 15
4) (integer) -1
5) (integer) 2
返回值含义:
线上发现一个问题,日志里的traceId数据是错误的。排查后发现是新同学近期引入线程池时,子线程没有更新MDC里数据引起的。
看下代码,就是常规的线程池使用:
这里就要引入MDC的特性了。
MDC(Mapped Diagnostic Context,映射调试上下文)是 log4j 和 logback 提供的一种方便在多线程条件下记录日志的类。
MDC类,简单来说就是日志的增强功能,如果配置了MDC,并添加了相应的key-value对,就会在打印日志的时候把key对应的value打印出来。
MDC内部是用ThreadLocal实现的,可以携带当前线程的context信息。
MDC类(slf4j包)中包装了一个MDCAdapter(接口),不同的log包有不同的实现。log4j 中实现类是 Log4jMDCAdapter,logback 的实现类是 LogbackMDCAdapter。
MDC类中put(),get() 等方法均是调用其成员的mdcAdapter实现。我们代码中是 log4j 实现的,这里的代码切图,都来自log4j。MDC的管理是基于一个线程,然后子线程自动的继承它的父线程的MDC的副本。
这里MDC.put()是放到了tlm这个threadLocalMap里。
ThreadLocalMap继承自InheritableThreadLocal,在创建线程时,子线程会复制父线程的inheritableThreadLocals变量,从而拿到父线程中的信息。
子线程在创建的时候会把父线程中的inheritableThreadLocals变量设置到子线程的inheritableThreadLocals中,而MDC内部是用InheritableThreadLocal实现的,所以自然会把父线程中的上下文带到子线程中。
但对于线程池中的线程来说,这部分线程是可以重用的,但是线程本身只会初始化一次,所以之后重用线程的时候,就不会进行初始化操作了,也就不会有上一段中提到的父线程inheritableThreadLocals拷贝到子线程中的过程了。
线上的traceId是在入口层统一处理了MDC的put()和remove(),然而代码中创建的线程池则没有处理这些。代码中使用的是线程池内子线程第一次创建时,copy自当时父线程的数据。因为子线程内没有更新策略,子线程自己也不会主动更新。导致日志内记录的traceId不会更新。
解决方法:可以在将任务提交给执行程序之前,在原始(主)线程上调用MDC.getCopyOfContextMap()得到当前线程的context拷贝。当任务运行时,作为其第一个操作,调用MDC.setContextMapValues()将原始MDC值的存储副本与新的Executor管理线程关联起来。
]]>Redis key过期的方式有三种:
只有key被操作时(如GET
),Redis才会被动检查该key是否过期,过期就删除,并返回NULL。
这种删除策略对CPU友好,不会在删除上浪费无谓的CPU时间。然而这种策略对内存不友好,一个key已经过期,但是在它被操作之前不会被删除,仍然占据内存空间。如果系统中存在大量的不会被访问的数据,就会造成资源浪费。
Redis会把有过期时间的key放在一个单独的字典里,默认每100ms检查,是否有过期的key,有过期的key则删除。
这里不是每100ms把所有key检查一次,而是随机抽取检查。典型的方式为,Redis每秒做10次如下的步骤:
这是一个基于概率的简单算法,基本的假设是抽出的样本能够代表整个key空间,Redis持续清理过期的数据直至将要过期的key的百分比降到了25%以下。这也意味着在任何给定的时刻已经过期但仍占据着内存空间的key的量最多为每秒的写操作量除以4。
除了主动淘汰的频率外,Redis对每次淘汰任务执行的最大时长也有一个限定,这样保证了每次主动淘汰不会过多阻塞应用请求。
当Redis启用主从模式时,只有主结点执行上述这两种过期删除策略。主节点在key到期时,会在AOF文件里增加一条DEL
指令,同步到所有的从节点,从节点通过执行这条del指令来删除过期的key。
当Redis实例的内存超过设置的maxmemory时,会根据配置的策略maxmemory-policy
来对key进行淘汰,可选的淘汰策略有如下几种:
线上的所有Reids实例默认的淘汰策略为volatile-lru。
上述策略可以在redis.conf
中配置。
执行
SAVE
或BGSAVE
时,数据库键空间中的过期键不会被保存在RDB文件中。
Master载入RDB时,文件中的未过期的键会被正常载入,过期键则会被忽略。 Slave载入RDB时,文件中的所有键都会被载入,当同步进行时,会和Master保持一致。
数据库键空间的过期键的过期但并未被删除释放的状态会被正常记录到
AOF
文件中,当过期键发生释放删除时,DEL
也会被同步到AOF
文件中去。
执行
BGREWRITEAOF
时 ,数据库键中过期的键不会被记录到AOF
文件中
Master删除过期key之后,会向所有Slave服务器发送一个
DEL
命令,从服务器收到之后,会删除这些key。
Slave在被动的读取过期键时,不会做出操作,而是继续返回该键,只有当Master发送
DEL
通知来,才会删除过期键,这是统一、中心化的键删除策略,保证主从服务器的数据一致性。
由于Slave不会主动删除过期key,对于做了读写分离的业务,有可能导致从库读取到过期的脏数据。
代码如下:(5.0版本,在db.c
中)
这是因为key的过期删除依赖于expireIfNeeded
函数,这个函数在任何访问数据的操作中都会被调用并用来检测客户端访问的数据是否过期。
代码第一行就判断了是否过期,如果未过期,直接返回0。
已过期的情况下,如果当前数据库实例角色是Slave,则不进行key过期的删除操作,直接返回1;如果当前数据库实例角色是Master,则首先更新关于失效主键的统计个数,然后将该主键失效的信息进行广播,最后将该主键从Redis中删除。
通过scan命令扫库:
当Redis中的key被scan的时候,相当于访问了该key,同样也会做过期检测,充分发挥Redis惰性删除的策略。这个方法能大大降低了脏数据读取的概率,但缺点也比较明显,会造成一定的数据库压力,谨慎合理使用,否则有可能影响线上业务的效率。
升级Redis到新的版本:
在redis 3.2-rc1版本中,增加了key是否过期以及对主从库的判断,如果key已过期,当前访问的是Master则返回NULL;当前访问的是从库,且执行的是只读命令也返回NULL(老版本从库真实的返回该操作的结果,如果该key过期后主库没有删除)。
–EOF–
]]>首先还是注册插件:XMPPServer类里的loadModules方法中加入
//加载数据集成模块。该模块为外部扩展模块,可能会不存在。
loadModule("com.dylanvivi.openfire.common.DylanModule");
新建xmpp-plugin项目,通过jetty搭建webservice服务,参考文章。
DylanModule类核心代码:
@Override
public void start() {
XFire xfire = XFireFactory.newInstance().getXFire();
ObjectServiceFactory serviceFactory = new ObjectServiceFactory(xfire.getTransportManager());
Service service = serviceFactory.create(OpenfireWebService.class);
//设置发布接口的实现类
service.setProperty(ObjectInvoker.SERVICE_IMPL_CLASS, OpenfireWebServiceImpl.class);
//接口注入到webservice中,发布该接口
xfire.getServiceRegistry().register(service);
//新建服务器
XFireHttpServer server = new XFireHttpServer();
//设置监听端口
server.setPort(8190);
try {
server.start();
} catch (Exception e) {
e.printStackTrace();
}
}
运行结果:
openfire启动…init plugin…
webservice服务
服务发布成功
测试代码
public class TestXFire {
public static void main(String[] args) {
TestXFire tMain = new TestXFire();
tMain.testWebservice();
}
public void testWebservice() {
Client client;
try {
client = new Client(new URL("http://127.0.0.1:8190/OpenfireWebService?wsdl"));
Object[] hello = client.invoke("sayHello", new Object[] { new String("dylan") });
System.out.println(hello[0]);
} catch (MalformedURLException e) {
e.printStackTrace();
} catch (Exception e) {
e.printStackTrace();
}
}
}
输出:
]]>首先xmpp中的roster和出席订阅是两回事。roster的关系更像微博的订阅,不需要对方同意即可完成。而出席信息则“有点儿”像加好友,需要对方批准才能获取信息。
roster是可以独立于订阅状态存在的,因此只使用“花名册”功能的话,roster属性中的subscribe值是none。只有要求订阅对方的出席状态,subscribe值才会出现from、to、both。网上关于openfire的from、to、both、none关系文章太多了,而他们对应在openfire的roster表中sub、ask、recv状态关系也不胜枚举,就不再赘述了。
我们主要想解决一个类似QQ的加好友流程。 User A 发送好友请求至 User B, User B点击同意后,与User A 成为好友(subscribe状态变成both)。
解决方案如下,其中红色的部分是重点关注的地方,也是客户端和服务器需要修改的工作。
以上解决方案用到了RFC-6121 第3.4节中提到的预批准被订阅请求协议,openfire在[3.9.1版本](http://issues.igniterealtime.org/browse/OF-738)已经支持了这个特性。
因此,我们需要做的只是:
当然,也可以通过重写RosterProvider的方式整个把Roster关系替换掉。不过我觉得,在遵从xmpp协议,并且之前已经有很多接口调用的情况下,这个方式还是比较值得考虑的。
PS:我的图画的是有多糟糕啊T^T
update
看样子openfire还没释放以上特性,我们可以通过插件形式完成以上操作。
核心代码如下:
@Override
public void interceptPacket(Packet packet, Session session, boolean incoming, boolean processed)
throws PacketRejectedException {
if (session != null) {
log.info("intercept packet from:" + packet.getFrom() + ",to:" + packet.getTo());
}
if (packet != null) {
//只处理出席信息。
if (packet instanceof Presence) {
Presence presence = (Presence) packet;
//处理订阅类的出席信息,会接收到两次出席信息,只处理resource不为空的那次。
if (Presence.Type.subscribe.equals(presence.getType())) {
if (presence.getFrom() != null && !StringUtils.isEmpty(presence.getFrom().getResource())) {
Presence subscribedPresence = new Presence();
subscribedPresence.setType(Presence.Type.subscribed);
subscribedPresence.setTo(presence.getFrom().toBareJID());
subscribedPresence.setFrom(presence.getTo().toBareJID());
log.info("acceptSubscription: " + subscribedPresence.toXML());
router.route(subscribedPresence);
}
}
}
}
}
原理:
使用插件实现PacketInterceptor接口,拦截presence包,收到订阅包的同时,新建一个presence包,把 from 和 to 换一下,类型为subscribe,发出去(代替客户端同意好友请求)。
效果:
自动同意好友请求,当对方上线时,按同意按钮自动反加。双方订阅关系变为both。
]]>有时候是这样:
决定处理一下这个“黑盒”,从异常信息看,这个异常有关socket连接超时或者是连接被重置。查了一下相关问题出现原因:(引)
常出现的Connection reset by peer: 原因可能是多方面的,不过更常见的原因是:
该异常在客户端和服务器端均有可能发生,引起该异常的原因有两个,一个是如果一端的Socket被关闭(或主动关闭或者因为异常退出而引起的关闭),另一端仍发送数据,发送的第一个数据包引发该异常(Connect reset by peer)。另一个是一端退出,但退出时并未关闭该连接,另一端如果在从连接中读数据则抛出该异常(Connection reset)。简单的说就是由连接断开后的读和写操作引起的。
如果频繁出现,就表示很多客户端连接到Apache服务器的响应时间太长了,可能是网络的问题或者服务器性能问题。
Connection timed out:
连接超时,其实还是找不到已连接的客户端。可能出现原因:客户端网络问题导致连接没断开。
由于Openfire是基于mina做的socket连接,mina本身封装了socket的各种异常,只剩下 ProtocolCodecException、ProtocolEncoderException、ProtocolDecoderException、RecoverableProtocolDecoderException 四种异常,其他都作为运行时异常抛出。
Openfire的连接管理类是ConnectionHandler,继承了mina的IoHandlerAdapter。通过exceptionCaught方法实现连接过程中的异常处理。
源码如下(Openfire 3.7.2版本):
@Override
public void exceptionCaught(IoSession session, Throwable cause) throws Exception {
if (cause instanceof IOException) {
// TODO Verify if there were packets pending to be sent and decide what to do with them
Log.info("ConnectionHandler reports IOException for session: " + session, cause);
}
else if (cause instanceof ProtocolDecoderException) {
Log.warn("Closing session due to exception: " + session, cause);
// PIO-524: Determine stream:error message.
final StreamError error;
if (cause.getCause() != null && cause.getCause() instanceof XMLNotWellFormedException) {
error = new StreamError(StreamError.Condition.xml_not_well_formed);
} else {
error = new StreamError(StreamError.Condition.internal_server_error);
}
final Connection connection = (Connection) session.getAttribute(CONNECTION);
connection.deliverRawText(error.toXML());
session.close();
}
else {
Log.error("ConnectionHandler reports unexpected exception for session: " + session, cause);
}
}
可以看出,openfire本身没有处理IO异常,只是打了一下log。这会导致断开的链接发生没断干净的情况。这种情况下面再讨论。
编写网络程序时需要注意的问题:(引)
是要正确区分长、短连接。所谓的长连接是指一经建立就永久保持。短连接的情况是,准备数据—>建立连接—>发送数据—>关闭连接。很多的程序员写了多年的网络程序,居然不知道什么是长连接,什么是短连接。
是对长连接的维护。所谓维护包括两个方面,首先是检测对方的主动断连(即调用 Socket的close方法),其次是检测对方的宕机、异常退出及网络不通。这是一个健壮的通信程序必须具备的。检测对方的主动断连很简单,主要一方主动断连,另一方如果在进行读操作,则此时的返回值只-1,一旦检测到对方断连,则应该主动关闭本端的连接(调用Socket的close方法)。而检测对方的宕机、异常退出及网络不通,常用方法是用“心跳”,也就是双方周期性的发送数据给对方,同时也从对方接收“心跳”,如果连续几个周期都没有收到对方心跳,则可以判断对方宕机、异常退出或者网络不通,此时也需要主动关闭本端连接,如果是客户端可在延迟一定时间后重新发起连接。虽然Socket有一个keep alive选项来维护连接,如果用该选项,一般需要两个小时才能发现对方的宕机、异常退出及网络不通。
是处理效率问题。不管是客户端还是服务器,如果是长连接一个程序至少需要两个线程,一个用于接收数据,一个用于发送心跳,写数据不需要专门的线程,当然另外还需要一类线程(俗称Worker线程)用于进行消息的处理,也就是说接收线程仅仅负责接收数据,然后再分发给Worker进行数据的处理。如果是短连接,则不需要发送心跳的线程,如果是服务器还需要一个专门的线程负责进行连接请求的监听。这些是一个通信程序的整体要求,具体到你的程序中,就看你如何对程序进行优化了。
openfire自带了心跳机制,是通过在服务端设置Client Connections –> Idle Connections Policy中的:
Disconnect client after they have been idle for [***] seconds
mina框架提供了空闲检测功能,这项功能可检测客户端口建立了TCP/IP连接、却不发送任何消息的情况。
当我们设置了选项时长,openfire会调用mina的session.setIdleTime()方法,在客户端口连接经过指定时长未发送任何消息的情况下触发sessionIdle事件,由sessionIdle()方法处理。
Openfire can send an XMPP Ping request to clients that are idle, before they are disconnected. Clients must respond to such a request, which allows Openfire to determine if the client connection has indeed been lost. The XMPP specification requires all clients to respond to request. If a client does not support the XMPP Ping request, it must return an error (which in itself is a response too).
选项中 Send an XMPP Ping request to idle clients对ConnectionHandler进行了再一次封装,在第一次触发sessionIdle时发送一次ping 消息,迫使客户端进行响应。
在ConnectionHandler 的sessionIdle()方法中判断当前的idle次数大于1次时将关闭客户端连接。我们设置了idle Time 之后这个idle的检测发生在达到一半时间和达到指定时间,每次检测都会将idle 的次数加1 。 例如我们设置了120s,则mina会在这个时长的一半时间内,60s,发送一个ping包,等待客户端响应,然后在到达指定时长后,客户端仍未发送消息时再发一次。如果两次都没有收到回复,则认为连接断掉了,触发sessionIdle()事件,关闭连接。
但是两次心跳中间的消息包就会丢失,并且不会抛出异常,因此选择通过消息回执的方式,保证消息的稳定性XEP-0184。
考虑解决方案:
由于服务端没有断开异常连接,会产生大量的CLOSE_WAIT
和FIN_WAIT2
状态。可以通过
netstat -na|grep 9090|grep FIN_WAIT2|wc -l
或者:
netstat -n | awk '/^tcp/ {++S[$NF]} END {for(a in S) print a, S[a]}'
命令查看。
返回结果:
TIME_WAIT 1
CLOSE_WAIT 87
FIN_WAIT1 4
ESTABLISHED 377
FIN_WAIT2 3305
解决方案:
修改linux系统内核参数:
vi /etc/sysctl.conf
加入以下数据:
net.ipv4.tcp_keepalive_time = 30
net.ipv4.tcp_keepalive_probes = 2
net.ipv4.tcp_keepalive_intvl = 2
net.ipv4.tcp_fin_timeout = 30
设置fin的连接超时时间为30s
PS: 想详细了解这些TCP的状态迁移,以及异常信息,需要用到计算机网络的知识。相关连接
]]>我们经常需要把某个用户的好友关系导出,进行一系列的操作,这时候就用到了RosterManager。通过插件的方式,把RosterManager的getRoster(String username)
方法,把用户的好友关系发布成Dubbo服务,为其他应用提供服务。
这部分主要表述的是为我们的服务加上缓存,Openfire本身自带缓存机制,这个之前已经讨论过了。这次的好友关系,我想把其改造成通过Redis的方式缓存,与公司内私有云环境集成,得到可伸缩扩展的效果。
为保证数据一致性,需要监听Openfire好友关系的变更状态,以同步更新缓存。google未果后,想到两个解决方案:XMPP协议入手,或者从Openfire源码入手。协议又臭又长,我选择了后者。
打开Openfire源码,找到roster包,里面就都是好友关系相关的实现啦。首先看到熟悉的RosterManager,好友关系的管理类,就从这儿入手吧。
@Override
public void initialize(XMPPServer server) {
super.initialize(server);
this.server = server;
this.routingTable = server.getRoutingTable();
RosterEventDispatcher.addListener(new RosterEventListener() {
public void rosterLoaded(Roster roster) {
// Do nothing
}
public boolean addingContact(Roster roster, RosterItem item, boolean persistent) {
// Do nothing
return true;
}
public void contactAdded(Roster roster, RosterItem item) {
// Set object again in cache. This is done so that other cluster nodes
// get refreshed with latest version of the object
rosterCache.put(roster.getUsername(), roster);
}
public void contactUpdated(Roster roster, RosterItem item) {
// Set object again in cache. This is done so that other cluster nodes
// get refreshed with latest version of the object
rosterCache.put(roster.getUsername(), roster);
}
public void contactDeleted(Roster roster, RosterItem item) {
// Set object again in cache. This is done so that other cluster nodes
// get refreshed with latest version of the object
rosterCache.put(roster.getUsername(), roster);
}
});
}
RosterManager的初始化方法里,看到了RosterEventDispatcher.addListener()
方法,正是我们想要的好友状态变更监听器。 已经看到,Openfire默认的实现就是在这里进行的缓存更新:rosterCache.put(roster.getUsername(), roster);
为了保证Openfire以后可以顺利升级,决定不改动源代码,而采用插件重写的方式,对外部应用需要调用的好友关系进行缓存。插件实现还是采用匿名内部类的方式,当然,直接实现RosterEventListener接口也是可行的~
public class RosterServiceImpl implements RosterService {
private static final Logger log = LoggerFactory.getLogger(RosterServiceImpl.class);
private static final String ROSTER_CACHE_KEY = "openfire_roster_";
//测试时模仿Openfire的缓存方式进行缓存,开发时替换成Redis
private static Map<String, Set<String>> caches = new ConcurrentHashMap<String, Set<String>>();
private RosterManager rosterManager;
public RosterServiceImpl() {
log.info("RosterServiceImpl init...");
//实例化RosterManager
rosterManager = XMPPServer.getInstance().getRosterManager();
//为RosterService增加监听
RosterEventDispatcher.addListener(new RosterEventListener() {
public void rosterLoaded(Roster roster) {
// Do nothing
}
public boolean addingContact(Roster roster, RosterItem item, boolean persistent) {
// Do nothing
return true;
}
public void contactAdded(Roster roster, RosterItem item) {
// Set object again in cache. This is done so that other cluster nodes
// get refreshed with latest version of the object
}
public void contactUpdated(Roster roster, RosterItem item) {
// 联系人关系更新事件(from。。。to。。。both。。。none。。。)
// 更新缓存中的联系人好友关系
log.info("----------------contact contactUpdated!~" + roster.getUsername() + ":" + item.getJid()
+ "&relation:" + item.getSubStatus());
//业务要求只有关系为Both的好友才算好友,因此只更新Both好友列表缓存(添加好友)
if (RosterItem.SUB_BOTH.equals(item.getSubStatus())) {
Set<String> friendList = getFriendList(roster.getUsername());
//更新缓存
caches.put(ROSTER_CACHE_KEY + roster.getUsername(), friendList);
log.info("cache update :" + ROSTER_CACHE_KEY + roster.getUsername()
+ " ...after update listsize is:" + friendList.size());
}
}
public void contactDeleted(Roster roster, RosterItem item) {
// 联系人关系删除事件
// 更新缓存中的好友关系
log.info("----------------contact contactDeleted!~" + roster.getUsername() + ":" + item.getJid()
+ "&relation:" + item.getSubStatus());
//取消关注时,更新缓存信息(取消关注时,好友关系为none)
if (RosterItem.SUB_NONE.equals(item.getSubStatus())) {
Set<String> friendList = getFriendList(roster.getUsername());
//更新缓存
caches.put(ROSTER_CACHE_KEY + roster.getUsername(), friendList);
log.info("cache delete :" + ROSTER_CACHE_KEY + roster.getUsername()
+ " ...after update listsize is:" + friendList.size());
}
}
});
}
@Override
public Set<String> getFriendList(String username) {
Set<String> set = new HashSet<String>();
try {
Roster roster = rosterManager.getRoster(username);
//联系人列表。
Collection<RosterItem> items = roster.getRosterItems();
//只查询互为好友关系的好友列表。
for (RosterItem item : items) {
//互为好友的加入到结果集合中。
if (RosterItem.SUB_BOTH.equals(item.getSubStatus()) && item.getJid() != null) {
set.add(item.getJid().getNode());
}
}
} catch (UserNotFoundException e) {
log.error("user not found", e);
}
return set;
}
@Override
public Set<String> getFriendListFromCache(String username) {
if (caches.containsKey(ROSTER_CACHE_KEY + username.trim())) {
log.info("cache hit!" + ROSTER_CACHE_KEY + username.trim() + "... after update listsize is:"
+ caches.get(ROSTER_CACHE_KEY + username.trim()).size());
return caches.get(ROSTER_CACHE_KEY + username.trim());
}
Set<String> set = getFriendList(username);
caches.put(ROSTER_CACHE_KEY + username.trim(), set);
log.info("not found in cache , and put friendList into cache ... with key:" + ROSTER_CACHE_KEY
+ username.trim() + " ...size:" + set.size());
return set;
}
}
写完丢到测试环境,运行正常~
既然问题已经解决了,开始思考如何从协议的角度入手解决此问题。首先肯定是对协议进行了解:RFC6121
通过协议可以知道,当好友关系状态改变时,服务器从用户向联系人递送”subscribed”类型的出席信息节,并且初始化一个名册推送给这个联系人的所有已请求名册的可用资源,包含一个关于这个用户的更新的名册条目,其’subscription’属性值进行改变。
因此,我们可以通过拦截iq包,或者presence包进行对好友关系变更的处理。代码如下:
public class SubPresenceInterceptor implements PacketInterceptor {
private static final Logger log = LoggerFactory.getLogger(SubPresenceInterceptor.class);
@Override
public void interceptPacket(Packet packet, Session session, boolean incoming, boolean processed)
throws PacketRejectedException {
if (session != null) {
log.debug("intercept packet from:" + packet.getFrom() + ",to:" + packet.getTo());
}
if (packet != null) {
//只处理出席信息。(Presence包示例,IQ包类似,只要筛选出NAMESPACE是jabber:iq:roster的就行了)
if (packet instanceof Presence) {
Presence presence = (Presence) packet;
//处理订阅类的出席信息,会接收到两次出席信息,只处理resource不为空的那次。
if (Presence.Type.subscribe.equals(presence.getType())) {
if (presence.getFrom() != null && !StringUtils.isEmpty(presence.getFrom().getResource())) {
RosterManager rosterManager = XMPPServer.getInstance().getRosterManager();
try {
Roster roster = rosterManager.getRoster(presence.getFrom().getNode());
RosterItem item = roster.getRosterItem(presence.getTo());//好友之间订阅关系
log.info("roster item relationship ...:" + presence.getFrom().getNode() + " with "
+ presence.getTo() + " is :" + item.getSubStatus());
if ((RosterItem.SUB_NONE.equals(item.getSubStatus()) || RosterItem.SUB_BOTH.equals(item
.getSubStatus())) && item.getJid() != null) {
//好友关系产生变化时,更新缓存
}
} catch (UserNotFoundException e) {
log.error("user not found", e);
}
}
}
}
}
}
}
-EOF-
]]>想吃掉这个总是捣乱的Openfire,唯一的办法就是搞懂他,迭代空挡,整理一下Openfire的缓存机制。
interface Cache<K,V> extends java.util.Map<K,V>
提供了基本的缓存接口,默认是通过DefaultCache类实现的。该类提供了cache操作的基本方法:put、get、remove、getCacheSize等。
Openfire通过CacheFactory管理cache创建,提供了一个统一的创建和使用Cache的工厂。
/**
* Storage for all caches that get created.
*/
private static Map<String, Cache> caches = new ConcurrentHashMap<String, Cache>();
/**
* This map contains property names which were used to store cache configuration data
* in local xml properties in previous versions.
*/
private static final Map<String, String> cacheNames = new HashMap<String, String>();
/**
* Default properties to use for local caches. Default properties can be overridden
* by setting the corresponding system properties.
*/
private static final Map<String, Long> cacheProps = new HashMap<String, Long>();
以上三个变量分别存储所有已创建的cache,已创建的cache的名称,已创建cache的属性。需要注意的是对Cache的操作需要考虑线程的同步和互斥,Openfire采用了ConcurrentHashMap来保证线程安全。
放入Cache的对象要实现Cacheable接口,可以看出,这是一个序列化的接口。并实现getCachedSize()方法,返回放入对象的size。
public interface Cacheable extends java.io.Serializable {
/**
* Returns the approximate size of the Object in bytes. The size should be
* considered to be a best estimate of how much memory the Object occupies
* and may be based on empirical trials or dynamic calculations.<p>
*
* @return the size of the Object in bytes.
*/
public int getCachedSize() throws CannotCalculateSizeException;
}
例如rosterCache中的缓存对象Roster,其计算cachedSize方法如下:
public int getCachedSize() throws CannotCalculateSizeException {
// Approximate the size of the object in bytes by calculating the size
// of the content of each field, if that content is likely to be eligable for
// garbage collection if the Roster instance is dereferenced.
int size = 0;
size += CacheSizes.sizeOfObject(); // overhead of object
size += CacheSizes.sizeOfCollection(rosterItems.values()); // roster item cache
size += CacheSizes.sizeOfString(username); // username
// implicitFrom
for (Map.Entry<String, Set<String>> entry : implicitFrom.entrySet()) {
size += CacheSizes.sizeOfString(entry.getKey());
size += CacheSizes.sizeOfCollection(entry.getValue());
}
return size;
}
Roster对象的size用CacheSizes.sizeOfObject()、CacheSizes.sizeOfCollection(rosterItems.values())、CacheSizes.sizeOfString(username)等来计算。
CacheFactoryStrategy是Openfire缓存策略的接口,默认实现是DefaultLocalCacheStrategy,该实现不支持集群。另外一个实现类:ClusteredCacheFactory则是缓存集群的解决方案。
-EOF-
]]>根据需求,把用户回复事件作为被观察者,而观察者则是存储、匹配关键词、发邮件等具体的业务。
观察者模式类图:
可以看出,在这个观察者模式的实现里有下面这些角色:
抽象主题(Subject)角色: 主题角色把所有对观察考对象的引用保存在一个聚集里,每个主题都可以有任何数量的观察者。抽象主题提供一个接口,可以增加和删除观察者对象,主题角色又叫做抽象被观察者(Observable)角色,一般用一个抽象类或者一个接口实现。
抽象观察者(Observer)角色: 为所有的具体观察者定义一个接口,在得到主题的通知时更新自己。这个接口叫做更新接口。抽象观察者角色一般用一个抽象类或者一个接口实现。在这个示意性的实现中,更新接口只包含一个方法(即Update()方法),这个方法叫做更新方法。
具体主题(ConcreteSubject)角色: 将有关状态存入具体现察者对象;在具体主题的内部状态改变时,给所有登记过的观察者发出通知。具体主题角色又叫做具体被观察者角色(Concrete Observable)。具体主题角色通常用一个具体子类实现。
具体观察者(ConcreteObserver)角色: 存储与主题的状态自恰的状态。具体现察者角色实现抽象观察者角色所要求的更新接口,以便使本身的状态与主题的状态相协调。如果需要,具体现察者角色可以保存一个指向具体主题对象的引用。具体观察者角色通常用一个具体子类实现。
从具体主题角色指向抽象观察者角色的合成关系,代表具体主题对象可以有任意多个对抽象观察者对象的引用。之所以使用抽象观察者而不是具体观察者,意味着主题对象不需要知道引用了哪些ConcreteObserver类型,而只知道抽象Observer类型。这就使得具体主题对象可以动态地维护一系列的对观察者对象的引用,并在需要的时候调用每一个观察者共有的Update()方法。
Spring中的 ApplicationListener 是 JDK 中提供事件监听者的接口 EventListener 的子类。任何自定义的事件监听者都实现了 EventListener 接口。
Spring中还提供了 EventObject 接口的子类 ApplicationEvent,EventObject 是 JDK 提供的时间类型基类,自定义类型的事件都继承于该类。
Spring中提供一些事件发布相关的接口,BeanFactoryAware、 ApplicationContextAware、ResourceLoaderAware、ServletContextAware 等等,其中最常用到的是 ApplicationContextAware。实现 ApplicationContextAware 的 Bean,Bean 被初始化后,会被注入 ApplicationContext 的实例。ApplicationContextAware 提供了 publishEvent()方法,实现 Observer (观察者)设计模式的事件传播机,提供了针对 Bean 的事件传播功能。通过 Application.publishEvent 方法,用来将事件通知系统内所有的ApplicationListener。
Spring事件处理一般过程:
定义Event类,继承org.springframework.context.ApplicationEvent。
编写发布事件类Publisher,实现org.springframework.context.ApplicationContextAware接口。
覆盖方法setApplicationContext(ApplicationContext applicationContext)和发布方法publish(Object obj)。
定义时间监听类EventListener,实现ApplicationListener接口,实现方法onApplicationEvent(ApplicationEvent event)。
代码如下:
######创建发布事件:ChatMsgEvent
/**
* 用户上行事件,
* 收到用户上行的时候触发本事件。
* @author dylan
*
*/
public class ChatMsgEvent extends ApplicationEvent{
/**
*
*/
private static final long serialVersionUID = 1L;
/**
* 会话消息。
* @param msgChat
*/
public ChatMsgEvent(MsgChat msgChat) {
super(msgChat);
}
}
上行事件内置MsgChat对象,属性包括上行时间,回复内容等。被发布的事件需要继承 ApplicationEvent 类,事件的类型是区分不同订阅者的重要参数之一。
######事件订阅者,负责具体业务逻辑实现:
/**
* 聊天会话消息事件监听器。
* 只处理回复关键词事件……
* @author dylan
*
*/
@Service
public class ChatMsgListener implements ApplicationListener<ChatMsgEvent> {
private static Logger logger = Logger.getLogger(ChatMsgListener.class);
//创建固定为10个的线程池。
private ExecutorService service = Executors.newFixedThreadPool(10);
@Autowired
private MessageService messageService;
public void setMessageService(MessageService messageService) {
this.messageService = messageService;
}
@Override
public void onApplicationEvent(ChatMsgEvent event) {
if (event == null) {
return;
}
//获得事件参数值。
Object arg = event.getSource();
//若参数为会话消息,则处理。
if (arg != null) {
final MsgChat msgChat = (MsgChat) arg;
//异步调用远程接口发送消息。
service.execute(new Runnable() {
@Override
public void run() {
//业务逻辑
}
});
}
}
}
可以通过实现 ApplicationListener 来进行监听,并通过泛型确定监听事件。通过泛型指定监听事件的话,就不用 arg instanceof MsgChat 来判断了。
业务逻辑在onApplicationEvent里实现。
当需要扩展功能时,只需新增一个Listener,并监听回复事件即可实现,无需对以前的代码进行修改。
######事件发布:
//收到上行,并且是真实用户发送的,则触发触发消息发送事件,关联的监听器执行事件更新。
if (msgChat.getIsFromPublic() != null && msgChat.getIsFromPublic().intValue() == 0) {
//通知所有监听器处理监听事件,发布事件。
if (applicationEventPublisher != null) {
applicationEventPublisher.publishEvent(new ChatMsgEvent(msgChat));
}
}
收到上行消息,进行一系列判断后,通过applicationEventPublisher.publishEvent() 来发布消息。Spring发布事件之后,所有注册的事件监听器,都会收到该事件,因此,事件监听器在处理事件时,需要先判断该事件是否是自己关心的,本系统通过泛型确定监听事件的类型。
观察者模式,是一种一对多的关系,即多个观察者监听一个主题。可以很好的解耦业务逻辑,把原来同步的事件变为异步处理,加快了响应速度。并且观察者模式支持广播通讯。被观察者会向所有的登记过的观察者发出通知,更加方便扩展。
………….以下是牢骚时间…………….
PS:好吧,本来最初使用观察者模式的时候就想总结一下,结果可恶的拖延症…… 不过我也有偷懒的办法,上述技术是实际应用到的,不过需求大部分就是杜撰出来的啦~ :) 其实没写的东西还挺多,要不是开始写论文了,几乎没时间总结这半年多的学到的东西。同样被耽搁的还有 maven 的 assembly plugin,用来自定义打包和发布的插件,很好用。以及 JMS ActiveMQ 相关知识点。还有持续集成的应用神马的。一些JS的神奇插件,SWFUpload.js、ckeditor.js……
我的论文也从最开始的简单匹配上下行进行回复发送,进化到了用Dubbo架构的SOA式的,可以负载可以异步运用了各种设计模式的CRM营销系统。哈哈,这么一说感觉还学到不少东西。不过不总结和看原理很容易变成一次性知识。
所谓“一次性知识”,就是遇到的时候Google一下,找到解决方法。我觉得判断一个人能力的高低,其中一个因素就是解决问题的能力。面对这种“一次性知识”如果Google不到,怎么样通过分解关键词,找到相关的知识作为下手点,又应该如何设计测试的参数来确定问题出现的原因,逐步找出答案。做完这些,如果加以总结梳理,“一次性知识”就可以变成自己的东西了,如果通过博客或其他方式分享出来,那就更好了。
这些体现了一个人解决问题的思路,也直接锻炼了学习能力、分析问题的能力,如果每次遇到问题都是直接请教大牛,没有经过思考的过程,所学到的东西可能就会少很多了。所以我喜欢先思考,准备出自己的解决方案再去请大家评审哪个更好,或者有什么更好的解决方案。如果实在没思路,就先找大牛给几个关键词,再去想解决方案。
一般来说,学习可以分为主动学习和被动学习,主动学习的过程就是自己拿起一本技术书籍开始各种啃,被动学习则是遇到问题了想办法去解决的过程。主动学习决定了知识的边界,可以更好的帮助我们在被动学习的过程中提取和分析问题。而被动学习则锻炼了解决问题的能力,毕竟生活中遇到问题不都是可以直接获取的。而单凭读书读来的知识,没有应用到实际中,也是空口白话罢了。
杨绛说,年轻的时候以为不读书不足以了解人生,直到后来才发现如果不了解人生,其实是读不懂书的。写程序也是这样的吧,我们读过的,看到过的,都是“知识”,在实际中用过的,才是“知道”。
]]>