一、业务场景
一个行业网盘产品,用户分为主账号和子账户,多个子账户都可以操作公司主账号下的网盘,在多个子账户操作文件结构的时候,存在并发问题,如果简单的串行处理又明显影响用户体验
举例:上传下载的时候肯定需要多线程上传文件,你串行化了怎么办。
两个人同时上传文件 你也不能串行化。
二、逻辑锁的设计
举例:
同一个文件夹 支持并发写入
同一个文件夹 支持并发删除
三、技术选型
3.1 redis
由于本身的并发并不高,锁的范围是主账户下,多个子账户操作,理论上可以使用java逻辑、数据库来实现并发逻辑控制,
系统本身是分布式的,java 中的逻辑不合适,总不能单独部署一个服务提供锁的逻辑,数据库到是可以支持,利用表字段逻辑 去实现锁的控制
但是数据库 Mysql 是MVVC 版本控制,在你访问数据库的时候 你怎么保证没有其他子账户向数据库中写入数据。
redis 本身支持分布式锁,性能也合适,redis 中的key 有过期时间, 悲观的认为假如出现了死锁 用户等待一段时锁就释放了。
事实上lua 封装的逻辑不可能出现死锁的
3.2 redision
锁的存储选择了redis 那么java 访问redis 选择哪个驱动呢,个人推荐redisson
redisson 本身利用lua 脚本实现了很多功能,适合java在redis 中执行lua 脚本。
3.3 lua 脚本
选择lua 脚本的原因就是为了 当一个子账户要进行一个操作的时候 需要分两步
1.检查这个操作有没有被其它子账户 锁住
举例:B 账户在删除 文件架 00100
A 账户需要向文件夹00100 中上传一个文件,需要先检查文件00100 的状态,在向文件00100 上加一个上传锁
这个过程 需要保证原子性
如果利用java实现 那么就破坏了操作的原子性。
3.4 lua 脚本示例
local lockKeys = {'001_delete','15_delete','001_17_delete'};
local supUserKeys = {'001_1_add_9','001_5_add_9','001_7_add_9'};
local checkKeys= {'001_1_delete','001_5_delete'};
local function join_and_filter(checkKeys, lockKeys)
local f=false;
for i = 1, #checkKeys
do for j=1 ,#lockKeys
do local tstring=string.match(lockKeys[j], checkKeys[i]);
if(not tstring)
then
else f = true
break
return f
end;
end;
end;
if(f)
then
return f
end;
end
----keys[1] 用户持有的锁
local keys = redis.call('keys', KEYS[1]); local keyValuePairs = {}; for i = 1, #keys do keyValuePairs[i] = keys[i] end;
---- keys[2] 需要检查的锁
local checkKeys=redis.call("sismember",KEYS[2]);
---- keys[3] 目标锁
local supUserKeys=redis.call("sismember",KEYS[3]);
if(join_and_filter(checkKeys, keyValuePairs))
then
return false
else
for k = 1, #supUserKeys
do redis.call("set",supUserKeys[k],'1')
redis.call('expire', supUserKeys[k], 30)
end
return true
end
这个是lua 脚本demo
不需要外部入参
通过循环检查 用户的某个操作是否被锁 如果没有加锁lua 脚本加锁
返回 true 或者false
四、 java通过redisson 执行lua 遇到的问题
正式开发之前先写了一个demo ,整个demo 大概花费了2周时间,
首先lua 现学现卖,学下来发现lua 真小众,还好自己会python ,lua 学起来不难, 但是lua 应用范围很窄,写业务不太有需要,大多数都是游戏脚本,待遇一般,稍微有真实需求的是 C 语言开发的网关顺带使用lua 写逻辑,或者nginx 、redis 这种 使用lua.
4.1 redisson 中入参和出参的类型
这里说明一下redisson 官方的文档很简单,需要执行复杂的入参和返回值的时候就有点蒙了,总不至于肯源码吧,搜索了github 上
有个国外的同行提交了redisson的各种测试用例,感觉没有半个月搞不清楚,感谢开源。同时国内的资料大部分抄一下官网的示例,小部分没有规划,只是集中在某些点上。
这里列举部分操作
有需要的可以联系我 提供GitHub地址
获取List 对象
获取boolean 对象
@Test
public void testEval() {
RScript script = redisson.getScript();
List<Object> res = script.eval("return {1,2,3.3333,'foo',nil,'bar'}", RScript.ReturnType.MULTI, Collections.emptyList());
MatcherAssert.assertThat(res, Matchers.<Object>contains(1L, 2L, 3L, "foo"));
}
@Test
public void testEvalAsync() {
RScript script = redisson.getScript();
Future<List<Object>> res = script.evalAsync("return {1,2,3.3333,'foo',nil,'bar'}", RScript.ReturnType.MULTI, Collections.emptyList());
MatcherAssert.assertThat(res.awaitUninterruptibly().getNow(), Matchers.<Object>contains(1L, 2L, 3L, "foo"));
}
@Test
public void testScriptExists() {
RScript s = redisson.getScript();
String r = s.scriptLoad("return redis.call('get', 'foo')");
Assert.assertEquals("282297a0228f48cd3fc6a55de6316f31422f5d17", r);
List<Boolean> r1 = s.scriptExists(r);
Assert.assertEquals(1, r1.size());
Assert.assertTrue(r1.get(0));
s.scriptFlush();
List<Boolean> r2 = s.scriptExists(r);
Assert.assertEquals(1, r2.size());
Assert.assertFalse(r2.get(0));
}
4.2 redisson 编码问题
使用redisson 主要编码,不同的编码在redis 中存储的数据格式不一样,我就遇到 key 无缘无故加了双引号
推荐使用String 编码
RSet<String> set = redissonClient.getSet(userID + "ok" + fileId, StringCodec.INSTANCE);
RScript script = redissonClient.getScript(StringCodec.INSTANCE);
4.3 lua 脚本redis.call(“set”,supUserKeys[k],‘1’) 权限问题
本地测试通过之后 发到测试环境发现 竟然不允许lua 脚本写入数据,
问了运维 测试环境的redis 版本是3.0 的
查了资料发现redis版本需要3.2 以上, 很多资料写了最低要求4.0
这里又卡了半天。
重点
lua 脚本报错:
Write commands not allowed after non deterministic commands. Call redis.replicate_commands() at the start of your script in order to switch to single commands replication mode.
需要:
-- 开启单命令复制模式
redis.replicate_commands();
redis版本低于3.2 的时候会有报错,当你执行redis.replicate_commands(); 的时候又会包命令不存在,导致死循环。
这里就是升级你的redis版本
在执行Lua时Redis默认不允许动态的不确定性的变量存在. 如果存在, 则需要开启命令复制模式, 即只复制Lua脚本里包含的写命令, 所有的写命令会被包装在MULTI … EXEC里.
Redis官方, 把默认的复制整个脚本内容的模式定义为whole scripts replication, 把只复制脚本里写命令的模式定义为 script effects replication.
在命令复制模式下, 还可以选择是否对某个命令进行目标复制, 即是否需要复制到‘从服务器’和‘AOF文件’ , 如下:
redis.set_repl(redis.REPL_ALL) -- Replicate to AOF and slaves.
redis.set_repl(redis.REPL_AOF) -- Replicate only to AOF.
redis.set_repl(redis.REPL_SLAVE) -- Replicate only to slaves.
redis.set_repl(redis.REPL_NONE) -- Don't replicate at all.
举个例子:
redis.replicate_commands() -- 开启命令复制模式
redis.call('set','A','1')
redis.set_repl(redis.REPL_NONE) -- 不进行复制
redis.call('set','B','2')
redis.set_repl(redis.REPL_ALL) -- 复制到所有,即从服务器和AOF文件
redis.call('set','C','3')