Redis 实现分布式锁

编程中多线程并发和锁的相关应用,尤其是现在应用都是部署在多台服务器上,分布式锁尤其重要。我们可以用 redis 来非常简单地实现分布式锁。其核心思想就是将对锁的操作转换为 redis 操作时,要保障原子性。

用到的两个关键 redis 操作 SETNXGETSET

基础设计

首先让我们自己来构思如何用 redis 实现一个分布式锁。
很简单,在 redis 中存放一个键值对,key 标识锁,value 做其他用途。
当一个线程需要同步操作时:

  1. 首先从 redis 中获取指定 key 的值,
  2. 如果返回 null,说明空闲,当前线程设置 keyvalue 值;如果获取该 key 时如果有值,说明是加锁状态,某个线程正在执行同步操作,当前线程应该挂起等待一段时间再执行。
  3. 当同步操作完成后再删除此 key

这时就要考虑并发情况下对锁的操作了,比如加锁操作包含两步 redis 操作,获取和设值,如果并发情况下两个线程先后交叉执行这两步操作,就会出问题。

  1. 线程一获取 key 的值为 null
  2. 线程二获取 key 的值为 null
  3. 线程一设置 value 为 value1,并认为自己获取到了锁。
  4. 线程二设置 value 为 value2, 并认为自己获取到了锁。

所以我们需要将这两步合为一步,成为原子操作,便可消除这个问题,这就需要上面提到的 SETNX

SETNX key value
将 key 的值设为 value ,当且仅当 key 不存在。
若给定的 key 已经存在,则 SETNX 不做任何动作。
SETNX 是『SET if Not eXists』(如果不存在,则 SET)的简写。
设置成功,返回 1 。设置失败,返回 0 。

很简单,返回 1 取到锁;返回 0 没有取到锁。

异常状况

上面其实已经解决了分布式锁的最基本的问题,包括对锁的操作。下面,我们就要考虑一些特殊情况了,比如某个线程挂掉或网络问题或其他原因导致没有释放锁,这样一来所有线程就陷入了死锁,为了解决这个问题,我们可以为锁设置超时时间,value 值设置为 当前时间戳+过期时长,当其他线程无法获取锁时,可以查看 value 值是否已过期,如果过期,则可以删除掉 value ,执行上面的争用锁的操作。

  1. 获取 key 的值 value 不为 null,说明有锁
  2. 查看 value 的值是否过期
  3. 如果过期,删除 key
  4. key 值设置为自己的 value,认为获取到锁。

这也会出现上面两个线程交叉执行出现的问题,所以我们需要保障 redis 操作的原子性。
我们需要 GETSET 操作:

将给定 key 的值设为 value ,并返回 key 的旧值(old value)。
当 key 存在但不是字符串类型时,返回一个错误。

当我们判断 value 值过期之后,我们直接用 GETSET 设置新值。如果返回值为原 value ,说明我们设置成功,并获取了锁,如果返回值不为原 value,说明有其他线程抢到了锁。

可重入锁

简单来说,可重入锁就是一个线程获取某个锁之后,可以再次获取该锁。为了实现这一特性,我们可以在 value 值中加入一些标识,比如 UUID,可以让线程确认是不是自己持有的锁。

有关工程各个分层中方法参数定义的一些思考

一般 Java 后端开发中,习惯上会将工程分为 3 层,controller,service 和 dao。controller 负责接收解析校验请求和返回,service 处理业务逻辑,dao 执行数据库语句。

最近在重构一个工程,里面很多业务模块都是不同的人写的,每个人的代码风格和习惯都不同,看了各式各样的方法命名和参数定义,我也略有感触和思考。见过最狠的就是从 controller 层解析请求到 dao 层的数据库语句,其中涉及的方法全用 Map 作为参数一层一层向下传,真是让我大开眼界。

总体来说,定义方法参数有 2 种风格习惯:

  • 第一种是把所需要的变量作为一个个单独参数来传递,
  • 第二种则是将零散的变量组装成单个复合参数来传递,比如上面说的 Map,或者自定义的 DTO。

controller 层

先说说 controller 层,我建议用第一种方式,原因很简单,第一种方式能直观地看出接口需要的各个参数及类型,并直接在 controller 中对参数进行处理和校验,IDE 也会对未使用到的参数进行提醒。使用 DTO 则有可能会由于业务变更出现冗余字段,维护时容易忽视,久而久之 DTO 变臃肿,开发者也不能搞清楚接口到底需要参数。下面两种方式对比一下:

public Response addUser(@RequestParam("id") Integer id,
                        @RequestParam("age") Integer age,
                        @RequestParam("sex") Integer sex,
                        @RequestParam("name") String name) 
public Response addUser(RequestUserDTO requestUserDTO)

service 层

然后说说 service 层。

  • 我认为在参数个数不是很多的情况下,建议用第一种方式,保持代码的简洁。
  • 如果参数很多,或者业务变动频繁导致参数变化频繁的情况下,可以考虑使用第二种方式,第二种方式的优势就在于参数的变动不会影响到方法声明,我们只需要关心 DTO 中的变量,然后代码逻辑中处理这些变量。尤其在 service 层十分方便,service 一般都是一个接口对应一个实现类,因为不确定其他工程对原方法的依赖和调用,所以如果方法变更参数,接口和类中都必须保留原方法,增加新的方法来重载原方法,而用第二种方式传递参数,则可以避免这些修改。
  • 在业务参数和逻辑参数混杂的情况下,可以使用两种方式结合,比如下面这样:
public PageInfo<User> userPageList(User user, Integer pageNum, Integer pageSize)

boolean updateUser(User user, String operator);

第一个方法使用了分页插件,所以分页参数并不会传递进 dao 层;第二个方法需要额外记录操作人

dao 层

最后看看 dao 层,我觉得 dao 层没什么多说的,两种方式都可以,看情况选择。

注意的地方

除了上面所说的工程中不同层级方法的参数定义方式,我还想说一些其他东西。

  1. 不要用 Map。这真的真的很蠢,不能描述出所需变量的个数、名称和类型,如果存的变量类型不同还不能用泛型,只能取的时候强制转型。
  2. 不同层不要用同一个 DTO。不同层都有自己的职责和意义,controller 层的 DTO 为了描述网络请求的参数,service 层的 DTO 为了描述业务相关的变量,dao 层的 DTO 为了描述数据库语句需要的参数。有的开发者为了方便,在开发一个功能时,定义一个字段很多的 DTO,从 controller 用到 dao,这也是很蠢的操作。不同层级的 DTO 放在各自的工程模块中,一方面可以解耦,另一方面可以让开发者加深工程化思想。

以上观点仅仅是我个人基于我当前的工作经验总结出来的,欢迎交流沟通。
我总结完才发现说的有点所谓的“理想化的最佳实践”了,工程中还是自由派比较多,大家写代码开心就好。

透明、灰度、兼容&半强制

最近重构了一个公司业务的一个模块,涉及的东西有点多,从需求评审到技术评审,再到各端沟通,花了很大功夫才确定架构和技术方案,最后开发提测灰度上线,清洗数据等等,整个重构经历了很长时间。现在一切尘埃落定,我也静下心来把工作中的思考和探索记录下来。业务不能说的太详细,用一些泛泛的概念,比如新业务逻辑,新业务数据,旧业务逻辑,旧业务数据,业务主体等等。

透明

我很喜欢透明这个词,在软件领域的意思就是关注应该关注的东西,不应该关注的东西应该是透明的、看不到的。
原业务是后端提供一个 HTML,前端各渠道各自编写相同逻辑处理 HTML 里面的信息,进而展示给用户。这样的设计真的是非常痛苦,首先各端工作量增加,其次耦合性太高,最重要的是需要使用 iframe 或引入 jquery 来执行逻辑。重构之后由后端处理完 HTML 的信息,传给各端一个纯展示用的 HTML,对于各端来说这个 HTML 里的内容是透明的,他们不必关注,只需要展示就可以了。
另一个是 HTML 里面有很多需要填写的关键信息夹杂在内容之中,在不同的 HTML 中有可能有相同的关键信息,原业务对相同的关键信息用相同 ID 的 input 标签作为区分,后端再根据 ID 来存取所填的值。这样导致每创建一个新的 HTML 都要开发人员编写并保存一个完整页面,并且确保其中关键信息的填空的 ID 和约定一致。重构之后 HTML 的文本内容和关键信息分离,文本内容相当于纯文案,关键信息则是 key-value 列表,由后端装填到一个 HTML 模板中即可,这样一来,这些业务无关的内容对于后端和前端来说都是透明的,文本内容和关键信息的修改不需要投入开发人员。

灰度

重构一个正在运行的服务,新旧逻辑和数据不互通,不能保证同一时间迁移数据的,而且变更内容过多,影响范围较大,一定要有一个灰度方案,保证出现问题能够把影响面降到最小,新的服务能够逐步稳定地替代旧服务。
首先是灰度范围的选择,从业务的哪一层面来划分灰度范围,选取的颗粒度不能太大也不能太小,根据业务来寻找一个层面进行划分。
然后要考虑如何方便地调整灰度范围直至全量,以及如何保证后续新的业务主体自动进入灰度范围,我在数据库的字典表用一条数据记录灰度范围,也就是一些类型 ID 用逗号拼接成的字符串,代码逻辑会根据业务主体判断其类别是否在灰度范围,当灰度范围调整时,更新这条数据,当这条数据为空字符串时,代码判断为全量切换,即所有业务主体走新的业务逻辑,保证后续新增的类型自动进入灰度范围。
最后是灰度策略的制定,灰度范围外的业务如何处理,在灰度范围内但没有新业务基础数据的怎么处理,其他上下游业务如何保证不受灰度策略的影响。我在这次重构中,灰度范围外的业务主体仍然按照旧的业务逻辑处理,加到了灰度范围中但没有新业务基础数据的,仍然按照旧业务逻辑处理。新的业务保证上下游数据的正常交互,就像一条河流在中间分成了两条河道,对应新旧协议,然后又汇聚回原河流。

兼容

在涉及很多端,不能保证各端同一时间上线,尤其是 APP 端不能强制更新的情况下,旧的接口和逻辑要能够保持正常运行,旧的接口可以调用新的服务,而新的接口也要能够调用旧的服务。也就是说在使用灰度方案时,要保证新旧接口都可以正常工作,并根据灰度范围来正确调用新旧服务。
举个例子,这次重构中,旧业务逻辑是前端根据占位符填充的某些内容,新接口无论新旧业务数据,统一直接在后端填充好了,为了让旧接口兼容并正常展示新业务数据,我在旧接口返回新业务数据时 hack 了相应的占位符,仍然走前端填充的方式。

半强制

在设计了灰度和兼容两个关键点之后,带来另一个业务上必须考虑的问题,就是用户太懒。因为我们有灰度和兼容方案,所以使用者感觉旧的业务还可以使用,便不愿意迁移到新的业务,而且对于他们来说,新旧业务并没有什么不同。
所以我们的半强制策略就是,进入灰度范围的业务主体将不能修改和展示旧业务数据,只有编辑新业务数据的入口,旧业务数据可以走旧业务逻辑完成整个流程,一旦编辑好新业务的基础数据,该业务主体将走新业务逻辑。

前后端数据校验与处理

最近做一些东西,需要前后端协调,遇到一个问题是表单提交中,某个字段我们需要确保它是唯一的,比如注册时的用户名字段username

1.前端
我选择用ajax来向后端校验username的唯一性。绑定了输入框的blur事件,当输入框失去焦点时校验。

$('#username').blur(function () {
    $.getJson('',{username:$('#username').val()},function(response){
        //如果不唯一,提示,并禁止表单提交
    });
});

2.后端
防御式编程核心思想就是”假设输入都是非法的”。这个思想在前后端编程中尤其重要,你永远不要相信网络那头传过来的数据。所以当用户提交表单时,我们需要在后端同样对数据校验,在校验username的唯一性时,我将所有的username作为List放在redis中。
尤其是在前端ajax校验时,会有大量请求,使用redis可以提高效率,减少数据库负担。

public boolean uniqueUserName(String username) {
    List<String> usernames = Redis.use().lrange("usernames",0,-1);
    return usernames == null || !usernames.contains(username) ;
}

3.数据库
在并发的可能性下,数据库可以作为我们最后一道防线,非常稳固。我在数据库用户表中,为username字段建立的unique索引。保证字段唯一性。

ALTER TABLE `user`
ADD UNIQUE INDEX `username` (`username`) ;

综上所述,即使构建一个小工程,也要缜密思考并详细设计每一个环节和流程,在工程架构的每一层都需要有独立处理数据的能力,又要联系上下游的数据传递和错误处理。

后续:当我测试Mysql的唯一索引这层保障时,发现抛出异常,因为后端配置了500页面,返回消息实体是页面,然而前端用的ajax请求,并不能解析消息,也不能直接跳转。所以错误处理和数据传递出现了问题。后来发现了jquery可以配置全局ajax错误处理函数,十分方便,之后无论前端跳转请求还是ajax请求,后端如果发生异常,都可以处理。

$.ajaxSetup({
    error: function () {
        alert("/(ㄒoㄒ)/~~哎呀,服务器有问题了,请刷新后再试试");
    }
});