- 告警信息要标明:机器、业务和环境等信息
- 制定合理的告警规则,梳理告警项,优化项目error日志等;避免大量无用的告警导致麻木
- 重要的特定业务可以单独配告警
- 是不是经常遇到临时打开某个开关,最后又忘记修改回来的事情?其实这种可以通过告警来解决,通过配置常规状态的固定值,定时检查告警即可。
使用“自旋”降低业务失败率
业务场景描述
不说废话,直接描述以下两个业务场景
- 使用分布式锁控制并发
请求----------> 服务A [1.尝试获取锁] [2.获取锁失败] [3.返回失败(提示操作太快)]
2. 强依赖服务超时或”请求处理中”
请求----------> 服务A [1.强依赖请求]----------> 服务B [2.执行慢(网络抖动等原因)] [3.请求服务B超时] [4.返回失败(提示服务繁忙)]
问题描述
- 场景1在获取锁失败时直接返回业务失败,可以考虑二次尝试;
- 场景2因为服务B执行慢,直接返回上层失败。
- 服务A重试请求服务B时,可能请求已经执行完,很快就返回成功;或者服务B仍在执行该请求,返回“执行中”错误码。
解决方案
- 场景1可以使用“自旋”减少失败率:在抢锁失败时,当前线程“自旋”100m以内的随机数,再二次获取锁;
- 场景2同样可以使用“自旋”减少失败率:服务A在请求超时或收到“执行中”错误码时,同样自旋后再次重试,较大概率得到成功结果。
扩展
- 自旋同样需要考虑“比例”问题,不然大量自旋会影响服务的吞吐量,具体可参考:由灾备工作中引发的对“比例”的思考
canal使用总结
前言
本文只讲自己在使用canal之后的一些总结,关于canal的基础用法和介绍,不在这里赘述。
原理
canal的原理是基于mysql binlog技术,所以这里一定需要开启mysql的binlog写入功能,建议配置binlog模式为row
原文:https://blog.csdn.net/liupeifeng3514/article/details/79687130
mysql的自带复制技术可分成三步:
- master将改变记录到二进制日志(binary log)中(这些记录叫做二进制日志事件,binary log events,可以通过show binlog events进行查看);
- slave将master的binary log events拷贝到它的中继日志(relay log),这里是I/O thread线程;
- slave重做中继日志中的事件,将改变反映它自己的数据,这里是SQL thread线程。
基于canal&otter的复制技术和mysql复制类似,具有类比性:
- Canal对应于I/O thread,接收Master Binary Log;
- Otter对应于SQL thread,通过Canal获取Binary Log数据,执行同步插入数据库;
两者的区别在于:
- otter目前嵌入式依赖canal,部署为同一个jvm,目前设计为不产生Relay Log,数据不落地;
- otter目前允许自定义同步逻辑,解决各类需求;
a. ETL转化. 比如Slave上目标表的表名,字段名,字段类型不同,字段个数不同等.
b. 异构数据库. 比如Slave可以是oracle或者其他类型的存储,nosql等.
c. M-M部署,解决数据一致性问题
d. 基于manager部署,方便监控同步状态和管理同步任务.
canal的工作原理:
- canal模拟mysql slave的交互协议,伪装自己为mysql slave,向mysql master发送dump协议
- mysql master收到dump请求,开始推送binary log给slave(也就是canal)
- canal解析binary log对象(原始为byte流)
使用简述
- 对MySQL给canal-server授权
CREATE USER 'canal_server' IDENTIFIED BY 'canal_server';
GRANT SELECT, REPLICATION SLAVE, REPLICATION CLIENT ON *.* TO 'canal_server';
- canal使用ZK来做HA,因为涉及多个client时,消费计算的业务较为复杂的问题,所以目前在使用上只是单点部署,基本能满足需求。
使用总结
- 可以通过指定binlog文件和position来重放binlog数据:
- 如何查看MySQL位点:https://blog.csdn.net/c1052981766/article/details/80604083
show master status
show binary logs
show binlog events in 'binlog.000368' limit 10;
//pos 就是位点, 重启 canal- 如何查到旧记录的位点?
- 由canal-server中meta.dat记录
- 通过 mysqlbinlog 命令分析 mysql-bin.xxx 文件内容,确定增量恢复到的时间点(运维协助)。
- 除了用pos点的办法进行恢复,也可以通过指定时间区间进行恢复,按时间恢复需要用mysqlbinlog命令读取binlog日志内容,找时间节点。
- 重跑。修改instance.properties文件设置重跑起始位点,删除meta.dat文件并重启。注意回放数据时视业务情况,可能要忽略第一个位点对应的数据。
- 重启之后batchId会变,所以不能使用batchId来做幂等处理
- 使用mysql主从同步和使用canal同步的区别? 同步数据为什么不直接用主从同步机制?
- mysql版本不一致,不支持主从同步
- 并不是自己的库,只需要同步几张表,运维不会做这种定制化
- 完成同步目标后,可方便拆卸和控制
- client重启如何对应原来的位点
- canal client和server交互之间的身份标识,目前clientId写死为1001. (目前canal server上的一个instance只能有一个client消费,clientId的设计是为1个instance多client消费模式而预留的,暂时不需要理会)
canal和MySQL相关资料
- 下载:https://github.com/alibaba/canal/releases/download/canal-1.1.4/canal.deployer-1.1.4.tar.gz
canal.instance.gtidon
是否启用mysql gtid的订阅模式- https://github.com/alibaba/canal/wiki/QuickStart
https://github.com/alibaba/canal/wiki/AdminGuide
https://github.com/alibaba/canal/wiki/ClientExample - MySQL数据库的授权原则
- canal 高可用介绍
- canal和otter的高可靠性分析
如何接手一个旧项目
改变心态,以前会抱怨业务乱,但是业务在发展过程中乱是很正常的,也要有人来主动整理,同时也在考验和锻炼整理人的能力。
- 梳理现有业务文档,进行归类(如果有的话?)
- 拉代码,服务本地跑起来,并跑通单元测试
- 梳理服务现有的所有监控平台和数据
- 梳理服务的依赖关系和超时时间
- 梳理服务的依赖组件(MQ,Redis,DB等)和部署情况
- 数据库表梳理
- 了解服务的部署和灾备架构(机器数,机房,流量入口)
- 代码核心逻辑阅读(核心流量接口)
- 了解服务的监控情况,常用配置和开关,相关告警配置通知人
- 待优化任务和遗留问题接手
扩展
- 软件泥潭真体验
- “定时炸弹”
附录1: 交接列表示例(设计与开发) ## 项目管理 - 项目日程 - 会议记录 - 体制图 ## 项目要件 - 业务功能清单 - 业务流程图 - 需求变更记录 - 操作说明书/用户手册 - 常见问题一览 ## 界面设计 - UE设计稿 - 高保真画面设计稿 - 需求变更一览 ## 系统设计 - 系统架构设计图 - 部署架构图DB关联图(ER图)和DB详细设计 - 系统间集成关系图 - 对接系统一览表和对接系统接口清单 ## 开发制作 - 源代码 - 代码运行说明 ## 测试 - 系统测试用例与系统测试报告书 - 性能测试用例与性能测试报告书 - 用户测试用例 - 用户测试签字 附录2: 交接列表示例(运维相关) ## 上线相关 - 上线判定表 - 上线操作记录 - 历次上线版本说明 - 临时对应体制 ## 基础设施 - 硬件资源一览 - 软件资源一览 - 服务器/系统账号权限 - 系统工具 - 付费/免费软件 ## 运维体制 - 运维工作一览表 - 近半年运维工作应对流程 - 运维体制 - 故障对应流程 - SLA(服务水平协议) ## DEVOPS(开发运维一体化) - CI/CD工具与使用 - 监控工具 - 备份管理 - 代码库与分支管理 - 数据库相关配置与策略
单元测试的工具和技巧
提高单元测试覆盖率
提高bean类的覆盖率
- pojo-tester
- lombok [lɒmˈbɒk]
- kotlin
定义一些过滤规则
工具
- mock神器:mockito
- sql模拟测试:h2
- 对测试进行参数化: JUnitParams
- Awaitility: 一个小型的Java领域专用语言(DSL),用于对异步的操作进行同步。
https://www.ctolib.com/topics-109441.html - WireMock: 用于模拟HTTP服务的工具 (对外部服务打桩)
- Selenium:编写UI驱动的端到端测试
其他
使用 集成测试 覆盖若干个完整的核心流程
业务重构实践总结
业务重构似乎是必然且合理的过程。一个项目刚开始为了快速上线和试错,总会或多或少出现不合理的地方。当项目活下来了,为了寻求下一步的发展,服务的稳定和扩展性提升了优先级,自然就需要有人来重构了,这也是项目历史遗留的技术债务。
项目背景
- 公司原有的业务几乎都是PHP编写的,并且处于一个很大的项目里维护和运行,可以说是大杂烩。随着业务发展和用户量的提升,渐渐得暴露了很多问题:
- PHP运行效率较低,业务未进行隔离,出问题时相互影响;
- PHP 过于灵活的语言特性,导致后期较高的后期维护成本;
- 基础框架和服务对PHP支持不足;
- 实现灾备比较困难。
重构语言选择
- 我们选择Java作为开发语言进行重构
- 静态类型,多人协作开发和维护更加安全可靠;
- 内部基础组件的 Java 版生态比较完善;
- 学习成本低,且开发效率较 PHP 没有明显降低。
重构的工作是什么?
- 最简单直接的主要工作:就是充当PHP到Java语言的翻译,将PHP代码转化成Java代码,并保持逻辑不变,对上层业务是无感知的;
- 除此之外,对业务逻辑进行优化和简化,提高后续的可维护性和开发效率;对服务架构进行优化,提升服务的稳定性和可靠性。这是重构的主要意义;
- 重构完成之后,制定完善的验证和上线方案。
实施过程
- Step 1. 用 Java 重构逻辑
- 对外暴露的协议(HTTP 、RPC 接口定义和返回数据)与之前保持一致(保持协议一致很重要,之后迁移依赖方会更方便)
- 尽量翻译而不是重构优化(至少第一版重构采取这样的策略)
- Step 2. 验证新逻辑正确性
- 当代码重构完成后,在将流量切换到新逻辑之前,我们会先验证新服务的正确性。
- 考验对业务的熟悉程度和翻译的准确度(代码太久远,最熟悉的可能只有你自己)
- 单元测试保证
- Step 3. 灰度放量
- 当一切验证通过之后,我们会开始按照百分比转发流量。
- PHP入口对接底层新逻辑(验证逻辑正确性;之所以不直接从流量入口切换,是为了保证稳定性,在出现问题时可以迅速回滚。)
- 外网代理切到新接口(保持协议一致,无需前端修改,切换彻底)
经验总结
- PHP代码太复杂,项目又不容易跑起来怎么办?
- 默默睁大眼睛翻译还能怎样。。。
- 如何验证是否翻译正确?
- 找个在线PHP执行平台把复杂的代码片断跑起来,按照执行结果编写测试用例
- PHP代码太狗血太久远,万一翻译到半死发现这段逻辑根本已经没用了怎么办?
- 把你的服务当nginx用,参数不符合条件的转发到旧服务,记录入参和返回结果,打日志和监控观察流量
最后
- 不能相信自己
- 不能相信别人
- 只能相信自己
- 乖乖覆盖测试用例
由灾备工作中引发的对“比例”的思考
前言
- 关于文章的题目我想了很久,暂时没想到合适的题目,姑且先这样吧
- 之前在做灾备工作的时候,经常涉及服务的强弱依赖,超时时间,降级,以及业务折中方面的思考,下面将通过一些例子简要进行描述
场景描述
从服务所依赖的重要程度上来区分有“强依赖”和“弱依赖”。
- 通常上,针对“弱依赖”我们会设置较小的超时时间(比如100ms以内),并且在失败率较高是进行降级;
- 而针对“强依赖”我们会设置一个较大的超时时间,当接口失败时返回“服务繁忙“,表示暂不可用(当然可以产品层面进行合适的引导)
我们在灾备演练中,通过停其中一个机房,触发以下的问题
- 强依赖超时问题(停机房瞬间)
- 弱依赖多变长的问题(停机房期间)
从而导致停机房瞬间接口失败率高,停机房期间接口时延上涨严重。由此引发我对“比例“问题的思考。
- 强依赖超时时间设长,可以减少失败率,但也有可能导致服务worker线程池被占满而拒绝请求。
- 弱依赖虽然每个都设置100ms以内,但是当依赖的服务超过10个时(真实业务真的有),可能导致总时长超过一秒(弱依赖慢但都没熔断,所以没降级;可异步并发的弱依赖的另说)
解决方案
- 对强依赖,可设置超时时间的比例(比如只有20%的请求可以超过1s,其他的控制在100ms以内),解决停机房瞬间,线程池占满拒绝请求的问题
- 对弱依赖,可控制多个弱依赖的总超时时间,解决停机房期间跨机房调用导致时延一直上涨的问题
- 单个调用超时时间可分级设置比例;多个调用(可熔断)可聚合在一起设置总超时时间比例
- 降级优先级排序,控制总时间。熔断线程池,控制比例超时。允许一定的时间超时。流量控制。物理隔离。
扩展
- 针对使用回调机制提高接口安全性所讲的,某些网络场景可以考虑针对大金额的请求才回调,减少RPC次数,这其实也是一个“比例”问题。
使用回调机制提高接口安全性
前言
- 接口回调在平常的业务开发中并不罕见。做过渠道推广业务,对接过广告平台接口的同学应该比较熟悉。
- 在业务的作用主要是两个
- 异步通知
- 业务查询
本文主要讲的第二种,并通过一个业务例子来展开描述
业务描述
有以下业务场景,用户通过充值加金币:
充值请求----------> 服务A(充值服务) [1.验证用户已经支付成功] [2.发起加金币请求]------------>服务B(金币服务)
服务B如何验证加金币操作的请求是合法,从而保证资金安全呢?
- 有一种方法是通过分配业务对应的秘钥和服务端签名对比,验证请求的合法性
通过秘钥和验签的方式无法避免以下问题
- 其他业务方误用秘钥
- 因误操作而发加币请求
- 业务方有bug一次充值多次加币
解决方案
- 其实以上描述的业务有一个特点:每一次操作都有对应的前置操作,比如
- 充值加金币的前置操作是:充值支付成功
- 活动类加金币的前置操作是:用户达到活动要求
那么作为底层的金币服务,可以使用定义一种同样的回查机制,由各业务方实现查询逻辑,金币服务通过回查来检验请求的合法性,从而提高安全性。
- 基础服务(金币服务)接收回调链接地址有两种方式:
- 通过后台配置,每个业务配置对应回调链接和相应的参数 (相比可能比较安全可控,但不灵活)
- 通过参数获取,如callback参数(灵活,但是要设计完善的校验机制保证安全)
- 可结合公司的基础服务实现,如可以通过服务发现获取服务对应的地址,只需传uri即可
充值请求----------> 服务A(充值服务) [1.验证用户已经支付成功] [2.发起加金币请求]------------>服务B(金币服务) <----------[3.回调验证] [4.加金币] < pre>
- 统一回调接口返回格式
回调接口返回格式 { "code": 0, "message": "success" } 0 代表成功;其他:失败 回调用于查询业务订单是否存在,检验是否非法订单 一般实现逻辑建议业务方直接查主库
- 回调无法绝对解决以上提出的问题,只能说一定程度上提升接口的安全性级别。
扩展
- 使用非对称加密,是不是可以替换回调验证?(客户端使用公钥解密,服务端使用私钥加密,前提是私钥不泄漏)
分布式限流简单总结
基本描述
限流分类
- 单机限流
- 分布式限流
限流的指标
- 每秒处理的事务数 (TPS),每秒请求数 (hits per second)
- 使用 hits per second 作为限流指标
限流规则包含三个部分:时间粒度,接口粒度,最大限流值。
选择单机限流还是分布式限流
- 单机限流一般针对服务负载,防止突发流量压垮服务器
- 分布式限流一般针对在业务侧做细粒度限流
限流算法
- 固定时间窗口
- 首先需要选定一个时间起点,之后每次接口请求到来都累加计数器,如果在当前时间窗口内,根据限流规则(比如每秒钟最大允许 100 次接口请求),累加访问次数超过限流值,则限流熔断拒绝接口请求。当进入下一个时间窗口之后,计数器清零重新计数。
- 限流策略过于粗略,无法应对两个时间窗口临界时间内的突发流量。
- 滑动时间窗口
- 滑动时间窗口限流算法可以保证任意时间窗口内接口请求次数都不会超过最大限流值,但是仍然不能防止在细时间粒度上面访问过于集中的问题
- 令牌桶算法(Token Bucket)
- 接口限制 t 秒内最大访问次数为 n,则每隔 t/n 秒会放一个 token 到桶中;桶中最多可以存放 b 个 token
- 令牌桶大小为 b,所以是可以应对突发流量的
- 没有提前预热的令牌桶,如果做否决式限流,会导致误杀很多请求
- 漏桶算法(Leaky Bucket)
- 漏桶算法稍微不同与令牌桶算法的一点是:对于取令牌的频率也有限制,要按照 t/n 固定的速度来取令牌,所以可以看出漏桶算法对流量的整形效果更加好,流量更加平滑,任何突发流量都会被限流。
分布式限流
- 采用redis+lua的方案做分布式限流 (参考业界实现方案即可,需注意原子性)
- 由于使用中心存储计数的方式性能较差,在业务允许的情况下可以考虑将限制的数量分摊到每个服务(服务数通过服务发现接口获取),间接使用单机限流提升性能。
扩展
- Google的Guava包中的RateLimiter
- 令牌桶算法的解决方案,假设1S需要限流5次;也就是1S会往桶里面方5个Token;如果在这1S内桶满了则不再加请求,如果空了则表示达到限制的上线了,会阻塞,直到有数据加入再次处理。
- [ratelimiter4j] (https://github.com/wangzheng0822/ratelimiter4j)
- 分布式限流算法的性能瓶颈主要在中心计数器 Redis,从我们开源的 ratelimiter4j 压测数据来看,在没有做 Redis sharding 的情况下,基于单实例 Redis 的分布式限流算法的性能要远远低于基于内存的单机限流算法,基于我们的压测环境,单机限流算法可以达到 200 万 TPS,而分布式限流算法只能做到 5 万 TPS。所以,在应用分布式限流算法时,一定要考量限流算法的性能是否满足应用场景,如果微服务接口的 TPS 已经超过了限流框架本身的 TPS,则限流功能会成为性能瓶颈影响接口本身的性能。
Reference
分布式锁简单总结
实现方式
- 数据库的行级排它锁(如select * from x for update);
- 基于zookeeper的瞬间顺序节点;
- 最小节点获得锁
- 基于 Redis 的 SETNX 命令。
- 使用lua脚本保证原子性 (Redisson 有封装实现 )
- RedLock
- 红锁并非是一个工具,而是Redis官方提出的一种分布式锁的算法。
- RedLock作者指出,之所以要用独立的,是避免了redis异步复制造成的锁丢失,比如:主节点没来的及把刚刚set进来这条数据给从节点,就挂了。
- 红锁算法认为,只要(N/2) + 1个节点加锁成功,那么就认为获取了锁, 解锁时将所有实例解锁。
- 细说Redis分布式锁
- Redisson 有封装实现
本文要讲的是第3种方式。
实现原理
使用setnx创建一个key,如果key不存在,则创建成功返回1,否则返回0。根据是否获得锁决定是否执行业务逻辑,执行完后删除key来实现释放锁。
SET resource_name my_random_value NX PX 30000
为了避免客户端挂了导致其他客户端无法获得锁的情况,为lock_key设置一个过期时间lock timeout
- 一旦业务逻辑执行时间过长,租约到期,就会引发并发问题。
- lock timeout 设置合适的时间,一般情况10s内
- 相对而言,ZooKeeper版本的分布式锁没有这个问题
- 锁的占用时间限制:redis就有占用时间限制,而ZooKeeper则没有,最主要的原因是redis目前没有办法知道已经获取锁的客户端的状态,是已经挂了呢还是正在执行耗时较长的业务逻辑。而ZooKeeper通过临时节点就能清晰知道,如果临时节点存在说明还在执行业务逻辑,如果临时节点不存在说明已经执行完毕释放锁或者是挂了。
- 使用ZooKeeper可以主动通知客户端释放锁,Redis则不行
设置一个随机字符串my_random_value是很有必要的,它保证了一个客户端释放的锁必须是自己持有的那个锁。
- 释放锁lua脚本
1
2
3
4
5if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
- 释放锁lua脚本
可重入锁
- https://juejin.cn/post/6961380552519712798
- lua 脚本,需要存储 锁名称lockName、获得该锁的线程id和对应线程的进入次数count
- 加锁 lock.lua
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17-- 不存在该key时
if (redis.call('exists', KEYS[1]) == 0) then
-- 新增该锁并且hash中该线程id对应的count置1
redis.call('hincrby', KEYS[1], ARGV[2], 1);
-- 设置过期时间
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
-- 存在该key 并且 hash中线程id的key也存在
if (redis.call('hexists', KEYS[1], ARGV[2]) == 1) then
-- 线程重入次数++
redis.call('hincrby', KEYS[1], ARGV[2], 1);
redis.call('pexpire', KEYS[1], ARGV[1]);
return nil;
end;
return redis.call('pttl', KEYS[1]); - 解锁 unlock.lua
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17-- 不存在key
if (redis.call('hexists', KEYS[1], ARGV[3]) == 0) then
return nil;
end;
-- 计数器 -1
local counter = redis.call('hincrby', KEYS[1], ARGV[3], -1);
if (counter > 0) then
-- 过期时间重设
redis.call('pexpire', KEYS[1], ARGV[2]);
return 0;
else
-- 删除并发布解锁消息
redis.call('del', KEYS[1]);
redis.call('publish', KEYS[2], ARGV[1]);
return 1;
end;
return nil;
锁续约
- 延长锁的releaseTime延迟释放锁来直到完成业务期望结果,这种不断延长锁过期时间来保证业务执行完成的操作就是锁续约。
基于单Redis节点的分布式锁无法解决的安全问题。
- 假如Redis节点宕机了,那么所有客户端就都无法获得锁了,服务变得不可用。为了提高可用性,我们可以给这个Redis节点挂一个Slave,当Master节点不可用的时候,系统自动切到Slave上(failover)。但由于Redis的主从复制(replication)是异步的,这可能导致在failover过程中丧失锁的安全性。
- Redlock算法
使用Redisson可以满足以上所有需求。
- Redisson 实现分布式锁原理分析
- Redisson没有设置一个随机值,也可以解决解锁误删的问题。因为Redisson在解决可重入时,已经定义了threadId进行重入计数,通过threadId就可以判断是否是自己之前加的锁。
- 锁续期:leaseTime 必须是 -1 才会开启 Watch Dog 机制,也就是如果你想开启 Watch Dog 机制必须使用默认的加锁时间为 30s。如果你自己自定义时间,超过这个时间,锁就会自定释放,并不会延长。
- 锁等待:当锁正在被占用时,等待获取锁的进程并不是通过一个 while(true) 死循环去获取锁,而是利用了 Redis 的发布订阅机制,通过 await 方法阻塞等待锁的进程,有效的解决了无效的锁申请浪费资源的问题。
- 缺点:Redis Cluster 或者说是 Redis Master-Slave 架构的主从异步复制导致的 Redis 分布式锁的最大缺陷(在 Redis Master 实例宕机的时候,可能导致多个客户端同时完成加锁)
- 用法:使用Redisson实现分布式锁
- 个人看法: 分布式锁并不是绝对可靠,只能尽量保证大多数时候可靠,业务应该自行保证一旦锁失效时的逻辑正确性。
Zookeeper和Redis分布式锁的比较
- 添加和删除,Reids性能较高
- Zookeeper有等待锁队列,大大提升抢锁效率;Redis需要考虑超时,原子性,误删等场景,客户端需要自旋等锁。
- 使用 Redis 实现分布式锁在很多企业中非常常见,而且大部分情况下都不会遇到所谓的“极端复杂场景”。所以使用 Redis 作为分布式锁也不失为一种好的方案,最重要的一点是 Redis 的性能很高,可以支撑高并发的获取、释放锁操作。
- ZK 天生设计定位就是分布式协调,强一致性。锁的模型健壮、简单易用、适合做分布式锁。
如果获取不到锁,只需要添加一个监听器就可以了,不用一直轮询,性能消耗较小。
但是 ZK 也有其缺点:如果有较多的客户端频繁的申请加锁、释放锁,对于 ZK 集群的压力会比较大。
扩展
可以尝试使用Redisson实现分布式锁
Redis的作者antirez给出了一个更好的实现,称为Redlock,算是Redis官方对于实现分布式锁的指导规范。Redlock的算法描述就放在Redis的官网上:
https://redis.io/topics/distlock举个场景的例子来详细说明:一提到分布式锁问题,大多数人想到的方案是基于Redis的Master-Slave模式来实现。这个实现方案行不行?分布式锁本质是一个CP需求,基于Redis的实现是一个AP需求,乍一看基于Redis的实现是无法满足的。脱离业务场景来谈架构都是耍流氓。
从技术战略的需求层面来看,如果分布式锁在极端情况下获取锁的不一致,社交业务场景能够接受,那么基于Redis的实现是完全可行的。如果业务是交易场景,分布式锁在极端情况下获取锁的不一致性无法接受,那么基于Redis的实现方案是不可行的。在锁强一致性的场景下,需要采取基于CP模型的etcd等方案来实现。redis-cli提供了EVAL与EVALSHA命令执行Lua脚本:
- EVAL
- EVAL script numkeys key [key …] arg [arg …]
- key和arg两类参数用于向脚本传递数据, 他们的值可在脚本中使用KEYS和ARGV两个table访问: KEYS表示要操作的键名, ARGV表示非键名参数(并非强制).
- EVALSHA
- EVALSHA命令允许通过脚本的SHA1来执行(节省带宽), Redis在执行EVAL/SCRIPT LOAD后会计算脚本SHA1缓存, EVALSHA根据SHA1取出缓存脚本执行.
- EVAL
redis一般都是单机房部署,如果要控制多个机房只有一个锁,考虑使用Consul来实现分布式锁。
-
- 非原子操作(setnx + expire)
- 如果刚要执行完setnx加锁,正要执行expire设置过期时间时,进程crash或者要重启维护了,那么这个锁就“长生不老”了,别的线程永远获取不到锁了
- 被别的客户端请求覆盖( setnx + value为过期时间)
- Getset 命令用于设置指定 key 的值,并返回 key 的旧值。
- 忘记设置过期时间
- 业务处理完,忘记释放锁
- B的锁被A给释放了
- 假设在这样的并发场景下:A、B两个线程来尝试给Redis的keylockKey加锁,A线程先拿到锁(假如锁超时时间是3秒后过期)。如果线程A执行的业务逻辑很耗时,超过了3秒还是没有执行完。这时候,Redis会自动释放lockKey锁。刚好这时,线程B过来了,它就能抢到锁了,开始执行它的业务逻辑,恰好这时,线程A执行完逻辑,去释放锁的时候,它就把B的锁给释放掉了。
- 正确的方式应该是,在用set扩展参数加锁时,放多一个这个线程请求的唯一标记,比如requestId,然后释放锁的时候,判断一下是不是刚刚的请求。 - 释放锁时,不是原子性
- 因为判断是不是当前线程加的锁和释放锁不是一个原子操作。如果调用unlock(lockKey)释放锁的时候,锁已经过期,所以这把锁已经可能已经不属于当前客户端,会解除他人加的锁。
- 判断和删除是两个操作,不是原子的,有一致性问题。释放锁必须保证原子性,可以使用Redis+Lua脚本来完成
- 锁过期释放,业务没执行完
- 是否可以给获得锁的线程,开启一个定时守护线程,每隔一段时间检查锁是否还存在,存在则对锁的过期时间延长,防止锁过期提前释放。
- 当前开源框架Redisson解决了这个问题: 只要线程一加锁成功,就会启动一个watch dog看门狗,它是一个后台线程,会每隔10秒检查一下,如果线程一还持有锁,那么就会不断的延长锁key的生存时间。
- Redis分布式锁和@transactional一起使用失效
- 正确的实现方法,可以在updateDB方法之前就上锁,即还没有开事务之前就加锁,那么就可以保证线程的安全性.
- 锁可重入
- 前面讨论的Redis分布式锁,都是不可重入的。
- 不可重入的分布式锁的话,是可以满足绝大多数的业务场景。但是有时候一些业务场景,我们还是需要可重入的分布式锁
- Redis只要解决这两个问题,就能实现重入锁了:
- 怎么保存当前持有的线程
- 怎么维护加锁次数(即重入了多少次)
- 实现一个可重入的分布式锁,我们可以参考JDK的ReentrantLock的设计思想。实际上,可以直接使用Redisson框架,它是支持可重入锁的。
- Redis主从复制导致的坑
- 如果线程一在Redis的master节点上拿到了锁,但是加锁的key还没同步到slave节点。恰好这时,master节点发生故障,一个slave节点就会升级为master节点。线程二就可以获取同个key的锁啦,但线程一也已经拿到锁了,锁的安全性就没了。
- 为了解决这个问题,Redis作者 antirez提出一种高级的分布式锁算法:Redlock。Redlock核心思想是这样的:
- 搞多个Redis master部署,以保证它们不会同时宕掉。并且这些master节点是完全相互独立的,相互之间不存在数据同步。同时,需要确保在这多个master实例上,是与在Redis单实例,使用相同方法来获取和释放锁。
- 简化下步骤就是:
- 按顺序向5个master节点请求加锁
- 根据设置的超时时间来判断,是不是要跳过该master节点。
- 如果大于等于3个节点加锁成功,并且使用的时间小于锁的有效期,即可认定加锁成功啦。
- 如果获取锁失败,解锁!
- 个人意见:一般情况下(绝大多数),业务不要强依赖于redis做互斥逻辑
- 非原子操作(setnx + expire)