第 88 期:奥卡姆剃刀定律
最后更新于
这有帮助吗?
最后更新于
这有帮助吗?
@Author : Lewis Tian (taseikyo@gmail.com)
@Link : github.com/taseikyo
@Range : 2024-11-10 - 2024-11-16
| |
本文总字数 19071 个,阅读时长约: 28 分 33 秒,统计数据来自:。
Redis MGET 性能衰减分析
瞧瞧别人家的 API 接口,那叫一个优雅
Golang 自定义 error 避坑实践
Go 神坑 1 —— interface{} 与 nil 的比较
奥卡姆剃刀定律
MGET是 redis 中较为常用的命令,用来批量获取给定 key 对应的 value。因为 redis 使用基于 RESP (REdis Serialization Protocol) 协议的 rpc 接口,而 redis 本身的数据结构非常高效,因此在日常使用中,IO 和协议解析是个不容忽略的资源消耗。通过 mget 将多个 get 请求汇聚成一条命令,可以大大降低网络、rpc 协议解析的开销,从而大幅提升缓存效率。mget的定义如下:
但是,在某些需要一次批量查询大量 key 的场景下,我们会发现mget并没有想象中的那么完美。
以电商库存系统(或价格系统)为例,作为原子级的服务,我们经常要面对商品列表页、活动页、购物车、交易等系统的批量查询,一次请求中动辄包含几十甚至上百个 sku,此时mget是否还能像我们想象中那般保持极高的吞吐?
我们先来设计一个实验,探一探mget性能的底。
1 实验设计
在本地进行了压测模拟,redis key 设计:
key 为 string 类型,固定为八位数字字符串以模拟 SKU ID,不足 8 位者高位填 0
value 为 5 位整型数字,模拟商品库存
实验中将 SKU ID 设置为 1~500000 的数字
单元测试代码设计原则:
可以方便地调整测试参数
尽量减少 GC 对结果的影响,设置合适的堆空间和垃圾回收器
压测代码做了局部优化以尽量保证结果的准确性,包括:
针对每一轮压测,提前准备好随机的 key 列表,避免随机生成 key 列表时大量的内存操作对测试结果造成影响
每一轮压测统计多次 mget 的平均执行时间
每一轮压测完成后,强制触发 fullgc,尽量避免在压测进行中发生 gc
2 JVM 调优
考虑到 redis 平均响应时间在 0.1ms 以内,而一次 minor gc 一般需要耗时 50ms 以上,而 full gc 更可能耗费数秒,因此需要格外注意压测时的 jvm 内存设置和 GC 配置。
经过调试,设置 Java 启动参数:-Xms3g -Xmx3g -XX:+UseG1GC -XX:MaxNewSize=1g -server -XX:MaxTenuringThreshold=1时,在本实验中可以获得理想的结果。
通过下面 jstat 日志可以看出,实验过程中没有出现 minor gc,每一轮结束后强制进行一次 full gc,full gc 触发次数与压测轮数一致。因此测试中统计的时间仅包含了 redis 响应时间和相关代码执行时间 (在性能越高的场景下,代码执行时间相对影响越大)。
另外,为了保证结果的可靠性,整个测试期间,通过 top 对系统性能进行监控,结果如下:
redis CPU 占用率很高但未饱和,即没有出现 redis 性能饱和导致的响应变慢
java 进程的 CPU 占用率维持在 30% 上下,表明 java 代码没有遇到瓶颈,时间主要用于等待 redis 返回 mget 结果。
可见压测过程中,redis 的 CPU 占比保持在 50%~80% 但没有饱和,java 进程的 cpu 保持 30%~50%。因此不存在因为 CPU 导致的响应变慢,结果完全反应在中重度压力下 redis 对mget的处理能力。压测过程中抓取的 top 图如下:
3 实验结果
通过针对不同的mgetkey 长度进行多轮压测,得到不同的 key 长度下 redis 响应能力表如下:
1
0.040
25056.377
100
0.000
18
0.049
20242.914
80.7
1.255
2
0.040
25284.449
100
0.301
20
0.055
18214.936
72.7
1.301
3
0.040
24752.475
99.8
0.477
25
0.053
18811.137
75.0
1.398
4
0.042
23618.328
94.2
0.602
32
0.057
17525.412
70.0
1.505
5
0.044
22696.322
90.6
0.699
40
0.063
15888.147
63.4
1.602
6
0.042
23849.273
95.2
0.778
50
0.067
14889.815
59.4
1.699
7
0.043
23255.814
92.8
0.845
60
0.075
13276.687
53.0
1.778
8
0.044
22888.533
91.3
0.903
80
0.091
10979.358
43.8
1.903
9
0.045
22040.996
88.0
0.954
100
0.096
10405.827
41.5
2.000
10
0.045
22065.313
88.0
1.000
200
0.161
6211.180
24.8
2.301
11
0.046
21901.008
87.4
1.041
500
0.348
2871.913
11.5
2.699
12
0.046
21691.975
86.6
1.079
800
0.552
1812.251
7.2
2.903
13
0.047
21105.951
84.2
1.114
1000
0.639
1564.945
6.2
3.000
14
0.047
21258.504
84.4
1.146
2000
1.201
832.639
3.3
3.301
15
0.049
20300.447
81.0
1.176
5000
3.140
318.471
1.2
3.699
16
0.050
20032.051
79.9
1.204
8000
5.297
188.786
0.7
3.903
17
0.049
20234.723
80.8
1.230
10000
6.141
162.840
0.6
4.000
3.1、单次 mget 的 key 数目在 50 以内时
一次操作 10 个 key 的性能达到一次操作 1 个 key 的 88%
一次操作 20 个 key 的性能达到一次操作 1 个 key 的 72%
一次操作 50 个 key 的性能达到一次操作 1 个 key 的 59%
keyLen<50 曲线拟合
可以看出,此时 redis 整体响应非常好,包含 50 个以内的 key 时,mget既可以保持高 qps,又可以大幅提升吞吐量。
3.2、单次 mget 的 key 数目在 100 以内时
一次操作 60 个 key 的性能达到一次操作 1 个 key 的 53%
一次操作 80 个 key 的性能达到一次操作 1 个 key 的 43%
一次操作 100 个 key 的性能大道一次操作 1 个 key 的 41%
keyLen<100 曲线拟合
单次操作 key 数量在 100 以内时,性能大概能达到 redis 最大性能的 40% 以上,考虑到 key 的吞吐量,这样做是有足够的收益的,但是需要清楚当前场景下单个 redis 实例的最大吞吐量,必要时需要进行分片以提高系统整体性能。
3.3、单次 mget 的 key 数目在 1000 以内
一次操作 200 个 key 的性能只能达到一次操作 1 个 key 的 25%,大约是一次处理 100 个 key 的 60%
一次操作 500 个 key 的性能只能达到一次操作 1 个 key 的 11%,大约是一次处理 100 个 key 的 28%
一次操作 800 个 key 的性能只能达到一次操作 1 个 key 的 7%,大约是一次处理 100 个 key 的 17%
keyLen<1000 曲线拟合
可见,虽然相比较较少的 key,单次请求处理更多的 key 还是有性能上的微弱优势,但是此时性能衰减已经比较严重,此时的 redis 实例不在是那个动辄每秒几万 qps 的超人了,可能从性能上来说可以接受,但是我们要清楚此时 redis 的响应能力,并结合业务场景并考虑是否需要通过其他手段来为 redis 减负,比如分片、读写分离、多级缓存等。
3.4 单次 mget 的 key 数目在 1000 以上
性能急剧恶化,即使在高性能服务器上,这样的操作在单 redis 实例上也只能维持在千上下,此时单次请求的响应时间退化到与 key 数目成正比。除非你确定需要这么做,否则就要尽量避免如此多的 key 的批量获取,而应该从业务上、架构上考虑这么做的必要性。
keyLen>1000 曲线拟合
3.5、请求时间与 key 数目对数的关系
对 mget 的 key 数目取对数,可以得到如下曲线。
log10(keyLen) and keyLen<10000
【注意】 x 轴为 key 的数目对 10 取对数,即 log10(keyLen)
当 key 数目在 10 以内时,mget 性能下降趋势非常小,性能基本上能达到 redis 实例的极限
当 key 数目在 10~100 之间时,mget 性能下降明显,需要考虑 redis 性能衰减对系统吞吐的影响
当 key 数目在 100 以上时,mget 性能下降幅度趋缓,此时 redis 性能已经较差,不建议使用在 OLTP 系统中,或者需要考虑其他手段来提升性能。
前言
在实际工作中,我们需要经常跟第三方平台打交道,可能会对接第三方平台 API 接口,或者提供 API 接口给第三方平台调用。
那么问题来了,如果设计一个优雅的 API 接口,能够满足:安全性、可重复调用、稳定性、好定位问题等多方面需求?
今天跟大家一起聊聊设计 API 接口时,需要注意的一些地方,希望对你会有所帮助。
1. 签名
为了防止 API 接口中的数据被篡改,很多时候我们需要对 API 接口做签名。
接口请求方将请求参数 + 时间戳 + 密钥拼接成一个字符串,然后通过 md5 等 hash 算法,生成一个前面 sign。
然后在请求参数或者请求头中,增加 sign 参数,传递给 API 接口。
API 接口的网关服务,获取到该 sign 值,然后用相同的请求参数 + 时间戳 + 密钥拼接成一个字符串,用相同的 m5 算法生成另外一个 sign,对比两个 sign 值是否相等。
如果两个 sign 相等,则认为是有效请求,API 接口的网关服务会将给请求转发给相应的业务系统。
如果两个 sign 不相等,则 API 接口的网关服务会直接返回签名错误。
问题来了:签名中为什么要加时间戳?
答:为了安全性考虑,防止同一次请求被反复利用,增加了密钥没破解的可能性,我们必须要对每次请求都设置一个合理的过期时间,比如:15 分钟。
这样一次请求,在 15 分钟之内是有效的,超过 15 分钟,API 接口的网关服务会返回超过有效期的异常提示。
目前生成签名中的密钥有两种形式:
一种是双方约定一个固定值 privateKey。
另一种是 API 接口提供方给出 AK/SK 两个值,双方约定用 SK 作为签名中的密钥。AK 接口调用方作为 header 中的 accessKey 传递给 API 接口提供方,这样 API 接口提供方可以根据 AK 获取到 SK,而生成新的 sgin。
2. 加密
有些时候,我们的 API 接口直接传递的非常重要的数据,比如:用户的银行卡号、转账金额、用户身份证等,如果将这些参数,直接明文,暴露到公网上是非常危险的事情。
由此,我们需要对数据进行加密。
目前使用比较多的是用BASE64加解密。
我们可以将所有的数据,安装一定的规律拼接成一个大的字符串,然后在加一个密钥,拼接到一起。
然后使用 JDK1.8 之后的 Base64 工具类处理,效果如下:
为了安全性,使用 Base64 可以加密多次。
API 接口的调用方在传递参数时,body 中只有一个参数 data,它就是 base64 之后的加密数据。
API 接口的网关服务,在接收到 data 数据后,根据双方事先预定的密钥、加密算法、加密次数等,进行解密,并且反序列化出参数数据。
3. ip 白名单
为了进一步加强 API 接口的安全性,防止接口的签名或者加密被破解了,攻击者可以在自己的服务器上请求该接口。
需求限制请求ip,增加ip白名单。
只有在白名单中的 ip 地址,才能成功请求 API 接口,否则直接返回无访问权限。
ip 白名单也可以加在 API 网关服务上。
但也要防止公司的内部应用服务器被攻破,这种情况也可以从内部服务器上发起 API 接口的请求。
这时候就需要增加 web 防火墙了,比如:ModSecurity 等。
4. 限流
如果你的 API 接口被第三方平台调用了,这就意味着着,调用频率是没法控制的。
第三方平台调用你的 API 接口时,如果并发量一下子太高,可能会导致你的 API 服务不可用,接口直接挂掉。
由此,必须要对 API 接口做限流。
限流方法有三种:
对请求 ip 做限流:比如同一个 ip,在一分钟内,对API接口总的请求次数,不能超过 10000 次。
对请求接口做限流:比如同一个 ip,在一分钟内,对指定的API接口,请求次数不能超过 2000 次。
对请求用户做限流:比如同一个AK/SK用户,在一分钟内,对 API 接口总的请求次数,不能超过 10000 次。
我们在实际工作中,可以通过nginx,redis或者gateway实现限流的功能。
5. 参数校验
我们需要对 API 接口做参数校验,比如:校验必填字段是否为空,校验字段类型,校验字段长度,校验枚举值等等。
这样做可以拦截一些无效的请求。
比如在新增数据时,字段长度超过了数据字段的最大长度,数据库会直接报错。
但这种异常的请求,我们完全可以在 API 接口的前期进行识别,没有必要走到数据库保存数据那一步,浪费系统资源。
有些金额字段,本来是正数,但如果用户传入了负数,万一接口没做校验,可能会导致一些没必要的损失。
还有些状态字段,如果不做校验,用户如果传入了系统中不存在的枚举值,就会导致保存的数据异常。
由此可见,做参数校验是非常有必要的。
在 Java 中校验数据使用最多的是hiberate的Validator框架,它里面包含了 @Null、@NotEmpty、@Size、@Max、@Min 等注解。
用它们校验数据非常方便。
当然有些日期字段和枚举字段,可能需要通过自定义注解的方式实现参数校验。
6. 统一返回值
我之前调用过别人的 API 接口,正常返回数据是一种 json 格式,比如:
签名错误返回的 json 格式:
没有数据权限返回的 json 格式:
这种是比较坑的做法,返回值中有多种不同格式的返回数据,这样会导致对接方很难理解。
出现这种情况,可能是 API 网关定义了一直返回值结构,业务系统定义了另外一种返回值结构。如果是网关异常,则返回网关定义的返回值结构,如果是业务系统异常,则返回业务系统的返回值结构。
但这样会导致 API 接口出现不同的异常时,返回不同的返回值结构,非常不利于接口的维护。
其实这个问题我们可以在设计API网关时解决。
业务系统在出现异常时,抛出业务异常的 RuntimeException,其中有个 message 字段定义异常信息。
所有的 API 接口都必须经过 API 网关,API 网关捕获该业务异常,然后转换成统一的异常结构返回,这样能统一返回值结构。
7. 统一封装异常
我们的 API 接口需要对异常进行统一处理。
不知道你有没有遇到过这种场景:有时候在 API 接口中,需要访问数据库,但表不存在,或者 sql 语句异常,就会直接把 sql 信息在 API 接口中直接返回。
返回值中包含了异常堆栈信息、数据库信息、错误代码和行数等信息。
如果直接把这些内容暴露给第三方平台,是很危险的事情。
有些不法分子,利用接口返回值中的这些信息,有可能会进行 sql 注入或者直接脱库,而对我们系统造成一定的损失。
因此非常有必要对 API 接口中的异常做统一处理,把异常转换成这样:
返回码code是500,返回信息message是服务器内部异常。
这样第三方平台就知道是 API 接口出现了内部问题,但不知道具体原因,他们可以找我们排查问题。
我们可以在内部的日志文件中,把堆栈信息、数据库信息、错误代码行数等信息,打印出来。
我们可以在gateway中对异常进行拦截,做统一封装,然后给第三方平台的是处理后没有敏感信息的错误信息。
8. 请求日志
在第三方平台请求你的 API 接口时,接口的请求日志非常重要,通过它可以快速的分析和定位问题。
我们需要把 API 接口的请求 url、请求参数、请求头、请求方式、响应数据和响应时间等,记录到日志文件中。
最好有traceId,可以通过它串联整个请求的日志,过滤多余的日志。
当然有些时候,请求日志不光是你们公司开发人员需要查看,第三方平台的用户也需要能查看接口的请求日志。
这时就需要把日志落地到数据库,比如:mongodb 或者 elastic search,然后做一个 UI 页面,给第三方平台的用户开通查看权限。这样他们就能在外网查看请求日志了,他们自己也能定位一部分问题。
9. 幂等设计
第三方平台极有可能在极短的时间内,请求我们接口多次,比如:在 1 秒内请求两次。有可能是他们业务系统有 bug,或者在做接口调用失败重试,因此我们的 API 接口需要做幂等设计。
也就是说要支持在极短的时间内,第三方平台用相同的参数请求 API 接口多次,第一次请求数据库会新增数据,但第二次请求以后就不会新增数据,但也会返回成功。
这样做的目的是不会产生错误数据。
我们在日常工作中,可以通过在数据库中增加唯一索引,或者在redis保存requestId和请求参来保证接口幂等性。
10. 限制记录条数
对于对我提供的批量接口,一定要限制请求的记录条数。
如果请求的数据太多,很容易造成 API接口超时等问题,让 API 接口变得不稳定。
通常情况下,建议一次请求中的参数,最多支持传入 500 条记录。
如果用户传入多余 500 条记录,则接口直接给出提示。
建议这个参数做成可配置的,并且要事先跟第三方平台协商好,避免上线后产生不必要的问题。
11. 压测
上线前我们务必要对 API 接口做一下压力测试,知道各个接口的qps情况。
以便于我们能够更好的预估,需要部署多少服务器节点,对于 API 接口的稳定性至关重要。
之前虽说对 API 接口做了限流,但是实际上 API 接口是否能够达到限制的阀值,这是一个问号,如果不做压力测试,是有很大风险的。
比如:你 API 接口限流 1 秒只允许 50 次请求,但实际 API 接口只能处理 30 次请求,这样你的 API 接口也会处理不过来。
我们在工作中可以用jmeter或者apache benc对 API 接口做压力测试。
12. 异步处理
一般的 API 接口的逻辑都是同步处理的,请求完之后立刻返回结果。
但有时候,我们的 API 接口里面的业务逻辑非常复杂,特别是有些批量接口,如果同步处理业务,耗时会非常长。
这种情况下,为了提升 API 接口的性能,我们可以改成异步处理。
在 API 接口中可以发送一条mq消息,然后直接返回成功。之后,有个专门的mq消费者去异步消费该消息,做业务逻辑处理。
直接异步处理的接口,第三方平台有两种方式获取到。
第一种方式是:我们回调第三方平台的接口,告知他们 API 接口的处理结果,很多支付接口就是这么玩的。
第二种方式是:第三方平台通过轮询调用我们另外一个查询状态的 API 接口,每隔一段时间查询一次状态,传入的参数是之前的那个 API 接口中的 id 集合。
13. 数据脱敏
有时候第三方平台调用我们 API 接口时,获取的数据中有一部分是敏感数据,比如:用户手机号、银行卡号等等。
这样信息如果通过 API 接口直接保留到外网,是非常不安全的,很容易造成用户隐私数据泄露的问题。
这就需要对部分数据做数据脱敏了。
我们可以在返回的数据中,部分内容用星号代替。
已用户手机号为例:182****887。
这样即使数据被泄露了,也只泄露了一部分,不法分子拿到这份数据也没啥用。
14. 完整的接口文档
说实话,一份完整的 API 接口文档,在双方做接口对接时,可以减少很多沟通成本,让对方少走很多弯路。
接口文档中需要包含如下信息:
接口地址
请求方式,比如:post 或 get
请求参数和字段介绍
返回值和字段介绍
返回码和错误信息
加密或签名示例
完整的请求 demo
额外的说明,比如:开通 ip 白名单。
接口文档中最好能够统一接口和字段名称的命名风格,比如都用驼峰标识命名。
统一字段的类型和长度,比如:id 字段用 Long 类型,长度规定 20。status 字段用 int 类型,长度固定 2 等。
统一时间格式字段,比如:time 用 String 类型,格式为:yyyy-MM-dd HH:mm:ss。
接口文档中写明 AK/SK 和域名,找某某单独提供等。
熟悉 go 开发的朋友都知道,在 go 的设计中是推荐显示去处理 error 的。在使用时,通过建议函数返回一个 error 值,通过把返回的 error 变量与 nil 的比较,来判定操作是否成功。
go 中的 error 定义如下:
原始的 error 被定义为 interface。
可以看出原生的 error 非常简单,只有 msg,但是一般业务开发上,我们还需要 code 信息。根据 go 的接口设计,只要我们实现了 Error() string 方法就可以实现自定义 error。
通过自定义一个 struct,并且实现 Error() string 方法,我们自定义了一个带 code 的 error。
这里需要注意,我们将 myErr 声明为小写开头,这样可以避免在在包外被直接声明,只允许通过函数 New(code int, msg string)
得到 error。
如果可以被在包外直接声明,会有什么问题呢?这就是本次要介绍的一个坑了。
请看下面这个例子:
我们会惊奇的发现 err == nil
输出为 false
这一切是由于 go 的 interface 设计导致的,go 将 interface 设计为了两部分:
type
value
其中,value 由一个任意的具体值表示,称作 interface 的 dynamic value ;而 type 则对应该 value 的类型(即 dynamic type);例如对于对于 var a int = 3 来说,把 a 赋值给 interface 时, interface 是使用 (int, 3) 进行存储的。
当想判断 interface 的值为 nil时 ,则必须是其内部 value 和 type 均未设置的情况,即 (nil, nil) ;
那么回到上面的例子,当把 var err *MyErr
赋值给 interface 时,interface 的存储数据为 (*MyErr, nil)
。这个时候进行 nil 判断的结果肯定是 false。
所以当自定义 error 时,我们要尽可能的避免 error 可以被直接声明。通过提供函数的形式去生成 error。
一来可以避免上述的问题,二来可以屏蔽实现细节,可以更好的扩展。
1. 前言
interface 是 Go 里所提供的非常重要的特性。一个 interface 里可以定义一个或者多个函数,例如系统自带的io.ReadWriter的定义如下所示:
任何类型只要它提供了 Read 和 Write 的实现,那么这个类型便实现了这个 interface(duck-type),而不像 Java 需要开发者使用 implements 标明。
然而 Go 的 interface 在使用过程中却有不少的坑,需要特别注意。本文就记录一个nil与interface{}比较的问题。
2. 出乎意料的比较结果
首先看一个比较 nil 切片的比较问题。
运行上面代码输出结果是怎样的呢?你可能我和我一样,很自信认为输出是下面这样的:
但实际输出的结果是:
Oh my god,为啥一个nil切片经过空接口interface{}一中转,就变成了非 nil。
3. 寻找问题所在
想要理解这个问题,首先需要理解 interface{} 变量的本质。
Go 语言中有两种略微不同的接口,一种是带有一组方法的接口,另一种是不带任何方法的空接口 interface{}。
Go 语言使用runtime.iface表示带方法的接口,使用runtime.eface表示不带任何方法的空接口interface{}。
一个 interface{} 类型的变量包含了 2 个指针,一个指针指向值的类型,另外一个指针指向实际的值。在 Go 源码中 runtime 包下,我们可以找到runtime.eface的定义。
从空接口的定义可以看到,当一个空接口变量为 nil 时,需要其两个指针均为 0 才行。
回到最初的问题,我们打印下传入函数中的空接口变量值,来看看它两个指针值的情况。
运行输出:
可见,虽然 sl 是 nil 切片,但是其本上是一个类型为 []string
,值为空结构体 slice 的一个变量,所以 sl 传给空接口时是一个非 nil 变量。
再细究的话,你可能会问,既然 sl 是一个有类型有值的切片,为什么又是个 nil。
针对具体类型的变量,判断是否是 nil 要根据其值是否为零值。因为 sl 一个切片类型,而切片类型的定义在源码包 src/runtime/slice.go 我们可以找到。
我们继续看一下值为 nil 的切片对应的 slice 是否为零值。
运行输出:
不出所料,果然是零值。
至此解释了开篇出乎意料的比较结果背后的原因: 空切片为 nil 因为其值为零值,类型为 []string
的空切片传给空接口后,因为空接口的值并不是零值,所以接口变量不是 nil。
4. 避坑大法
知道前面有个坑,如何避免不掉进去。我们想到的做法无非就两种:一填坑,二绕过。
因为这是由 Go 的特性决定,直白点就是设计如此。面对此坑,我们开发者只能望洋兴叹,无可奈何,留给 Go 核心团队那帮天才来填坑吧。
我们能做的就是绕过此坑。如何绕过呢,有两个办法:
(1)既然值为 nil 的具型变量赋值给空接口会出现如此莫名其妙的情况,我们不要这么做,再赋值前先做判空处理,不为 nil 才赋给空接口;
(2)使用reflect.ValueOf().IsNil()来判断。不推荐这种做法,因为当空接口对应的具型是值类型,会 panic。
5. 小结
Go 简单高效,功能强大,如此优秀的语言也存在很多让人误用的特性,本文便记录万坑之一的 nil 赋给空接口时判空失效的问题,后面会继续给出 Go 使用的常见问题,帮助大家少踩坑。
切记,Go 中变量是否为 nil 要看变量的值是否是零值。
切记,不要将值为 nil 的变量赋给空接口。
1、什么是奥卡姆剃刀
奥卡姆剃刀(Occam's Razor, Ockham's Razor)又称“奥康的剃刀”。
奥卡姆剃刀,是由14世纪逻辑学家、圣方济各会修士奥卡姆的威廉(William of Occam,约1285年至1349年)提出。奥卡姆(Ockham)在英格兰的萨里郡,那是他出生的地方。他在《箴言书注》2卷15题说“切勿浪费较多东西去做用较少的东西同样可以做好的事情。”
这个原理称为“如无必要,勿增实体”(Entities should not be multiplied unnecessarily)。有时为了显示其权威性,人们也使用它原始的拉丁文形式:
Pluralitas non est ponenda sine necessitate. Frustra fit per plura quod potest fieri per pauciora. Entia non sunt multiplicanda praeter necessitatem.
公元14世纪,英国奥卡姆的威廉对当时无休无止的关于“共相”、“本质”之类的争吵感到厌倦,于是著书立说,宣传唯名论,只承认确实存在的东西,认为那些空洞无物的普遍性要领都是无用的累赘,应当被无情地“剃除”。
他所主张的“思维经济原则”,概括起来就是“如无必要,勿增实体。”因为他是英国奥卡姆人,人们就把这句话称为“奥卡姆剃刀”。
这把剃刀出鞘后,剃秃了几百年间争论不休的经院哲学和基督教神学,使科学、哲学从神学中分离出来,引发了欧洲的文艺复兴和宗教改革。同时,这把剃刀曾使很多人感到威胁,被认为是异端邪说,威廉本人也受到伤害。然而,这并未损害这把刀的锋利,相反,经过数百年越来越快,并早已超越了原来狭窄的领域而具有广泛的、丰富的、深刻的意义。
奥卡姆的代表作是《逻辑大全(Summa Logical)》(商务印书馆有中译本),书中指出,世界是由具体的事物构成,不存在包罗万象的实体,而逻辑学是独立于形而上学的自然哲学工具。科学是关于具体事物的,事物是个别的,但在词语中有共相,逻辑是研究共相、词语和概念的。奥卡姆使逻辑学独立于形而上学和神学之外。
今天,这把阴冷闪光的剃刀又向我们复杂的企业管理发出了挑战,指出许多东西是有害无益的,我们正在被这些自己制造的麻烦压跨。事实上,我们的组织正不断膨胀,制度越来越烦琐,文件越来越多,但效率却越来越低。这迫使我们使用“奥卡姆剃刀”,采用简单管理,化繁为简,将复杂的事物变简单。
为什么要将复杂变简单呢?因为复杂容易使人迷失,只有简单化后才利于人们理解和操作。随着社会、经济的发展,时间和精力成为人们的稀缺资源,管理者的时间更加有限,许多终日忙忙碌碌的管理者却鲜有成效,究其原因正是缺乏简单管理的思维和能力,分不清“重要的事”与“紧迫的事”,结果成为了低绩效或失败的管理者。从这个意义上讲,管理之道就是简化之道,简化才意味着对事务真正的掌控。
简单管理对于处于转型和成长时期的中国企业具有非凡的意义,但简单管理本身却不是简单。奥卡姆剃刀定律也认为:把事情变复杂很简单,把事情变简单很复杂。一些人动辄以“无为而治”、“治大国若烹小鲜”来概括简单管理,但又有几人能若庖丁般游刃有余?我们所知道的一流的企业家无不抱着异常谨慎的态度经营企业,如比尔盖茨“微软离破产只有18个月”的论断、张瑞敏“战战兢兢、如履薄冰”的心态以及任正非一直所担忧的“华为的冬天”。可见,简单管理作为一种古老而崭新的管理思维和能力,蕴涵着深刻的内涵。
奥卡姆剃刀定律的影响
在影视作品中,朱迪·福斯特主演的科幻片《Contact》中提到奥卡姆剃刀,但影片讲述的是信仰问题,科学是否也是一种信仰?
在管理学领域,重要的事实是百人法则,即很多国际集团公司的总部不超过100名职员,这种人少高效率的组织结构,不能排除根植于西方文化领域中的奥卡姆剃刀作用。
在哲学领域,逻辑经验主义的要义也是人类认识的“思维的经济性”,思维是用最经济的方式来思考和表达客观世界,因果性只是思维的一种节约方式,物理学的公式也是这种经济性的具体实现,而形而上学的体系是无法证实的同语反复,形而上学体系的关键是不要自相矛盾,在逻辑经验主义里,形而上学被剔除出哲学。
在语言学领域,语言表达的是事实还是情感,或者是毫无意义,而科学或哲学的命题一定要是关于事实的命题,才具有实证的意义,当然关于情感的表达属于文学和美学范畴。
奥卡姆剃刀定律在目标管理中的运用
“奥卡姆剃刀”三法
对于组织在目标设置与执行过程中因上述种种原因而出现的目标曲解与置换,有一个根本的解决之道,即“无情地剔除所有累赘”,这也正是“奥卡姆剃刀”所倡导的“简化”法则:保持事物的简单化是对付复杂与繁琐的最有效方式。具体而言,有三种措施可以帮助我们避免目标曲解与置换现象的发生:
1、精兵简政,不断简化组织结构
组织结构扁平化与组织结构非层级化已经成为企业组织变革的基本趋势。在新型的组织结构中,传统的企业组织结构中严格的等级制度已经不复存在,组织中上下有序的传统规则被淡化,员工之间的关系是平等的分工合作关系,基层员工被赋予更多的权力,他们有可能参与部门目标甚至于组织目标的制定,组织内的信息不再是上下级之间的单向传递,而是一种网络化的即时式双向沟通。在这种组织中,顾客的需要成为员工行动的向导,人们的行为具有明确的目标导向。同时,由于员工的积极参与,组织目标与个人目标之间的矛盾得到最大程度地消除。
2、关注组织的核心价值,始终将组织资源集中于自己的专长
也就是说,组织需要从众多可供选择的业务中筛选出最重要的、拥有核心竞争能力的业务,在自己最具竞争优势的领域确定组织的目标。这样,才能确保组织集中精力,就可以以最少的代价获得最丰厚的利润。反之,如果目标数量过多,往往会使经营者难以同时兼顾太多的业务,从而顾此失彼。韦尔奇上任通用电气公司总裁时,从简洁高效的角度出发,提出“非一即二”原则:必须把本产品做成数一数二的产品,否则一律卖掉。
3、简化流程,避免不必要的文书作业
事实上,由于个体受自身思维方式的限制,简单的信息远比复杂的信息更有利于人们的思考与决策。因此一个优秀企业的主要特征,就是他们知道如何保持事情的简单化,不管多复杂的事情都能将其变得简单易行。
尽管导致组织目标曲解与置换的原因很多,但奥卡姆剃刀定律对解决目标的曲解与置换为我们提供了一种“简单”的理念与思路。
*Photo by on
| |