随着互联网项目的访问量增大,对系统的要求越来越高。应运而生出分布式系统,高可用集群等技术。而且已经非常成熟,在公司里面访问量再小的应用标配都是2台集群,单机应用已经一去不复返了。
java的多线程加锁的方式已经没有办法支撑这种分布式应用,因为java的线程锁,只起作用于当前运行的JVM中,多个JVM之间是相互分隔,无法控制的。
核心就是将锁置于一个集中式的地方管理,让锁只有一把,所有集群应用争夺的就是这把锁。
因为是集中式访问,所以要求的性能较高-轻快,且要保证高可用-稳定:业内选择有比较多,zookeeper,redis等等,早期还有用DB(淘汰了)。
这边介绍下当前项目中设计的利用redis实现的。
加锁,释放锁排队等等都是一个标准的流程,所以可以建立一个模板。调用者只要关注加锁代码块的业务逻辑实现。
提供两个方法,一个是默认超时时间及排队时间。另一个可以指定锁的超时时间以及排队等待锁的时间。
public interface OneByOneTemplate {
<T> T execute(OneByOne oneByOne, CallBack<T> callBack);
<T> T execute(OneByOne oneByOne, boolean waitInQueue, int timeoutMsecs, int expireMsecs, CallBack<T> callBack);
}
业务实现逻辑,实现回调接口,并且实现回调方法即可
public interface CallBack<T> {
T invoke();
}
public class OneByOneTemplateImpl implements OneByOneTemplate {
private static final int DEFAULT_TIME_OUT_MSECS = 10000;
private static final int DEFAULT_EXPIRE_MSECS = 30000;
@Override
public <T> T execute(OneByOne oneByOne, CallBack<T> callBack) {
return execute(oneByOne, Boolean.TRUE, DEFAULT_TIME_OUT_MSECS, DEFAULT_EXPIRE_MSECS, callBack);
}
@Override
public <T> T execute(OneByOne oneByOne, boolean waitInQueue, int timeoutMsecs, int expireMsecs, CallBack<T> callBack) {
// 需要排队
if (waitInQueue) {
// 当参数timeoutMsecs取值小于等于零时,则使用默认的排队10秒
if (timeoutMsecs <= 0) {
timeoutMsecs = DEFAULT_TIME_OUT_MSECS;
} else {
timeoutMsecs = 0;
}
// 不需要排队
} else {
timeoutMsecs = 0;
}
// 当参数expireMsecs取值小于等于零时,则使用默认的有效期30秒
if (expireMsecs <= 0) {
expireMsecs = DEFAULT_EXPIRE_MSECS;
}
return invoke(oneByOne, timeoutMsecs, expireMsecs, callBack);
}
private <T> T invoke(OneByOne oneByOne, int timeoutMsecs, int expireMsecs, CallBack<T> callBack) {
final String key = RedisCacheKeyConstants.REDIS_ONE_BY_ONE + oneByOne.getBizType() + "_" + oneByOne.getBizId();
SedisLock sedisLock = new SedisLock(RedisUtil.redisClient, key, timeoutMsecs, expireMsecs);
try {
if (sedisLock.acquire()) { // 启用锁
return callBack.invoke();
} else {
throw new AppException("bizType:" + oneByOne.getBizType() + ",bizId:" + oneByOne.getBizId() + ",并发执行!");
}
} catch (InterruptedException e) {
throw new AppException("");
} finally {
sedisLock.release();
}
}
}
声明redis锁的类,这段代码是在上面模板里面。这边列出来主要是为了控制释放锁时的owner
SedisLock sedisLock = new SedisLock(RedisUtil.redisClient, key, timeoutMsecs, expireMsecs);
加锁过程中会进行锁等待排队,利用的是轮询+wait()。
redis中存储的这个锁的key,并且要存放这个对象的随机属性,owner属性可以是一个UUID。
public synchronized boolean acquire() throws InterruptedException {
int timeout = timeoutMsecs;
while (timeout >= 0) {
if ("OK".equals(redisClient.execute(new ShardedJedisAction<String>() {
@Override
public String doAction(ShardedJedis jedis) {
Jedis j = jedis.getShard(lockKey);
// redis中不存在就设置lockKey对应的值,同时设置毫秒级过期时间
return j.set(lockKey, owner, "NX", "PX", expireMsecs);
}
}))) {
// lock acquired
locked = true;
return true;
}
int spinWatingTime = random.nextInt(200) + 1;
timeout -= spinWatingTime;
wait(spinWatingTime);
}
return false;
}
轮询200内的随机毫秒进行一次尝试获取锁,并且排队时间减去相应的时间,一直等待时间小于0则不再尝试。
这边random.nextInt(200)获取的值是0-199,容易忽略会导致wait(0),造成线程长期占用。所以需要+1
需要校验这个锁是否加锁状态,并且判断是否是锁拥有者
利用redis支持的LUA脚本来将get和del两个动作做成原子性
public void release() {
if (locked) {
// 判断锁拥有者和释放锁
final String script = "if redis.call('get',KEYS[1]) == ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end";
Object result = redisClient.execute(new ShardedJedisAction<Object>() {
//
@Override
public Object doAction(ShardedJedis jedis) {
List<String> keysList = Collections.singletonList(lockKey);
List<String> argsList = Collections.singletonList(owner);
Jedis jedisKey = jedis.getShard(lockKey);
return jedisKey.eval(script, keysList, argsList);
}
});
if (1 == (Long) result) {
locked = false;
}
}
}
这样整体上一个OneByOne的分布式防并发锁就完成了。性能方面基于redis的高性能读写来说还是比较好的。